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.
{
"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.
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:
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.
{
"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
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:
{
"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"
}
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:
{
"credits": {
"charged": 10,
"balance": 9847
}
}
/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.
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
{
"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:
{
"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 502 upstream_error upstream_blocked 502 upstream_error upstream_error 504 upstream_timeout upstream_timeout