Sonar 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 Indie (1,000 included requests per day) or Agency (5,000 per day) for tracking, history, and competitor analytics.

A machine-readable OpenAPI 3.1 spec covers every endpoint below — point your codegen, API client, or AI agent at it.

At a glance

REST API
https://trysonar.app/api/v1 — Bearer aso_… API key or an OAuth access token. OpenAPI 3.1 spec: https://trysonar.app/openapi.json
Free tier
No account or key: 7 stateless endpoints (app lookup, app search, ASO score, keyword extraction, keyword suggestions, keyword metrics and top charts). 30 requests/day per IP shared, and keyword metrics allows 5 keywords/day per IP.
API credits
50 free credits on signup. Prepaid packs, no subscription, credits never expire: $10 / 1,000 credits · $20 / 2,500 credits · $50 / 7,000 credits · $100 / 15,000 credits.
Credit costs
1 credit per call for app lookup, app search, keyword suggestions, keyword extraction, ASO score, reviews and top charts · 10 credits per call for keyword research · 1 credit per app for revenue estimates · 1 credit per keyword for keyword metrics. Failed calls are free.
Indie plan
$29/month or $290/year · 10 apps · 2,500 tracked keywords · competitors uncapped · 1 seat · 1,000 API requests/day
Agency plan
$149/month or $1,490/year · 50 apps · 10,000 tracked keywords · competitors uncapped · 5 seats · 5,000 API requests/day · portfolio rollup, white-label client reports, App Store Connect data via the API
Free trial
7 days on Indie or Agency, card required, $0 today, cancel anytime. The API allows 150 requests/day during the trial. Credit packs start at $10 with no trial needed.
Stores and countries
App Store (175 storefronts) and Google Play (145 markets) for app lookup, search, reviews and ASO scores. Keyword research and rank tracking cover 72 markets.
Data freshness
Tracked keywords are re-ranked once per UTC day; iOS ranks follow Apple's own App Store search order. iOS popularity is Apple's Search Popularity where Apple reports it, with a calibrated estimate below Apple's floor.

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. OAuth 2.0 access tokens (from the MCP browser sign-in flow) are accepted as a second bearer type — see the dedicated authentication guide for the OAuth flow, discovery metadata, and the anonymous free tier.

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/lookup, /apps/search, /apps/aso-score, /apps/extract-keywords, /keywords/suggestions, and /charts/top 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)Indie, Agency or credits
writePOST / PATCH on org-scoped resources (products, tracked apps, tracked keywords, competitor scans)Indie or Agency

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 (reason: daily_limit, resets 00:00 UTC), or a credit-only account is out of credits (no_credits). Credits are managed at /settings/credits.
403forbiddenValid key but the endpoint requires the Indie or Agency plan (reason: plan_required + required_plan), or the key lacks the write scope (scope_required)
403plan_limitYour plan's app or keyword cap is reached — the message names the limit. Upgrade at /settings/plan (bulk keyword adds instead degrade to partial success with per-term errors)
400bad_requestMissing or invalid query parameters, or a country the endpoint doesn't serve (reason: unsupported_market, see Countries)
404not_foundResource not found or not tracked by your org
409bad_requestConflict — e.g. app already linked to another product, store slot already filled
409conflictReview responder: the reply is being posted right now, or isn't in a state that allows the action (e.g. cancelling a reply that isn't scheduled)
429rate_limitedKeyless per-IP daily allowance used up, too many concurrent lookups, or the app store is throttling and nothing is stored for this request — see reason and retry_after_seconds (mirrors Retry-After)
503unavailableApple/Google store data is temporarily throttling or unreachable. Retryable — wait for Retry-After and retry. On /charts/top, reason: refresh_in_progress means the chart is still being fetched. Bulk endpoints report this per-item inside a 200 response
500internal_errorServer error
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid Authorization header. Use: Bearer aso_xxx"
  }
}

Errors may also carry optional structured fields, so a client can tell a daily allowance from an empty credit balance or a passing store throttle without parsing the message: reason (stable sub-code), retry_after_seconds, reset_at (next 00:00 UTC for daily allowances), limit + limit_unit, required_plan (indie or agency), required_scope, credits_balance and credits_cost. Branch on reason when present.

ReasonWhat to do
daily_limit402 — the plan's included daily quota is used up and credits can't cover the overflow. Wait for reset_at (00:00 UTC)
no_credits402 — a credit-only account's balance is below this call's cost
anonymous_daily_limit429 — the keyless per-IP allowance is used up (resets 00:00 UTC); an API key lifts it
batch_too_large429 — a keyless call asks for more keywords than the daily allowance. The only case where a smaller batch helps
store_rate_limited429 — the app store is throttling or the scrape queue is saturated. Retry after retry_after_seconds
concurrency_limit429 — too many lookups in flight for this account (~8). Send fewer requests in parallel
store_unavailable503 — the store failed or timed out. Retry in ~30s
refresh_in_progress503 — /charts/top is fetching this chart for the first time. Retry in ~20s
plan_required403 — the endpoint needs the plan in required_plan
scope_required403 — the key lacks required_scope (create a write-scope key)
payment_past_due403 — writes are paused after the 3-day past-due grace; reads keep working
no_workspace403 — the OAuth user owns no Sonar workspace yet (non-free endpoints; free ones serve them on the anonymous tier)
unsupported_market400 — the country isn't served by this endpoint for that store
daily_scan_limit429 — competitor scans only: the account has scanned its daily allowance of different competitors (50 on trial and Indie, 250 on Agency). Re-scanning one already scanned today stays free; wait for reset_at (00:00 UTC) for new ones

