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.
{
"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.
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:
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.
{
"error": {
"type": "invalid_request_error",
"code": "idempotency_key_reused",
"message": "This Idempotency-Key was already used with a different request body.",
"param": "Idempotency-Key"
}
}
<= 6h stored result returned -- a cache read, uncharged, no new work 6h -> 24h 409 idempotency_key_result_expired -- nothing runs, nothing charged
{
"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:
{
"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"
}
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.
{
"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):
{
"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"
}
}
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