# 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_vaqa36dz2qble5iobr3wz2eb",
  "request_id": "req_kp6yi3y2ojevfnzoqeemujf2my",
  "object": "search",
  "created_at": "2026-08-08T17:16:27Z",
  "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_hhbkgxlg45gulf2yg3in7asrfq"
}
```

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