Countries

country is an ISO 3166-1 alpha-2 code (default us). App lookup, search, reviews, ASO score, keyword extraction and revenue accept every App Store storefront (175) and Google Play market (145). /charts/top and /keywords/suggestions accept every Play market and every App Store storefront except 58 small ones Apple can't be addressed for by storefront id. /keywords/metrics and /keywords/search cover Sonar's 72 keyword-research markets. Anything else returns 400 with reason: "unsupported_market".

Time budgets

/keywords/metrics, /keywords/search and /charts/top accept an optional X-Sonar-Time-Budget-Ms header — the total time you'll wait. The server stops starting store work before it ends and answers with what it has: cached or stale rows, pending keywords (queued on the scrape fleet, not billed), or the newest stored chart with stale: true. Without it the server default (~40s) applies; the header can only shorten a request.

Data freshness

Tracked keywords are re-measured once per UTC day on a rolling schedule, so the current day fills in gradually. For reports, read day D after 00:00 UTC on D+1: a finished day is complete and doesn't change. Same-day data is usually complete by ~12:00 UTC on Google Play and ~17:00 UTC on the App Store, but that isn't guaranteed. Visibility history in the app overview weights every day by each keyword's current popularity, so a popularity re-measurement shifts past days too; rank history never changes.

Stored copies when the store is throttling

App lookup, app search, reviews and keyword suggestions fetch live from the store. When the store is throttling, they answer from Sonar's stored copy instead of failing: a normal 200 with the usual data plus a meta object. Live answers carry no meta.

{
  "data": [ ... ],
  "meta": {
    "stale": true,
    "stale_as_of": "2026-09-30",
    "partial": true,
    "retry_after_seconds": 120,
    "note": "App Store search is throttled right now — this is Sonar's stored top 10 ..."
  }
}

stale_as_of dates the copy. partial marks a copy that holds less than a live call: stored search results stop at the top 10 (name, developer and icon only), and stored suggestions are a subset ranked by popularity. App lookup adds stale_country when the copy came from another storefront. A stored copy is billed like any other successful call. Only when nothing is stored does the request fail with a retryable 429 or 503.

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
Indie plan$29/mo or $290/yr. 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.1,000 req/day (150 on trial), then credits
Agency plan$149/mo or $1,490/yr. Everything in Indie, plus the portfolio rollup and App Store Connect sales and engagement endpoints. 5,000 requests/day included; overflow into credit balance when exhausted.5,000 req/day (150 on trial), then credits

The 7-day free trial on either plan is capped at 150 requests/day to discourage bulk extraction before subscribing.

Failed requests are free. Only successful responses (status below 400) count against the daily limit — validation errors, scraper-busy 429s, and server errors don't consume quota, and any credits charged for a failed call are refunded automatically. Neither does a 202: keyword endpoints return it only when nothing was served yet and every keyword was queued as pending — those keywords aren't billed, and the follow-up request that reads the result is the one that counts. When you get a 429 with a Retry-After header, waiting and retrying costs you nothing.

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, /charts/top
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 Indie and Agency plans, so credits only apply once the included daily quota is exhausted — overage on write traffic is rare in practice.

Response headers

HeaderDescription
X-RateLimit-LimitSubscriptions: total daily limit (1,000 Indie, 5,000 Agency, 150 on trial)
X-RateLimit-RemainingSubscriptions: requests remaining today
X-RateLimit-ResetSubscriptions: Unix timestamp when the limit resets
X-RateLimit-Overagetrue when a subscription call burned credits instead of the included quota — signal for agents to slow down
X-Credits-CostCredits deducted by this call (credit mode + subscription 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.

Errors

Every error is a JSON envelope with a machine-readable code — including 404s for unknown routes, so you never have to parse HTML:

{ "error": { "code": "not_found", "message": "..." } }

Rate-limit headers

Responses on metered tiers carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds, reset at midnight UTC), plus the IETF-style RateLimit-* aliases with the same values. 429 responses add Retry-After.


Versioning & Deprecation

The API is URL-versioned: every endpoint lives under /api/v1. Within a version, changes are additive only — new fields and endpoints may appear, but existing response shapes, parameters, and status codes will not change or disappear. Breaking changes only ever ship under a new version path.

If an endpoint is ever deprecated, it keeps working for at least 6 months: the timeline is announced in the changelog and signaled on responses with Deprecation and Sunset headers, so both humans and agents get advance warning. No endpoint has been deprecated to date.


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
cursorstring—Pagination 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_source": null,
      "popularity_apple": null,
      "popularity_proxy": null,
      "results_count": 10
    }
  ],
  "pagination": { "next_cursor": "next-uuid", "has_more": true }
}

popularity is null until Sonar has measured the keyword. The popularity fields carry the same values as the dashboard — see Popularity fields.


App Rankings

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

