API Reference

The Sonar REST API provides programmatic access to keyword research, rank tracking, and competitor analysis. New accounts get 50 free credits on signup — top up with prepaid packs from $10, or subscribe to the Full plan for 1,000 included requests per day plus tracking, history, and competitor analytics.

Authentication

Include your API key in the Authorization header:

Authorization: Bearer aso_your_api_key_here

API keys are managed at /developers in the dashboard. Keys use the prefix aso_ followed by 64 hex characters. The full key is shown once at creation time — we store only the SHA-256 hash.

Free anonymous access

A few stateless endpoints work with no Authorization header at all, rate-limited per IP per day — try the API before signing up: /apps/search, /apps/lookup, /apps/aso-score, /apps/extract-keywords, and /keywords/suggestions share 30 requests/day; /keywords/metrics allows 5 keywords/day (bulk calls count per keyword). Over the limit you get a 429 with signup instructions. Everything else requires a key.

Scopes

Every key has at least the implicit read scope. Mutating endpoints (anything under Write Endpoints) additionally require the write scope. Pick the scope when creating a key — keys are scoped at issuance and cannot be upgraded later (create a new key instead).

ScopeGrantsAvailable on
readAll read endpoints (stateless lookups + org-scoped GETs)Full plan or credits
writePOST / PATCH on org-scoped resources (products, tracked apps, tracked keywords, competitor scans)Full plan only

A request with the wrong scope returns 403 forbidden with code: "forbidden" and a message naming the missing scope.

Error responses

StatusCodeMeaning
401unauthorizedMissing, invalid, or revoked API key
402insufficient_creditsDaily limit exhausted and credit balance below the call's cost. Top up at /settings/credits or wait for daily reset.
403forbiddenValid key but endpoint requires Full plan, or scope is missing
400bad_requestMissing or invalid query parameters
404not_foundResource not found or not tracked by your org
409bad_requestConflict — e.g. app already linked to another product, store slot already filled
429rate_limitedDaily rate limit exceeded (Full plan)
500internal_errorServer error
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid Authorization header. Use: Bearer aso_xxx"
  }
}

Pricing & Rate Limits

Sonar has two billing modes. Pick whichever matches your usage:

ModeHow it worksLimits
CreditsPrepaid packs from $10 (1,000 credits). 50 free credits on signup. Each call deducts the cost listed below. Stateless endpoints only — no rank tracking, no org-scoped data.Pay per call, no daily cap
Full plan$19/mo or $149/yr annual. Unlocks org-scoped endpoints (tracked apps, rank history, competitor analytics) and the write scope. 1,000 requests/day included; overflow into credit balance when exhausted. Free 7-day trial is capped at 150 req/day to discourage bulk-extraction before subscribing.1,000 req/day (150 on trial), then credits

Endpoint costs (credit mode)

Costs reflect upstream scraper traffic, not just our HTTP work. Heavy endpoints fan out into many App Store / Play Store calls behind the scenes.

CostEndpoints
1/apps/lookup, /apps/search, /apps/aso-score, /apps/extract-keywords, /apps/reviews, /keywords/suggestions
1 per app/apps/revenue (bulk up to 25 in one HTTP request)
1 per keyword/keywords/metrics (single + bulk)
10/keywords/search (fans out into ~11 upstream calls)

Write endpoints cost 1 credit each. They're only available on the Full plan, so credits only apply once the 1,000/day included quota is exhausted — overage on write traffic is rare in practice.

Response headers

HeaderDescription
X-RateLimit-LimitFull plan: total daily limit (1,000)
X-RateLimit-RemainingFull plan: requests remaining today
X-RateLimit-ResetFull plan: Unix timestamp when limit resets
X-RateLimit-Overagetrue when a Full-plan call burned credits instead of the included quota — signal for agents to slow down
X-Credits-CostCredits deducted by this call (credit mode + Full overage)
X-Credits-RemainingCredit balance after this call

Response Format

Single resource

{ "data": { ... } }

List

{ "data": [ ... ] }

Paginated list

{
  "data": [ ... ],
  "pagination": {
    "next_cursor": "uuid-of-last-item",
    "has_more": true
  }
}

Pass ?cursor=<next_cursor> to fetch the next page. When has_more is false, you've reached the end.


Endpoints

List Products

GET/api/v1/products

List your products (the cross-store unit) with their linked store versions and competitor counts. Returned whole — no pagination. Use it to discover the product/app UUIDs the write + delete endpoints need.

