Skip to main content

Errors

Every failure comes back in one envelope, whichever route refused it:

{
"error": {
"type": "https://reelwire.io/errors/forbidden",
"code": "forbidden",
"message": "This key may not read here. Give it the \"Team: read\" permission, or use a key that has it.",
"requestId": "01M2SR17R25EKN6DM1H9DVDS0D",
"details": []
}
}

The fields, and what each is for:

FieldWhat it is
codeThe machine-readable reason. Branch on this, never on the message.
typeA stable documentation URI, https://reelwire.io/errors/{code}. It carries no extra information beyond the code.
messageWritten for a person to read and safe to show in your own interface. It never carries a stack trace or a secret.
requestIdA ULID that correlates this failure with the log line in every container the request touched. The same value is on the x-request-id response header of every response, successful or not. Send a ULID of your own in that header and it is used instead; anything that is not a ULID is replaced with a fresh one.
detailsOne entry per problem, each with a JSON Pointer into the request body.
retryAfterSecondsPresent when waiting is what fixes it: on a rate limit, with the same number in a Retry-After header.

Quote the requestId when you ask about a failure. It is the one value that finds the request again.

The pages a person opens in a browser rather than calls are the exception, because a person reads them: the post kit, a syndication group's page and the pages its emails open answer with a page of their own, such as 410 Gone for a link past its time.

Details carry a pointer per problem​

details is what makes a refusal actionable: a client can point at the exact field rather than print a paragraph and leave somebody to find it.

curl http://localhost:4000/v1/stream \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"streamId":"st_30472168d941","eventId":"not-a-ulid","occurredAt":"yesterday","data":{}}'
{
"error": {
"type": "https://reelwire.io/errors/schema_validation",
"code": "schema_validation",
"message": "Stream envelope is not valid",
"requestId": "01M2SQNH1MDKPWQ83Y3ECG60M3",
"details": [
{ "path": "/eventId", "message": "must be a Crockford base32 ULID", "rule": "invalid_string" },
{ "path": "/occurredAt", "message": "Invalid datetime", "rule": "invalid_string" }
]
}
}

path is an RFC 6901 JSON Pointer into the body you sent. An empty string means the document as a whole. rule is an optional machine hint, and expected and received appear where the checker knew both.

The codes, and the status each carries​

A route never picks its own status: the code decides it, in one table. The table is shared by every boundary of Reelwire, so a few codes, such as replayed and timestamp_skew, belong to signed deliveries rather than to anything you call.

CodeStatusWhen
malformed_json400The body is not JSON.
schema_validation400Well-formed JSON that does not fit the shape.
ratio_not_supported400The template does not come in that shape.
locale_not_supported400Nothing is drawn in that language.
blocked_url400A URL that Reelwire will not fetch or deliver to.
unauthenticated401Missing or invalid credentials, or an AI assistant's token sent to the API directly rather than through Reelwire's MCP server.
signature_invalid401A signature that does not check out.
timestamp_skew401A signed request too far from now.
forbidden403A valid credential that may not do this, or a wrong password where a route asks for it again, such as showing a key.
not_found404No such thing, or not yours.
unknown_source404No shipped feed under that source slug.
unknown_format404No such format under that source.
unknown_template404No such template.
unknown_stream404No stream with that id on this account.
conflict409A conflict with what is already there.
idempotency_conflict409The same eventId with a different body.
invalid_state_transition409The thing is not in a state this action applies to.
stream_disabled409The stream exists and is switched off.
no_sample409Asked about a stream's own field names before it has received anything.
template_not_approved409The template is not cleared for this workspace.
replayed409A delivery id that has already been processed.
payload_too_large413Over the size limit for this route.
unsupported_media_type415The wrong Content-Type, such as JSON sent to a route that takes a file.
rate_limited429Too many requests. retryAfterSeconds says how long to wait. Also an allowance used up: no videos left this month, or no room left in the media library's share of the storage. That one carries no retryAfterSeconds, because waiting does not fix it.
internal500A fault on Reelwire's side.
render_failed502The renderer refused the job.
asset_unavailable502A file the render needed could not be fetched, or a video was asked for before it had finished.
checksum_mismatch502A file arrived but is not the file that was promised.
upstream_unavailable503A dependency is down, such as every renderer being unreachable.
render_timeout504The renderer took too long.

Two outcomes that sound like failures deliberately are not. An event older than its feed accepts is answered 202 with status: "stale" in an ordinary response rather than in an error envelope: it was well formed and was stored, and nothing was drawn. A producer replaying history sees success rather than a wall of 4xx that would hide a real error among it. The code stale_event carries 202 in the table for the same reason. See Sending events.

The other is a change to what a workspace pays when the card's bank wants the cardholder to confirm the payment: 202 with { "confirmation": { "url": "...", "label": "...", "expiresAt": "..." } }, the page where they confirm it. It was accepted and is not done yet. Those routes are for a signed-in person, so only a client acting for one meets it.

404 and 403 are not interchangeable​

A thing that belongs to another workspace answers exactly as a thing that does not exist. That is on purpose: telling a stranger that st_000000000000 exists but is not theirs is telling them something. A 403 means the credential is yours and is not allowed the action, and the message names the permission to add. See Permissions.

Retrying​

429 and 503 are worth retrying, after retryAfterSeconds where it is given and with a backoff otherwise. The limits themselves are listed under Rate limits. A 429 without retryAfterSeconds is the exception: it is an allowance used up, and it answers the same until the month resets, more is bought, or files are deleted to make room; its message says which. 502 and 504 on a render are worth one retry. Everything else in the 4xx range is not: the request will be refused the same way until it changes.

Sends carry an eventId that makes a retry idempotent, so retrying a timeout cannot post twice. See Sending events.