Errors & retries
One error shape, a small set of status codes, and a simple rule: retry confirmed no-charge failures, but inspect an unknown outcome before sending it again.
The error envelope
Every non-2xx response from the API is a Stripe-style envelope. type is the stable category, code is the machine-readable reason, message is for humans, and param names the offending field when there is one:
{
"error": {
"type": "api_error",
"code": "insufficient_credits",
"message": "Insufficient credits: need 4, have 1. Purchase credits to continue."
}
}type is one of: invalid_request_error, authentication_error, permission_error, not_found_error, conflict_error, rate_limit_error, api_error, platform_error.
/v1/data/* endpoints keep their own envelope on failure too, so success and failure parse the same way. An unavailable data action returns 501:
{
"status": "ERROR",
"request_id": "01K3ZB...",
"error": { "code": "not_implemented", "message": "This data action is currently unavailable." }
}Status codes
| Status | Codes | What to do |
|---|---|---|
| 400 | bad_request, parameter_invalid, capability_not_supported | Fix the request. The message says what is wrong; don't retry unchanged. |
| 401 | api_key_invalid, unauthenticated | The key is missing, revoked or mistyped. Check the prefix and the header. |
| 402 | insufficient_credits, spend_cap_exceeded | Out of credits, or a spend cap stopped the call. Top up or raise the cap — the call was not executed and nothing was charged. |
| 403 | permission_denied | The key is valid but lacks the scope (e.g. a read-only key on a mutation). |
| 404 | resource_missing | Genuinely missing OR outside your tenant's scope — we never leak existence across tenants, so both look identical. |
| 409 | conflict | State conflict (e.g. redriving a delivery that isn't dead). |
| 422 | parameter_invalid | Schema validation failed. param points at the field. |
| 429 | rate_limited | Slow down. A Retry-After header tells you exactly how many seconds to wait. |
| 500 | internal_error | Our bug. Quote the request_id and inspect the outcome before retrying. |
| 501 | not_implemented | This data action is not currently available. Retrying won't help — contact support if you expected coverage. |
| 502 | platform_error | The data service was temporarily unavailable. Never charged. Retry with backoff. |
Retry policy
- 429 — wait
Retry-Afterseconds, then retry. That header is authoritative; don't invent your own interval. - 502 — exponential backoff with a cap (1s → 2s → 4s → …, max ~60s, ~5 attempts). A confirmed 502 is never charged; an unknown timeout or 500 needs inspection.
- 4xx (except 429) — do not retry. Fix the request, the key, or the balance first.
- POSTs — persisted
Idempotency-Keyreplay is available for bulk-job submissions and subscription Checkout. Synchronous data and workflow requests need receipt inspection before retrying an unknown outcome.
import time
import requests
def call_with_retry(url, *, headers, json=None, max_attempts=5):
"""429/502 honor backoff; unknown 5xx outcomes stop for inspection."""
delay = 1.0
for attempt in range(max_attempts):
resp = requests.post(url, headers=headers, json=json, timeout=60)
if resp.status_code == 429:
retry_after = resp.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else delay)
delay = min(delay * 2, 60)
continue
if resp.status_code == 502:
time.sleep(delay) # confirmed service failures are never charged
delay = min(delay * 2, 60)
continue
if resp.status_code >= 500:
raise RuntimeError("unknown server outcome; inspect request_id before retrying")
resp.raise_for_status() # 4xx: fix the request, don't retry
return resp.json()
raise RuntimeError("exhausted retries")Talking to support? Every response — success or error — carries a request_id. Quote it; it is the primary key to the call in our ledger. You can also replay any call yourself from the playground.