---
title: "Keyword research with difficulty + popularity"
method: GET
path: "/api/v1/keywords/search"
tags: ["Keywords"]
---

# Keyword research with difficulty + popularity

`GET /api/v1/keywords/search`

## Request

- Base URL: `https://api.trysonar.app`
- URL: `https://api.trysonar.app/api/v1/keywords/search`
- Auth: HTTP bearer

Keyword research with difficulty + popularity

## Query parameters

- `q` string, required
- `store` 'ios' | 'android', required
- `country` string

## Headers

- `X-Sonar-Time-Budget-Ms` integer

## Response `200`

Keyword data

- object
  - `data` Keyword[]
    - `keyword` string
    - `store` 'ios' | 'android'
    - `country` string: Two-letter storefront code
    - `popularity` number, nullable: 0–100 popularity score. Null when unknown and on pending/error rows.
    - `popularity_proxy` number, nullable: Sonar's in-house estimate, present only when Apple censors an iOS keyword's real Search Popularity to its floor (5). Disambiguates keywords inside the censored band (rendered as '5 (58)'). Null otherwise.
    - `popularity_source` 'apple' | 'proxy', nullable: Provenance of `popularity`: 'apple' = real Apple Search Popularity reported by Apple Search Ads (not an estimate), 'proxy' = Sonar's in-house estimate. Null when unknown or when popularity is null.
    - `difficulty` number: 0–100 proprietary difficulty score
    - `est_downloads_at_1` number, nullable: Estimated downloads/day for the app ranking #1 on this keyword (rough order of magnitude from the Apple-calibrated popularity curve, 2 significant figures). iOS only — null on Android or when popularity is unknown.
    - `difficulty_breakdown` object, nullable: Explainable difficulty ingredients. `beatable: true` = a top-3 slot looks winnable (an app holds it with ≥10x less strength than the SERP median, or the term is under-targeted in titles with a weak top-3 app). Null for rows cached before the field existed or SERPs with <3 results.
      - `titleMatches` integer: Top-10 apps whose title contains the keyword
      - `appsAnalyzed` integer
      - `top3Strength` number[]: Ratings count (iOS) / installs (Android) of the top 3 apps, in rank order
      - `medianStrength` number
      - `weakSpotRank` integer, nullable
      - `beatable` boolean
    - `results_count` integer: Number of apps the store returned for the term (0 on pending/error rows).
    - `measured_at` string, date-time, nullable: When difficulty, difficulty_breakdown and results_count were measured (ISO 8601, UTC); the compute time for a keyword computed during the request. Null on pending/error rows.
    - `popularity_measured_at` string, date-time, nullable: When the returned `popularity` value was measured (ISO 8601, UTC) — can be older than `measured_at`. Apple Search Popularity: when Sonar last fetched it from Apple. Sonar's estimates: same as `measured_at`. Android values checked against Play autocomplete: the start of the UTC day they were checked (a lower bound, never later than the real time). Null when `popularity` is null.
    - `stale` boolean: False only when `measured_at` and `popularity_measured_at` (when present) are both under 7 days old; true otherwise, including stored measurements served because the store could not be reached. Stale rows are real data and are billed like cache hits. To enforce a maximum age, compare both timestamps.
    - `error` union: Present only on rows that carry no data. /keywords/metrics: an object `{ code, message, retry_after_seconds? }` (`code` is `pending`, `rate_limited`, `unavailable` or `internal_error`). /keywords/search: the string `pending` or `scrape_failed`, with `retry_after_seconds` alongside on pending rows.
      - 'pending' | 'scrape_failed'
      - object
        - `code` string
        - `message` string
        - `retry_after_seconds` integer
    - `retry_after_seconds` integer: /keywords/search pending rows only: seconds to wait before re-requesting the term.

## Other responses

- `202`: Every row is `pending`: nothing could be computed inside the request, so each term was queued on the scrape fleet (`error: "pending"` + `retry_after_seconds` per row). Not billed, and doesn't count against the daily quota — re-request after the hint. When only some rows are pending the status is 200.
- `400`: Missing or invalid request parameters
- `401`: Missing or invalid API key
- `429`: Rate limited — see `error.reason`: daily_limit (Indie plan: 1,000 requests/day, resets 00:00 UTC; see `reset_at`), anonymous_daily_limit / batch_too_large (keyless free tier), store_rate_limited or concurrency_limit (transient, honor `retry_after_seconds`)
- `500`: Unexpected server error
- `503`: unavailable (`reason: store_unavailable`) — the store couldn't be reached and nothing is stored for the requested terms; honor Retry-After.

---

[API](https://skmtc.dev/trysonar/apis/sonar-api.md) · [All operations](https://skmtc.dev/trysonar/apis/sonar-api/llms.txt) · [OpenAPI document](https://skmtc.dev/trysonar/apis/sonar-api/revisions/73fe8bc46522?raw)