Rank history for an app's tracked keywords. The legacy history array contains numeric ranks only. Use observations for daily outcomes: ranked includes a rank;not_found means a completed search did not return the app;not_observed means no confirmed app check was saved, including older gaps. Missing ranks are null, never an invented position. results_count is the returned search size.

ParamTypeDefaultDescription
daysinteger30History window (1–365)
keyword_idstring—Filter 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
typestring—Filter: release, metadata, screenshots, price, category, header (iOS product page header added/changed/removed), or in_app_event (iOS In-App Event newly listed)
limitinteger50Max entries (1–200)
curl -H "Authorization: Bearer aso_xxx" \
  "https://api.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
countrystringusAny App Store storefront or Google Play market (ISO 3166-1 alpha-2) — see Countries
sortstringrecentrecent or helpful
min_ratinginteger—Only reviews with score >= min_rating (1–5)
max_ratinginteger—Only reviews with score <= max_rating (1–5)
limitinteger—Cap returned reviews (1–200, applied after rating filter)
curl -H "Authorization: Bearer aso_xxx" \
  "https://api.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.

When the store is throttling, Sonar's stored reviews for that storefront are served with meta.stale (see stored copies). Filters and limit still apply. Single-language Android requests (lang) have no stored fallback.


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
idstring—Single store ID. Pass id OR ids, not both
idsstring—Comma-separated store IDs (max 25 per call) for bulk lookup
countrystringusAny App Store storefront or Google Play market (ISO 3166-1 alpha-2) — see Countries

Single app.

curl -H "Authorization: Bearer aso_xxx" \
  "https://api.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://api.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
countrystringusOne of the 72 keyword-research markets — see Countries
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_source": null, "popularity_apple": null, "popularity_proxy": null, "results_count": 10,
      "measured_at": "2026-10-06T09:14:03.512Z", "popularity_measured_at": null, "stale": false },
    { "keyword": "free recipe app", "store": "ios", "country": "us", "difficulty": 70, "popularity": 41, "popularity_source": "apple", "popularity_apple": 41, "popularity_proxy": null, "results_count": 10,
      "measured_at": "2026-10-04T18:40:11.090Z", "popularity_measured_at": "2026-08-12T06:02:55.000Z", "stale": true }
  ]
}

Returns up to 10 suggestions. difficulty is 0–100 based on top-10 competitor strength. popularity is the value the dashboard shows — see Popularity fields. Every row carries measured_at, popularity_measured_at and stale \u2014 see Keyword freshness. A row that couldn't be computed in time carries error: "pending" and retry_after_seconds (queued, not billed; a 202 when every row is pending). Accepts X-Sonar-Time-Budget-Ms (see Time budgets).


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
qstring—Single keyword (use this OR qs, not both)
qsstring—Comma-separated bulk keywords (up to 25, use this OR q)
storestringrequiredios or android
countrystringusOne of the 72 keyword-research markets — see Countries
# Single
curl -H "Authorization: Bearer aso_xxx" \
  "https://api.trysonar.app/api/v1/keywords/metrics?store=ios&q=habit+tracker"

# Bulk (1 credit per keyword)
curl -H "Authorization: Bearer aso_xxx" \
  "https://api.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_source": "apple",
    "popularity_apple": 48,
    "popularity_proxy": null,
    "est_downloads_at_1": 320,
    "difficulty_breakdown": {
      "titleMatches": 6,
      "appsAnalyzed": 10,
      "top3Strength": [412000, 380000, 95000],
      "medianStrength": 120000,
      "weakSpotRank": null,
      "beatable": false
    },
    "results_count": 10,
    "measured_at": "2026-10-05T22:31:07.204Z",
    "popularity_measured_at": "2026-10-03T04:10:42.000Z",
    "stale": false
  }
}
{
  "data": [
    { "keyword": "habit tracker", "store": "ios", "country": "us", "difficulty": 62, "popularity": 48, "popularity_source": "apple", "popularity_apple": 48, "popularity_proxy": null, "results_count": 10,
      "measured_at": "2026-10-05T22:31:07.204Z", "popularity_measured_at": "2026-10-03T04:10:42.000Z", "stale": false },
    { "keyword": "meditation",    "store": "ios", "country": "us", "difficulty": 30, "popularity": 14, "popularity_source": "proxy", "popularity_apple": 5, "popularity_proxy": 14, "results_count": 10,
      "measured_at": "2026-09-20T11:02:40.771Z", "popularity_measured_at": "2026-09-20T11:02:41.000Z", "stale": true },
    { "keyword": "workout",       "store": "ios", "country": "us", "difficulty": 0,  "popularity": null, "popularity_source": null, "popularity_apple": null, "popularity_proxy": null, "results_count": 0,
      "measured_at": null, "popularity_measured_at": null, "stale": false,
      "error": { "code": "unavailable", "message": "Store data is temporarily unavailable — retry shortly." } }
  ]
}

Bulk responses use an array; per-keyword failures are isolated in anerror field so one bad term doesn't fail the whole batch. Results are cached (see below) — repeated calls for the same keyword in the same country are free upstream but still cost the listed credits.

Popularity fields