curl -H "Authorization: Bearer aso_xxx" \
  https://your-domain.com/api/v1/products
{
  "data": [
    {
      "id": "prod-uuid",
      "name": "Tasty",
      "icon_url": "https://...",
      "country": "us",
      "created_at": "2026-02-12T12:56:14.318Z",
      "competitor_count": 3,
      "apps": [
        {
          "id": "app-uuid-ios",
          "store": "ios",
          "store_id": "1217680999",
          "name": "Tasty: Recipes, Cooking Videos",
          "icon_url": "https://..."
        }
      ]
    }
  ]
}

List Apps

GET/api/v1/apps

List all tracked apps with the latest snapshot.

curl -H "Authorization: Bearer aso_xxx" \
  https://your-domain.com/api/v1/apps
{
  "data": [
    {
      "id": "6a2b1e60-fa88-4816-a0e3-c0d3c042f478",
      "store": "ios",
      "store_id": "com.buzzfeed.tasty",
      "name": "Tasty: Recipes, Cooking Videos",
      "developer": "BuzzFeed",
      "category": "Food & Drink",
      "icon_url": "https://...",
      "is_own": false,
      "added_at": "2026-02-12T12:56:14.318Z",
      "latest_snapshot": {
        "rating": 4.90836,
        "review_count": 431859,
        "version": "3.39.1",
        "installs": null,
        "measured_at": "2026-02-15"
      }
    }
  ]
}

installs is only populated for Android apps. latest_snapshot is null if no snapshots exist yet.


Get App

GET/api/v1/apps/:id

App metadata with snapshot history (last 90 days).

curl -H "Authorization: Bearer aso_xxx" \
  https://your-domain.com/api/v1/apps/6a2b1e60-...
{
  "data": {
    "id": "6a2b1e60-...",
    "store": "ios",
    "store_id": "com.buzzfeed.tasty",
    "name": "Tasty: Recipes, Cooking Videos",
    "developer": "BuzzFeed",
    "category": "Food & Drink",
    "icon_url": "https://...",
    "metadata": { "url": "...", "free": true, "price": 0, "rating": 4.9, "reviews": 431859 },
    "is_own": false,
    "added_at": "2026-02-12T12:56:14.318Z",
    "last_scraped_at": "2026-02-15T04:40:59.481Z",
    "snapshots": [
      { "rating": 4.90836, "review_count": 431859, "version": "3.39.1", "installs": null, "measured_at": "2026-02-15" }
    ]
  }
}

Returns 404 if the app is not tracked by your organization.


App Keywords

GET/api/v1/apps/:id/keywords

Tracked keywords for an app with latest metrics. Cursor paginated.

ParamTypeDefaultDescription
cursorstringPagination cursor from previous response
limitinteger50Results per page (1–200)
{
  "data": [
    {
      "id": "tracked-kw-uuid",
      "keyword_id": "kw-uuid",
      "keyword": "recipe app",
      "store": "ios",
      "country": "us",
      "added_at": "2026-01-20T00:00:00.000Z",
      "note": null,
      "starred_at": "2026-07-10T12:00:00.000Z",
      "difficulty": 77,
      "popularity": null,
      "popularity_proxy": null,
      "popularity_source": null,
      "results_count": 10
    }
  ],
  "pagination": { "next_cursor": "next-uuid", "has_more": true }
}

popularity is null unless an Apple Search Ads account is connected (iOS) or Google Ads data is available (Android).


App Rankings

GET/api/v1/apps/:id/rankings

Rank history for an app's tracked keywords.

ParamTypeDefaultDescription
daysinteger30History window (1–365)
keyword_idstringFilter to a specific keyword
{
  "data": [
    {
      "keyword_id": "kw-uuid",
      "keyword": "recipe app",
      "history": [
        { "rank": 12, "measured_at": "2026-02-14" },
        { "rank": 14, "measured_at": "2026-02-13" }
      ]
    }
  ]
}

App Changes

GET/api/v1/apps/:id/changes

Activity feed for a tracked app — version releases, metadata edits, screenshot swaps, price/IAP changes, and category moves. Use this to monitor competitors automatically. :id is the internal app UUID from /api/v1/apps; the app must be tracked by your org.

