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:
| Field | What it is |
|---|---|
code | The machine-readable reason. Branch on this, never on the message. |
type | A stable documentation URI, https://reelwire.io/errors/{code}. It carries no extra information beyond the code. |
message | Written for a person to read and safe to show in your own interface. It never carries a stack trace or a secret. |
requestId | A 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. |
details | One entry per problem, each with a JSON Pointer into the request body. |
retryAfterSeconds | Present 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.
| Code | Status | When |
|---|---|---|
malformed_json | 400 | The body is not JSON. |
schema_validation | 400 | Well-formed JSON that does not fit the shape. |
ratio_not_supported | 400 | The template does not come in that shape. |
locale_not_supported | 400 | Nothing is drawn in that language. |
blocked_url | 400 | A URL that Reelwire will not fetch or deliver to. |
unauthenticated | 401 | Missing or invalid credentials, or an AI assistant's token sent to the API directly rather than through Reelwire's MCP server. |
signature_invalid | 401 | A signature that does not check out. |
timestamp_skew | 401 | A signed request too far from now. |
forbidden | 403 | A valid credential that may not do this, or a wrong password where a route asks for it again, such as showing a key. |
not_found | 404 | No such thing, or not yours. |
unknown_source | 404 | No shipped feed under that source slug. |
unknown_format | 404 | No such format under that source. |
unknown_template | 404 | No such template. |
unknown_stream | 404 | No stream with that id on this account. |
conflict | 409 | A conflict with what is already there. |
idempotency_conflict | 409 | The same eventId with a different body. |
invalid_state_transition | 409 | The thing is not in a state this action applies to. |
stream_disabled | 409 | The stream exists and is switched off. |
no_sample | 409 | Asked about a stream's own field names before it has received anything. |
template_not_approved | 409 | The template is not cleared for this workspace. |
replayed | 409 | A delivery id that has already been processed. |
payload_too_large | 413 | Over the size limit for this route. |
unsupported_media_type | 415 | The wrong Content-Type, such as JSON sent to a route that takes a file. |
rate_limited | 429 | Too 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. |
internal | 500 | A fault on Reelwire's side. |
render_failed | 502 | The renderer refused the job. |
asset_unavailable | 502 | A file the render needed could not be fetched, or a video was asked for before it had finished. |
checksum_mismatch | 502 | A file arrived but is not the file that was promised. |
upstream_unavailable | 503 | A dependency is down, such as every renderer being unreachable. |
render_timeout | 504 | The 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.