Errors
Every failure — 4xx and 5xx alike — arrives in the same envelope. Parse it once.
The envelope
{
"error": {
"type": "invalid_request_error",
"code": "brand_forbidden",
"message": "You do not have access to this brand."
}
}
A 422 adds an errors map, and nothing else changes:
{
"error": {
"type": "validation_error",
"code": "invalid_request",
"message": "The request was invalid.",
"errors": {
"accounts": ["Select at least one social account."],
"schedule_type": ["This field is required."]
}
}
}
Note the 422: its type is validation_error but its
code is invalid_request — they are not the same string. Branch on
code.
| Field | Use it for |
|---|---|
type | The broad category. Useful for routing to a handler. |
code | Branch on this. Stable, machine-readable, and it will not change under you. |
message | Human-readable. Log it, show it to a developer — but never parse it. The wording may improve at any time. |
errors | Only on 422: a map of field name → messages. |
Types
| Type | Means |
|---|---|
authentication_error | We do not know who you are. |
invalid_request_error | We know who you are; the request is wrong, or not allowed. |
validation_error | The body failed validation. Look at errors. (Its code is invalid_request.) |
idempotency_error | Something is wrong with the Idempotency-Key: it is missing, or it was reused with a different body. |
rate_limit_error | Too many requests for this key. |
api_error | Something broke on our side. |
The catalog
Grouped by what you should do, which matters more than the number.
Fix the request — never retry
| Status | Code | What happened |
|---|---|---|
400 | idempotency_key_required | A write arrived without an Idempotency-Key header. |
401 | unauthorized | Missing, malformed or deleted key. |
403 | insufficient_scope | The key lacks the scope the endpoint requires. |
403 | brand_forbidden | The key's user is not a member of that brand. |
403 | test_mode_write_forbidden | A vd_test_ key tried to write. |
404 | not_found | No such resource — or it belongs to another tenant. |
409 | idempotency_error | Same Idempotency-Key, different body. |
413 | payload_too_large | Upload exceeds the size ceiling. |
415 | unsupported_media_type | The uploaded bytes are not an allowed image type. We check magic bytes, not your Content-Type. |
422 | invalid_request | Body failed validation. Read errors for the field-level messages. (The type is validation_error.) |
422 | invalid_media_url · invalid_feed_url · invalid_webhook_url · invalid_site_url | A URL you supplied is unusable or points somewhere we refuse to fetch (private or reserved addresses are blocked). |
422 | invalid_credentials | The platform rejected the credentials you supplied. |
422 | interactive_setup_required | That platform cannot be connected purely over the API yet. |
422 | not_oauth_platform · oauth_required | Wrong connect method for that platform — see Connecting accounts. |
Money and plan limits — retrying will not help
A 402 always means the request was valid but your plan will not allow it. The
code tells you which lever to pull.
| Status | Code | What to do |
|---|---|---|
402 | limit_reached | A plan cap is full (brands, social accounts, posts this month, reports…). Upgrade, or free some capacity. |
402 | feature_not_available | The plan does not include this feature at all. |
402 | insufficient_credits | Not enough AI credits for this generation. Nothing was charged. |
402 | storage_limit_exceeded | The file would push you past your storage allowance. Checked before any credit is spent, so an AI image that cannot be stored costs you nothing. |
Wait, then retry
| Status | Code | What to do |
|---|---|---|
409 | report_not_ready | The export is still generating. Poll the delivery until it is sent. |
409 | reconnect_required | The account's token cannot be refreshed. A human must reconnect it. |
429 | rate_limited | Back off. Honour Retry-After — see Rate limits. |
5xx | api_error | Our fault. Retry with backoff — and because writes are idempotent, a retry is safe. |
Handling them
Branch on code, and let the categories drive the behaviour rather than writing a case per endpoint:
const NEVER_RETRY = new Set([
'unauthorized', 'insufficient_scope', 'brand_forbidden',
'test_mode_write_forbidden', 'invalid_request', 'not_found',
'idempotency_key_required', 'idempotency_error', 'limit_reached',
'feature_not_available', 'insufficient_credits', 'storage_limit_exceeded',
])
async function call(req, attempt = 1) {
const res = await fetch(req)
if (res.ok) return res.json()
const { error } = await res.json()
if (NEVER_RETRY.has(error.code)) {
throw new ApiError(error) // a bug, or a billing decision — surface it
}
if (res.status === 429) {
await sleep(Number(res.headers.get('Retry-After') ?? 5) * 1000)
return call(req, attempt + 1) // same Idempotency-Key: safe
}
if (res.status >= 500 && attempt < 4) {
await sleep(2 ** attempt * 1000)
return call(req, attempt + 1)
}
throw new ApiError(error)
}
Retrying a write is only safe because you sent an Idempotency-Key — reuse the
same key on the retry and a request that already succeeded returns its original result
instead of doing the work twice. See
Idempotency.