Every keyword row, on every endpoint that returns popularity, carries the same four fields, resolved by the same code that serves the dashboard — so the API and the dashboard always show the same number:

  • popularity (5–100) — the value Sonar shows. iOS: Apple's Search Popularity when Apple has a real reading. Apple reports everything below its lowest real reading (about 20 since Oct 22, 2026) as 5; for those keywords it is Sonar's estimate, at most 18. Android: Sonar's estimate.
  • popularity_source — "apple" (Apple's own reading, not an estimate) or "proxy" (Sonar's estimate). "apple_suggestions" was returned Oct 20–23, 2026 and is no longer used. null when there is no popularity value.
  • popularity_apple — Apple's own reading for an iOS keyword; 5 means below Apple's threshold. null on Android and when Sonar hasn't asked Apple about the keyword.
  • popularity_proxy — Sonar's estimate beside a floored Apple reading (at most 37), else null.

Changed in October 2026: for a keyword Apple reports as 5, popularity used to be 5 with the disambiguating value in popularity_proxy. It now carries the value the dashboard shows; read popularity_apple for Apple's own reading.

Keyword freshness

Every keyword row from /keywords/metrics and /keywords/search carries two ISO 8601 UTC timestamps. measured_at is when the store search behind difficulty and results count was read (the compute time for a keyword computed during your request). popularity_measured_at is when the returned popularity value itself was measured, and can be older: for real Apple Search Popularity it's when Sonar last fetched it from Apple; for Android values checked against Play autocomplete it's the start of the UTC day they were checked (a lower bound, never later than the real time). It's null when popularity is null, and both are null on pending and error rows.

stale is false only when both timestamps are under 7 days old. Rows measured 7\u201330 days ago are served with stale: true and re-measured in the background; older ones are re-measured during the request. An iOS Apple popularity older than 7 days is re-fetched from Apple during the request; if Apple doesn't answer in time, the stored value is served with its original popularity_measured_at and stale: true. Android popularity older than 7 days is served with stale: true and re-checked in the background. When the store can't be reached, a stored measurement of any age is returned instead of an error, always with stale: true. To enforce a maximum age, compare both timestamps rather than relying on stale.

A keyword that can't be computed inside the request comes back as pending with retry_after_seconds: it's queued on the scrape fleet, isn't billed, and the next request after the hint is a cache hit. If every keyword is pending the status is 202, which doesn't count against the daily quota. Send X-Sonar-Time-Budget-Ms to cap how long the request may take (see Time budgets).

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
countrystringusAny Play market or App Store storefront except 58 small ones — see Countries
{
  "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.

When the store is throttling, seeds of 3+ characters get up to 10 stored terms that start with the seed and that Sonar has seen in the store's autocomplete in the last 90 days. They are ranked by popularity, not the store's live order, and flagged meta.stale + meta.partial.


Top Charts

GET/api/v1/charts/top

Store top chart (free, paid or grossing) with day-over-day movement. Market-wide — no tracked apps required. The first request for a store/country/chart/category combo on a given day fetches it live and snapshots it; later requests that day are served from that snapshot.

ParamTypeDefaultDescription
storestringrequiredios or android
countrystringusAny Play market or App Store storefront except 58 small ones — see Countries
chartstringfreefree, paid or grossing
categorystringoverallCategory key (e.g. HEALTH_AND_FITNESS) or overall
limitinteger50Entries to return (1–100)
{
  "data": {
    "store": "ios",
    "country": "us",
    "chart": "free",
    "category": "overall",
    "measuredAt": "2026-07-26",
    "previousMeasuredAt": "2026-07-25",
    "stale": false,
    "summary": { "total": 100, "newToday": 3, "dropped": 3 },
    "entries": [
      {
        "rank": 1,
        "storeId": "com.example.app",
        "numericId": 1234567890,
        "name": "Example",
        "iconUrl": "https://.../icon.png",
        "developer": "Example Inc.",
        "rating": 4.7,
        "delta": 2,
        "isNew": false
      }
    ],
    "movers": [
      { "storeId": "com.example.riser", "name": "Riser", "iconUrl": null, "rank": 14, "delta": 21 }
    ],
    "droppedApps": [
      { "storeId": "com.example.gone", "name": "Gone", "iconUrl": null, "previousRank": 97 }
    ]
  }
}

delta is previousRank - rank (positive = moved up), and null for an app new to the chart or when there is no previous snapshot yet. summary, movers and droppedApps always describe the whole chart (top 200) — limit truncates entries only. stale: true means an older snapshot (≤7 days) was served — the live fetch failed, or didn't finish before the request deadline (~40s, or 2s before your X-Sonar-Time-Budget-Ms); the fetch then finishes in the background, so the next request gets today's chart. When nothing recent is stored the endpoint returns 503: refresh_in_progress (retry in ~20s) while a first fetch is still running, or store_unavailable when it failed.


Competitor Keywords

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

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

ParamTypeDefaultDescription
app_idstring—Your app ID for gap analysis
cursorstring—Pagination 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,
      "popularity_source": null,
      "popularity_apple": null,
      "popularity_proxy": null
    },
    {
      "keyword_id": "kw-uuid-2",
      "keyword": "cooking videos",
      "store": "ios",
      "country": "us",
      "competitor_rank": 5,
      "own_rank": null,
      "gap": "missing",
      "difficulty": 45,
      "popularity": 14,
      "popularity_source": "proxy",
      "popularity_apple": 5,
      "popularity_proxy": 14
    }
  ],
  "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://api.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,
        "popularity_source": "apple",
        "popularity_apple": 38,
        "popularity_proxy": null,
        "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.


