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.

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 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",
      "difficulty": 77,
      "popularity": 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."
    }
  }
}

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": "..." },
      "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, "results_count": 10 },
    { "keyword": "free recipe app", "store": "ios", "country": "us", "difficulty": 70, "popularity": 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"
      }
    ]
  }
}

Entries are sorted by date (newest first), then by rank. Returns 404 if the keyword ID doesn't exist.


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,
    "results_count": 10
  }
}
{
  "data": [
    { "keyword": "habit tracker", "store": "ios", "country": "us", "difficulty": 62, "popularity": 48, "results_count": 10 },
    { "keyword": "water log",     "store": "ios", "country": "us", "difficulty": 38, "popularity": null, "results_count": 10 },
    { "keyword": "workout",       "store": "ios", "country": "us", "difficulty": 0,  "popularity": 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. 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.


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.


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.


Update Tracked Keyword

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

Set or clear the per-org note on a tracked keyword. Pass note: null or an empty string to clear. Notes are stored on the tracked_keywords row and are never shared across orgs.

ParamTypeDefaultDescription
notestring | nullrequiredNew note (max 2000 chars), or null to clear
curl -X PATCH -H "Authorization: Bearer aso_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Push for Q3 — high intent, low difficulty" }' \
  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"
  }
}

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


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.