ParamTypeDefaultDescription
typestringFilter: release, metadata, screenshots, price, or category
limitinteger50Max entries (1–200)
curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/apps/6a2b1e60-.../changes?type=release&limit=10"
{
  "data": [
    {
      "id": "change-uuid",
      "change_type": "release",
      "detected_at": "2026-04-22T03:14:11.000Z",
      "data": { "version": "5.4.1", "release_notes": "Bug fixes and performance improvements" }
    },
    {
      "id": "change-uuid-2",
      "change_type": "screenshots",
      "detected_at": "2026-04-19T03:11:42.000Z",
      "data": { "added": 2, "removed": 1, "first_screenshot_url": "https://..." }
    }
  ]
}

The shape of data varies by change_type. Entries are sorted by detected_at descending. Returns 404 if the app is not tracked by your organization.


Reviews

GET/api/v1/apps/reviews

Fetch app reviews from the store. Stateless — works on any app. Optional filters let you pull only low-star reviews (bug signals) or high-star reviews (positive copy) without looping client-side.

ParamTypeDefaultDescription
storestringrequiredios or android
idstringrequiredStore ID
countrystringusCountry code (ISO 3166-1 alpha-2)
sortstringrecentrecent or helpful
min_ratingintegerOnly reviews with score >= min_rating (1–5)
max_ratingintegerOnly reviews with score <= max_rating (1–5)
limitintegerCap returned reviews (1–200, applied after rating filter)
curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/apps/reviews?store=ios&id=284882215&max_rating=3&limit=20"
{
  "data": [
    {
      "id": "review-id",
      "userName": "John D.",
      "score": 2,
      "title": "Keeps crashing",
      "text": "The app crashes every time I open it...",
      "date": "2026-02-15T00:00:00.000Z",
      "version": "5.4.1",
      "url": null,
      "thumbsUp": 0
    }
  ]
}

The store returns up to ~50 reviews per page on iOS and up to ~150 on Android. Filters are applied to that page in-memory.


Revenue Estimate

GET/api/v1/apps/revenue

Monthly revenue estimate for one or many apps on the App Store or Google Play. Stateless — works on any app, no need to add it to your account first.

ParamTypeDefaultDescription
storestringrequiredios or android
idstringSingle store ID. Pass id OR ids, not both
idsstringComma-separated store IDs (max 25 per call) for bulk lookup
countrystringusCountry code (ISO 3166-1 alpha-2)

Single app.

curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/apps/revenue?store=ios&id=389801252&country=us"
{
  "data": {
    "app": {
      "store": "ios",
      "store_id": "389801252",
      "name": "Instagram",
      "icon_url": "https://..."
    },
    "revenue": {
      "monthly": 1234567.89,
      "monthly_formatted": "$1.2M/mo",
      "model": "ad-supported",
      "methodology": "Estimated from reviews-to-install ratio for the Photo & Video category, retention decay across the app's lifetime, and ad-supported monetization signals.",
      "confidence": "medium",
      "confidence_factors": [
        "Install base is estimated from review counts, not a public install count",
        "Chart position corroborates the estimate (overall top-free #4)"
      ]
    }
  }
}

Bulk (up to 25 apps in one call, counts as 1 request).

curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/apps/revenue?store=ios&ids=284882215,389801252,544007664"
{
  "data": [
    {
      "store_id": "284882215",
      "app": { "store": "ios", "store_id": "284882215", "name": "Facebook", "icon_url": "https://..." },
      "revenue": { "monthly": 25000, "monthly_formatted": "$25K/mo", "model": "subscription", "methodology": "...", "confidence": "medium", "confidence_factors": ["..."] },
      "error": null
    },
    {
      "store_id": "544007664",
      "app": null,
      "revenue": null,
      "error": { "code": "not_found", "message": "App not found" }
    }
  ]
}

Bulk responses return 200 even with partial failures — inspect each item's error field for per-app issues. monthly is in USD. model is one of paid, freemium, subscription, ad-supported, hybrid, or unknown. Android estimates are tighter than iOS because Google Play exposes install counts directly.


GET/api/v1/keywords/search

Keyword research. Returns autocomplete suggestions with difficulty scores and popularity estimates.

ParamTypeDefaultDescription
qstringrequiredSearch query
storestringrequiredios or android
countrystringusCountry code
curl -H "Authorization: Bearer aso_xxx" \
  "https://your-domain.com/api/v1/keywords/search?q=recipe+app&store=ios"
{
  "data": [
    { "keyword": "recipe app", "store": "ios", "country": "us", "difficulty": 77, "popularity": null, "popularity_proxy": null, "popularity_source": null, "results_count": 10 },
    { "keyword": "free recipe app", "store": "ios", "country": "us", "difficulty": 70, "popularity": null, "popularity_proxy": null, "popularity_source": null, "results_count": 10 }
  ]
}

