# Documentation

Get a key, make one call, and read what comes back -- about five minutes. Then: the full contract, a reference page per endpoint, and worked examples.

## 1. Get a key

Sign up and generate an API key from the dashboard at https://seofetch.com/app/. Every account starts with 1500 free credits -- enough to run the request below 1,500 times before you need to pay for anything.

## 2. Your first request

One endpoint, one query, no header required. Copy this, swap in your own key, run it:

```bash
curl https://api.seofetch.com/v1/search \
  -H "Authorization: Bearer sof_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"query": "best running shoes"}'
```

## 3. Read the envelope

Every endpoint wraps your data in the same seven fields: id, request_id, object, created_at, elapsed_ms, cache, and credits. The results themselves live under data. Learn this shape once and you can already read what all nineteen endpoints return.

credits.charged is what this call cost -- 0 on a replay, since a replay is never re-billed; credits.balance is what's left. cache is "hit" or "miss" -- either way the shape is identical. The full rules -- errors, timeouts, rate limits, credit refunds -- live on The Contract.

```json
{
  "id": "srch_tnq5objwpmflh2poczbwxnfr",
  "request_id": "req_c7leybmgtzholcwoyhozbqsqkq",
  "object": "search",
  "created_at": "2026-08-08T16:50:38Z",
  "elapsed_ms": 142,
  "cache": "miss",
  "credits": {
    "charged": 1,
    "balance": 9857
  },
  "data": {
    "query": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "total_results": 84900000,
    "serp_url": "https://www.google.com/search?q=best+running+shoes",
    "result_types": [
      "organic"
    ],
    "results_count": 10,
    "items": [
      {
        "type": "organic",
        "rank": 1,
        "page": 1,
        "domain": "example.com",
        "title": "The 12 Best Running Shoes",
        "url": "https://example.com/best-running-shoes",
        "description": "Our team tested 40 pairs...",
        "displayed_link": "example.com › reviews › shoes",
        "date": null,
        "site_name": "Example Running Co.",
        "rating": {
          "value": 4.6,
          "votes": 1284,
          "max": 5
        },
        "sitelinks": [
          {
            "type": "sitelink",
            "title": "Best Trail Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/trail"
          },
          {
            "type": "sitelink",
            "title": "Best Budget Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/budget"
          }
        ],
        "price": null
      }
    ]
  }
}
```

## 4. Retries are free

You didn't send anything special above, and that's fine -- success responses carry a request_id, and so does the 504 you'd reconnect on if the connection drops mid-job. Resend it and you're never charged twice: mid-job it reconnects you to the same run; once that run settles, for 6 hours you get back the exact stored result -- a cache read, uncharged, no new work. Past that it's a 409, not a free rerun -- mint a new key. The exact mechanics and timing are on The Contract.

## 5. What's next

That's the whole quickstart. From here: The Contract for the exact rules, a generated reference page for each of the nineteen endpoints, and worked examples you can copy wholesale. Building an agent or a script instead of reading by hand? See For agents next.

## 6. For agents

This whole site is available as plain Markdown, not just HTML -- built for agents and scripts as much as for people. Start with https://docs.seofetch.com/overview.md -- a short hand-picked brief, not the whole corpus. https://docs.seofetch.com/llms.txt indexes every page as a Markdown link; https://docs.seofetch.com/llms-full.txt is the entire corpus -- this page, The Contract, and all nineteen endpoint references -- in one fetch.

Every page offers the same three ways to ask for Markdown instead of HTML: the query string ?format=md, a literal .md suffix on the URL (for example https://docs.seofetch.com/the-contract.md), or an Accept: text/markdown request header. All three return byte-identical Markdown.

Endpoint request and response schemas are also published as OpenAPI: https://api.seofetch.com/v1/openapi.json.

One rule matters most for unattended retries: if a call might crash before you see the response, bring your own key up front -- a lost response means no request_id to recover. Called keyless? The request_id in the response is your replay key. See The Contract for the exact mechanics and timing.

```bash
curl "https://docs.seofetch.com/the-contract/?format=md"
curl "https://docs.seofetch.com/the-contract.md"
curl -H "Accept: text/markdown" "https://docs.seofetch.com/the-contract/"
```


---

# The Contract

Read this once. Every endpoint plays by the same rules -- one envelope, one idempotency rule, one timeout contract, one credit accounting. Where an endpoint bends a rule -- search's depth pricing, site audit's per-page metering, the free GET /v1/locations -- it's called out right where the rule is stated. The rest of what's endpoint-specific lives on the reference pages.

## The envelope

Every successful response is the same eight fields: seven that never change -- id, request_id, object, created_at, elapsed_ms, cache, credits -- plus data, where your results live. Learn the shape once and all nineteen endpoints read identically.

credits is two numbers: charged (what this call cost -- 0 on a replay, never re-billed) and balance (what's left). cache is "hit" or "miss" -- a hit still returns the same shape, just faster.

```json
{
  "id": "srch_tnq5objwpmflh2poczbwxnfr",
  "request_id": "req_c7leybmgtzholcwoyhozbqsqkq",
  "object": "search",
  "created_at": "2026-08-08T16:50:38Z",
  "elapsed_ms": 142,
  "cache": "miss",
  "credits": {
    "charged": 1,
    "balance": 9857
  },
  "data": {
    "query": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "total_results": 84900000,
    "serp_url": "https://www.google.com/search?q=best+running+shoes",
    "result_types": [
      "organic"
    ],
    "results_count": 10,
    "items": [
      {
        "type": "organic",
        "rank": 1,
        "page": 1,
        "domain": "example.com",
        "title": "The 12 Best Running Shoes",
        "url": "https://example.com/best-running-shoes",
        "description": "Our team tested 40 pairs...",
        "displayed_link": "example.com › reviews › shoes",
        "date": null,
        "site_name": "Example Running Co.",
        "rating": {
          "value": 4.6,
          "votes": 1284,
          "max": 5
        },
        "sitelinks": [
          {
            "type": "sitelink",
            "title": "Best Trail Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/trail"
          },
          {
            "type": "sitelink",
            "title": "Best Budget Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/budget"
          }
        ],
        "price": null
      }
    ]
  }
}
```

## Authentication

Every request carries an Authorization: Bearer header with your API key, issued from the dashboard.

A missing or malformed header, or a key we don't recognize, is 401 (authentication_error / key_invalid). A revoked key fails the same way with key_revoked. A key you've restricted to an IP allowlist gets 403 (ip_not_allowed) from any other address.

```http
Authorization: Bearer sof_live_your_key_here
```

## Idempotency -- optional key, replayable request_id

The Idempotency-Key header is optional on every POST. Send one and you choose the value -- a UUID, a job id, keyword+date, a commit SHA, anything you can reconstruct. Format: Idempotency-Key must be 8-255 chars of [A-Za-z0-9_-].

Send none and the request just runs -- charged normally, nothing blocks a first call. Every response carries a request_id -- a success and the 504 you get when a job outruns the connection both carry one. Send it back as your Idempotency-Key -- or send your own key up front instead -- and you're never charged twice: one rule, keyed or keyless, mid-flight or settled.

A replay is a cache read, not a re-run: presenting the same key or request_id back returns the exact result already produced -- byte-identical, uncharged, with zero new upstream calls -- for up to 6 hours after the original request. What happens before and after that window, in full, is on Retries are free, below.

A key is scoped to the one endpoint it was first used on: present it at a different endpoint and it isn't a key there at all -- rejected, never charged, never run. Minting a key at a cheap endpoint buys nothing at an expensive one.

GET endpoints are uncharged and need no key -- there's nothing to bill and nothing to dedupe. GET /v1/locations is the one endpoint this applies to today; its request_id is not a replay handle -- there's no charge and no stored request behind it to reconnect to.

Optionality is for interactive and agent use, not for a pipeline that can crash before it sees the response: a keyless call that never receives its 504 JSON has no request_id to recover, and a keyless blind retry is a brand-new charged call, not a replay. Mint your own key up front for those.

Worked example -- your first search, key my-first-search-001:

```bash
curl https://api.seofetch.com/v1/search \
  -H "Authorization: Bearer sof_live_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: my-first-search-001" \
  -d '{"query": "best running shoes"}'
```

## Retries are free

Same key, same body, within 24 hours: charged once, never twice.

What comes back depends on how long ago the original call happened, not on luck. While the job is still running, that's the reconnect case above: the retry waits and collects the result the moment it lands, charged once. Once it's settled, up to 6 hours later, you get that same stored result back -- a cache read, uncharged, no new work. Past 6 hours the result itself is gone, but the key still blocks a second charge: you get 409 idempotency_key_result_expired instead, and nothing runs, nothing is charged.

A stored failure is just as sticky as a stored success: if the original call failed (upstream_error or upstream_timeout), it was already refunded once, automatically -- so every replay inside the 6-hour window costs nothing, but it also hands back that SAME failure, not a fresh shot at succeeding. Want a genuine retry after a transient failure? Use a new key.

Past 24 hours, a key that ever ran to completion -- success or refunded failure alike -- is retired: reusing it is rejected (400 idempotency_key_reused), never quietly treated as a fresh charged request. (The one exception: a call rejected before it ever reached a worker, such as insufficient_credits, leaves the key free again after the window -- there was nothing to be idempotent about.) The same key with a different body is always a 400 too, at any point in the window.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request body.",
    "param": "Idempotency-Key"
  }
}
```

```text
<= 6h      stored result returned -- a cache read, uncharged, no new work
6h -> 24h  409 idempotency_key_result_expired -- nothing runs, nothing charged
```

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_key_result_expired",
    "message": "This Idempotency-Key already completed a request, and its stored result is no longer retained (results are kept for 6 hours). If that request succeeded, the charge stands and is not refunded by this response -- retrying now is a new, separately charged request, not a free replay. If it failed, it was already refunded automatically. If you chose this key yourself, mint a new one; if it is a request_id we handed you, just make a fresh request and leave out the Idempotency-Key header.",
    "param": "Idempotency-Key"
  }
}
```

