# Error Handling (/docs/errors) Every SocialCrawl error code with its status, retry verdict, and refund behavior, plus copy-paste backoff loops Every failure returns the same JSON envelope: a stable `error.type` to branch on, an HTTP `status`, an `error.retryable` flag, and a `doc_url` pointing at that code's entry on this page. Six failures are worth retrying. `credits_used` always reports the charge committed at response time; automatic compensation covers the rare case where an immediate refund cannot commit. ```json title="Response" { "success": false, "error": { "type": "INSUFFICIENT_CREDITS", "message": "Your account has 0 credits remaining. This endpoint requires 1 credits. Retrying this request will not succeed until your account balance changes.", "status": 402, "doc_url": "https://www.socialcrawl.dev/docs/errors#insufficient-credits", "retryable": false, "details": { "reason": "balance_too_low", "retry_will_succeed": false, "next_step": "Top up your credits on the billing page (or turn on auto-recharge there), then send the request again.", "top_up_url": "https://www.socialcrawl.dev/dashboard/billing/payments", "credits_required": 1 } }, "credits_used": 0, "credits_remaining": 0, "request_id": "req-abc123" } ``` Four fields carry the debugging weight: - **`error.type`** is what you branch on. It is stable for a given failure and never changes meaning under `/v1`. - **`error.retryable`** is `true` when sending the identical request again can succeed, and `false` when it will fail the same way until you change something. It matches the Retryable column in the table below, so a client can decide without keeping its own list of codes. - **`credits_used`** is the **net committed** charge for the failed request. It is 0 when no deduction occurred or the refund committed. A non-zero value on a retryable failure means the immediate refund did not commit and automatic compensation is pending; retain `request_id`. - **`request_id`** matches the `X-Request-Id` header, which is present on every response, success or error. Include it when you contact support so we can trace the exact request in our logs. The `doc_url` is an anchor on this page, so you can jump straight to a code with `#insufficient-credits` or `#upstream-error`. ## Error codes | Code | Status | Retryable | Refund | What it means | | ------------------------------------------------------------------------------- | ------ | ------------------ | ---------------------------------- | --------------------------------------------------------------------------------- | | [`MISSING_API_KEY`](#missing-api-key) | 401 | No | Not charged | No API key on the request: send `x-api-key` or `Authorization: Bearer` | | [`INVALID_API_KEY`](#invalid-api-key) | 401 | No | Not charged | Key is malformed, not found, revoked, or expired | | [`INSUFFICIENT_SCOPE`](#insufficient-scope) | 403 | No | Not charged | An OAuth access token lacks a scope this endpoint needs | | [`INSUFFICIENT_CREDITS`](#insufficient-credits) | 402 | No | Not charged | Balance is lower than the endpoint cost | | [`KEY_BUDGET_EXCEEDED`](#key-budget-exceeded) | 402 | No | Not charged | This key has spent its own per-key credit limit. The account balance is untouched | | [`INVALID_REQUEST`](#invalid-request) | 400 | No | Not charged | Missing, malformed, conflicting, or non-empty undeclared parameters | | [`METHOD_NOT_ALLOWED`](#method-not-allowed) | 405 | No | Not charged | The method is not accepted on this path | | [`ENDPOINT_NOT_FOUND`](#endpoint-not-found) | 404 | No | Not charged | Platform or resource is not supported | | [`RESOURCE_NOT_FOUND`](#resource-not-found) | 404 | No | Refunded | Upstream returned 404, or the resource exists but contains no usable data | | [`PAYLOAD_TOO_LARGE`](#payload-too-large) | 413 | No | Not charged | The JSON request body exceeds the 1 MB size limit, rejected before parsing | | [`IDEMPOTENCY_KEY_CONFLICT`](#idempotency-key-conflict) | 409 | No | Not charged | The key conflicts with an incompatible active reservation | | [`IDEMPOTENCY_IN_PROGRESS`](#idempotency-in-progress) | 409 | Yes, Retry-After | Not charged | Another request with the same key and payload is still running | | [`IDEMPOTENCY_REPLAY_UNAVAILABLE`](#idempotency-replay-unavailable) | 409 | No | Not charged | The completed response exceeded the replay limit (160 KB compressed) | | [`IDEMPOTENCY_KEY_PAYLOAD_MISMATCH`](#idempotency-key-payload-mismatch) | 422 | No | Not charged | You reused an `Idempotency-Key` with different query parameters or request body | | [`COHORT_LIMIT_EXCEEDED`](#cohort-limit-exceeded) | 400 | No | Not charged | The account already holds the maximum of 100 cohorts | | [`COHORT_MEMBER_LIMIT_EXCEEDED`](#cohort-member-limit-exceeded) | 400 | No | Not charged | A cohort member upload would exceed the cohort limit | | [`COHORT_IDENTITY_PLATFORM_UNSUPPORTED`](#cohort-identity-platform-unsupported) | 400 | No | Not charged | The identity platform is not supported for cohort queries | | [`COHORT_IDENTITY_CONFLICT`](#cohort-identity-conflict) | 409 | No | Not charged | A normalized identity is already assigned to another external ID | | [`COHORT_QUERY_NOT_CANCELLABLE`](#cohort-query-not-cancellable) | 409 | No | Not charged | The cohort query is terminal and cannot be cancelled | | [`COHORT_QUERY_NOT_READY`](#cohort-query-not-ready) | 409 | No | Not charged | The cohort query has not completed | | [`COHORT_RESULT_TOO_LARGE`](#cohort-result-too-large) | 413 | No | Not charged | One cohort result exceeds the response-page byte ceiling | | [`RATE_LIMITED`](#rate-limited) | 429 | Yes, after backoff | Not charged | More than 600 requests in a 1-minute window on the same API key | | [`CONCURRENCY_LIMIT`](#concurrency-limit) | 429 | Yes, after backoff | Not charged | More than 50 concurrent requests on the same API key | | [`UPSTREAM_ERROR`](#upstream-error) | 502, 504 | Yes, with backoff | Refunded | Upstream platform returned an error, or the call reached the platform time limit | | [`SERVICE_UNAVAILABLE`](#service-unavailable) | 503 | Yes, with backoff | Refunded | Circuit breaker open, upstream provider throttling us, or a withdrawn endpoint | | [`INTERNAL_ERROR`](#internal-error) | 500 | Yes, with backoff | Refunded | Unexpected error on our side | | [`RUN_NOT_FOUND`](#run-not-found) | 404 | No | Not charged | No discover run with this id belongs to your account | | [`RUN_STATE_CONFLICT`](#run-state-conflict) | 409 | No | Not charged | The discover run's state does not allow this call (for example resuming a completed run) | | [`RUN_EXPIRED`](#run-expired) | 410 | No | Not charged | The discover run's retention passed and its rows were deleted | | [`NEEDS_ASYNC`](#needs-async) | 422 | No | Not charged | A discover request with `async=false` is over what one call can answer | | [`CURSOR_EXPIRED`](#cursor-expired) | 410 | No | Not charged | An events feed cursor is older than the 30-day retention | | [`SCHEDULE_NOT_FOUND`](#schedule-not-found) | 404 | No | Not charged | No discover schedule with this id belongs to your account | | [`STREAM_NOT_FOUND`](#stream-not-found) | 404 | No | Not charged | No listener with this id belongs to this API key | | [`STREAM_STATE_CONFLICT`](#stream-state-conflict) | 409 | No | Not charged | The listener's state does not allow this call (for example resuming a deleted listener) | ## Which errors are safe to retry The retryable failures are `IDEMPOTENCY_IN_PROGRESS`, `RATE_LIMITED`, `CONCURRENCY_LIMIT`, `UPSTREAM_ERROR`, `SERVICE_UNAVAILABLE`, and `INTERNAL_ERROR`. Every error body says the same thing in `error.retryable`, so you can branch on that field instead of copying this list. The flag follows the code, so a withdrawn endpoint's `SERVICE_UNAVAILABLE` still reads `true`; tell that one apart by its missing `Retry-After` header, as described under [`SERVICE_UNAVAILABLE`](#service-unavailable). The one response that overrides the code's flag is described under [`UPSTREAM_ERROR`](#upstream-error). Every other client error is deterministic. `IDEMPOTENCY_IN_PROGRESS` is the one retryable 409: wait for `Retry-After`, then send the same key and payload again. See [Handling retries](#handling-retries) for copy-paste backoff loops. ## Error reference Each code below carries a stable anchor, the exact target of the `doc_url` in the error envelope. ### MISSING_API_KEY [#missing-api-key] **401.** No API key was present on the request. Send one of your keys from Dashboard → API Keys in the `x-api-key` header, or as `Authorization: Bearer `. An `Authorization` header with any other scheme, such as a bare key or `Basic`, is not read, and the message says so. No credits are deducted. ### INVALID_API_KEY [#invalid-api-key] **401.** The key is malformed, not found, revoked, or expired. Check the key in Dashboard → API Keys. No credits are deducted. An [OAuth access token](/docs/authentication.md#oauth-for-agents) that is expired, revoked, or issued for another service gets this code too, with `error="invalid_token"` in its `WWW-Authenticate` header. ### INSUFFICIENT_SCOPE [#insufficient-scope] **403.** Only an [OAuth access token](/docs/authentication.md#oauth-for-agents) can get this. The token does not carry a scope this endpoint needs, and the `scope` in its `WWW-Authenticate` header names the ones it does need. Sign in again asking for them. No credits are deducted. An API key never gets this code. ### INSUFFICIENT_CREDITS [#insufficient-credits] **402.** Your balance is lower than the endpoint's cost. Top up in Dashboard → Billing. No credits are deducted. `credits_remaining` reports your current balance. Sending the same request again will not succeed until your balance changes, so keep this code out of your retry loop. The message ends with a sentence saying so, and `error.details` gives the same answer as fields: | `error.details` key | What it holds | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `reason` | `balance_too_low` | | `retry_will_succeed` | Always `false`. The identical request fails the same way until credits are added, by a top-up or by auto-recharge. | | `next_step` | One sentence saying what to do next. | | `top_up_url` | The billing page in the dashboard, where you buy credits or turn on auto-recharge. With `recommended_pack`, it opens with that pack chosen (`?pack=growth`). | | `credits_required` | The credits this request needed. Present on `GET` endpoints. | | `recommended_pack` | The pack to buy, sized to how fast the account uses credits: `id`, `name`, `credits`, `price_gbp`, `price_display`, `days_at_current_burn` (whole days the pack lasts at the account's rate over the last `burn_window_days` days, or `null` with no recent use), `credits_per_day` and `burn_window_days`. It is the smallest pack that lasts about 30 days, the largest pack for a faster rate, and Growth with no recent use. Present on `GET` endpoints. | | `checkout_url` | A link that opens checkout for `recommended_pack` once the account owner is logged in. Opening it charges nothing: checkout still asks the buyer to pay. It works for 7 days and only for the account that got the 402. | | `auto_recharge` | The account's auto-recharge: `enabled`, `armed` (on, with a pack and a saved card), `threshold_credits`, `pack_id`, `has_card`, `disabled_reason`, `blocked_reason` (`monthly_cap`, `retry_after_failure` or `needs_reconfirm` when it cannot top up right now; `needs_reconfirm` means the bank asked the owner to re-confirm the saved card), `setup_url`, and `top_up_requested`, which is `true` when an armed auto-recharge has been asked for a top-up and nothing is holding it back. | | `pricing` | On a metered endpoint, what `credits_required` is. `"ceiling"`: the most the call can cost, held up front, with whatever the call does not use refunded afterwards. `"exact"`: the call is charged what it holds. Absent on fixed-price endpoints, and on a metered one we have too few recent calls to say. | | `credits_typical` | Sent with `pricing: "ceiling"`: what a call like this one is usually charged once its refund lands. | | `lower_cost_with` | The parameters of this request that would lower `credits_required`, biggest saving first. Present on any metered endpoint where one exists. | | `lower_cost_options` | The same parameters as objects: `param`, the `value` to send (`null` means leave the parameter out) and the `credits_required` with only that change. For a number such as `limit`, `value` is the largest one that still gives that price. | | `credits_required_cheapest` | What this request needs with every parameter in `lower_cost_with` at its cheapest. | | `key_prefix` | The prefix of the API key that sent the request, as Dashboard → API Keys shows it, for example `sc_...hYFw`. Present on `GET` endpoints. | | `key_name` | The name you gave that key. Present on `GET` endpoints. | | `account_email_masked` | The account's login email with all but its first characters and domain hidden, for example `ni***@example.com`. Present on `GET` endpoints. | If your team has more than one account, `key_prefix` and `account_email_masked` tell you which one ran out. The message names them as well, before the sentence about which pack to buy. A 402 carries no `Retry-After` header. Waiting does not help: only a top-up or auto-recharge changes the answer, and either one takes effect on your next request. **Which pack to buy.** The 402 names the pack that fits how fast the account uses credits, its price, how long it lasts, and a link that opens checkout for it. The message says the same in one sentence. In the example below the account used about 640 credits a day over the last 14 days, so Growth lasts about 31 days. The token at the end of `checkout_url` is cut short here. ```json title="Response" { "success": false, "error": { "type": "INSUFFICIENT_CREDITS", "message": "Your account has 0 credits remaining. This endpoint requires 1 credits. This request was sent with the API key \"Default\" (sc_...hYFw) on the account ni***@example.com. The Growth pack (20,000 credits, £49) lasts about 31 days at your last 14 days' rate. Open checkout: https://www.socialcrawl.dev/dashboard/billing/checkout?token=eyJzdWIi... Retrying this request will not succeed until your account balance changes.", "status": 402, "doc_url": "https://www.socialcrawl.dev/docs/errors#insufficient-credits", "retryable": false, "details": { "reason": "balance_too_low", "retry_will_succeed": false, "next_step": "Top up your credits on the billing page (or turn on auto-recharge there), then send the request again.", "top_up_url": "https://www.socialcrawl.dev/dashboard/billing/payments?pack=growth", "credits_required": 1, "key_prefix": "sc_...hYFw", "key_name": "Default", "account_email_masked": "ni***@example.com", "recommended_pack": { "id": "growth", "name": "Growth", "credits": 20000, "price_gbp": 49, "price_display": "£49", "days_at_current_burn": 31, "credits_per_day": 640, "burn_window_days": 14 }, "checkout_url": "https://www.socialcrawl.dev/dashboard/billing/checkout?token=eyJzdWIi...", "auto_recharge": { "enabled": false, "armed": false, "threshold_credits": null, "pack_id": null, "has_card": false, "disabled_reason": null, "blocked_reason": null, "setup_url": "https://www.socialcrawl.dev/dashboard/billing/payments?autorecharge=enable#auto-recharge", "top_up_requested": false } } }, "credits_used": 0, "credits_remaining": 0, "request_id": "req-abc123" } ``` With no recent use, the sentence reads "The Growth pack (20,000 credits, £49) is the usual next step." When auto-recharge is armed, the refusal asks it for a top-up, `auto_recharge.top_up_requested` is `true`, and the sentence says the call can be retried once the top-up lands instead of naming a pack. `retry_will_succeed` stays `false`, because the identical request still fails until the top-up lands. If the offer cannot be read in time, the 402 leaves out `recommended_pack`, `checkout_url` and `auto_recharge`, and `top_up_url` is the plain billing page. **What an agent should do with this 402.** 1. Read `details.recommended_pack` to tell the account owner which pack fits, what it costs and how long it lasts. 2. Show the owner `details.checkout_url`. A person opens it and pays; an agent never needs a card or a login. 3. Send the request again once the top-up lands (`credits_remaining` on the next response, or `GET /v1/credits/balance`, shows the new balance). If `details.auto_recharge.top_up_requested` is `true`, skip the first two steps and retry once the top-up lands. **When the number is a ceiling.** A metered endpoint holds the most the request can cost before it runs and refunds whatever the call does not use. `/v1/threads/search`, for example, holds 1 credit for every search window its `limit` allows, plus the query relaxation searches a multi-word query can fan out to. A balance below that hold gets a 402 even though the finished call would usually have cost less, so the 402 says what the number is and how to make the call fit: - `pricing` says whether the number is a ceiling. We work this out from the endpoint's recent production calls: an endpoint is a ceiling when a real share of them were charged less than they held. - `credits_typical` is what a call like yours is usually charged after the refund: your hold times the median share of the hold those calls were charged, rounded, and never less than the least a successful call to that endpoint costs. - `lower_cost_options` lists each parameter that would lower the hold, with the value to send and what the request would then need. `credits_required_cheapest` is the hold with all of them. Each option is priced by the same calculation the API charges with, so it is never a guess. A smaller request that fits your balance can be sent straight away, and it is often enough. If you need the full request, top up. **When the API makes the call fit for you.** On `/v1/threads/search`, a balance that covers the call with `expand=false` but not as sent does not get a 402. The call runs with `expand=false`, is charged as usual, and `data._warnings` says so and gives both holds. Query expansion only runs when no post matches your exact phrase, so a page with posts is the same either way. The same applies when the call has to fit your API key's credit limit or `max_credits`. When a `max_credits` page has switched to `expand=false` and your balance still does not cover it, the 402 quotes the price with `expand=false`, says so, and gives the price as sent in `details.balance_fallback`. In the example below, 3 credits do not cover the 7 credits that `limit=100` still holds with `expand=false`, so the call gets a 402. ```json title="Response" { "success": false, "error": { "type": "INSUFFICIENT_CREDITS", "message": "Your account has 3 credits remaining. This endpoint requires 11 credits. The 11 credits required are the most this call can cost: what it does not use is refunded afterwards, and calls like it are usually charged about 2. It holds fewer credits with `limit` at 15 or below (5 credits), `expand=false` (7 credits), or all of these (1 credit). Retrying this request will not succeed until your account balance changes.", "status": 402, "doc_url": "https://www.socialcrawl.dev/docs/errors#insufficient-credits", "retryable": false, "details": { "reason": "balance_too_low", "retry_will_succeed": false, "next_step": "Top up your credits on the billing page (or turn on auto-recharge there), then send the request again.", "top_up_url": "https://www.socialcrawl.dev/dashboard/billing/payments", "credits_required": 11, "pricing": "ceiling", "credits_typical": 2, "lower_cost_with": ["limit", "expand"], "lower_cost_options": [ { "param": "limit", "value": "15", "credits_required": 5 }, { "param": "expand", "value": "false", "credits_required": 7 } ], "credits_required_cheapest": 1 } }, "credits_used": 0, "credits_remaining": 3, "request_id": "req-abc123" } ``` **Stopping a runaway client.** If one script or agent spends the balance that your other keys depend on, give its key a [per-key credit limit](/docs/authentication.md#per-key-credit-limits). That key then stops at its own cap with [`KEY_BUDGET_EXCEEDED`](#key-budget-exceeded), and the rest of the account keeps working. ### KEY_BUDGET_EXCEEDED [#key-budget-exceeded] **402.** The API key you used has its own **credit limit**, and this request would take it past that limit. Your account balance is untouched and nothing was deducted. This is not the same problem as [`INSUFFICIENT_CREDITS`](#insufficient-credits), and topping up will not fix it. A per-key limit is a cap you set yourself so that one key (typically a test, CI, or staging key) can never spend more than a fixed amount of the shared account balance. Hitting it means that key did its job. To resolve it, open Dashboard → API Keys and either raise the key's limit, reset its usage counter back to zero, or remove the limit entirely. You can also switch to a key with no limit set, which is how production keys are usually configured. `credits_remaining` still reports your **account** balance, so a large number there alongside this error is expected. It is the clearest signal that the cap, not the balance, is what stopped the request. ```json title="Response" { "success": false, "error": { "type": "KEY_BUDGET_EXCEEDED", "message": "This API key has a limit of 500 credits and has used 498. This endpoint requires 5 credits. Your account balance (12480 credits) was not charged. Raise or reset this key's limit in Dashboard → API Keys, or use a key with no limit. Retrying this request will not succeed until this key's credit limit is raised, removed or reset.", "status": 402, "doc_url": "https://www.socialcrawl.dev/docs/errors#key-budget-exceeded", "retryable": false, "details": { "reason": "key_budget_reached", "retry_will_succeed": false, "next_step": "Raise or remove this key's credit limit, or reset its usage, on the API keys page, or send the request with a key that has no limit.", "key_settings_url": "https://www.socialcrawl.dev/dashboard/api/keys", "credits_required": 5 } }, "credits_used": 0, "credits_remaining": 12480, "request_id": "req-abc123" } ``` `error.details` has the same keys as [`INSUFFICIENT_CREDITS`](#insufficient-credits), with three differences: `reason` is `key_budget_reached`, `key_settings_url` (the API keys page) replaces `top_up_url`, and there is no `recommended_pack`, `checkout_url` or `auto_recharge`, because buying credits does not lift a key's own limit. Do not retry this one on a timer. The counter is cumulative rather than windowed, so it never clears on its own. Only a limit change or a usage reset will let the key spend again. See [Authentication](/docs/authentication.md#per-key-credit-limits). ### INVALID_REQUEST [#invalid-request] **400.** A required parameter is missing, a value failed format validation, or no `oneOf` member was satisfied. Check the endpoint's required parameters in the [API Reference](/docs/api-reference.md). A rejected request costs 0 credits. Most rejections run before billing, so nothing is deducted. A few can only be detected by the data source after the call has been charged, such as asking for a page past the end of a search. Those are refunded in full before the response is sent, so `credits_used` is still 0. Some rejections carry `error.details` you can branch on. It has two shapes and they never appear together, so branch on which key is present. **error.details.reason: encoding rejections** | `error.details.reason` | What it means | How to fix it | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_utf8` | The value contains Unicode replacement characters (`U+FFFD`), which is what invalid UTF-8 decodes to. Your client almost certainly sent the value in a non-UTF-8 charset such as windows-1251 or latin-1. | Send the value as UTF-8. With cURL, pass the query via `--data-urlencode` rather than interpolating it into the URL. If your shell hands cURL non-UTF-8 bytes in the first place, set your locale (`LANG=en_US.UTF-8`, or `chcp 65001` on Windows). | | `double_encoded` | The value was percent-encoded before your HTTP client encoded it again, so it arrived as the literal text `%D1%80%D0%B0…`. Searching for that matches nothing. | Pass the raw UTF-8 value and let your HTTP client encode it. Do not call `urlencode` / `encodeURIComponent` yourself first. | This most often bites on non-Latin search queries. A corrupted query cannot match anything, and it used to come back as an empty `200`, which looked like "this platform has no content in my language". It is now a `400` that names the fault: ```json title="Response" { "success": false, "error": { "type": "INVALID_REQUEST", "message": "Parameter 'query' is not valid UTF-8. It contains Unicode replacement characters (U+FFFD), which almost always means the client sent the value in a non-UTF-8 charset (e.g. windows-1251 or latin-1). Send the value as UTF-8.", "details": { "reason": "invalid_utf8" } } } ``` A correctly encoded non-Latin query needs no special handling. Cyrillic, Greek, Arabic, Korean, and Japanese all work as-is: ```bash title="cURL" curl -G https://www.socialcrawl.dev/v1/threads/search \ -H "x-api-key: YOUR_KEY" \ --data-urlencode "query=работа" ``` **error.details.did_you_mean: self-correcting rejections** A second family of `INVALID_REQUEST` carries `error.details.did_you_mean` instead of a `reason`: the exact parameter, or `param=value` pair, to send instead. You get it when you send a foreign pagination param (`did_you_mean: "cursor"`, see [Pagination](/docs/pagination.md#sending-the-wrong-param-is-a-400-not-a-silent-bug)), when you combine two options the upstream cannot honor together, or when an option you sent needs a companion parameter you left out. A few of these also carry `remove_params`, a list of parameters to drop. Apply both fields and you land on a valid request. The hint is stable enough for an SDK to auto-correct against. A value outside a parameter's accepted set is rejected the same way, before any credit is spent, with `details.invalid_parameters`, `details.allowed_values` and `details.example` (one valid `param=value`). A one-letter slip (`label=spma`) adds `did_you_mean: "spam"`. A search term sent to `relevance` or `label` (`relevance=traumeel`, `label=alberto`) adds `did_you_mean: "relevant_to"` on a search that judges relevance, or `"query"` elsewhere, and the message shows the request to send instead. From the 100th identical rejection on one API key in a UTC day, `details.repeated_today` carries the count and the message says so: the same request will keep failing the same way, so change it rather than retrying it. Only a rejection with a specific reason is counted (a named limit, parameter or value), so two different mistakes on one endpoint are never added together. **error.details.fields: none of a required group was sent** Some endpoints take one of several parameters, such as `handle` or `user_id`, or one of the subjects of `/v1/prism/discover`. When the request sends none of a group, the 400 names the group in `details.fields` and says what to send in `details.next_step`. Nothing is charged. ```json title="Response" { "success": false, "error": { "type": "INVALID_REQUEST", "message": "Missing required parameter(s): one of handle, user_id. This endpoint accepts: one of (handle, user_id).", "details": { "fields": ["handle", "user_id"], "next_step": "Add one of handle or user_id to the request and send it again." } } } ``` Every 400 from the `/v1/prism/discover/runs` routes also carries `details.next_step`. **error.details.reason: not_offered, a discover value that is not offered** `/v1/prism/discover` answers `not_offered` for a field or value it has decided not to offer, and says what to do instead in `details.next_step`. Nothing is charged, and the same request always answers the same way. | Field or value | Instead | | ----------------- | --------------------------------------------------------------------------------------------------------------------- | | `include=archive` | Leave it out: each row's media URLs still point to its image. | | `webhook` | Register your URL once with `POST /v1/webhook-endpoints`, or read the same events with `GET /v1/events`. | **error.details.reason: a listener body that is refused** A Listening request answers each of these as a 400 before anything is fetched. Nothing is charged, and the same request always answers the same way, so change the field named in `details.fields` and follow `details.next_step` rather than retrying. | `details.reason` | What it means | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `invalid_param` | A field, platform or value is not one a listener takes. `did_you_mean` names the current or nearest name where one exists (`google_news` gives `news`, `page_size` gives `limit`, `format=raw` gives `include=raw`). A keyword that starts with a label such as `Query:`, or holds more than six unquoted words, carries `suggested_value`: the short phrases to send instead. `comments` takes `off` or `new_posts`: discover's `inline` and `bulk` give `did_you_mean: "new_posts"` and say why the meaning differs, and discover's `comments.top_posts`, `comments.per_post`, `comments.select_by`, `comments.min_length` and `comments.labels` name the fixed per-lane bounds that replace them. A create from an API key without `schedule.max_credits_per_month` carries `recommended_max_credits_per_month`. `why: "lane_not_ready_for_language"` names a platform not ready for the rule's script or language, with the ready ones in `next_step`. On `GET /v1/listening/usage`, a `group_by` that is not `client`, `stream`, `group` or `platform` (`allowed_values` lists them), a `from` or `to` that is not a UTC date such as `2026-10-01`, or a `from` after `to`. On `POST /v1/listening/preview`, a `max_credits` that is not a whole number of at least 1. An account panel answers `why: "panel_needs_topic"` when it has no `keywords`, `rule` or `groups` (every new post of a known account, with no topic, is a Monitor with `webhook_rows: "new"`), `why: "account_not_parsed"` for an entry that is not a handle, `platform:handle` or profile URL, `why: "account_needs_platform"` for a bare handle sent without `platforms`, `why: "account_platform_not_listed"` for an entry on a platform that `platforms` does not list, `why: "platform_has_no_account"` for a listed platform that no entry is on, `why: "platform_has_no_account_lane"` for a platform with no account feed a listener reads (Instagram is one), `why: "linkedin_company_needs_id"` for a LinkedIn company named by its page rather than its numeric id (`GET /v1/linkedin/company?url=` returns the id), `why: "linkedin_person_not_offered"` for a LinkedIn member's profile, `why: "facebook_group_needs_organisation"` for a Facebook group on a panel whose `schedule.purpose` is not `organisation` (a public group is read only as an organisation), `why: "panel_topic_too_broad"` for a topic that holds only words a post on any subject says (such as `the`, `a OR e`, or one letter), so it would filter nothing, `why: "account_read_by_no_group"` for accounts on a platform that no group's `platforms` reads, and `why: "unknown_author_list"` for an `author_list_id` that is not one of your stored lists or has expired. `schedule.purpose` is required with a panel (`allowed_values` lists `business`, `brand`, `creator` and `organisation`) and refused on a keyword listener. | | `conflicting_params` | Two fields that cannot be sent together, both in `details.fields`: more than one of `keywords`, `rule` and `groups`, `keywords` and `rule` in one group, `relevance: "off"` with `relevance_definition`, `accounts` with `author_list_id`, `search_in` beside a panel, or a change that would turn a keyword listener into a panel (create a new listener instead). | | `not_released` | A route, field, value, platform or operator that arrives later. `details.release` names the release or the condition it waits on, and `details.not_released` lists each field and value. `sentiment` names `label=stance` and never suggests another field. | | `not_offered` | A name that is not offered, with `docs_url` and, where one exists, `did_you_mean`: `rules` gives `groups`, `query` gives `rule`, `refresh_interval_minutes` gives `schedule.every`, `stream_id` gives `subject_id` and `stream_ids` gives `subject_ids`. `match_in`, `budget`, `delivery.endpoint_ids` and `threads` are not offered either. The rule operator `from:` gives `did_you_mean: "accounts"`: the posts of named accounts are a panel, not an author filter on a search. | | `interval_below_lane_minimum` | `schedule.every` is shorter than a chosen platform allows, for example `30m` on a platform that does not return the newest posts first. `details.lane` names it. | | `unsupported_operator` | The rule uses an operator a listener does not take, such as `NEAR/5` or a wildcard, or mixes `AND` and `OR` without brackets. `offset` points at it, and `nearest` gives a rule that works. | | `plan_limit` | More listeners than an account may hold (50), a group that searches more than 16 terms, or a panel of more than 300 accounts once its bare handles are counted on each listed platform, or of more on one platform than one check reads there in its slot (270 on YouTube and TikTok, 180 on Reddit and X, 150 on Threads, 60 on Facebook, 30 on LinkedIn; `details.platform` names it). Both answer `why: "too_many_accounts"`; split the panel across listeners. The limits are the same on every pack. `limit` gives the bound. | | `budget_below_one_check` | `schedule.max_credits_per_month` or `schedule.max_credits_per_run` is too low for one full check. `field` and `suggested_value` give the value to send. | | `prohibited_use` | The rule or `context` describes a use the Terms of Service section 7.2 forbid: monitoring a named private individual, a protest group or its organisers, or government or intelligence monitoring of people. Do not rephrase the request to pass; the use is refused. A dry run (`?dry_run=1`) is not refused: it returns the verdict in `data.screen` with a warning and creates nothing. A create or a change refuses it from LRel2a, and a preview from LRel2b. A panel entry that the screen's word list matches is refused the same way, with `accounts` or `author_list_id` in `details.fields`. A `schedule.purpose` that names people (`individual`, `consumers`, `people we follow`) answers `prohibited_use` with `target: "private_individual"` and `terms_section: "7.2(b)"`: a listener never watches a private individual's account. A purpose that only describes a business or a brand (`private company`, `consumer brand`) is `invalid_param` with the four allowed values. A Facebook personal profile in a panel (a `profile.php?id=` or `/people/` address) answers the same way, with `why: "facebook_personal_profile"`, whatever the panel declares. | **error.details.reason: a creator search request that is refused** `/v1/prism/creators` and its run routes answer each of these with `details.reason` and a `details.next_step` that names the field to change and a ready value. A refusal costs nothing, and the same request always answers the same way, so change the request rather than retrying it. [Creator search](/docs/prism/creators.md#errors) has the whole table and the retry policy. | HTTP | `error.type` | `details.reason` | Retryable | Charged | `next_step` names | | --- | --- | --- | --- | --- | --- | | 400 | `INVALID_REQUEST` | `invalid_param` (`did_you_mean` when a near name exists; a discover-only field names its creator equivalent) | No | no | the field and a ready value | | 400 | `INVALID_REQUEST` | `conflicting_params` (`fields`) | No | no | both fields and a ready value | | 400 | `INVALID_REQUEST` | `nothing_to_search` | No | no | a ready example with `keywords` | | 400 | `INVALID_REQUEST` | `missing_purpose` (`allowed_values`) | No | no | `purpose` and its values | | 400 | `INVALID_REQUEST` | `get_only_field` | No | no | the GET, or the field to drop | | 400 | `INVALID_REQUEST` | `post_only_field` | No | no | the POST | | 400 | `INVALID_REQUEST` | `not_released` (`release`, `suggested_url`) | No | no | the request that serves the same need today | | 400 | `INVALID_REQUEST` | `too_many_handles` (`limit`, `suggested_url`) | No | no | the POST, or a stored list | | 400 | `INVALID_REQUEST` | `max_credits_below_estimate` (`estimate`, `suggested_max_credits`) | No | no | the value to ask the user to approve | | 400 | `INVALID_REQUEST` | `sensitive_selection` (`category`) | No | no | how to describe the content instead | | 400 | `INVALID_REQUEST` | `order_rank_while_running` (`suggested_url`) | No | no | `order=arrival` | | 413 | `PAYLOAD_TOO_LARGE` | `payload_too_large` | No | no | the POST, or a stored list for long handles | | 402 | `INSUFFICIENT_CREDITS` | `balance_too_low` (`credits_typical`, `credits_ceiling`, `suggested_max_credits`, `top_up_url`) | No | no | top up, or lower `max_credits` | | 402 | `KEY_BUDGET_EXCEEDED` | `key_budget_reached` | No | no | raise the key's credit limit | | 404 | `RUN_NOT_FOUND` | `unknown_run` | No | no | start a run, or list your runs | | 409 | `RUN_STATE_CONFLICT` | `run_completed`, `run_not_completed`, `run_cancelled` | No | no | the valid call for the run's state | | 410 | `RUN_EXPIRED` | `retention_passed` | No | no | start a new run | | 422 | `NEEDS_ASYNC` | none (only with `async=false` over the call's budget: `estimate`, `suggested_url`) | No | no | drop `async=false` | | 429 | `RATE_LIMITED` | none | Yes | no | back off for `Retry-After` | | 429 | `CONCURRENCY_LIMIT` | none | Yes | no | back off for `Retry-After`; an eleventh background run is queued (202), never a 429 | | 502 | `UPSTREAM_ERROR` | `all_legs_failed` (nothing was collected; anything collected makes it a 200 `partial`) | Yes | refunded | retry later, or drop the failing platforms | | 503 | `SERVICE_UNAVAILABLE` | `suppression_unavailable`, `judge_unavailable`, `store_unavailable`, `queue_unavailable` (refused before any work, or the run pauses `service_unavailable`) | Yes | no | retry after `Retry-After` | **error.details.reason: page_limit, a search with no more pages** Some searches stop at a fixed page number. `/v1/instagram/search/reels` stops at page 11 when `date_posted` is set. Asking for a later page returns `INVALID_REQUEST` with `details.reason` set to `page_limit`, refunded in full: | Key | What it holds | | ---------------- | ------------------------------------------------------------------ | | `max_page` | The last page this search serves | | `requested_page` | The page you asked for | | `narrow_with` | Parameters that narrow the search, for example `["query", "date_posted"]` | | `example` | A valid request: your own narrowing parameters with the last page | ```json title="Response" { "success": false, "error": { "type": "INVALID_REQUEST", "message": "This search stops at page 11 when `date_posted` is set, so page 12 is past its last page and there are no more pages for this query and date window. To reach different reels, use a more specific `query` or a narrower `date_posted` window (`last-week`, `last-month` or `last-year`), or leave `date_posted` out to page further without a date filter. Your credits have been refunded.", "status": 400, "doc_url": "https://www.socialcrawl.dev/docs/errors#invalid-request", "retryable": false, "details": { "reason": "page_limit", "max_page": 11, "requested_page": 12, "narrow_with": ["query", "date_posted"], "example": "/v1/instagram/search/reels?query=top%20restaurants&date_posted=last-month&page=11" } }, "credits_used": 0, "credits_remaining": 12480, "request_id": "req-abc123" } ``` Treat it as the end of the walk. Paging further will not return more results for the same query and date window. A more specific query or a narrower date window returns a different set of reels, and the same query without `date_posted` pages past 11. Getting `200` with an empty `items` array on a **correctly-encoded** query is a different thing entirely, and is not an error. Most platform search endpoints are keyword indexes rather than semantic ones, so a full sentence tends to return nothing in any language (`find a job` returns results; `i cannot find a job` returns none). Search with keywords, not sentences. ### METHOD_NOT_ALLOWED [#method-not-allowed] **405.** The method is not accepted on this path. Registry read paths advertise `Allow: GET, HEAD`; POST-only routes advertise `Allow: POST`. The JSON body and `X-Request-Id` are still canonical. No credits are deducted. The exceptions are the `POST` calls (the batch, job, Discover, cohort and webhook endpoint calls, each listed on [Idempotency](/docs/idempotency.md)) and the `/v1/web/*` job surface, which takes `POST` on job, monitor, and session creation and `PATCH` or `DELETE` on monitors, sessions, and jobs. ### ENDPOINT_NOT_FOUND [#endpoint-not-found] **404.** The platform or resource is not supported. Check the [API Reference](/docs/api-reference.md) for the correct path. No credits are deducted. Listening has no estimate route: `POST /v1/listening/estimate` and `POST /v1/listening/rules/validate` answer 404 with `did_you_mean` naming `POST /v1/listening/streams?dry_run=1`, which returns the same estimate, the rule as a tree and in words, and the plan. Send the same body there. ### RESOURCE_NOT_FOUND [#resource-not-found] **404.** The upstream returned 404, or the resource exists but contains no usable data, for example a nonexistent handle. Verify the handle or URL. The deduction is refunded in full, so `credits_used` is 0. Three endpoint families narrow the cause with `error.details.reason`, so you can tell a permanent gap from a fixable mistake. **Transcript endpoints: why no transcript came back** Transcript endpoints such as `/v1/youtube/video/transcript` set `error.details.reason`: | `details.reason` | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------ | | `captions_disabled` | The video exists, but its owner has disabled captions. YouTube: also returned for a video uploaded in the last day whose automatic captions are not generated yet; a retry in a few hours can succeed | | `no_captions` | The video exists, but it has no caption track (or none in the requested `language`). Facebook and X: the video has no speech, or the post has no video, which they answer the same way. TikTok without `use_ai_as_fallback=true`: repeat the call with it set to transcribe the audio instead | | `no_captions_or_gone` | No caption track, or the post is unavailable. Reddit answers both the same way, so we do not say which | | `no_speech` | Instagram: the video exists, but no speech was detected in it, so there is nothing to transcribe | | `no_video_or_gone` | Instagram: the URL is a photo or carousel post, or the post is unavailable. Instagram answers both the same way | | `language_unsupported` | TikTok: the video is long enough to need AI transcription, and its speech cannot be transcribed in the `language` you set. Retrying returns the same result | | `post_not_public` | LinkedIn: LinkedIn does not show this post to logged-out viewers and it could not be resolved to an original post | | `login_required` | The video requires login to view, so its transcript cannot be retrieved | | `members_only` | YouTube: the video is available only to the channel's paying members, so no transcript can be read | | `video_gone` | The video is deleted, private, or never existed | Every reason is deterministic for a given request. Retrying the same URL with the same parameters returns the same result and never costs credits. Around 5 to 10% of public YouTube videos have no retrievable captions, so batch pipelines should expect and skip these rather than retry them. Instagram, Facebook and X transcripts, and TikTok's `use_ai_as_fallback=true`, cover videos up to 10 minutes (600 seconds); Facebook Ad Library ad transcripts cover up to 2 minutes. A longer video returns **400** `INVALID_REQUEST` with `error.details.reason: video_too_long`, `max_seconds`, and `duration_seconds` when the length is known, also at no charge. Like the reasons above, it is deterministic, so retrying the same URL returns the same 400 (and a repeat within the hour is answered from cache). The batch `POST /v1/youtube/transcripts` endpoint reports these per row as `status: "not_found"` with `ext.reason`, and refunds each affected row. Its vocabulary is coarser: only `no_captions` (which also covers captions-disabled and login-gated videos), `members_only`, and `video_gone`. If you need to tell a captions-disabled video from a genuinely caption-less one, re-fetch that id through the singular endpoint. **Web scrape: a site that cannot be scraped** `/v1/web/scrape` sets `error.details.reason` to `site_not_supported` when it cannot fetch the site at all. This is deterministic: `retry_will_succeed` is `false`, and nothing is charged. When the URL is on a platform SocialCrawl reads directly, `error.details.suggestion` names the endpoint that reads it, such as `/v1/instagram/post` or `/v1/linkedin/profile`. A profile or post URL on one of those platforms rarely reaches this error. When the scrapers cannot open it, `/v1/web/scrape` reads it through the platform's endpoint at that endpoint's price (see [Social platform URLs](/docs/web.md#social-platform-urls)). You get `site_not_supported` for such a URL only when that endpoint could not read it either, or when you asked for a format the record cannot provide, such as `screenshot`. **Profile endpoints: why a handle stopped resolving** Profile endpoints such as `/v1/tiktok/profile` set `error.details.reason` for the same purpose, because a handle that stops resolving has causes that need opposite responses from you. Account-keyed list endpoints carry it too, whenever a source explained itself. That covers the post, reel and roster lanes you poll a tracked list through, such as `/v1/instagram/profile/posts`, `/v1/instagram/profile/reels` and `/v1/instagram/followers`. A list 404 with no explanation from any source carries no `details`, so the field's presence is itself the signal that a source gave a verdict. The exception is three Instagram endpoints, `/v1/instagram/profile/posts`, `/v1/instagram/profile/reels` and `/v1/instagram/stories`, which say so explicitly with `details.reason: unexplained` (below). | `details.reason` | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------ | | `account_gone` | The platform reports no account under this identifier: removed, deactivated, renamed, or it never existed | | `account_private` | The account exists, but the platform does not serve this data publicly (audience controls, or a private or limited account) | | `handle_unresolved` | No source could resolve the account and none reported a reason | | `unexplained` | Instagram post, reel and story lists only: the list could not be read and no source said why, so nothing is known about the account. Not a verdict on the handle: retry later, and if it keeps happening, check the handle with `/v1/instagram/profile` | `account_private` is the one that matters most for a tracked-handle list. The account is still live and usually still publishing, so pruning it on the 404 drops a creator you wanted. On TikTok its posts remain readable through `/v1/tiktok/profile/videos` with the same handle, and the response says so in `details.suggestion`. On Instagram a private account's posts are not readable by anyone, so there is no lane that still serves them, but you can confirm the handle is worth keeping. `/v1/instagram/profile` returns `200` for a private account, with `author.private` set to `true`, while a handle Instagram no longer carries returns `404` at no cost: ```bash title="cURL" curl -H "x-api-key: $SOCIALCRAWL_API_KEY" "https://www.socialcrawl.dev/v1/instagram/profile?handle=aleecapalbo" ``` That call costs one credit when the account exists and nothing when it does not, so sweeping the handles that failed is priced by how many are still alive. `account_gone` covers a rename as well as a removal, because the platform answers both the same way: the username is simply not in use. TikTok numeric user ids are permanent, so you can separate the two yourself. Keep the `author.id` from any earlier successful call and re-run the lookup with it: ```bash title="cURL" curl -H "x-api-key: $SOCIALCRAWL_API_KEY" "https://www.socialcrawl.dev/v1/tiktok/profile?user_id=5831967" ``` A `200` means the account was renamed, and `author.username` in that response is the current handle. A second `404` means it is genuinely gone. Both the handle call and the id call cost nothing when they miss. YouTube works the same way. A channel id (the 24-character id starting with `UC`) never changes, while a handle can. When `/v1/youtube/channel` misses on a `handle` with `account_gone`, `details.suggestion` says to re-run it with `channelId` set to the `author.id` you kept from an earlier successful call: ```bash title="cURL" curl -H "x-api-key: $SOCIALCRAWL_API_KEY" "https://www.socialcrawl.dev/v1/youtube/channel?channelId=UCX6OQ3DkcsbYNE6H8uQQuVA" ``` A `200` means the channel changed its handle, and `author.username` in that response is the current one. A second `404` means the channel is gone. `account_gone`, `account_private` and `handle_unresolved` are deterministic for a given identifier and never cost credits, so a pipeline should record them and move on rather than retry. `unexplained` is the one to retry: it costs nothing either, and it says no source answered, not that the account is missing. These values are an open set: handle any value you do not recognise like the generic not-found. There is no reason for an account with nothing to list. A public Instagram account with no posts, no reels or no active stories returns `200` with `items: []` at no cost, never a 404. ### PAYLOAD_TOO_LARGE [#payload-too-large] **413.** The request's JSON body exceeded the 1 MB size limit and was rejected before parsing, so no credits were deducted. Reduce the batch size, for example by splitting a large `ids` or `urls` array across several requests. ### IDEMPOTENCY_KEY_CONFLICT [#idempotency-key-conflict] **409.** The `Idempotency-Key` conflicts with an incompatible active reservation. Generate a fresh key, ideally a UUIDv4. Keys are scoped per account; this error does not reveal or depend on another account's keys. No credits are deducted. ### IDEMPOTENCY_IN_PROGRESS [#idempotency-in-progress] **409.** Another request with this key and the same payload is still running. This retry was not dispatched or charged. Wait for the `Retry-After` seconds, then retry with the same key. The wait is 1 second while the original is young and grows to a quarter of the time it has been running, up to 30, so a long call is not polled every second. Once the original finishes, the retry replays its stored result at 0 credits. ### IDEMPOTENCY_REPLAY_UNAVAILABLE [#idempotency-replay-unavailable] **409.** The original request completed and its billing outcome is known, but its response exceeded the idempotent replay storage limit: a response over 64 KB is stored compressed and replays normally, unless it is still over 160 KB compressed. This retry was not dispatched or charged. The same key cannot reconstruct the original body. Send the request with a new key to get the data again: inside the endpoint's cache window (`freshness.cache_ttl_s` on [`/v1/utility/endpoint`](/docs/utility.md#discover-the-surface)) it is served from the cache at 0 credits, and after the window it is a new billable request. ### IDEMPOTENCY_KEY_PAYLOAD_MISMATCH [#idempotency-key-payload-mismatch] **422.** You reused an `Idempotency-Key` with a different payload, meaning different query parameters on a `GET` or a different JSON body on a `POST`. Use a new key for the new payload. No credits are deducted. ### COHORT_LIMIT_EXCEEDED [#cohort-limit-exceeded] **400.** The account already holds the maximum of 100 cohorts under this API key. Delete an unused cohort (`DELETE /v1/cohorts/{cohortId}`) before creating another. ### COHORT_MEMBER_LIMIT_EXCEEDED [#cohort-member-limit-exceeded] **400.** The member upload would exceed the cohort limit. Reduce the upload or remove members before retrying. ### COHORT_IDENTITY_PLATFORM_UNSUPPORTED [#cohort-identity-platform-unsupported] **400.** The supplied identity platform is not supported for cohort queries. Use a supported platform. ### COHORT_IDENTITY_CONFLICT [#cohort-identity-conflict] **409.** The normalized platform identity is already assigned to another external ID in this cohort. Use the existing external ID or a different identity. ### COHORT_QUERY_NOT_CANCELLABLE [#cohort-query-not-cancellable] **409.** The query is already terminal and cannot be cancelled. Read its status or results instead. ### COHORT_QUERY_NOT_READY [#cohort-query-not-ready] **409.** The query has not completed, so results are not available yet. Wait for a terminal status before reading results. ### COHORT_RESULT_TOO_LARGE [#cohort-result-too-large] **413.** One decrypted result exceeds the 1,000,000-byte response-page ceiling. No partial result is returned. ### RATE_LIMITED [#rate-limited] **429.** More than 600 requests in a 1-minute sliding window on the same API key. Slow down and retry after `Retry-After`. The response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. No credits are deducted. See [Rate Limits](/docs/rate-limits.md). ### CONCURRENCY_LIMIT [#concurrency-limit] **429.** More than 50 concurrent requests were in flight on the same API key. Reduce concurrency and retry. No credits are deducted. The response carries `X-Concurrency-Limit` and `X-Concurrency-Remaining`, not `X-RateLimit-*`. See [Rate Limits](/docs/rate-limits.md). ### UPSTREAM_ERROR [#upstream-error] **502.** The upstream platform returned an error, after our own automatic retries. Retry after a short backoff. Credits are refunded. When `error.details.retry_with` is present, the same request will fail the same way: the response reads `retryable: false`, carries no `Retry-After`, and `retry_with` names the parameter to change (for example `{"proxy": "auto"}` on `/v1/web/scrape`). The same applies to a 504 that carries `retry_with`. These `error.details.reason` values can accompany an `UPSTREAM_ERROR` (or, for `url_unreachable`, a `RESOURCE_NOT_FOUND`). All of them are refunded: | `reason` | Where | What it means | | ----------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plain_fetch_failed` | `/v1/web/scrape` with `proxy` unset or `basic` | A plain fetch of the page failed. `retry_with.proxy` is `auto`: send `proxy=auto`, which holds 5 credits and settles to actual use. Repeating the same parameters is unlikely to help. | | `url_unreachable` | `/v1/web/scrape` (404 `RESOURCE_NOT_FOUND`) | The URL was rejected as invalid or does not resolve to a reachable site. Check the URL. | | `listings_feed_timeout` | `/v1/facebook/group/posts`, first page | The group's listings feed (`sort_by=CHRONOLOGICAL_LISTINGS`) did not answer in time. `retry_with.sort_by` is `CHRONOLOGICAL`, which returns the group's posts in date order. | | `zero_results` | `/v1/search/everywhere` | No source returned rows and at least one could not be searched (or answered that it has nothing). `sources_failed` says why per source; `sources_succeeded` and `coverage` list the rest. | | `task_queued` | Slow asynchronous data lanes | The provider queued the request but did not finish it in time. Retrying the same request within the hour picks up the queued job instead of starting a new one. | **504.** A `GET` call has 48 seconds to finish its upstream work; a few long-running endpoints, such as transcripts and Discover, have longer limits. When the 48 seconds run out, the call stops and answers 504 `UPSTREAM_ERROR` without `Retry-After`, and its credits are refunded; retry with backoff. If a call is still running at 54 seconds, it is answered with a 504 `UPSTREAM_ERROR` and `Retry-After`, its credits are refunded, and the work is left to finish. Retrying after `Retry-After` may then return it from the cache at no charge. Every `GET` call is answered within 60 seconds, so a client timeout of 60 seconds never gives up before we answer. If your client gives up earlier, the call still finishes on our side and is charged, and the result is cached for the endpoint's cache window (`freshness.cache_ttl_s` on [`/v1/utility/endpoint`](/docs/utility.md#discover-the-surface)). Sending the identical request again inside that window joins the call that is still running, or reads its result from the cache, at 0 credits. A request that sends `Cache-Control: no-cache` skips the cache and is charged again. `latency_ms.recommended_timeout_s` on the same endpoint is 1.5 times the measured p99 of successful uncached calls over the 7 days named in `latency_ms.sampled`, and it is null when fewer than 30 calls were measured. To tell an upstream outage from a transient blip, check `error.incident` on the response, or `GET /v1/status/{platform}`. See [Status on 5xx responses](#status-on-5xx-responses). ### SERVICE_UNAVAILABLE [#service-unavailable] **503.** Several causes. An open circuit breaker, an upstream provider throttle, or an unavailable data source carries `Retry-After` with the number of seconds after which a retry can succeed. The value depends on the cause, so always read the header rather than assume a fixed wait. If the idempotency claim store is temporarily unavailable, a keyed request fails closed before billing with `Retry-After: 1`; retry with the same key. No balance lookup is attempted on that path, so `credits_remaining` is `null` and `X-Credits-Remaining` is omitted. A 503 **without** a `Retry-After` header is not transient. It means the endpoint has been withdrawn because its upstream no longer serves it, and the `message` names the reason. Retrying will never succeed, so branch on the presence of `Retry-After` rather than on the status alone. Nothing is deducted in the first place on a withdrawn endpoint, since the router rejects it before billing, so there is no refund to wait for. Check the [changelog](/changelog) for the replacement. A Listening request answers 503 with `Retry-After` and `details.reason` set to `judge_unavailable` when the check of a listener's rules and `context` for a refused use could not answer. Nothing is written or charged; retry the same request after the delay. A Listening preview that fails after its pages were billed refunds every billed page (reason `preview_not_served`) and answers 503 with `credits_used: 0`, `X-Credits-Used: 0` and `Retry-After: 5`: `suppression_unavailable` when the privacy check could not run (if it cannot run before the first page, nothing is fetched at all), and `store_unavailable` for any other failure, whose message says how many billed pages were refunded. A retry under the same `Idempotency-Key` runs the preview again and bills its pages again; the failed attempt's pages were refunded. If the refund itself fails, the 503 keeps its reason but `credits_used` and `X-Credits-Used` carry the pages' credits, and the message asks you to write to socialcrawl@ridio.io with the `request_id` so they are refunded by hand. ### INTERNAL_ERROR [#internal-error] **500.** An unexpected error occurred on our side. Retry, and if it persists, contact support with the `request_id`. The server attempts the refund before responding. If it cannot commit, `credits_used` remains non-zero and the exact outstanding remainder is queued for automatic compensation rather than falsely reported as refunded. ### RUN_NOT_FOUND [#run-not-found] **404.** No `/v1/prism/discover` run with this `run_id` belongs to your account. A run started by any active key of your account is readable with any other; another account's run answers this code too, so a run id never confirms that someone else's run exists. `details.reason` is `unknown_run`. Start a run, or list your runs with `GET /v1/prism/discover/runs`. No credits are deducted. ### RUN_STATE_CONFLICT [#run-state-conflict] **409.** The run's state does not allow this call. `details.reason` names the state: `run_completed` (a completed run cannot be resumed, and a completed or partial run cannot be cancelled), `run_failed` or `run_cancelled` (it cannot be cancelled again), or `run_not_completed` (`POST .../more` needs a completed run; `resume` continues the others). `details.next_step` names the call that is valid in this state. No credits are deducted. ### RUN_EXPIRED [#run-expired] **410.** The run's `retention_days` passed, so its rows were deleted. `details.reason` is `retention_passed`. Every run route answers this code for an expired run. A queued, running or paused run never expires while it is live. Start a new run. No credits are deducted. ### NEEDS_ASYNC [#needs-async] **422.** A `GET /v1/prism/discover` request sent with `async=false` is over what one call can answer: more than 36 searches (keywords, platforms, lane kinds and pages, past-window pages included), more than 50 joins, comments read inline on more than 3 posts per group, or a `limit` over the call's cap. `details.reason` is one of `legs`, `joins`, `comment_posts` and `limit`, `details.over` lists every one, `details.legs` and `details.joins` give the counts against `details.legs_max` and `details.joins_max`, and `details.causes` names the request fields behind them, with `details.estimate` and a `details.suggested_url` without `async=false`, which runs the same request in the background and answers `202` with the run. No credits are deducted. ### CURSOR_EXPIRED [#cursor-expired] **410.** A `GET /v1/events` cursor or a named consumer's position is older than the event log's 30-day retention, so events after it were deleted. `details.reason` is `retention_passed`. `details.oldest_cursor` is the position before the oldest event still kept, and `details.missed_from` and `details.missed_until` bound the window you missed (`missed_from` is `null` when the cursor carries no time). Restart from `cursor=oldest` (or `details.oldest_cursor`) and tell the user which window was missed. For a consumer the 410 commits nothing: its next read restarts at the oldest retained event and returns `restarted_from` once. See [Events feed](/docs/events.md). No credits are deducted. ### SCHEDULE_NOT_FOUND [#schedule-not-found] **404.** No `/v1/prism/discover` schedule with this `schedule_id` belongs to your account. A schedule created with any active key of your account is readable with any other; another account's schedule, and one you deleted, answer this code too. `details.reason` is `schedule_not_found`. List your schedules with `GET /v1/prism/discover/schedules`. No credits are deducted. ### STREAM_NOT_FOUND [#stream-not-found] **404.** No `/v1/listening/streams` listener with this id belongs to the API key that sent the request. A listener is read only by the key that created it, so another key of your account, another account, and an id that does not exist all answer this code. `details.reason` is `unknown_stream`. List this key's listeners with `GET /v1/listening/streams`. No credits are deducted. ### STREAM_STATE_CONFLICT [#stream-state-conflict] **409.** The listener's state does not allow this call. `details.reason` is `stream_deleted` when the listener is deleted (a PATCH, pause or resume of it): create a new listener instead. It is `resume_from_dashboard` when the listener is `stopped` because its key was revoked or expired: an API key cannot resume it, and the account owner rebinds it to another active key from the dashboard. No credits are deducted. ## Idempotency Send an `Idempotency-Key` header and a retry of the same request with the same key replays the first answer for 0 credits (`X-Idempotent-Replay: true`, `Idempotent-Replayed: true`) instead of running again. Every `GET` takes a key, and so do the batch, job-submit, webhook endpoint and cohort `POST` calls; cohort writes require one. Seven `POST` calls ignore it, among them `POST /v1/web/sessions` and `POST /v1/web/agent`, so never retry those blind after a timeout. [Idempotency](/docs/idempotency.md) lists every call in each group, how long a key is kept, and when a call is not replayed. The errors a key can produce are all free: - [`IDEMPOTENCY_IN_PROGRESS`](#idempotency-in-progress) (409): the first call is still running. A `GET` retry waits up to 20 seconds before this answer; a `POST` gets it at once. Retry after `Retry-After` (1 to 30 seconds) with the same key. - [`IDEMPOTENCY_KEY_PAYLOAD_MISMATCH`](#idempotency-key-payload-mismatch) (422): the same key with a different query or body. Use a new key. - [`IDEMPOTENCY_REPLAY_UNAVAILABLE`](#idempotency-replay-unavailable) (409): the first answer was over 160 KB compressed and was not kept. Send it with a new key. - `SERVICE_UNAVAILABLE` (503) with `Retry-After: 1`: the key store was unavailable, and nothing ran. Retry with the same key. ## Refund rules Credits are refunded automatically when: - Upstream returns a 5xx error (`UPSTREAM_ERROR`, 502 or 504). - The circuit breaker rejects the request, or the upstream provider rate-limits us (`SERVICE_UNAVAILABLE`, 503). - An unexpected server error occurs (`INTERNAL_ERROR`, 500). - Upstream returns 200 with an empty body, for example a nonexistent handle, triggering `RESOURCE_NOT_FOUND` (404). Credits are **never deducted** for these outcomes, so no refund is needed: - Cache hits (`X-Cache: HIT`, `X-Credits-Used: 0`). - `METHOD_NOT_ALLOWED` (405), `IDEMPOTENCY_KEY_CONFLICT` (409), `IDEMPOTENCY_IN_PROGRESS` (409), `IDEMPOTENCY_REPLAY_UNAVAILABLE` (409), and `IDEMPOTENCY_KEY_PAYLOAD_MISMATCH` (422). - `KEY_BUDGET_EXCEEDED` (402). The request is refused before the charge lands, and the key's own usage counter is left where it was. - Idempotent replays. The original charge already appears on your earlier request. Client errors (`MISSING_API_KEY`, `INVALID_API_KEY`, `INVALID_REQUEST`, `ENDPOINT_NOT_FOUND`) are rejected before any deduction. The one exception is an `INVALID_REQUEST` that only the data source can detect, such as a page past the end of a search: that call is charged and then refunded in full before the response is sent. See [Credits](/docs/credits.md#when-are-credits-refunded) for the full refund matrix. ## Handling retries Retry only the errors marked retryable above: `IDEMPOTENCY_IN_PROGRESS` (409), `RATE_LIMITED` or `CONCURRENCY_LIMIT` (429), and transient `500`, `502`, `503`, or `504` responses. Honor `Retry-After`: idempotency contention sends 1 to 30 (it grows with the original request's running time), claim-store outages send `1`, other transient 503s send a wait that depends on the cause, rate-limit 429s send the seconds until reset, and concurrency 429s send a short hint. Always read the header rather than assume a fixed wait. Otherwise back off exponentially with full jitter, so a pool of your own workers does not resynchronize and stampede the cap together. Cap the attempts and surface the error rather than retrying forever. Never retry a `4xx` client error other than `429`: the request is deterministic and will fail the same way. Retryable failures attempt an immediate refund; if that database mutation fails, exact compensation is queued and the response keeps a non-zero committed `credits_used`. Add an `Idempotency-Key` header (see [Idempotency](#idempotency)) so a network-level retry of a successful sync call replays rather than re-runs. Each loop below retries only the transient statuses (429, 500, 502, 503, 504), honors `Retry-After` when present, falls back to exponential backoff with jitter, and gives up after five attempts. **cURL** ```bash title="Terminal" #!/usr/bin/env bash url="https://www.socialcrawl.dev/v1/tiktok/profile?handle=charlidamelio" max_retries=5 attempt=0 while :; do body=$(mktemp); headers=$(mktemp) status=$(curl -s -o "$body" -D "$headers" -w '%{http_code}' \ -H "x-api-key: $SOCIALCRAWL_API_KEY" "$url") if [ "$status" -lt 400 ]; then cat "$body"; rm -f "$body" "$headers"; break fi case "$status" in 429|500|502|503|504) ;; # retryable, fall through *) echo "Non-retryable $status"; cat "$body"; rm -f "$body" "$headers"; exit 1 ;; esac attempt=$((attempt + 1)) if [ "$attempt" -gt "$max_retries" ]; then echo "Gave up after $max_retries retries (last status $status)" rm -f "$body" "$headers"; exit 1 fi # Retry-After (seconds) wins; else exponential backoff 2^attempt with jitter. retry_after=$(grep -i '^retry-after:' "$headers" | tr -d '\r' | awk '{print $2}') if [ -n "$retry_after" ]; then delay="$retry_after" else delay=$(awk "BEGIN{srand(); print (2 ^ $attempt) * (0.5 + rand() / 2)}") fi rm -f "$body" "$headers" sleep "$delay" done ``` **Python** ```python title="Python" import os import random import time import requests # pip install requests BASE_URL = "https://www.socialcrawl.dev/v1" RETRYABLE = {429, 500, 502, 503, 504} def get_with_retry(path, params=None, max_retries=5): headers = {"x-api-key": os.environ["SOCIALCRAWL_API_KEY"]} for attempt in range(max_retries + 1): resp = requests.get(f"{BASE_URL}{path}", params=params, headers=headers) # Success, or a client error we must not retry: hand it back / raise. if resp.ok or resp.status_code not in RETRYABLE or attempt == max_retries: resp.raise_for_status() return resp.json() # Retry-After (seconds) wins; else exponential backoff with full jitter. retry_after = resp.headers.get("Retry-After") delay = float(retry_after) if retry_after else (2 ** attempt) * (0.5 + random.random() / 2) time.sleep(delay) data = get_with_retry("/tiktok/profile", {"handle": "charlidamelio"}) print(data) ``` **Node** ```js title="JavaScript" // Node 18+ (built-in fetch). No dependencies. const BASE_URL = "https://www.socialcrawl.dev/v1"; const RETRYABLE = new Set([429, 500, 502, 503, 504]); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function getWithRetry(path, { params = {}, maxRetries = 5 } = {}) { const url = new URL(BASE_URL + path); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); const headers = { "x-api-key": process.env.SOCIALCRAWL_API_KEY }; for (let attempt = 0; ; attempt++) { const res = await fetch(url, { headers }); if (res.ok || !RETRYABLE.has(res.status)) { if (!res.ok) throw new Error(`SocialCrawl ${res.status}: ${await res.text()}`); return res.json(); } if (attempt >= maxRetries) { throw new Error( `Gave up after ${maxRetries} retries (last status ${res.status})`, ); } // Retry-After (seconds) wins; else exponential backoff with full jitter. const retryAfter = Number(res.headers.get("Retry-After")); const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000 * (0.5 + Math.random() / 2); await sleep(delayMs); } } const data = await getWithRetry("/tiktok/profile", { params: { handle: "charlidamelio" }, }); console.log(data); ``` ## Status on 5xx responses Every `500`, `502`, `503` and `504` body carries two additive fields inside `error`: - **`error.status_url`**: the public status page, filtered to the platform when one is known, for example `https://www.socialcrawl.dev/status?platform=youtube`. - **`error.incident`**: present only when an open public incident explains the failure. It has `id`, `title`, `status`, `impact` and `url`. ```json title="Response" { "status_url": "https://www.socialcrawl.dev/status?platform=youtube", "incident": { "id": "k3v9w2m8q1zt", "title": "Elevated request failures on YouTube", "status": "investigating", "impact": "major", "url": "https://www.socialcrawl.dev/status/incidents/k3v9w2m8q1zt" } } ``` When `error.incident` is present, retrying sooner will not help: wait for the incident to clear, then resume. Without it, treat the failure as transient and honour `Retry-After`. [Status](/docs/status.md) has the full recipe in cURL, TypeScript and Python, and explains how the status page decides each state. ## Debugging tips - Every error envelope carries a `doc_url` pointing at the matching section on this page, for example `https://www.socialcrawl.dev/docs/errors#insufficient-credits`. - `request_id` matches the `X-Request-Id` header and the `request_id` column on Dashboard → Activity Logs. - Persistent 502s on a known-good input usually indicate an upstream outage. Check `error.incident` on the response, the [status page](/status), or `GET /v1/status/{platform}`. - A high retry count on a slow-but-successful call is surfaced in the `X-Upstream-Retries` response header. ## Next steps - [Rate limits](/docs/rate-limits.md): The two 429s, the headroom headers, and how to size a batch job. - [Credits](/docs/credits.md): What each request costs, and the full refund matrix. - [Response schema](/docs/response-schema.md): The success envelope and every response header. - [Versioning](/docs/versioning.md): Why an error