Returns up to 10 suggestions. difficulty is 0\u2013100 based on top-10 competitor strength. popularity requires an Apple Search Ads account (iOS) or is estimated from install data (Android). Results are cached for 6 hours.


Keyword Rankings

GET/api/v1/keywords/:id/rankings

SERP history for a keyword \u2014 which apps rank for it and how positions change.

ParamTypeDefaultDescription
daysinteger30History window (1–365)
{
  "data": {
    "keyword_id": "kw-uuid",
    "keyword": "recipe app",
    "store": "ios",
    "country": "us",
    "entries": [
      {
        "rank": 1,
        "store_id": "com.buzzfeed.tasty",
        "app_name": "Tasty: Recipes, Cooking Videos",
        "app_icon_url": "https://...",
        "measured_at": "2026-02-14",
        "outlier": null
      }
    ]
  }
}

Entries are sorted by date (newest first), then by rank. Returns 404 if the keyword ID doesn't exist. outlier is non-null when the app holds its slot with an order of magnitude less social proof than the SERP median — e.g. { "strength": 138, "median": 40000 } (ratings on iOS, installs on Android). Such apps usually win on metadata and are prime candidates for a competitor scan.


Keyword Metrics

GET/api/v1/keywords/metrics

Difficulty + popularity for a specific keyword (or up to 25 in bulk). Use this when you already know which keywords you care about — it's 1 credit per keyword vs. 10 for /keywords/search, which fans out into related ideas.

ParamTypeDefaultDescription
qstringSingle keyword (use this OR qs, not both)
qsstringComma-separated bulk keywords (up to 25, use this OR q)
storestringrequiredios or android
countrystringusCountry code
# Single
curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/keywords/metrics?store=ios&q=habit+tracker"

# Bulk (1 credit per keyword)
curl -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/keywords/metrics?store=ios&qs=habit+tracker,water+log,workout"
{
  "data": {
    "keyword": "habit tracker",
    "store": "ios",
    "country": "us",
    "difficulty": 62,
    "popularity": 48,
    "popularity_proxy": null,
    "popularity_source": "apple",
    "est_downloads_at_1": 320,
    "difficulty_breakdown": {
      "titleMatches": 6,
      "appsAnalyzed": 10,
      "top3Strength": [412000, 380000, 95000],
      "medianStrength": 120000,
      "weakSpotRank": null,
      "beatable": false
    },
    "results_count": 10
  }
}
{
  "data": [
    { "keyword": "habit tracker", "store": "ios", "country": "us", "difficulty": 62, "popularity": 48, "popularity_proxy": null, "popularity_source": "apple", "results_count": 10 },
    { "keyword": "meditation",    "store": "ios", "country": "us", "difficulty": 30, "popularity": 5,  "popularity_proxy": 58, "popularity_source": "apple", "results_count": 10 },
    { "keyword": "workout",       "store": "ios", "country": "us", "difficulty": 0,  "popularity": null, "popularity_proxy": null, "popularity_source": null, "results_count": 0,
      "error": { "code": "internal_error", "message": "Scraper blocked" } }
  ]
}

Bulk responses use an array; per-keyword failures are isolated in anerror field so one bad term doesn't fail the whole batch. popularity_proxy is Sonar's own estimate, set only when Apple censors an iOS keyword's real Search Popularity to its floor of 5 — it disambiguates keywords in that band (shown as 5 (58)).popularity_source states the provenance of popularity: "apple" = real Apple Search Popularity reported by Apple Search Ads (not an estimate), "proxy" = Sonar's in-house estimate (always the case for Android), null = unknown or no popularity value. Both fields also appear on /keywords/search, /apps/:id/keywords, and /competitors/:id/keywords. Results are cached for ~7 days — repeated calls for the same keyword in the same country are free upstream but still cost the listed credits.

est_downloads_at_1 is the estimated downloads/day the #1-ranking app gets from this keyword — a rough order of magnitude derived from the Apple-calibrated popularity curve. iOS only; null on Android. difficulty_breakdown exposes the raw signals behind the difficulty score: title-match count in the analyzed top 10, top-3 app strength (ratings on iOS, installs on Android) vs the SERP median, and a beatable flag — true when a top-3 slot looks winnable (an app holds it with ≥10x less strength than the median, or the term is under-targeted in titles with a weak top-3 app). null for rows cached before the fields existed or SERPs with fewer than 3 results. Both also appear on /keywords/search.


