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_hereAPI 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).
| Scope | Grants | Available on |
|---|---|---|
| read | All read endpoints (stateless lookups + org-scoped GETs) | Full plan or credits |
| write | POST / 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
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, invalid, or revoked API key |
| 402 | insufficient_credits | Daily limit exhausted and credit balance below the call's cost. Top up at /settings/credits or wait for daily reset. |
| 403 | forbidden | Valid key but endpoint requires Full plan, or scope is missing |
| 400 | bad_request | Missing or invalid query parameters |
| 404 | not_found | Resource not found or not tracked by your org |
| 409 | bad_request | Conflict — e.g. app already linked to another product, store slot already filled |
| 429 | rate_limited | Daily rate limit exceeded (Full plan) |
| 500 | internal_error | Server 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:
| Mode | How it works | Limits |
|---|---|---|
| Credits | Prepaid 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.
| Cost | Endpoints |
|---|---|
| 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
| Header | Description |
|---|---|
| X-RateLimit-Limit | Full plan: total daily limit (1,000) |
| X-RateLimit-Remaining | Full plan: requests remaining today |
| X-RateLimit-Reset | Full plan: Unix timestamp when limit resets |
| X-RateLimit-Overage | true when a Full-plan call burned credits instead of the included quota — signal for agents to slow down |
| X-Credits-Cost | Credits deducted by this call (credit mode + Full overage) |
| X-Credits-Remaining | Credit 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
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
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
Tracked keywords for an app with latest metrics. Cursor paginated.
| Param | Type | Default | Description |
|---|---|---|---|
| cursor | string | — | Pagination cursor from previous response |
| limit | integer | 50 | Results 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
Rank history for an app's tracked keywords.
| Param | Type | Default | Description |
|---|---|---|---|
| days | integer | 30 | History window (1–365) |
| keyword_id | string | — | 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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| type | string | — | Filter: release, metadata, screenshots, price, or category |
| limit | integer | 50 | Max 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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| store | string | required | ios or android |
| id | string | required | Store ID |
| country | string | us | Country code (ISO 3166-1 alpha-2) |
| sort | string | recent | recent or helpful |
| min_rating | integer | — | Only reviews with score >= min_rating (1–5) |
| max_rating | integer | — | Only reviews with score <= max_rating (1–5) |
| limit | integer | — | Cap 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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| store | string | required | ios or android |
| id | string | — | Single store ID. Pass id OR ids, not both |
| ids | string | — | Comma-separated store IDs (max 25 per call) for bulk lookup |
| country | string | us | Country 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.
Keyword Search
Keyword research. Returns autocomplete suggestions with difficulty scores and popularity estimates.
| Param | Type | Default | Description |
|---|---|---|---|
| q | string | required | Search query |
| store | string | required | ios or android |
| country | string | us | Country 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
SERP history for a keyword \u2014 which apps rank for it and how positions change.
| Param | Type | Default | Description |
|---|---|---|---|
| days | integer | 30 | History 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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| q | string | — | Single keyword (use this OR qs, not both) |
| qs | string | — | Comma-separated bulk keywords (up to 25, use this OR q) |
| store | string | required | ios or android |
| country | string | us | Country 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
Raw autocomplete suggestions from the store. Lighter than /keywords/search \u2014 no difficulty calculation.
| Param | Type | Default | Description |
|---|---|---|---|
| q | string | required | Seed term |
| store | string | required | ios or android |
| country | string | us | Country 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
Keywords a competitor ranks for, with optional gap analysis against your app. Cursor paginated.
| Param | Type | Default | Description |
|---|---|---|---|
| app_id | string | — | Your app ID for gap analysis |
| cursor | string | — | Pagination cursor |
| limit | integer | 50 | Results 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
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).
| Param | Type | Default | Description |
|---|---|---|---|
| apps | array | required | 1–2 entries: { store_id, store, country? }. At most one per store. |
| name | string | — | 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://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.
Link Store Version
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).
| Param | Type | Default | Description |
|---|---|---|---|
| store_id | string | required | Store-side ID of the app to link |
| store | string | required | ios or android |
| country | string | — | 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://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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| store_id | string | required | Store-side ID of the competitor app |
| store | string | required | Which store version of the product this competitor sits under (ios or android) |
| country | string | — | 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://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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| keywords | string[] | required | 1–200 terms (each 1–120 chars). Trimmed + lowercased + deduped on insert. |
| country | string | — | 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://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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| note | string | null | required | New 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
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.
| Param | Type | Default | Description |
|---|---|---|---|
| own_app_id | string (uuid) | required | UUID 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.