Discovered Keywords

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

Keywords Sonar's discovery engine surfaced for one of your apps but that aren't tracked yet — the "what should I track next" answer, sorted by opportunity score (best first). Each row carries a bucket: ranked (the app already ranks for it), gap (a competitor ranks, you don't), or idea (verified research suggestion with no rank evidence yet). Track the winners with Track Keywords.

curl -H "Authorization: Bearer aso_xxx" \
  "https://api.trysonar.app/api/v1/apps/app-uuid/discovered-keywords?bucket=gap&min_relevance=60"
{
  "data": {
    "app_id": "app-uuid",
    "total": 132,
    "keywords": [
      {
        "id": "discovered-uuid",
        "keyword_id": "kw-uuid",
        "keyword": "meal planner",
        "store": "ios",
        "country": "us",
        "rank": null,
        "source": "competitor",
        "status": "new",
        "bucket": "gap",
        "popularity": 42,
        "popularity_source": "apple",
        "popularity_apple": 42,
        "popularity_proxy": null,
        "difficulty": 35,
        "ai_relevance": 85,
        "opportunity": 21,
        "discovered_at": "2026-08-12T09:14:00.000Z",
        "ranks_checked_at": "2026-08-18T04:20:00.000Z"
      }
    ]
  }
}

Filters: country, source (autocomplete, metadata, serp_scan, competitor, apple_ads, ai), status (new default / tracked / hidden / all), bucket, min_relevance, min_opportunity, limit (default 200, max 500). total is the filtered count before limit.


App Overview

GET/api/v1/apps/:id/overview

The dashboard's computed scoreboard for one of your own apps, in one call — the same numbers the dashboard renders, computed by the same code: visibility index + share of voice with 7-day deltas and a daily spark, ranked / top-10 keyword counts with movement, best rank, top improvements and drops, the rank-distribution trend, and the actionable opportunity list. days sets the rank-history window (7-90, default 30).

{
  "data": {
    "app_id": "app-uuid",
    "app_name": "Example App",
    "store": "ios",
    "days": 30,
    "keywords": { "tracked": 42, "ranked": 30, "ranked_delta_7d": 3, "top_10": 9, "best_rank": { "rank": 1, "keyword": "meal planner" } },
    "competitors": 4,
    "visibility": { "score": 120, "delta_7d": 8, "share_of_voice": 0.31, "share_delta_7d": 0.02, "branded_excluded": 2, "spark": [] },
    "movement": {
      "improved_7d": 12,
      "dropped_7d": 5,
      "top_improvements": [{ "keyword": "meal planner", "country": "us", "rank": 8, "change_7d": 4 }],
      "top_drops": []
    },
    "rank_distribution": [{ "date": "2026-08-18", "total": 30, "top3": 4, "top10": 5, "top30": 10, "top100": 8, "deep": 3 }],
    "opportunities": [{ "keyword": "meal prep", "country": "us", "kind": "near_page_one", "rank": 14, "popularity": 50, "popularity_source": "apple", "popularity_apple": 50, "popularity_proxy": null, "difficulty": 33 }]
  }
}

share_of_voice is computed over generic keywords only — own-brand terms are excluded (branded_excluded reports how many). Opportunity kind is near_page_one (ranked 11-30), top_three_push (ranked 4-10), or easy_target (low difficulty, not ranking well).


Portfolio (Agency)

GET/api/v1/portfolio

Agency plan only. The portfolio rollup across every own app — the same numbers as the /portfolio page: per-app KPIs (visibility + matched-cohort 7-day delta, ranked/top-10 counts, net rank movement, rating), org-wide totals, the biggest keyword movers in both directions, a needs-attention triage list (visibility_drop / rating_drop / not_ranked signals), and the best discovered-keyword opportunities across apps. Returns 403 forbidden on non-Agency plans.

curl -H "Authorization: Bearer aso_xxx" \
  https://api.trysonar.app/api/v1/portfolio

App Sales (Agency)

GET/api/v1/apps/:id/sales

Agency plan only. App Store Connect sales for one of your own iOS apps — the same numbers as the Performance page: daily first-time downloads, redownloads, in-app purchase units and approximate USD proceeds, window totals, and a per-country breakdown. Requires an App Store Connect connection (Settings → Connections). Window: start / end (YYYY-MM-DD, inclusive; end defaults to yesterday UTC) or days (1-366, default 30). :id is the Sonar app ID or the app's store id (add store=ios if it's tracked in both stores).

curl -H "Authorization: Bearer aso_xxx"   "https://api.trysonar.app/api/v1/apps/APP_ID/sales?start=2026-09-01&end=2026-09-30"
{
  "data": {
    "app_id": "app-uuid",
    "app_name": "Example App",
    "store": "ios",
    "status": "ready",
    "connected": true,
    "message": null,
    "apple_app_id": "1234567890",
    "last_synced_at": "2026-09-30T14:55:12.000Z",
    "range": { "start": "2026-09-01", "end": "2026-09-30" },
    "reported_days": 29,
    "totals": { "downloads": 4210, "redownloads": 380, "iap_units": 96, "proceeds_usd_approx": 812.4 },
    "days": [
      { "date": "2026-09-01", "reported": true, "downloads": 140, "redownloads": 12, "iap_units": 3, "proceeds_usd_approx": 26.9 },
      { "date": "2026-09-30", "reported": false, "downloads": null, "redownloads": null, "iap_units": null, "proceeds_usd_approx": null }
    ],
    "countries": [
      { "country": "us", "downloads": 2100, "redownloads": 190, "iap_units": 61, "proceeds_usd_approx": 540.1 }
    ]
  }
}