Suggestions

GET/api/v1/keywords/suggestions

Raw autocomplete suggestions from the store. Lighter than /keywords/search \u2014 no difficulty calculation.

ParamTypeDefaultDescription
qstringrequiredSeed term
storestringrequiredios or android
countrystringusCountry code
{
  "data": [
    { "term": "recipe keeper", "priority": 0 },
    { "term": "recipes app free", "priority": 0 },
    { "term": "recipe book", "priority": 0 }
  ]
}

priority is the autocomplete priority score (0\u201310000). Higher values indicate more popular suggestions.


Competitor Keywords

GET/api/v1/competitors/:id/keywords

Keywords a competitor ranks for, with optional gap analysis against your app. Cursor paginated.

ParamTypeDefaultDescription
app_idstringYour app ID for gap analysis
cursorstringPagination cursor
limitinteger50Results per page (1–200)
{
  "data": [
    {
      "keyword_id": "kw-uuid",
      "keyword": "recipe app",
      "store": "ios",
      "country": "us",
      "competitor_rank": 3,
      "own_rank": 15,
      "gap": null,
      "difficulty": 72,
      "popularity": null
    },
    {
      "keyword_id": "kw-uuid-2",
      "keyword": "cooking videos",
      "store": "ios",
      "country": "us",
      "competitor_rank": 5,
      "own_rank": null,
      "gap": "missing",
      "difficulty": 45,
      "popularity": null
    }
  ],
  "pagination": { "next_cursor": null, "has_more": false }
}

gap is "missing" when the competitor ranks but your app doesn't. null when both rank. own_rank is only populated when app_id is provided.


Competitor Landscape

GET/api/v1/apps/:id/competitor-landscape

The live competitive keyword landscape for one of your own tracked apps vs all its tracked competitors: gap / winnable / threat / lead stats, the top rows of each, and the latest AI insight (if one has been generated). :id is your own app's UUID, not a competitor's.

curl -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/apps/my-app-uuid/competitor-landscape
{
  "data": {
    "app_id": "my-app-uuid",
    "stats": { "competitors": 3, "keywords_compared": 84, "gaps": 22, "winnable": 7, "threats": 2, "leads": 5 },
    "gaps": [
      {
        "keyword_id": "kw-uuid",
        "keyword": "meal planner",
        "country": "us",
        "own_rank": null,
        "best_competitor": { "app_id": "comp-uuid", "name": "MealPrep Pro", "rank": 4 },
        "popularity": 38,
        "difficulty": 31,
        "opportunity": 54,
        "tracked": false
      }
    ],
    "threats": [
      {
        "competitor_app_id": "comp-uuid",
        "competitor_name": "MealPrep Pro",
        "keyword_id": "kw-uuid-2",
        "keyword": "recipe app",
        "country": "us",
        "from_rank": 18,
        "to_rank": 9,
        "own_rank": 12
      }
    ],
    "leads": [],
    "competitors": [{ "app_id": "comp-uuid", "name": "MealPrep Pro" }],
    "insight": {
      "generated_at": "2026-07-18T08:12:44.000Z",
      "posture": "challenger",
      "overview": "...",
      "opportunities": [{ "title": "Meal-planning verticals", "detail": "...", "priority": "high", "keywords": [] }],
      "threats": [{ "competitor_name": "MealPrep Pro", "headline": "...", "detail": "..." }],
      "strengths": ["..."],
      "changes_since_last": "..."
    },
    "insight_cooldown": { "in_cooldown": true, "next_available_at": "2026-07-25T08:12:44.000Z" }
  }
}

gaps (top 25) — a competitor ranked top-30 in the last 7 days and your app has no current rank; winnable gaps have difficulty ≤ 40 and popularity ≥ 20. threats (top 10) — a competitor climbed ≥ 5 positions into the top 20 (or newly entered it, from_rank: null) on one of your tracked keywords vs a ~2-week baseline. leads (top 10) — your app is top-10 and ahead of every competitor. insight is null until you generate one with the POST variant. With no tracked competitors you get an empty landscape (zeroed stats) rather than an error; 404 if the app isn't one of your tracked apps.


List Alerts

GET/api/v1/alerts

List your alert rules (subscriptions). Each rule is org-wide (scope_app_id: null) or scoped to one app.

