---
title: "Difficulty + popularity for a specific keyword (or bulk)"
method: GET
path: "/api/v1/keywords/metrics"
tags: ["Keywords"]
---

# Difficulty + popularity for a specific keyword (or bulk)

`GET /api/v1/keywords/metrics`

## Request

- Base URL: `https://api.trysonar.app`
- URL: `https://api.trysonar.app/api/v1/keywords/metrics`
- Auth: one of:
  - none
  - HTTP bearer

Pass `q` for a single keyword or `qs` (comma-separated, max 25) for bulk. 1 credit per keyword. Use this when you already have a keyword list — `/keywords/search` is the heavier endpoint that also fans out into related terms.

## Query parameters

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

## Headers

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

## Response `200`

Single keyword data (when `q`) or array of keyword data (when `qs`)

- union
  - object
    - `data` Keyword: One keyword row from /keywords/metrics or /keywords/search. Freshness: `stale` is false only when `measured_at` and `popularity_measured_at` (when present) are both under 7 days old. Rows measured 7–30 days ago are served with `stale: true` and re-measured in the background; older ones are re-measured during the request. 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 checked against Play autocomplete and older than 7 days is served with `stale: true` and re-checked in the background. When the store cannot be reached, a stored measurement of any age is served instead of an error, always with `stale: true`.
      - `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.
  - 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`: pending — nothing could be computed inside the request (store allowance spent, throttling, per-caller cap, or the time budget/deadline ran out), so every term was queued on the scrape fleet. Single `q`: the keyword object with `error.code: "pending"` + `error.retry_after_seconds`; bulk: every item pending. Not billed and doesn't count against the daily quota — re-request after the hint (a cache hit).
- `400`: Missing or invalid params (incl. `reason: unsupported_market` for a country outside the 72 keyword-research markets)
- `401`: Missing or invalid API key
- `429`: rate_limited (`reason: store_rate_limited`, or `concurrency_limit` when the per-caller cap refused every term) — every requested term was shed (scraper queue congested or too many concurrent lookups for this caller); honor Retry-After. When only SOME bulk terms are shed the response is a 200 whose affected items carry `error.code: "rate_limited"` plus `error.retry_after_seconds` (the same hint as the Retry-After header, which is nonstandard on a 200 and may be stripped by proxies — prefer the body field) — completed terms are returned and cached, and no unserved term is billed. Keep bulk requests sequential (per-caller budget ~8 concurrently resolving keywords).
- `500`: Unexpected server error
- `503`: unavailable (`reason: store_unavailable`) — the store data source is temporarily throttling; check Retry-After and retry. Bulk (`qs`) requests report this per-item inside the 200 array instead

---

[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)