## Timeouts & reconnecting

/v1 calls are synchronous -- you get the result on the same connection. If the job is still running when your connection would time out, you get 504. This is not a failure: your credits are already charged and the job keeps running on our side. Reconnect by re-POSTing the exact same body -- with the same key you sent, or with the request_id from this 504 body as your Idempotency-Key. Either works here, keyed or keyless: mid-flight, both resolve to the same still-running job and collect the result the moment it's ready -- and for 6 hours after it settles, too.

504 covers two different situations -- branch on code, not just the status. code "connection_timeout" means the charge stands and there's nothing to refund -- reconnect with the same key any time inside the 6-hour retention window and you get the real result back, not a repeated block-and-504 (see Retries are free, above); past that window a reconnect against an already-settled job comes back 409 instead. code "upstream_timeout" means the upstream itself gave up -- a definite failure, already refunded automatically; reconnecting won't produce a result because none is coming. Every endpoint has its own ceiling:

```json
{
  "error": {
    "type": "upstream_timeout",
    "code": "connection_timeout",
    "message": "The result hasn't come back on this connection yet. Re-POST the same body with this request_id (or your own Idempotency-Key) to reconnect."
  },
  "request_id": "req_mraz2ji6grhhbokpqg75wejo3q"
}
```

```text
POST /v1/search              180s ceiling
POST /v1/keywords/volume     60s ceiling
POST /v1/keywords/difficulty 120s ceiling
POST /v1/backlinks/summary   60s ceiling
POST /v1/backlinks/list      60s ceiling
POST /v1/backlinks/domains   60s ceiling
POST /v1/backlinks/anchors   60s ceiling
POST /v1/domains/overview    60s ceiling
POST /v1/page/lighthouse     120s ceiling
POST /v1/page/crawl          120s ceiling
POST /v1/page/accessibility  120s ceiling
POST /v1/site/audit          600s ceiling
POST /v1/serp/history        60s ceiling
POST /v1/domains/card        60s ceiling
POST /v1/keywords/questions  60s ceiling
POST /v1/ai/visibility       60s ceiling
GET /v1/locations           10s ceiling
POST /v1/domains/competitors 60s ceiling
POST /v1/domains/gap         60s ceiling
```

## Credits & refunds

Credits are charged the moment your request is accepted -- before the upstream call runs. If we fail to deliver (upstream error or upstream timeout), the charge is refunded automatically, in credits, back to your balance. You never pay for data we didn't produce.

Most endpoints charge a flat rate per call. /v1/search is the one exception: depth=10 (the default) costs 1 credit; depth=100 costs 10 credits. /v1/site/audit is metered too -- billed per delivered page x enabled check, not a flat rate, since the total isn't known until the crawl finishes.

```json
{
  "credits": {
    "charged": 10,
    "balance": 9847
  }
}
```

## Rate limits

100 requests per second per organization -- not per key -- is the service policy. Plan integrations around it: sustained traffic well above that may be rejected. There is no live per-request throttling today, so don't build against a specific 429 response shape or threshold; treat the number as a ceiling to design for, not a guarantee you'll get a clean rejection right at 101 req/s.

## Errors

Every non-2xx response is the same shape: an "error" object with type, code, message, and an optional param naming the field at fault. type groups the failure; code is what you actually branch on.