{
  "data": [
    {
      "id": "rule-uuid",
      "type": "rank_drop",
      "scope_app_id": null,
      "threshold": null,
      "effective_threshold": 5,
      "enabled": true,
      "created_at": "2026-02-12T12:56:14.318Z"
    }
  ]
}

threshold is what you set (null = per-type default); effective_threshold is what actually fires. Manage rules with Set Alert and Delete Alert.


Write Endpoints

These endpoints mutate org-scoped state. All require the Full plan and an API key with the write scope. The Agent plan and credit-only access cover read endpoints only.

The data model is product-centric: a product is the cross-store unit (one iOS version + one Android version, or just one of the two). Tracked keywords and competitors hang off the product's app(s), so a multi-store product never has orphaned rows.

Create Product

POST/api/v1/products

Create a product owned by the calling org. Send 1 app entry for single-store products, or 2 entries (one ios, one android) for cross-store products. Every app is scraped from the store up front — a single store-side 404 aborts before any database writes happen, so partial products are impossible.

If name is omitted the first app's name is used. Country defaults to the first app's country (or us).

ParamTypeDefaultDescription
appsarrayrequired1–2 entries: { store_id, store, country? }. At most one per store.
namestringOptional product name override (1–120 chars)
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "apps": [
      { "store_id": "284882215", "store": "ios", "country": "us" },
      { "store_id": "com.facebook.katana", "store": "android" }
    ]
  }' \
  https://trysonar.app/api/v1/products
{
  "data": {
    "id": "prod-uuid",
    "name": "Facebook",
    "icon_url": "https://...",
    "country": "us",
    "apps": [
      { "id": "app-uuid-ios",     "store": "ios",     "store_id": "284882215",          "name": "Facebook", "developer": "Meta", "category": "Social Networking", "icon_url": "https://..." },
      { "id": "app-uuid-android", "store": "android", "store_id": "com.facebook.katana", "name": "Facebook", "developer": "Meta", "category": "Social",            "icon_url": "https://..." }
    ]
  }
}

Returns 201 on success. 404 if a store_id doesn't exist in its store. 409 if one of the supplied apps is already linked to a different product in your org, or if you tried to pass two apps with the same store.


POST/api/v1/products/:id/apps

Add a second-store version to an existing product. Use this when the product already has, say, the iOS app linked and you want to add the Android version (or vice versa).

ParamTypeDefaultDescription
store_idstringrequiredStore-side ID of the app to link
storestringrequiredios or android
countrystringOptional country override (defaults to the product's country)
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "store_id": "com.facebook.katana", "store": "android" }' \
  https://trysonar.app/api/v1/products/prod-uuid/apps
{
  "data": {
    "product_id": "prod-uuid",
    "app": {
      "id": "app-uuid-android",
      "store": "android",
      "store_id": "com.facebook.katana",
      "name": "Facebook",
      "developer": "Meta",
      "category": "Social",
      "icon_url": "https://..."
    }
  }
}

201 on success. 404 if the product doesn't belong to your org, or the app doesn't exist in its store. 409 if the requested store slot is already filled, or the app is already linked to a different product.


Add Competitor

POST/api/v1/products/:id/competitors

Add a competitor under a product. The product must have an own app linked in the supplied store — that own app becomes the parent of the competitor relationship internally. Existing SERP snapshots for the parent app's keywords are backfilled into the competitor's rank history immediately, so keyword data shows up without waiting for the daily cron.

Idempotent on (orgId, parentAppId, competitorAppId) — re-posting the same competitor is a no-op.

ParamTypeDefaultDescription
store_idstringrequiredStore-side ID of the competitor app
storestringrequiredWhich store version of the product this competitor sits under (ios or android)
countrystringOptional country override (defaults to the product's country)
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "store_id": "835599320", "store": "ios" }' \
  https://trysonar.app/api/v1/products/prod-uuid/competitors
{
  "data": {
    "product_id": "prod-uuid",
    "parent_app_id": "app-uuid-ios",
    "competitor": {
      "id": "comp-app-uuid",
      "store": "ios",
      "store_id": "835599320",
      "name": "TikTok",
      "developer": "TikTok Ltd.",
      "category": "Entertainment",
      "icon_url": "https://..."
    }
  }
}

201 on success. 404 if the product is not yours, has no own app in the requested store, or the competitor app_id doesn't exist in the store.


Track Keywords

