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.

FieldUse it for
typeThe broad category. Useful for routing to a handler.
codeBranch on this. Stable, machine-readable, and it will not change under you.
messageHuman-readable. Log it, show it to a developer — but never parse it. The wording may improve at any time.
errorsOnly on 422: a map of field name → messages.

Types

TypeMeans
authentication_errorWe do not know who you are.
invalid_request_errorWe know who you are; the request is wrong, or not allowed.
validation_errorThe body failed validation. Look at errors. (Its code is invalid_request.)
idempotency_errorSomething is wrong with the Idempotency-Key: it is missing, or it was reused with a different body.
rate_limit_errorToo many requests for this key.
api_errorSomething broke on our side.

The catalog

Grouped by what you should do, which matters more than the number.

Fix the request — never retry

StatusCodeWhat happened
400idempotency_key_requiredA write arrived without an Idempotency-Key header.
401unauthorizedMissing, malformed or deleted key.
403insufficient_scopeThe key lacks the scope the endpoint requires.
403brand_forbiddenThe key's user is not a member of that brand.
403test_mode_write_forbiddenA vd_test_ key tried to write.
404not_foundNo such resource — or it belongs to another tenant.
409idempotency_errorSame Idempotency-Key, different body.
413payload_too_largeUpload exceeds the size ceiling.
415unsupported_media_typeThe uploaded bytes are not an allowed image type. We check magic bytes, not your Content-Type.
422invalid_requestBody failed validation. Read errors for the field-level messages. (The type is validation_error.)
422invalid_media_url · invalid_feed_url · invalid_webhook_url · invalid_site_urlA URL you supplied is unusable or points somewhere we refuse to fetch (private or reserved addresses are blocked).
422invalid_credentialsThe platform rejected the credentials you supplied.
422interactive_setup_requiredThat platform cannot be connected purely over the API yet.
422not_oauth_platform · oauth_requiredWrong 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.

StatusCodeWhat to do
402limit_reachedA plan cap is full (brands, social accounts, posts this month, reports…). Upgrade, or free some capacity.
402feature_not_availableThe plan does not include this feature at all.
402insufficient_creditsNot enough AI credits for this generation. Nothing was charged.
402storage_limit_exceededThe 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

StatusCodeWhat to do
409report_not_readyThe export is still generating. Poll the delivery until it is sent.
409reconnect_requiredThe account's token cannot be refreshed. A human must reconnect it.
429rate_limitedBack off. Honour Retry-After — see Rate limits.
5xxapi_errorOur 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.