# The Contract

Read this once. Every endpoint answers on the connection you opened: no task IDs, no polling loop, no webhooks. A job that outlives the connection gets a 504 you reconnect to, and that is the whole exception. Beyond that, one envelope, one idempotency rule, one timeout contract, one way of charging. The three exceptions (search's depth pricing, site audit's per-page metering, the free GET /v1/locations) are called out where the rule is stated. Everything else that is endpoint-specific lives on the reference pages.

## The envelope

A successful response has seven fields. Six never change: id, request_id, object, created_at, elapsed_ms, credits. The seventh, data, is where your results live. All twenty-one endpoints read the same way.

credits.charged is what this call cost (0 on a replay). credits.balance is what's left.

```json
{
  "id": "srch_7f34stnlffnsbalonq66dpuj",
  "request_id": "req_7e5465ge5fjmziqx5dyueaqyvy",
  "object": "search",
  "created_at": "2026-07-29T12:00:00Z",
  "elapsed_ms": 244,
  "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": [
          {
            "title": "Best Trail Running Shoes",
            "url": "https://example.com/best-running-shoes/trail",
            "description": null
          },
          {
            "title": "Best Budget Running Shoes",
            "url": "https://example.com/best-running-shoes/budget",
            "description": null
          }
        ],
        "price": null,
        "highlighted_words": [
          "running shoes"
        ]
      }
    ]
  }
}
```

## Authentication

Every request carries an Authorization: Bearer header with a key from the dashboard.

A missing, malformed or unknown key gets 401 (authentication_error / key_invalid). A revoked key gets the same status with code key_revoked. A key 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. You choose the value: a UUID, a job id, keyword plus date, a commit SHA. Anything you can reconstruct later. Format: Idempotency-Key must be 8-255 chars of [A-Za-z0-9_-].

Send none and the request just runs, charged normally. Every success response carries a request_id, and so does the 504 you get when a job outruns the connection. Send it back as your Idempotency-Key, or send your own key up front, and you're never charged twice. That holds keyed or keyless, whether the job is still running or already done.

A replay is a cache read, not a re-run. Present the same key or request_id and you get the same data back, uncharged, nothing re-runs, for 6 hours after the original request. What happens outside that window is under Retries are free.

A key is scoped to the one endpoint it was first used on. At any other endpoint it is rejected before anything runs or is charged.

GET endpoints are uncharged and need no Idempotency-Key. Today that means GET /v1/locations only. Its request_id is not a replay handle; there is no charge and no stored run behind it.

Skipping the key is fine for interactive and agent use. It is wrong for a pipeline that can crash before it reads the response: a keyless call that never sees its 504 has no request_id to recover, and a blind keyless retry is a new charged call. Mint your own key up front for those.

Worked example, 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 when the original call ran. Still running: the retry waits and collects the result, charged once. Finished less than 6 hours ago: you get the stored result, a cache read, uncharged. Older than that: the result is gone but the key still blocks a second charge. You get 409 idempotency_key_result_expired, nothing runs, nothing is charged.

A stored failure is just as sticky as a stored success. If the original call failed with upstream_error or upstream_timeout it was already refunded, so every replay inside the 6-hour window costs nothing. It also hands back that same failure, not a fresh shot at succeeding. For a real retry, use a new key.

Past 24 hours a key that ever ran to completion, success or refunded failure, is retired: reusing it is rejected (400 idempotency_key_reused), never quietly treated as a fresh charged request. One exception: a call rejected before it ever ran, 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 a 400 at any point.

```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

Calls are synchronous: the result comes back on the same connection. If the job is still running when the connection would time out, you get a 504. That is not a failure. The credits are charged and the job keeps running. Reconnect by POSTing the exact same body again, with the key you sent or with the request_id from the 504 body as your Idempotency-Key. That works keyed or keyless: both resolve to the same running job and return the result when it lands, and for 6 hours after.

A 504 means one of two things, so branch on code rather than the status. connection_timeout: the charge stands and there is nothing to refund. Reconnect inside the 6-hour window and you get the result; past it, a reconnect against a finished job comes back 409 (see Retries are free). upstream_timeout: the data did not arrive in time. A definite failure, already refunded; reconnecting returns nothing. Each 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_7lku3wasp5j63hnl5bssybmvlq"
}
```

```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/ideas      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
POST /v1/backlinks/changes   60s ceiling
```

## Credits & refunds

Credits are charged when the request is accepted, before any work runs. If we fail to deliver (upstream_error or upstream_timeout) the charge goes back to your balance automatically. You never pay for data we didn't produce.

Most endpoints charge a flat rate per call. Two don't. /v1/search is priced by depth: depth=10 (the default) costs 1 credit, depth=100 costs 10 credits. /v1/site/audit is metered per delivered page and enabled check, because the total isn't known until the crawl finishes.

Every endpoint's price, by path:

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

```text
/v1/search               1-10 credits (depth 10-100)
/v1/keywords/volume      20 credits
/v1/keywords/difficulty  110 credits
/v1/backlinks/summary    20 credits
/v1/backlinks/list       50 credits
/v1/backlinks/domains    50 credits
/v1/backlinks/anchors    50 credits
/v1/domains/overview     10 credits
/v1/page/lighthouse      2 credits
/v1/page/crawl           2 credits
/v1/page/accessibility   2 credits
/v1/site/audit           metered -- per delivered page x enabled check
/v1/serp/history         5 credits
/v1/domains/card         10 credits
/v1/keywords/ideas       110 credits
/v1/keywords/questions   20 credits
/v1/ai/visibility        10 credits
/v1/locations            free
/v1/domains/competitors  30 credits
/v1/domains/gap          15 credits
/v1/backlinks/changes    50 credits
```

## Rate limits

Throughput is capped per hour and per organization. Not per second, not per key. The cap follows net USD spent in the trailing 30 days rather than the last pack you bought. Spend accumulates: five $49 packs inside the window reach the $199 tier. A top-up never lowers your tier.

Over the cap, the request is rejected with a 429 ("rate_limit_error" / "rate_limited"). Nothing queues, nothing slows down.

This is a ceiling we enforce, not capacity we promise. Some endpoints take long enough per call that you will not reach it. Need more? Email support@seofetch.com.

```text
under $49 spent  100/hour
$49+ spent       1,000/hour
$199+ spent      3,000/hour
$499+ spent      6,000/hour
$999+ spent      12,000/hour
```

```json
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Throughput limit exceeded: 100 requests per hour. Contact support@seofetch.com for higher throughput."
  }
}
```

## Errors

Every error carries an "error" object of the same shape: type, code, message, and an optional param naming the field at fault. type groups the failure. code is what you branch on.

A 5xx means retry. A 4xx means fix the request.

The 23 request-validation codes (invalid_url, invalid_depth, invalid_query and so on) all share type invalid_request_error and status 400; code says which one fired. The common pairs, plus one more 504 code (connection_timeout) 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
502  upstream_error         upstream_blocked
502  upstream_error         upstream_error
504  upstream_timeout       upstream_timeout
```