POST/api/v1/apps/:id/keywords

Track one or more keywords for one of your tracked apps. Always accepts an array — for a single keyword send { "keywords": ["foo"] }. The store is implied by the app (you don't and can't override it). Country defaults to the parent product's country, then us.

Idempotent on (orgId, appId, keyword) — terms already tracked are returned under already_tracked rather than duplicated. Bulk size is capped at 200 per request.

ParamTypeDefaultDescription
keywordsstring[]required1–200 terms (each 1–120 chars). Trimmed + lowercased + deduped on insert.
countrystringOptional country override (ISO 3166-1 alpha-2)
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "keywords": ["recipe app", "meal planner", "healthy cooking"] }' \
  https://trysonar.app/api/v1/apps/app-uuid-ios/keywords
{
  "data": {
    "added": 2,
    "already_tracked": 1,
    "failed": [],
    "results": [
      { "term": "recipe app",      "status": "created",         "trackedKeywordId": "tk-uuid-1" },
      { "term": "meal planner",    "status": "already_tracked", "trackedKeywordId": "tk-uuid-2" },
      { "term": "healthy cooking", "status": "created",         "trackedKeywordId": "tk-uuid-3" }
    ]
  }
}

201 on success. 404 if the app isn't tracked by your org. Each call fires a background scrape so the new rows have SERP + metrics ready by the next read.


Bulk Untrack Keywords

DELETE/api/v1/apps/:id/keywords

Untrack many keywords for an app in one call — the fast way to undo a POST that landed on the wrong app. Requires an explicit selector, so a bare DELETE is rejected with 400 (no accidental wipes). Pass exactly one of the two query params below; only the per-org tracking rows are removed.

ParamTypeDefaultDescription
allboolean (query)all=true untracks every keyword tracked for this app
idsstring (query)Comma-separated tracked-keyword ids (the id from GET, not keyword_id). Up to 1000; unmatched ids are ignored.
# undo a bulk add that hit the wrong app
curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  "https://trysonar.app/api/v1/apps/app-uuid-ios/keywords?all=true"
{
  "data": { "deleted": 100, "requested": null }
}

requested is the number of ids you asked for (null for all=true), so deleted < requested flags ids that didn't match. 200 on success. 404 if the app isn't tracked by your org.


Update Tracked Keyword

PATCH/api/v1/tracked-keywords/:trackedKeywordId

Update the per-org note and/or starred flag on a tracked keyword. Pass note: null or an empty string to clear the note; starred: true marks the keyword as a favorite/target. Both are stored on the tracked_keywords row and are never shared across orgs. At least one field must be present.

ParamTypeDefaultDescription
notestring | nullNew note (max 1000 chars), or null to clear
starredbooleantrue to star (favorite/target), false to unstar
curl -X PATCH -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Push for Q3 — high intent, low difficulty", "starred": true }' \
  https://trysonar.app/api/v1/tracked-keywords/tk-uuid-1
{
  "data": {
    "id": "tk-uuid-1",
    "keyword_id": "kw-uuid",
    "app_id": "app-uuid-ios",
    "note": "Push for Q3 — high intent, low difficulty",
    "starred_at": "2026-07-10T12:00:00.000Z"
  }
}

200 on success. 404 if the tracked keyword doesn't exist or belongs to another org. 400 if the note exceeds the length cap or neither field is present.


Delete Tracked Keyword

DELETE/api/v1/tracked-keywords/:trackedKeywordId

Untrack a single keyword/app pair — handy for cleaning up after a bulk POST .../keywords that landed on the wrong app. :trackedKeywordId is the tracked-keyword id (the id field from GET /apps/:id/keywords, not keyword_id). Only the per-org tracking row is removed; the shared keyword and its rank history stay intact.

curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/tracked-keywords/tk-uuid-1
{
  "data": {
    "id": "tk-uuid-1",
    "keyword_id": "kw-uuid",
    "app_id": "app-uuid-ios",
    "deleted": true
  }
}

200 on success. 404 if the tracked keyword doesn't exist, was already deleted, or belongs to another org.


Scan Competitor

POST/api/v1/competitors/:id/scan

Discover keywords a competitor ranks for and record their ranks vs. your own app. :id is the competitor's internal app UUID. The same competitor can sit under multiple of your own apps, so the body must specify which own app to scan against.

Heavier than the other write endpoints — fans out to multiple scraper calls internally — but counts as a single request against your daily limit.

