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. |
403 | forbidden | Not allowed on this resource — for example, the conversation is not in the brand named in the URL. |
404 | not_found | No such resource — or it belongs to another tenant. |
405 | method_not_allowed | That HTTP method does not exist on this endpoint. Check the reference. |
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. |
422 | platform_unavailable | That platform is not open for connecting right now. |
422 | invalid_account | One or more account ids are not connected to this brand. List them with GET /brands/{brand}/social-accounts. |
422 | platform_capability_unavailable | The platform can't do this right now, and the message says why — for example, Instagram direct messages can't be replied to yet. Checked before anything is queued. GET /capabilities lists what each platform supports. |
422 | post_not_editable | The post is already published, so it can't be edited. Create a new post instead. |
422 | post_not_draft | Only drafts can be submitted for approval. |
422 | post_not_pending | Only posts waiting for approval can be approved or rejected. |
4xx | http_error | A request we refused for a reason without a more specific code yet. Read message; don't retry unchanged. |
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. |
409 | idempotency_conflict | A request with this Idempotency-Key is still running. Wait a few seconds and retry with the same key and body. |
422 | authorize_failed | The platform would not start authorization. Try again later. |
429 | rate_limited | Back off. Honour Retry-After — see Rate limits. |
500 | internal_error | Our fault (its type is api_error). 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',
'forbidden', 'method_not_allowed', 'invalid_account', 'platform_unavailable',
'platform_capability_unavailable', 'post_not_editable', 'post_not_draft', 'post_not_pending',
])
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.