The ~13 request-validation codes (invalid_url, invalid_depth, invalid_query, ...) all share type invalid_request_error and status 400 -- code tells you which one fired. The common type/code pairs (one more 504 code, connection_timeout, lives under Timeouts & reconnecting):

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_key_invalid",
    "message": "Idempotency-Key must be 8-255 chars of [A-Za-z0-9_-].",
    "param": "Idempotency-Key"
  }
}
```

```text
400  invalid_request_error  idempotency_key_invalid
400  invalid_request_error  idempotency_key_reused
400  invalid_request_error  invalid
401  authentication_error   key_invalid
401  authentication_error   key_revoked
402  account_suspended      account_suspended
402  insufficient_credits   insufficient_credits
403  ip_not_allowed         ip_not_allowed
409  invalid_request_error  idempotency_key_result_expired
429  rate_limit_error       rate_limited  (reserved, not yet enforced)
502  upstream_error         upstream_error
504  upstream_timeout       upstream_timeout
```


---

# POST /v1/search

Run one search query and get the parsed SERP back — ranked items, not raw HTML.

**Credits:** 1 credit at the default depth (10 results); 10 credits at depth=100.

**Timeout:** 180s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `query` | string | yes | — | Search query text. Required, max 700 chars. |
| `engine` | string | no | "google" | Search engine. Optional, default "google". One of: google, google-maps, google-news, google-images, google-shopping, bing, yandex, youtube. |
| `location` | any | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `device` | string | no | "desktop" | One of: desktop, mobile. Optional, default "desktop". |
| `depth` | integer | no | 10 | 10 results — 1 credit (default); 100 results — 10 credits. |

## Request

```http
POST /v1/search HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "query": "best running shoes"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "srch_tnq5objwpmflh2poczbwxnfr",
  "request_id": "req_c7leybmgtzholcwoyhozbqsqkq",
  "object": "search",
  "created_at": "2026-08-08T16:50:38Z",
  "elapsed_ms": 142,
  "cache": "miss",
  "credits": {
    "charged": 1,
    "balance": 9857
  },
  "data": {
    "query": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "total_results": 84900000,
    "serp_url": "https://www.google.com/search?q=best+running+shoes",
    "result_types": [
      "organic"
    ],
    "results_count": 10,
    "items": [
      {
        "type": "organic",
        "rank": 1,
        "page": 1,
        "domain": "example.com",
        "title": "The 12 Best Running Shoes",
        "url": "https://example.com/best-running-shoes",
        "description": "Our team tested 40 pairs...",
        "displayed_link": "example.com › reviews › shoes",
        "date": null,
        "site_name": "Example Running Co.",
        "rating": {
          "value": 4.6,
          "votes": 1284,
          "max": 5
        },
        "sitelinks": [
          {
            "type": "sitelink",
            "title": "Best Trail Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/trail"
          },
          {
            "type": "sitelink",
            "title": "Best Budget Running Shoes",
            "description": null,
            "url": "https://example.com/best-running-shoes/budget"
          }
        ],
        "price": null
      }
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.query` | The query string you sent. |
| `data.engine` | Search engine that served this result set. |
| `data.location` | Google Ads geotarget ID this call ran against. |
| `data.language` | ISO language code this call ran against. |
| `data.device` | Device the search was simulated from. |
| `data.total_results` | The search engine's own total-results estimate for the query -- not the number of items in this response. |
| `data.serp_url` | URL of the search-results page that was fetched. |
| `data.result_types` | Distinct SERP item types observed on the page. Only "organic" is populated today. |
| `data.results_count` | Number of entries in items. |
| `data.items` | One parsed result per SERP item, in rank order. |
| `data.items[].type` | Item type. Only "organic" is populated today. |
| `data.items[].rank` | 1-based rank on the results page. |
| `data.items[].page` | Results page number this item was found on (1 for the first page). |
| `data.items[].domain` | Registrable domain hosting the result. |
| `data.items[].title` | Result title. |
| `data.items[].url` | Result URL. |
| `data.items[].description` | Result description/snippet text. |
| `data.items[].displayed_link` | Breadcrumb-style path shown under the title. |
| `data.items[].date` | Result date, if the SERP surfaced one; null otherwise. |
| `data.items[].site_name` | Publisher or site name shown next to the result, if the SERP surfaced one; null otherwise. |
| `data.items[].rating` | Star rating block ({value, votes, max}), or null if the result carries no rating. |
| `data.items[].sitelinks` | Sub-links shown under the result; empty array if none. |
| `data.items[].sitelinks[].type` | Sitelink type, e.g. "sitelink". |
| `data.items[].sitelinks[].title` | Sitelink title. |
| `data.items[].sitelinks[].url` | Sitelink URL. |
| `data.items[].sitelinks[].description` | Sitelink description, or null if the SERP didn't include one. |
| `data.items[].price` | Price block ({current, currency, displayed}), or null if the result carries no price. |


---

# POST /v1/keywords/volume

Historical Google keyword search volume and competition metrics.

**Credits:** 20 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `keywords` | array | yes | — | Keywords to fetch search volume for. Required, non-empty. Array of string. Max 500 items. |
| `location` | any | yes | — | Google Ads geotarget ID, or a location name (e.g. a country/state/city name). Required. Look one up with /v1/locations. |
| `language` | string | yes | — | ISO language code. Required. |
| `search_partners` | boolean | no | false | Include Google Search Network partner sites in the volume figures. Optional, default false. |
| `tag` | string | no | — | Optional client-defined label forwarded to the upstream job for your own bookkeeping; not echoed back in the response. |

## Request

```http
POST /v1/keywords/volume HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "keywords": [
    "running shoes",
    "trail running shoes"
  ],
  "location": "US",
  "language": "en"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "keyw_bhxsimngdxt6fr2eeodzb6jv",
  "request_id": "req_zoldxo6z2nabrbzfol6lrv3mfi",
  "object": "keyword_volume",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 20,
    "balance": 9857
  },
  "data": {
    "location": {
      "code": 2840,
      "name": "United States"
    },
    "language": {
      "code": "en",
      "name": "English"
    },
    "network": "google_search",
    "items_count": 2,
    "items": [
      {
        "keyword": "running shoes",
        "avg_monthly_searches": 90500,
        "competition": "HIGH",
        "competition_index": 88,
        "cpc": 1.24,
        "low_bid": 0.42,
        "high_bid": 2.1,
        "monthly": [
          {
            "month": "2026-06",
            "search_volume": 91000
          },
          "…"
        ]
      },
      {
        "keyword": "trail running shoes",
        "avg_monthly_searches": 8100,
        "competition": "MEDIUM",
        "competition_index": 54,
        "cpc": 0.87,
        "low_bid": 0.31,
        "high_bid": 1.55,
        "monthly": [
          {
            "month": "2026-06",
            "search_volume": 8300
          },
          "…"
        ]
      }
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.location` | Resolved geotarget for this call. |
| `data.location.code` | Google Ads geotarget ID. |
| `data.location.name` | Human-readable geotarget name. |
| `data.language` | Resolved language for this call. |
| `data.language.code` | ISO language code. |
| `data.language.name` | Human-readable language name. |
| `data.network` | Search network the volume figures are drawn from, e.g. "google_search". |
| `data.items_count` | Number of entries in items. |
| `data.items` | One row per requested keyword. |
| `data.items[].keyword` | The keyword. |
| `data.items[].avg_monthly_searches` | Average monthly search volume over the trailing 12 months. |
| `data.items[].competition` | Advertiser competition level: LOW, MEDIUM, or HIGH. |
| `data.items[].competition_index` | 0-100 advertiser competition score (finer-grained than competition). |
| `data.items[].cpc` | Average cost-per-click for advertisers bidding on this keyword. |
| `data.items[].low_bid` | Low end of the typical top-of-page bid range. |
| `data.items[].high_bid` | High end of the typical top-of-page bid range. |
| `data.items[].monthly` | Month-by-month search volume history. |
| `data.items[].monthly[].month` | Month this row covers, YYYY-MM. |
| `data.items[].monthly[].search_volume` | Search volume for that month. |


---

# POST /v1/keywords/difficulty

Keyword difficulty (0-100) per keyword; unscored keywords return pending and are scored for a later call.

**Credits:** 110 credits per call.

**Timeout:** 120s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `keywords` | array | yes | — | Keywords to score difficulty for. Required, non-empty. Array of string. Max 500 items. |
| `location` | any | yes | — | Google Ads geotarget ID, or a location name (e.g. a country/state/city name). Required. Look one up with /v1/locations. |
| `language` | string | yes | — | ISO language code. Required. |
| `tag` | string | no | — | Optional client-defined label forwarded to the upstream task for your own bookkeeping; not echoed back in the response. |

## Request

```http
POST /v1/keywords/difficulty HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "keywords": [
    "buy running shoes",
    "cold keyword"
  ],
  "location": 2840,
  "language": "en"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "keyw_6acl57bxyazlqpw4mzpl5dc2",
  "request_id": "req_y43esoxvyrbp5l4mkykl6e6g6m",
  "object": "keyword_difficulty",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 110,
    "balance": 9857
  },
  "data": {
    "items_count": 2,
    "items": [
      {
        "keyword": "buy running shoes",
        "difficulty": 42,
        "status": "available"
      },
      {
        "keyword": "cold keyword",
        "difficulty": null,
        "status": "pending"
      }
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.items_count` | Number of entries in items. |
| `data.items` | One row per requested keyword. |
| `data.items[].keyword` | The keyword. |
| `data.items[].difficulty` | 0-100 difficulty score, or null while status is "pending". |
| `data.items[].status` | "available" once scored, or "pending" if the keyword needed a fresh scrape that hadn't completed yet -- retry the same keyword later to resolve it. |


---

# POST /v1/backlinks/summary

Backlink profile summary metrics for a domain or URL.

**Credits:** 20 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `target` | string | yes | — | Domain or URL to analyze backlinks for. Required. |

## Request

```http
POST /v1/backlinks/summary HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "target": "example.com"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "back_t6jjl5flauvkbxxrfg2tr6ce",
  "request_id": "req_krbpcodq3fdetccntq7csdwvlu",
  "object": "backlink_summary",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 20,
    "balance": 9857
  },
  "data": {
    "target": "example.com",
    "authority": 33,
    "backlinks": 617091,
    "spam_score": 9,
    "referring_domains": 3582,
    "referring_root_domains": 3160,
    "referring_pages": 210044,
    "link_breakdown": {
      "tlds": {
        "com": 2891,
        "net": 412,
        "org": 279
      },
      "types": {
        "anchor": 17226,
        "image": 421,
        "redirect": 12
      },
      "attributes": {
        "nofollow": 1769,
        "ugc": 340,
        "external": 88,
        "sponsored": 26
      },
      "platforms": {
        "blogs": 3021,
        "cms": 1104,
        "news": 214
      },
      "page_sections": {
        "article": 12904,
        "footer": 2200,
        "section": 1122
      },
      "countries": {
        "US": 9821,
        "GB": 2011,
        "DE": 1502
      }
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.target` | The domain or URL you requested. |
| `data.authority` | 0-100 authority score for the target (higher is stronger). |
| `data.backlinks` | Total backlink count pointing at the target. |
| `data.spam_score` | 0-100 spam score for the target's backlink profile (higher is spammier). |
| `data.referring_domains` | Number of distinct domains linking to the target. |
| `data.referring_root_domains` | Number of distinct registrable (root) domains linking to the target -- referring_domains collapsed to one per apex. |
| `data.referring_pages` | Number of distinct pages linking to the target. |
| `data.link_breakdown` | Backlink counts broken down along six axes: tlds, types, attributes, platforms, page_sections, countries. |
| `data.link_breakdown.tlds` | Backlink counts by source TLD. |
| `data.link_breakdown.types` | Backlink counts by link type (anchor, image, redirect, ...). |
| `data.link_breakdown.attributes` | Backlink counts by link attribute (nofollow, ugc, external, sponsored, ...). |
| `data.link_breakdown.platforms` | Backlink counts by source platform (blogs, cms, news, ...). |
| `data.link_breakdown.page_sections` | Backlink counts by the page section the link appeared in (article, footer, section, ...). |
| `data.link_breakdown.countries` | Backlink counts by source country (ISO country code). |


---

# POST /v1/backlinks/list

Individual backlinks pointing at a domain or URL.

**Credits:** 50 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `target` | string | yes | — | Domain or URL to analyze backlinks for. Required. |
| `limit` | integer | no | 100 | Page size. Optional, default 100. 1-1000. |
| `offset` | integer | no | 0 | Pagination offset. Optional, default 0. 0-10000. |

## Request

```http
POST /v1/backlinks/list HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "target": "example.com",
  "limit": 100,
  "offset": 0
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "back_ys2nxkwocccijfxddfbfg2o2",
  "request_id": "req_4ie52asnzrbahf742mhcbwfrk4",
  "object": "backlink_list",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 50,
    "balance": 9857
  },
  "data": {
    "target": "example.com",
    "total_count": 355648,
    "limit": 100,
    "offset": 0,
    "items": [
      {
        "source_url": "https://blog.example.net/best-shoes",
        "target_url": "https://example.com/",
        "source_domain": "blog.example.net",
        "target_domain": "example.com",
        "anchor": "running shoes guide",
        "dofollow": true,
        "first_seen": "2024-03-11",
        "last_seen": "2026-06-02",
        "authority": 54,
        "spam_score": 0
      },
      "…"
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.target` | The domain or URL you requested. |
| `data.total_count` | Total number of rows matching the target, independent of limit/offset. |
| `data.limit` | Page size actually used (echoes the request). |
| `data.offset` | Pagination offset actually used (echoes the request). |
| `data.items` | One row per backlink, up to limit. |
| `data.items[].source_url` | URL of the page carrying the link. |
| `data.items[].target_url` | URL the link points at. |
| `data.items[].source_domain` | Registrable domain of the linking page. |
| `data.items[].target_domain` | Registrable domain of the destination. |
| `data.items[].anchor` | Anchor text of the link. |
| `data.items[].dofollow` | true if the link passes authority (no nofollow/sponsored/ugc attribute). |
| `data.items[].first_seen` | Date this backlink was first observed. |
| `data.items[].last_seen` | Date this backlink was last confirmed live. |
| `data.items[].authority` | 0-100 authority score of the linking page. |
| `data.items[].spam_score` | 0-100 spam score of the linking page. |


---

# POST /v1/backlinks/domains

Domains that link to a target, with per-domain metrics.

**Credits:** 50 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `target` | string | yes | — | Domain or URL to analyze backlinks for. Required. |
| `limit` | integer | no | 100 | Page size. Optional, default 100. 1-1000. |
| `offset` | integer | no | 0 | Pagination offset. Optional, default 0. 0-10000. |

## Request

```http
POST /v1/backlinks/domains HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "target": "example.com",
  "limit": 100,
  "offset": 0
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "refe_su7crg5jqttzm3w5lrssqy5c",
  "request_id": "req_4aao5brqrfeyzb7iwhkzfcszc4",
  "object": "referring_domains",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 50,
    "balance": 9857
  },
  "data": {
    "target": "example.com",
    "total_count": 3160,
    "limit": 100,
    "offset": 0,
    "items": [
      {
        "domain": "caglrc.cc",
        "backlinks": 526,
        "first_seen": "2023-11-02",
        "lost_at": null,
        "authority": 27,
        "spam_score": 0
      },
      "…"
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.target` | The domain or URL you requested. |
| `data.total_count` | Total number of rows matching the target, independent of limit/offset. |
| `data.limit` | Page size actually used (echoes the request). |
| `data.offset` | Pagination offset actually used (echoes the request). |
| `data.items` | One row per referring domain, up to limit. |
| `data.items[].domain` | The referring domain. |
| `data.items[].backlinks` | Number of backlinks from this domain to the target. |
| `data.items[].first_seen` | Date a link from this domain was first observed. |
| `data.items[].lost_at` | Date the last link from this domain was lost, or null if it's still linking. |
| `data.items[].authority` | 0-100 authority score of the referring domain. |
| `data.items[].spam_score` | 0-100 spam score of the referring domain. |


---

# POST /v1/backlinks/anchors

Anchor-text distribution of backlinks to a target.

**Credits:** 50 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `target` | string | yes | — | Domain or URL to analyze backlinks for. Required. |
| `limit` | integer | no | 100 | Page size. Optional, default 100. 1-1000. |
| `offset` | integer | no | 0 | Pagination offset. Optional, default 0. 0-10000. |

## Request

```http
POST /v1/backlinks/anchors HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "target": "example.com",
  "limit": 100,
  "offset": 0
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "back_bai4wnytk27zcjvybjvgm6bd",
  "request_id": "req_7qjyg6d2fbglrpqd4mfbk3c47a",
  "object": "backlink_anchors",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 50,
    "balance": 9857
  },
  "data": {
    "target": "example.com",
    "total_count": 3316,
    "limit": 100,
    "offset": 0,
    "items": [
      {
        "anchor": "running shoes",
        "backlinks": 597726,
        "referring_domains": 247,
        "first_seen": "2023-05-14"
      },
      {
        "anchor": null,
        "backlinks": 1201,
        "referring_domains": 88,
        "first_seen": "2024-01-09"
      },
      "…"
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.target` | The domain or URL you requested. |
| `data.total_count` | Total number of rows matching the target, independent of limit/offset. |
| `data.limit` | Page size actually used (echoes the request). |
| `data.offset` | Pagination offset actually used (echoes the request). |
| `data.items` | One row per distinct anchor text, up to limit. |
| `data.items[].anchor` | Anchor text, or null for backlinks with no anchor text (e.g. image links). |
| `data.items[].backlinks` | Number of backlinks using this anchor text. |
| `data.items[].referring_domains` | Number of distinct domains using this anchor text. |
| `data.items[].first_seen` | Date this anchor text was first observed. |


---

# POST /v1/domains/overview

Return a domain's organic and paid search overview for one market.

**Credits:** 10 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to look up (bare hostname, e.g. example.com). Required, max 253 chars. |
| `location` | integer | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |

## Request

```http
POST /v1/domains/overview HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com",
  "location": 2840,
  "language": "en"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "doma_te4c2vvf24oh7zsbhrx2y27c",
  "request_id": "req_w5a5pxwpuvgjnk7se4xemjfbpi",
  "object": "domain_overview",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 10,
    "balance": 9857
  },
  "data": {
    "organic": {
      "keywords_count": 101,
      "traffic_estimate": 70.17,
      "traffic_value": 704.38,
      "positions": {
        "top_3": 0,
        "top_10": 3,
        "top_20": 11,
        "top_100": 101
      }
    },
    "paid": {
      "keywords_count": 0,
      "traffic_estimate": 0,
      "traffic_value": 0,
      "positions": {
        "top_3": 0,
        "top_10": 0,
        "top_20": 0,
        "top_100": 0
      }
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.organic` | Organic (unpaid) search metrics for the requested market. |
| `data.organic.keywords_count` | Number of keywords the domain ranks for organically. |
| `data.organic.traffic_estimate` | Estimated monthly organic traffic. |
| `data.organic.traffic_value` | Estimated monthly value of that traffic at prevailing CPC. |
| `data.organic.positions` | Cumulative keyword counts ranking in the top 3/10/20/100 positions. |
| `data.organic.positions.top_3` | Keywords ranking in positions 1-3. |
| `data.organic.positions.top_10` | Keywords ranking in positions 1-10. |
| `data.organic.positions.top_20` | Keywords ranking in positions 1-20. |
| `data.organic.positions.top_100` | Keywords ranking in positions 1-100. |
| `data.paid` | Paid (ads) search metrics for the requested market, same shape as organic. |
| `data.paid.keywords_count` | Number of keywords the domain has paid ads ranking for. |
| `data.paid.traffic_estimate` | Estimated monthly paid-ads traffic. |
| `data.paid.traffic_value` | Estimated monthly value of that traffic at prevailing CPC. |
| `data.paid.positions` | Cumulative keyword counts ranking in the top 3/10/20/100 positions. |
| `data.paid.positions.top_3` | Keywords ranking in positions 1-3. |
| `data.paid.positions.top_10` | Keywords ranking in positions 1-10. |
| `data.paid.positions.top_20` | Keywords ranking in positions 1-20. |
| `data.paid.positions.top_100` | Keywords ranking in positions 1-100. |


---

# POST /v1/page/lighthouse

Lighthouse scores and core web vitals for a single page.

**Credits:** 2 credits per call.

**Timeout:** 120s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `url` | string | yes | — | Absolute http(s) page URL to audit. Required, max 2048 chars. |
| `device` | string | no | "mobile" | One of: mobile, desktop. Optional, default "mobile". |

## Request

```http
POST /v1/page/lighthouse HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "url": "https://example.com/",
  "device": "mobile"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "ligh_lcckxhefzgaczm46jqxiaxev",
  "request_id": "req_j7ldkqojp5eerjguun4qbe6oxi",
  "object": "lighthouse",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 2,
    "balance": 9857
  },
  "data": {
    "url": "https://example.com/",
    "device": "mobile",
    "scores": {
      "performance": 100,
      "accessibility": 96,
      "best_practices": 96,
      "seo": 80
    },
    "metrics": {
      "lcp_ms": 762,
      "fcp_ms": 611,
      "cls": 0.02,
      "tbt_ms": 40,
      "si_ms": 900,
      "tti_ms": 1100
    },
    "fetched_at": "2026-07-26T10:15:00Z",
    "audits": {
      "largest-contentful-paint": {
        "id": "largest-contentful-paint",
        "title": "Largest Contentful Paint",
        "description": "Largest Contentful Paint marks the time at which the largest text or image is painted.",
        "score": 1,
        "scoreDisplayMode": "numeric",
        "numericValue": 762.4,
        "numericUnit": "millisecond",
        "displayValue": "0.8 s",
        "scoringOptions": {
          "p10": 2500,
          "median": 4000
        }
      },
      "…": "…"
    },
    "screenshots": {
      "full_page": {
        "data": "data:image/webp;base64,…",
        "width": 412,
        "height": 6200
      },
      "final": {
        "data": "data:image/webp;base64,…"
      },
      "thumbnails": [
        {
          "data": "data:image/webp;base64,…",
          "timing": 375
        },
        "…"
      ]
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.url` | The final URL Lighthouse actually audited, after any redirects. |
| `data.device` | Device profile the audit ran under: mobile or desktop. |
| `data.scores` | Four Lighthouse category scores, each 0-100. |
| `data.scores.performance` | Performance category score. |
| `data.scores.accessibility` | Accessibility category score. |
| `data.scores.best_practices` | Best-practices category score. |
| `data.scores.seo` | SEO category score. |
| `data.metrics` | Core Web Vitals and related timing metrics. |
| `data.metrics.lcp_ms` | Largest Contentful Paint, in milliseconds. |
| `data.metrics.fcp_ms` | First Contentful Paint, in milliseconds. |
| `data.metrics.cls` | Cumulative Layout Shift (unitless). |
| `data.metrics.tbt_ms` | Total Blocking Time, in milliseconds. |
| `data.metrics.si_ms` | Speed Index, in milliseconds. |
| `data.metrics.tti_ms` | Time to Interactive, in milliseconds. |
| `data.fetched_at` | When this Lighthouse run completed, ISO 8601. |
| `data.audits` | Every Lighthouse audit as produced by the analysis backend, keyed by audit id -- some diagnostic audits are empty depending on backend. Each audit's own `details` payload (e.g. script-treemap-data's node tree) passes through as-is, unfiltered. |
| `data.screenshots` | Screenshot captures produced by this Lighthouse run. |
| `data.screenshots.full_page` | A single stitched screenshot of the entire scrollable page, or null if the backend didn't produce one. |
| `data.screenshots.final` | The page's final-state screenshot, or null if the backend didn't produce one -- carries the image data plus the moment it was captured. |
| `data.screenshots.thumbnails` | Filmstrip of screenshots captured across the page load, oldest first; empty if the backend didn't produce one. |


---

# POST /v1/page/crawl

Render a single page and return a slim page-SEO snapshot.

**Credits:** 2 credits per call.

**Timeout:** 120s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `url` | string | yes | — | Absolute http(s) page URL to render and analyze. Required, max 2048 chars. |

## Request

```http
POST /v1/page/crawl HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "url": "https://example.com/"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "craw_my2cncjm5ihugekqrmk5bojd",
  "request_id": "req_zmrrabmekraevdr5vnjetp3czi",
  "object": "crawl",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 2,
    "balance": 9857
  },
  "data": {
    "url": "https://example.com/",
    "final_url": "https://example.com/",
    "http_status": 200,
    "title": "Example Domain",
    "meta_description": "An example page used for documentation.",
    "canonical": "https://example.com/",
    "headings": {
      "h1_count": 1,
      "h2_count": 2
    },
    "links": {
      "internal": 2,
      "external": 1
    },
    "images": {
      "total": 2,
      "missing_alt": 1
    },
    "is_html": true,
    "load_ms": 812
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.url` | The URL you requested. |
| `data.final_url` | The URL actually rendered, after following any redirects. |
| `data.http_status` | HTTP status code of the final response. |
| `data.title` | Page title element content. |
| `data.meta_description` | Page meta description content, or null if absent. |
| `data.canonical` | Canonical URL declared by the page, or null if absent. |
| `data.headings` | Heading-tag counts. |
| `data.headings.h1_count` | Number of h1 elements on the page. |
| `data.headings.h2_count` | Number of h2 elements on the page. |
| `data.links` | Outbound link counts, split by scope. |
| `data.links.internal` | Links pointing at the same site. |
| `data.links.external` | Links pointing off-site. |
| `data.images` | Image counts. |
| `data.images.total` | Number of img elements on the page. |
| `data.images.missing_alt` | Number of those images with no alt text. |
| `data.is_html` | true if the response was rendered as an HTML document. |
| `data.load_ms` | Time to render the page, in milliseconds. |


---

# POST /v1/page/accessibility

Axe accessibility scan of a single page — violations, keyboard-navigation checks, honest scan status.

**Credits:** 2 credits per call.

**Timeout:** 120s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `url` | string | yes | — | Absolute http(s) page URL to scan. Required, max 2048 chars. |

## Request

```http
POST /v1/page/accessibility HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "url": "https://seojuice.io/"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "acce_ivghjr2ro5lghxhoy53ojbrf",
  "request_id": "req_vvf7y26enjcp5myc7ypywdc5bi",
  "object": "accessibility",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 2,
    "balance": 9857
  },
  "data": {
    "url": "https://seojuice.io/",
    "status": "complete",
    "violations": [
      {
        "id": "color-contrast",
        "impact": "serious",
        "help": "Elements must meet minimum color contrast ratio thresholds",
        "help_url": "https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI",
        "nodes_count": 77
      }
    ],
    "passes_count": 23,
    "incomplete_count": 1,
    "keyboard": {
      "interactive_total": 100,
      "reached": 95,
      "focus_trap": false,
      "has_skip_link": false
    },
    "engine": {
      "axe_version": "4.10.3"
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.url` | The URL you requested. |
| `data.status` | Scan outcome: "complete" (axe + keyboard walk both ran), "partial" (axe ran, the keyboard walk was skipped or truncated), or "blocked" (no work product -- refunded automatically). |
| `data.violations` | Axe rule violations found on the page, one entry per rule. |
| `data.violations[].id` | Axe rule id, e.g. "color-contrast". |
| `data.violations[].impact` | Axe severity for this rule: minor, moderate, serious, or critical. |
| `data.violations[].help` | One-line description of what the rule requires. |
| `data.violations[].help_url` | Deque University reference page for the rule. |
| `data.violations[].nodes_count` | How many elements on the page violate this rule. |
| `data.passes_count` | Number of axe rules the page passed. |
| `data.incomplete_count` | Number of rules axe could not conclusively evaluate. |
| `data.keyboard` | Keyboard-navigation walk results. |
| `data.keyboard.interactive_total` | Interactive elements found on the page. |
| `data.keyboard.reached` | How many of them keyboard focus could reach. |
| `data.keyboard.focus_trap` | true if focus got stuck somewhere it couldn't leave. |
| `data.keyboard.has_skip_link` | true if the page offers a skip-to-content link. |
| `data.engine` | Scanner version info. |
| `data.engine.axe_version` | The axe-core version that produced this scan. |


---

# POST /v1/site/audit

Crawl and audit a whole site; billed per delivered page (sum of enabled checks x pages delivered).

**Credits:** 2 credits per delivered page per enabled check -- billed on what's actually delivered, never a flat rate.

**Timeout:** 600s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain or URL to crawl -- the hostname is extracted from either form. Required, max 253 chars. |
| `max_pages` | integer | yes | — | Maximum number of pages to crawl before stopping. 1-500. |
| `checks` | array | no | ["crawl"] | Which checks to run per crawled page. Optional, default ["crawl"]. Duplicates removed, order preserved. Array of: crawl, lighthouse, accessibility. |
| `device` | string | no | "mobile" | One of: mobile, desktop. Optional, default "mobile". |

## Request

```http
POST /v1/site/audit HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com",
  "max_pages": 5,
  "checks": [
    "crawl",
    "lighthouse"
  ],
  "device": "mobile"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "site_lcno3bogfk2jbvuomhff5772",
  "request_id": "req_lkw2glfn25do5ou42eqfequrem",
  "object": "site_audit",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 12,
    "balance": 9857
  },
  "data": {
    "domain": "example.com",
    "pages_requested": 5,
    "pages_crawled": 4,
    "pages_delivered": 3,
    "credits_charged": 12,
    "pages": [
      {
        "url": "https://example.com/",
        "status": "ok",
        "crawl": {
          "http_status": 200,
          "links": {
            "internal": 2,
            "external": 0
          }
        },
        "lighthouse": {
          "device": "mobile",
          "scores": {
            "performance": 90,
            "accessibility": 91,
            "best_practices": 93,
            "seo": 80
          }
        }
      },
      "…"
    ],
    "summary": {
      "score": {
        "value": 82,
        "band": "excellent",
        "grade": "A"
      },
      "axes": [
        {
          "key": "speed",
          "score": 73,
          "band": "good"
        }
      ],
      "issues": {
        "critical": 2,
        "major": 1,
        "minor": 1
      },
      "broken_links": 1,
      "pages_with_errors": 1
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The resolved hostname that was crawled. |
| `data.pages_requested` | max_pages from the request, echoed back. |
| `data.pages_crawled` | Number of pages the crawl actually visited (can be less than pages_requested on a smaller site, never more). |
| `data.pages_delivered` | Number of pages that passed every enabled check -- the billing unit. |
| `data.credits_charged` | Actual credits charged for this call: pages_delivered times the enabled checks' per-page cost -- 0 on a replay, since a replay is never re-billed. Always equal to the envelope's own credits.charged. |
| `data.pages` | One row per crawled page, whether delivered or not. |
| `data.pages[].url` | The page URL. |
| `data.pages[].status` | "ok" if every enabled check passed for this page, "error" otherwise -- an error page isn't billed. |
| `data.pages[].crawl` | This page's crawl-check result, when the crawl check is enabled and passed -- same shape as the page/crawl endpoint's data. |
| `data.pages[].crawl.http_status` | HTTP status code for this page. |
| `data.pages[].crawl.links` | Outbound link counts for this page. |
| `data.pages[].crawl.links.internal` | Links pointing at the same site. |
| `data.pages[].crawl.links.external` | Links pointing off-site. |
| `data.pages[].lighthouse` | This page's lighthouse-check result, when the lighthouse check is enabled and passed -- same scores/metrics shape as the page/lighthouse endpoint's data, but without that endpoint's audits/screenshots (kept lean per-page so a multi-page site audit doesn't balloon). |
| `data.pages[].lighthouse.device` | Device profile the audit ran under. |
| `data.pages[].lighthouse.scores` | The same four 0-100 category scores as the standalone lighthouse endpoint. |
| `data.summary` | Site-wide rollup computed across every delivered page. |
| `data.summary.score` | Overall site score. |
| `data.summary.score.value` | 0-100 overall score. |
| `data.summary.score.band` | "excellent" (80+), "good" (50+), or "improve" (below 50). |
| `data.summary.score.grade` | Letter grade A-F derived from value. |
| `data.summary.axes` | Per-dimension scores (speed, accessibility) for whichever checks were enabled. |
| `data.summary.axes[].key` | Which dimension this axis measures, e.g. "speed". |
| `data.summary.axes[].score` | 0-100 score for this axis. |
| `data.summary.axes[].band` | Same excellent/good/improve banding as score.band, for this axis. |
| `data.summary.issues` | Accessibility issues found, bucketed by severity (only populated when the accessibility check is enabled). |
| `data.summary.issues.critical` | Count of critical-severity issues. |
| `data.summary.issues.major` | Count of major-severity issues. |
| `data.summary.issues.minor` | Count of minor-severity issues. |
| `data.summary.broken_links` | Count of internal links returning a non-2xx status, across delivered pages. |
| `data.summary.pages_with_errors` | Count of pages that failed to load cleanly or failed the crawl check. |


---

# POST /v1/serp/history

Position history for a (domain, keyword) pair from our SERP archive.

**Credits:** 5 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to check ranking history for (bare hostname, lowercased). Required, max 255 chars, no spaces. |
| `keyword` | string | yes | — | Keyword to check ranking history for. Required, max 700 chars. |
| `engine` | string | no | "google" | Optional, default "google". Same enum as /v1/search. |
| `location` | any | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `device` | string | no | "desktop" | One of: desktop, mobile. Optional, default "desktop". |
| `date_from` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default 730 days ago -- the rankings archive's TTL floor. An empty result is still a chargeable answer: coverage.observations counts the distinct days we archived this exact SERP in the window, so it can mean either "we observed you on N days and you never ranked" (observations > 0) or "we never looked" (observations: 0). |
| `date_to` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default today. |

## Request

```http
POST /v1/serp/history HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com",
  "keyword": "best running shoes",
  "engine": "google",
  "location": 2840,
  "language": "en",
  "device": "desktop",
  "date_from": "2026-05-20",
  "date_to": "2026-07-28"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "serp_sxgzphkladwxhiusdqqo22fp",
  "request_id": "req_vr3kmza745ajjgxnnhxq7earuq",
  "object": "serp_history",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 5,
    "balance": 9857
  },
  "data": {
    "domain": "example.com",
    "keyword": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "points": [
      {
        "date": "2026-06-03",
        "rank": 9,
        "url": "https://example.com/best-running-shoes",
        "type": "organic"
      },
      {
        "date": "2026-06-17",
        "rank": 6,
        "url": "https://example.com/best-running-shoes",
        "type": "organic"
      }
    ],
    "first_seen": "2026-06-03",
    "best_rank": 6,
    "coverage": {
      "from": "2026-05-20",
      "observations": 14
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The domain you requested. |
| `data.keyword` | The keyword you requested. |
| `data.engine` | Search engine this history is drawn from. |
| `data.location` | Google Ads geotarget ID this history is drawn from. |
| `data.language` | ISO language code this history is drawn from. |
| `data.device` | Device this history is drawn from: desktop or mobile. |
| `data.points` | One entry per day with an archived observation for this (domain, keyword) pair, in the requested date window. |
| `data.points[].date` | Date of this observation, YYYY-MM-DD. |
| `data.points[].rank` | Absolute rank on that day's results page, or null if the domain didn't appear. |
| `data.points[].url` | URL that ranked, or null if the domain didn't appear. |
| `data.points[].type` | Result type this rank came from, e.g. "organic". |
| `data.first_seen` | Date of the earliest point in this response, or null if points is empty. |
| `data.best_rank` | Best (lowest-numbered) rank across all points, or null if the domain never appeared. |
| `data.coverage` | How much of the rankings archive this response drew from. |
| `data.coverage.from` | Earliest date the archive could have observed, floor-clamped to the archive's retention window. |
| `data.coverage.observations` | Number of distinct days we archived this exact result page in the window -- can be > 0 even when every point shows no ranking. |


---

# POST /v1/domains/card

Company card — identity, contacts, socials, technology from our crawl.

**Credits:** 10 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to look up (bare hostname, lowercased). Required, max 255 chars, no spaces. |

## Request

```http
POST /v1/domains/card HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "doma_ecpmjh6xnsbdqrtvyec2luxo",
  "request_id": "req_j4ajuwyu4bhqzipr24dusvrcza",
  "object": "domain_card",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 10,
    "balance": 9857
  },
  "data": {
    "domain": "example.com",
    "site_name": "Example Inc.",
    "description": "Compression socks for trail runners.",
    "logo_url": "https://example.com/logo.svg",
    "emails": [
      "hello@example.com"
    ],
    "phones": [
      "+1 415 555 0100"
    ],
    "postal_address": "548 Market St, San Francisco, CA",
    "social_profiles": {
      "linkedin": "https://linkedin.com/company/example",
      "x": "https://x.com/example"
    },
    "technology": {
      "cms": "wordpress",
      "framework": null,
      "server": "nginx",
      "hosting": "cloudflare"
    },
    "country": "US",
    "language": "en",
    "as_of": "2026-07-21"
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The domain you requested. |
| `data.site_name` | Company/site display name, or null if not identified. |
| `data.description` | Site description, or null if not identified. |
| `data.logo_url` | URL of the site's logo, or null if not identified. |
| `data.emails` | Contact email addresses found on the site; empty array if none. |
| `data.phones` | Contact phone numbers found on the site; empty array if none. |
| `data.postal_address` | Postal address found on the site, or null if none. |
| `data.social_profiles` | Social profile URLs found on the site, keyed by platform (e.g. "linkedin", "x"); empty object if none. |
| `data.technology` | Detected technology stack. |
| `data.technology.cms` | Detected CMS, or null if not identified. |
| `data.technology.framework` | Detected web framework, or null if not identified. |
| `data.technology.server` | Detected server software, or null if not identified. |
| `data.technology.hosting` | Detected hosting provider, or null if not identified. |
| `data.country` | Detected country, or null if not identified. |
| `data.language` | Detected primary language, or null if not identified. |
| `data.as_of` | Date this card was last built from a crawl. |


---

# POST /v1/keywords/questions

People-Also-Ask questions observed for a topic, deduplicated.

**Credits:** 20 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `keyword` | string | yes | — | Topic keyword to fetch People-Also-Ask questions for. Required, max 700 chars. |
| `location` | any | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `limit` | integer | no | 50 | Optional, default 50. 1-200. |

## Request

```http
POST /v1/keywords/questions HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "keyword": "running shoes",
  "location": 2840,
  "language": "en",
  "limit": 50
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "keyw_55nvyskckd6mfq6ssxnhsot3",
  "request_id": "req_iqspzqzuibgingwspgjtljsqie",
  "object": "keyword_questions",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 20,
    "balance": 9857
  },
  "data": {
    "keyword": "running shoes",
    "items": [
      {
        "question": "How often should you replace running shoes?",
        "first_seen": "2026-06-11",
        "last_seen": "2026-07-25",
        "times_seen": 18
      },
      {
        "question": "Are carbon plate shoes worth it?",
        "first_seen": "2026-07-02",
        "last_seen": "2026-07-27",
        "times_seen": 7
      },
      "…"
    ],
    "count": 42,
    "coverage": {
      "from": "2026-07-01",
      "observations": 130
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.keyword` | The keyword you requested. |
| `data.items` | People-Also-Ask questions observed for this keyword, up to limit. |
| `data.items[].question` | The question text. |
| `data.items[].first_seen` | Date this question was first observed. |
| `data.items[].last_seen` | Date this question was last observed. |
| `data.items[].times_seen` | Number of times this question has been observed. |
| `data.count` | Total number of distinct questions found, before limit truncation. |
| `data.coverage` | How much of the archive this response drew from. |
| `data.coverage.from` | Earliest date the archive could have observed, floor-clamped to the archive's retention window. |
| `data.coverage.observations` | Number of times we checked for this keyword in the window -- can be > 0 even when items is empty. |


---

# POST /v1/ai/visibility

How often AI assistants mention a domain, per provider, over time.

**Credits:** 10 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to check AI-assistant mentions for (bare hostname, lowercased). Required, max 255 chars, no spaces. |
| `providers` | array | no | ["chatgpt", "perplexity", "gemini", "copilot", "lechat"] | Optional, default all five (order preserved). Must be non-empty when present; unknown or internal-only lanes are rejected, never exposed. Array of: chatgpt, perplexity, gemini, copilot, lechat. |
| `granularity` | string | no | "week" | One of: day, week. Optional, default "week". |
| `date_from` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default 90 days ago. |
| `date_to` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default today. |

## Request

```http
POST /v1/ai/visibility HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com",
  "providers": [
    "chatgpt",
    "perplexity"
  ],
  "date_from": "2026-06-01",
  "date_to": "2026-07-28",
  "granularity": "week"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "ai_v_66yi55jaw3tgkp5htto37w7e",
  "request_id": "req_gbssrpg4zbbxzn2ys3uwswdll4",
  "object": "ai_visibility",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 10,
    "balance": 9857
  },
  "data": {
    "domain": "example.com",
    "providers": {
      "chatgpt": {
        "mentions": 41,
        "prompts_seen": 380,
        "avg_rank": 2.6,
        "avg_sentiment": 0.31,
        "series": [
          {
            "period": "2026-07-14",
            "mentions": 6,
            "prompts_seen": 55,
            "avg_rank": 2.2
          },
          {
            "period": "2026-07-21",
            "mentions": 9,
            "prompts_seen": 61,
            "avg_rank": 2.9
          }
        ]
      },
      "perplexity": {
        "mentions": 12,
        "prompts_seen": 140,
        "avg_rank": 3.4,
        "avg_sentiment": 0.12,
        "series": []
      }
    },
    "coverage": {
      "from": "2026-06-01",
      "observations": 520,
      "note": "mentions are drawn from prompts run through this API; not a census of all AI traffic"
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The domain you requested. |
| `data.providers` | Per-provider mention stats, keyed by provider name (only the providers you requested). |
| `data.providers.chatgpt` | Stats for one provider (chatgpt shown here; every requested provider has this same shape). |
| `data.providers.chatgpt.mentions` | Number of times this domain was mentioned by this provider in the window. |
| `data.providers.chatgpt.prompts_seen` | Number of prompts run through this provider in the window. |
| `data.providers.chatgpt.avg_rank` | Average position of this domain's mention among an answer's cited sources, or null if never mentioned. |
| `data.providers.chatgpt.avg_sentiment` | Average sentiment of mentions, roughly -1 (negative) to 1 (positive), or null if never mentioned. |
| `data.providers.chatgpt.series` | Per-period breakdown, bucketed by the requested granularity (day or week). |
| `data.providers.chatgpt.series[].period` | Start date of this bucket, YYYY-MM-DD. |
| `data.providers.chatgpt.series[].mentions` | Mentions in this period. |
| `data.providers.chatgpt.series[].prompts_seen` | Prompts run through this provider in this period. |
| `data.providers.chatgpt.series[].avg_rank` | Average mention rank in this period, or null if none. |
| `data.coverage` | How much of the AI-observation archive this response drew from. |
| `data.coverage.from` | Start of the requested date window. |
| `data.coverage.observations` | Number of prompts observed across all requested providers in the window. |
| `data.coverage.note` | Caveat on what "mentions" means -- read the value itself for the exact wording. |


---

# GET /v1/locations

Look up Google Ads geotarget IDs by name — country, state/region, city, or neighborhood.

**Credits:** Free -- uncharged and unmetered.

**Timeout:** 10s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `q` | string | yes | — | Name to search for. Required, min 2 chars. Common names match many places worldwide -- narrow multi-match names with a region or country word, e.g. "austin texas" instead of just "austin". |
| `limit` | integer | no | 10 | Optional, default 10. 1-100. |

## Request

```http
GET /v1/locations?q=austin+texas HTTP/1.1
Authorization: Bearer sof_live_your_key_here

```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "loca_zyhrt7nmecjgs5cigyo2qbxb",
  "request_id": "req_rb664qn7bjf5raohs5yjybaj4e",
  "object": "location_list",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 0,
    "balance": 9857
  },
  "data": {
    "locations": [
      {
        "id": 1026201,
        "name": "Austin,Texas,United States",
        "type": "city",
        "country": "US",
        "parent_id": 21176
      },
      {
        "id": 9198393,
        "name": "Central Austin,Texas,United States",
        "type": "neighborhood",
        "country": "US",
        "parent_id": 21176
      },
      {
        "id": 9060225,
        "name": "Downtown Austin,Texas,United States",
        "type": "neighborhood",
        "country": "US",
        "parent_id": 21176
      },
      {
        "id": 9198961,
        "name": "East Austin,Texas,United States",
        "type": "neighborhood",
        "country": "US",
        "parent_id": 21176
      },
      {
        "id": 9194496,
        "name": "Old West Austin,Texas,United States",
        "type": "neighborhood",
        "country": "US",
        "parent_id": 21176
      },
      {
        "id": 9197266,
        "name": "South Austin,Texas,United States",
        "type": "neighborhood",
        "country": "US",
        "parent_id": 21176
      }
    ]
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.locations` | Matching geotargets, ranked by relevance (exact match, then prefix, then substring), up to limit. |
| `data.locations[].id` | Google Ads geotarget ID -- pass this as the location param on any other endpoint. |
| `data.locations[].name` | Canonical name, comma-separated from most to least specific, e.g. "Austin,Texas,United States". |
| `data.locations[].type` | Geotarget type, e.g. "country", "state", "region", "city", "neighborhood". |
| `data.locations[].country` | ISO country code this geotarget belongs to. |
| `data.locations[].parent_id` | Geotarget ID of the immediate parent (e.g. a city's state), or null for a top-level geotarget. |


---

# POST /v1/domains/competitors

Competitor discovery from the SERP archive: domains that rank for the same keywords, ranked by overlap.

**Credits:** 30 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to find competitors for (bare hostname, lowercased). Required, max 255 chars, no spaces. |
| `engine` | string | no | "google" | Optional, default "google". Same enum as /v1/search. |
| `location` | integer | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `device` | string | no | "desktop" | One of: desktop, mobile. Optional, default "desktop". |
| `limit` | integer | no | 25 | Number of competitors to return. Optional, default 25. 1-100. |
| `date_from` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default 730 days ago -- sets how far back in the archive the comparison reaches. |

## Request

```http
POST /v1/domains/competitors HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "semrush.com",
  "engine": "google",
  "location": 2840,
  "language": "en",
  "device": "desktop",
  "limit": 25
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "doma_xntqno2lnczhuc4ockqhosor",
  "request_id": "req_jrliy36atng6jezngcakswbqam",
  "object": "domain_competitors",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 30,
    "balance": 9857
  },
  "data": {
    "domain": "semrush.com",
    "keywords_total": 2834,
    "keywords_considered": 200,
    "total_competitors": 1743,
    "competitors": [
      {
        "domain": "reddit.com",
        "shared_keywords": 106,
        "keywords_total": 114768,
        "avg_rank": 3.9,
        "target_avg_rank": 1,
        "overlap": 0.53,
        "shared_volume": 370910,
        "sample_keywords": [
          {
            "keyword": "google search console",
            "volume": 368000
          },
          "…"
        ]
      },
      "…"
    ],
    "coverage": {
      "from": "2026-05-20",
      "observations": 200
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The domain you requested. |
| `data.keywords_total` | Total keywords the domain ranks for (unbounded by the archive window). |
| `data.keywords_considered` | Number of the domain's archived keyword rankings actually considered for this comparison (bounded by the archive window). |
| `data.total_competitors` | Total number of competing domains found, before limit truncation. |
| `data.competitors` | Competing domains, ranked by keyword overlap, up to limit. |
| `data.competitors[].domain` | The competitor's domain. |
| `data.competitors[].shared_keywords` | Number of keywords both domains rank for. |
| `data.competitors[].keywords_total` | Total keywords the competitor ranks for. |
| `data.competitors[].avg_rank` | Competitor's average rank across the shared keywords. |
| `data.competitors[].target_avg_rank` | Your domain's average rank across the same shared keywords. |
| `data.competitors[].overlap` | shared_keywords / keywords_considered, rounded to 2dp; null when keywords_considered is 0. |
| `data.competitors[].shared_volume` | Combined search volume of the shared keywords. |
| `data.competitors[].sample_keywords` | A few example shared keywords, each with its search volume. |
| `data.competitors[].sample_keywords[].keyword` | The sample keyword. |
| `data.competitors[].sample_keywords[].volume` | Search volume for the sample keyword. |
| `data.coverage` | How much of the rankings archive this comparison drew from. |
| `data.coverage.from` | Earliest observation date the comparison could have used. |
| `data.coverage.observations` | Same as keywords_considered. |


---

# POST /v1/domains/gap

Keyword gap between two domains: opportunity, shared, or unique rankings, volume-enriched.

**Credits:** 15 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domains` | array | yes | — | Put your own domain first -- every mode is defined relative to domains[0]. Array of string. Max 2 items. |
| `mode` | string | no | "gap" | gap: keywords domains[1] ranks for that domains[0] does not. shared: both rank. unique: only domains[0] ranks. Optional, default "gap". |
| `engine` | string | no | "google" | Optional, default "google". Same enum as /v1/search. |
| `location` | integer | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `device` | string | no | "desktop" | One of: desktop, mobile. Optional, default "desktop". |
| `date_from` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default 730 days ago -- sets how far back in the archive the comparison reaches. |
| `limit` | integer | no | 100 | Optional, default 100. 1-1000. |
| `offset` | integer | no | 0 | Optional, default 0. 0-10000. |

## Request

```http
POST /v1/domains/gap HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domains": [
    "semrush.com",
    "ahrefs.com"
  ],
  "mode": "gap",
  "engine": "google",
  "location": 2840,
  "language": "en",
  "device": "desktop",
  "limit": 100,
  "offset": 0
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "doma_i2qqfnddm5ij4xuxqewmjq2w",
  "request_id": "req_wr6frwu22zhzjmpi4wfuig3uke",
  "object": "domain_gap",
  "created_at": "2026-08-09T07:23:20Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 15,
    "balance": 9857
  },
  "data": {
    "mode": "gap",
    "domains": [
      "semrush.com",
      "ahrefs.com"
    ],
    "items": [
      {
        "keyword": "search engine optimization",
        "volume": 18100,
        "ranks": [
          null,
          {
            "rank": 27,
            "url": "https://ahrefs.com/blog/what-is-seo/",
            "date": "2026-06-28"
          }
        ]
      },
      "…"
    ],
    "totals": [
      2834,
      1315
    ],
    "matched_count": 409,
    "coverage": {
      "from": "2026-06-28",
      "observations": 409
    },
    "limit": 100,
    "offset": 0
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.mode` | Comparison mode that was applied: gap, shared, or unique. |
| `data.domains` | The two domains you compared, in the order you sent them. |
| `data.items` | One row per matching keyword, up to limit. |
| `data.items[].keyword` | The keyword. |
| `data.items[].volume` | Search volume for the keyword. |
| `data.items[].ranks` | Two-element array positionally aligned to domains: ranks[0] is domains[0]'s rank, ranks[1] is domains[1]'s rank on this keyword. Each entry is null (that domain doesn't rank) or {rank, url, date}. |
| `data.totals` | Two-element array positionally aligned to domains: total ranked-keyword count for each domain. |
| `data.matched_count` | Total keywords matching mode, before limit/offset truncation. |
| `data.coverage` | How much of the rankings archive this comparison drew from. |
| `data.coverage.from` | Earliest observation date among the returned items, floor-clamped to date_from. |
| `data.coverage.observations` | Same as matched_count. |
| `data.limit` | Page size actually used (echoes the request). |
| `data.offset` | Pagination offset actually used (echoes the request). |