proceeds_usd_approx is converted with a static FX table, so treat it as approximate. Days Apple hasn't reported yet have reported: false and null metrics. Missing data is explained by status (with a human-readable message) instead of an empty series: not_connected, not_ios, app_not_in_account, and for engagement also pending (Apple delivers the first analytics 24-48 hours after connecting) and key_lacks_analytics (the key needs the Admin role). Returns 403 forbidden on non-Agency plans.


App Engagement (Agency)

GET/api/v1/apps/:id/engagement

Agency plan only. App Store Connect App Analytics for one of your own iOS apps — the same numbers as the Engagement page: the impressions → product page views → downloads funnel with conversion rates and App Store search share, impressions by source (search, browse, app referrer, web referrer), and installs, deletions and sessions by day. Same window params and status values as App Sales. Impressions follow App Store Connect's definition and include product page views, so totals line up with App Store Connect. Downloads come from the Sales report.

{
  "data": {
    "app_id": "app-uuid",
    "app_name": "Example App",
    "store": "ios",
    "status": "ready",
    "connected": true,
    "message": null,
    "apple_app_id": "1234567890",
    "last_synced_at": "2026-09-30T14:55:12.000Z",
    "range": { "start": "2026-09-01", "end": "2026-09-30" },
    "reported_days": 28,
    "totals": { "impressions": 182000, "product_page_views": 21400, "downloads": 4590, "installs": 4300, "deletions": 610, "sessions": 39800 },
    "rates": { "page_view_rate": 0.1176, "download_rate": 0.2145, "search_share": 0.6421 },
    "sources": [
      { "source_type": "App Store Search", "impressions": 116860, "product_page_views": 12100, "share_of_impressions": 0.6421 }
    ],
    "days": [
      { "date": "2026-09-01", "reported": true, "impressions": 6100, "product_page_views": 720, "installs": 140, "deletions": 20, "sessions": 1300, "downloads": 152 }
    ]
  }
}

page_view_rate = page views ÷ impressions, download_rate = downloads ÷ page views, search_share = App Store search impressions ÷ all impressions (fractions; null when the base is 0).


Review Insights

GET/api/v1/apps/:id/review-insights

The latest AI review analysis for a tracked app — your own or a competitor: praise/complaint themes with verbatim quotes and trend movement, overall sentiment, and feature requests. Insights are per (app, country); country defaults to us.

{
  "data": {
    "app_id": "app-uuid",
    "country": "us",
    "insight": {
      "generated_at": "2026-08-10T12:00:00.000Z",
      "reviews_analyzed": 214,
      "avg_score": 4.3,
      "overview": "Users love the simplicity but sync complaints are growing.",
      "sentiment": "positive",
      "praises": [
        {
          "theme": "Ease of use",
          "detail": "Onboarding and daily logging are repeatedly called effortless.",
          "frequency": "very common",
          "quotes": ["So easy my kids use it"],
          "trend": "persisting"
        }
      ],
      "complaints": [],
      "feature_requests": ["home-screen widgets"],
      "changes_since_last": null
    },
    "cooldown": { "in_cooldown": true, "next_available_at": "2026-11-08T12:00:00.000Z" }
  }
}

insight is null until one is generated (see Generate Review Insights). Theme trend is movement vs the previous analysis: new, persisting, growing, improving, or resolved.


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,
      "countries": null,
      "cadence": null,
      "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. cadence is a new_review rule's email timing (null = daily digest). Manage rules with Set Alert and Delete Alert.


Alert Events

GET/api/v1/alerts/events

Your detected alert events, newest first — the same feed as the in-app "Recent alerts" panel and the email digest. Events only exist for alert types you have enabled rules for; detection runs once daily, except new_review events, which arrive within minutes of a review syncing. Poll with since (your last poll time) to react to changes programmatically.

curl -H "Authorization: Bearer aso_xxx" \
  "https://api.trysonar.app/api/v1/alerts/events?type=rank_drop&since=2026-08-01T00:00:00Z"
{
  "data": [
    {
      "id": "event-uuid",
      "type": "rank_drop",
      "app_id": "app-uuid",
      "keyword_id": "kw-uuid",
      "measured_at": "2026-08-18",
      "payload": { "appName": "Example App", "keyword": "meal planner", "country": "us", "from": 4, "to": 11 },
      "emailed_at": "2026-08-18T08:31:02.000Z",
      "created_at": "2026-08-18T08:30:12.000Z"
    }
  ]
}

Filters: type, app_id (UUID or store id), since (ISO timestamp), limit (default 50, max 200). payload is type-specific; emailed_at is null when the event hasn't gone out in a digest.


Write Endpoints