ParamTypeDefaultDescription
own_app_idstring (uuid)requiredUUID of your own app the competitor is linked under
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "own_app_id": "app-uuid-ios" }' \
  https://trysonar.app/api/v1/competitors/comp-app-uuid/scan
{
  "data": {
    "competitor_app_id": "comp-app-uuid",
    "own_app_id": "app-uuid-ios",
    "discovered": 47,
    "ranked": 31
  }
}

discovered is the number of net-new keywords recorded; ranked is the number for which a rank was captured. 404 if the (own_app, competitor) link doesn't exist for your org. 400 if :id isn't a UUID.


Analyze Competitors (AI)

POST/api/v1/apps/:id/competitor-landscape

Generate a fresh AI competitive insight for one of your own apps: clusters the keyword gaps vs your competitors into named opportunity themes with per-keyword metrics, writes threat narratives for climbing competitors, and diffs against the previous analysis. Body is an empty JSON object ({}).

curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{}' \
  https://trysonar.app/api/v1/apps/my-app-uuid/competitor-landscape
{
  "data": {
    "app_id": "my-app-uuid",
    "insight": { "generated_at": "2026-07-21T09:30:00.000Z", "posture": "challenger", "...": "..." }
  }
}

insight has the same shape as the GET endpoint's insight field. At most one analysis per app per 7 days 429 cooldown with the next available time while in cooldown (read the current insight via GET). 422 no_competitors if no competitor is tracked, 422 too_little_data with fewer than 5 compared keywords, 502 ai_failed if the analysis fails.


Untrack App

DELETE/api/v1/apps/:id

Untrack an app. Removes the org's tracking link and all org-scoped data (tracked keywords, discovered keywords, competitor edges), detaches it from any owned product, and deletes that product if it's left empty. Shared rows (the app, snapshots, rank history) survive — other orgs may still track it.

curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/apps/app-uuid
{ "data": { "id": "app-uuid", "deleted": true } }

200 on success. 404 if the app isn't tracked by your org.


Delete Product

DELETE/api/v1/products/:id

Delete a product and untrack every app linked to it (cascading the org-scoped data above). Shared rows survive for other orgs.

curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/products/prod-uuid
{ "data": { "id": "prod-uuid", "deleted": true, "untracked_apps": 2 } }

untracked_apps is how many linked apps were untracked. 404 for a foreign/unknown product.


Remove Competitor

DELETE/api/v1/products/:id/competitors/:competitorId

Remove a competitor from a product. :competitorId is the competitor's Sonar app UUID (the id from Competitor Keywords). Drops the competitor edge under every own app of the product. If another own app still references the competitor it stays tracked; otherwise the orphaned competitor app is untracked automatically.

curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/products/prod-uuid/competitors/comp-app-uuid
{
  "data": {
    "product_id": "prod-uuid",
    "competitor_app_id": "comp-app-uuid",
    "deleted": true,
    "edges_removed": 1
  }
}

404 if the product isn't yours or no matching competitor edge exists.


Set Alert

POST/api/v1/alerts

Create or update an alert rule. Upserts on (type, scope_app_id), so re-sending the same type+scope toggles the existing subscription instead of duplicating it. Omit threshold for the per-type default; omit scope_app_id for an org-wide rule.

ParamTypeDefaultDescription
typestringrequiredrank_drop | rank_gain | entered_top10 | left_top10 | new_ranking | rating_drop | review_spike | competitor_change
scope_app_idstring | nullApp UUID to scope to; omit for all tracked apps
thresholdinteger | nullOmit for the per-type default
enabledbooleantrueSet false to keep the rule but stop firing
curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "type": "rank_drop", "threshold": 10 }' \
  https://trysonar.app/api/v1/alerts
{
  "data": {
    "id": "rule-uuid",
    "type": "rank_drop",
    "scope_app_id": null,
    "threshold": 10,
    "effective_threshold": 10,
    "enabled": true,
    "created_at": "2026-02-12T12:56:14.318Z"
  }
}

201 on success. 400 if scope_app_id isn't an app you track, or the type is unknown.


Delete Alert

DELETE/api/v1/alerts/:id

Delete an alert rule (unsubscribe). :id is the rule id from List Alerts.

curl -X DELETE -H "Authorization: Bearer aso_xxx" \
  https://trysonar.app/api/v1/alerts/rule-uuid
{ "data": { "id": "rule-uuid", "deleted": true } }

200 on success. 404 for a foreign/unknown id.