These endpoints mutate org-scoped state. All require an Indie or Agency plan and an API key with the write scope. API Credits access covers the stateless 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.
namestring—Optional 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://api.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
countrystring—Optional 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://api.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)
countrystring—Optional 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://api.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.
countrystring—Optional 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://api.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://api.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 | null—New note (max 1000 chars), or null to clear
starredboolean—true 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://api.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://api.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 — an AI generation plus store searches for every candidate term — but counts as a single request against your daily limit. The first batch verifies inline (~30s); the rest verify in the background over the following hours.

Each account can scan a limited number of different competitors per UTC day: 50 on trial and Indie, 250 on Agency. Re-scanning a competitor already scanned today is free, and the background scan that runs when you add a competitor counts toward the same allowance, so adding a competitor and scanning it counts once. Adding competitors is never limited — past the allowance an add still succeeds, and its background pass only records the keywords the competitor already ranks for in Sonar's data.

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://api.trysonar.app/api/v1/competitors/comp-app-uuid/scan
{
  "data": {
    "competitor_app_id": "comp-app-uuid",
    "own_app_id": "app-uuid-ios",
    "generated": 120,
    "queued": 95,
    "verified_now": 24
  }
}

generated is the number of candidate terms from the competitor's listing, queued the candidates handed to background verification, and verified_now those verified before the response returned — read results with GET /api/v1/competitors/:id/keywords. 404 if the (own_app, competitor) link doesn't exist for your org.

Past the daily allowance the scan answers 429 with reason: "daily_scan_limit", limit, reset_at (next 00:00 UTC) and retry_after_seconds. The refused call doesn't count against your request quota.


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://api.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). 403 trial_locked on trial plans (AI-cost feature, paid plans only; the GET stays available), 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.


Generate Review Insights (AI)

POST/api/v1/apps/:id/review-insights

Generate a fresh AI review analysis for a tracked app (your own or a competitor) from its recent reviews. country defaults to us (query param); body is an empty JSON object. Returns the same shape as Review Insights.

curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{}' \
  "https://api.trysonar.app/api/v1/apps/app-uuid/review-insights?country=us"

At most one analysis per app+country every 90 days — 429 cooldown with the next available time. 403 trial_locked on trial plans (AI-cost feature, paid plans only; reading via GET stays available), 422 too_few_reviews with fewer than 5 recent reviews, 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://api.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://api.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://api.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 | top_chart | new_review
scope_app_idstring | null—App UUID to scope to; omit for all tracked apps
thresholdinteger | null—Omit for the per-type default. top_chart: rank cutoff (1-200, default 200). new_review: highest star rating that alerts (1-5, default 5)
countriesstring[] | null—top_chart only: storefronts to watch (max 10). Omit to keep the stored list; null or [] = tracked-keyword countries
cadencestring | null—new_review only: instant (within ~5-10 minutes) | hourly (at most one email an hour) | daily (morning digest). Omit to keep the stored value; daily on create
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://api.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://api.trysonar.app/api/v1/alerts/rule-uuid
{ "data": { "id": "rule-uuid", "deleted": true } }

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


Review Responder

Reply to your own iOS apps' App Store reviews. Sonar pulls reviews from App Store Connect every 5 minutes with the key connected at /settings/connections, drafts an AI reply per new review (in the reviewer's language, with English copies of non-English reviews and drafts) and posts the replies you approve — or, in auto mode, posts AI replies after a delay. Indie or Agency plan (a trial counts); mutations need a write-scope key. Posting needs an Admin-role App Store Connect key; a key that can't post returns 403 forbidden.

Metering: AI drafts count against a monthly allowance (trial 50, Indie 300, Agency 3,000), then cost 1 credit each from your prepaid balance — with none left, new reviews are held for a person and regenerate returns 402 insufficient_credits. Posting and replies you write yourself are never metered.

Reply statuses: backlog (existed before the responder was switched on — the first sync stores the latest 200), pending, draft, held (see hold_reason: needs_human, quota, daily_cap), scheduled, posting, pending_publish (Apple takes up to 24 hours), published, failed, dismissed, answered (someone replied in App Store Connect — Sonar never overwrites it). The inbox status filter groups them into needs_reply, scheduled, replied and dismissed.

Responder Settings

GET/api/v1/apps/:id/review-responder
PATCH/api/v1/apps/:id/review-responder

GET returns asc_connected, settings (null until switched on) and this month's usage. PATCH is a partial update (unknown fields → 400); an empty body switches the responder on, and the first switch-on starts the backfill. Auto mode only answers reviews written after it was switched on, inside the star range and up to the daily cap; anything the AI flags as needing a person is held.

ParamTypeDefaultDescription
enabledboolean—Switch the responder on or off
modestring—manual (every reply waits for approval) | auto
auto_delay_minutesinteger5Auto mode: minutes before posting (0-1440)
auto_min_ratinginteger4Auto mode: lowest star rating to auto-reply to
auto_max_ratinginteger5Auto mode: highest star rating to auto-reply to
auto_daily_capinteger50Auto mode: max auto replies per app per day (1-1000)
instructionsstring—Guidance for the AI — tone, support contact, product facts (≤2,000 chars)
sign_offstring | null—Appended to every reply (≤120 chars); null clears it
reply_languagestringreviewreview (the reviewer's language) | en (always English)
{
  "data": {
    "app_id": "app-uuid",
    "asc_connected": true,
    "settings": {
      "enabled": true,
      "mode": "auto",
      "auto_delay_minutes": 5,
      "auto_min_rating": 4,
      "auto_max_rating": 5,
      "auto_daily_cap": 50,
      "auto_since": "2026-10-08T09:12:00.000Z",
      "instructions": "Friendly and short. Support: [email protected].",
      "sign_off": "— The Example team",
      "reply_language": "review",
      "last_synced_at": "2026-10-08T10:05:02.000Z",
      "last_error": null
    },
    "usage": {
      "month": "2026-10-01",
      "ai_drafts_used": 37,
      "ai_drafts_included": 300,
      "ai_drafts_paid_with_credits": 0,
      "credits_per_extra_draft": 1
    }
  }
}

Review Inbox

GET/api/v1/apps/:id/review-replies
GET/api/v1/review-replies/:id

The app's reviews with their reply state, newest first, plus per-group counts and a next_cursor. Filters: status, rating (1-5), country (alpha-2), language (ISO 639-1), limit (1-100, default 25), cursor. The single-review route returns one item; :id is Sonar's review id, not Apple's.

{
  "id": "review-uuid",
  "app_id": "app-uuid",
  "asc_review_id": "00000123-4567-89ab-cdef-000000000000",
  "rating": 2,
  "title": "Se cierra al importar",
  "body": "Cada vez que importo una receta la app se cierra.",
  "reviewer_nickname": "chef_ana",
  "country": "es",
  "territory": "ESP",
  "created_at": "2026-10-08T07:41:00.000Z",
  "language": "es",
  "title_en": "Closes when importing",
  "body_en": "Every time I import a recipe the app closes.",
  "reply": {
    "status": "draft",
    "body": "Hola Ana, sentimos el problema. La versión 2.1 lo corrige…",
    "language": "es",
    "body_en": "Hi Ana, sorry about the trouble. Version 2.1 fixes it…",
    "source": "ai",
    "hold_reason": null,
    "scheduled_for": null,
    "posted_at": null,
    "posted_by": null,
    "error": null
  },
  "app_store_response": null
}

Reply Actions

PATCH/api/v1/review-replies/:id

Replace the reply text (body, required). Not metered, doesn't post. With translate_from_english: true the text is treated as English and translated into the reviewer's language.

POST/api/v1/review-replies/:id/regenerate

A fresh AI draft with an optional hint (≤300 chars). Metered; never posts. 502 ai_failed if the AI call fails.

POST/api/v1/review-replies/:id/post

Publish the reply on the App Store under your developer name — now, or after delay_minutes (0-1440). An optional body replaces the text first. Apple keeps one response per review, so posting again overwrites it. 503 unavailable when App Store Connect doesn't accept the reply (retry).

curl -X POST -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "delay_minutes": 60 }' \
  https://api.trysonar.app/api/v1/review-replies/review-uuid/post
POST/api/v1/review-replies/:id/dismiss
POST/api/v1/review-replies/:id/cancel

Dismiss marks a review as not needing a reply (and cancels a scheduled post); cancel moves a scheduled reply back to draft. Every action returns the updated review; 409 conflict when the reply is being posted or isn't in a state that allows the action.


Screenshot Studio

Programmatic access to the Screenshot Studio — agents author app-store screenshot sets as layout JSON, and humans review and fine-tune the same sets in the dashboard (every set response carries its studio_url). Layouts follow the documented JSON scene format; every write is validated against the canonical schema with field-level validation_error messages. Image fields (screenshotDataUrl / imageDataUrl) accept https:// URLs at the API boundary — fetched and inlined server-side (PNG/JPEG/WebP, ≤4 MB each). Mutations require a write-scope key.

Endpoints:

GET/api/v1/screenshots/devices

Supported device sizes with canvas dimensions — pick one before creating a set. Includes the landscape App Store creative-asset canvases (ios-header-16x9, ios-header-21x9, ios-search-3x2), which take the creative-* templates and export as opaque PNGs.

GET/api/v1/screenshots/sets

List a product's sets (metadata only). product_id required.

POST/api/v1/screenshots/sets

Create a set from explicit screens layouts, a built-in template_id, or blank. Returns the full set including screen ids.

GET/api/v1/screenshots/sets/:id

Full set: every screen's layout + per-locale translations (large responses).

GET/api/v1/screenshots/sets/:id/export

Server-rendered store-ready PNGs — application/zip per locale (?locale=), a single slice as PNG (?screen=), or ?meta=1 for a lightweight JSON manifest. No browser needed anywhere.

PATCH/api/v1/screenshots/sets/:id

Rename, replace the locales list, or reorder screens via screen_order.

DELETE/api/v1/screenshots/sets/:id

Delete the set, its screens, and translations.

POST/api/v1/screenshots/sets/:id/screens

Append a screen ({ "layout": { … } }, or empty for a blank slice).

PUT/api/v1/screenshots/screens/:id

Replace one screen's layout (whole-document replace).

DELETE/api/v1/screenshots/screens/:id

Delete a screen — a set keeps at least one (deleting the last is a 400).

PUT/api/v1/screenshots/sets/:id/translations/:locale

Replace one locale's translation overrides (sparse, keyed by layer id; the locale is auto-enabled on the set). Geometry and styling always come from the source layout.

Full field-by-field reference incl. the layout format: openapi.json and the MCP tools (Screenshot Studio tools).