Relay — Error Reference
Version: 1.0
Companion to: 04-srs.md EIR-API-04, EIR-WS-06
Purpose: Every error code the platform can emit, what caused it, and what a client
should do about it. This is the page docs_url points at: an error response carries
docs_url in its envelope, and the fragment is the code verbatim, so
https://relay.dev/docs/error-reference#quota_exceeded lands on that code's section.
The set is closed and checked. ERROR_CODES in @relay/protocol is the registry, and a
check in the tutorial repository compares it against the h2 headings below in both
directions — a code with no section fails the build, and a section for a code that
cannot be emitted fails it too. A reference documenting a retired code is how a
documentation set starts lying.
Retryable or not, always stated. A client that retries a refusal it can never satisfy waits for ever, and a client that gives up on a transient one loses a message. Every section below says which it is.
Errors arrive in one shape on both surfaces (EIR-API-04, and the same payload inside a
WebSocket error frame):
{
"code": "invalid_request",
"message": "metadata must be at most 8192 bytes of JSON",
"docs_url": "https://relay.dev/docs/error-reference#invalid_request",
"request_id": "9c2f8a1e-4b7d-4f3a-9e51-6d8c2b0a7f14",
"field": "metadata"
}
field is present only when one key is at fault. request_id is on every response,
header and body alike, and it is the thing to quote in a support request.
WebSocket close codes
A socket refusal has two parts: an error frame carrying one of the codes below, and then
a close code. The close code is the part that survives — a client that reconnects
automatically often sees only the close, so the number has to distinguish the cause on its
own (EIR-WS-06).
The set is closed and checked in both directions against CLOSE_CODES in @relay/protocol,
exactly as the error codes below are.
| Close | Meaning | Sent with | What to do |
|---|---|---|---|
| 4001 | invalid or expired token | no error frame | Mint a new token and reconnect. The socket closes before the handshake completes, so there is nothing to carry a frame. |
| 4002 | protocol violation | unknown_frame_type | Fix the client. It sent a frame only the server may send. |
| 4003 | banned in this environment | user_banned | Do not reconnect. The token is valid and the user is barred; re-authenticating succeeds and connecting fails again. |
| 4004 | connection limit reached | connection_limit_reached | Close one of the connections you already hold, then reconnect immediately. |
| 4008 | quota exhausted | quota_exceeded | Wait for the quota window, or raise the plan's limit. |
| 4009 | server shutdown (drain) | not currently emitted | Reconnect. Declared and unused: the gateway's shutdown closes sockets without a drain code, and this row says so rather than describing a behaviour the platform does not have. |
4001 carries no error frame, and that is the one asymmetry here. Authentication is checked before the connection is established, so the close is all there is. Every other row sends the frame first.
invalid_request
Status: 400 · Retryable: no, not without changing the request
The body, query string or path failed validation. field names the first offending key —
metadata for a payload over 8 KB, type for a channel type the platform does not
accept, user_ids for a member list over 100.
Unknown fields are rejected rather than ignored, which is deliberate: a client that sends
externalId where the API expects external_id finds out on the first call instead of
wondering why the name never appears.
What to do: read field, fix that key, send again. Retrying the same body will fail
identically.
unauthorized
Status: 401 · Retryable: no, not with the same credential · on a WebSocket connect the socket closes 4001 with no error frame
No credential was presented, or the one presented is not valid for this route: an expired
end-user token, a malformed one, one signed by another environment's secret, or no
Authorization header at all.
The message says what the route wanted. It never repeats the credential — a key in a support ticket is a leaked key (NFR-SEC-06).
What to do: for an end-user token, mint a fresh one. For an application key, check it is the key for the environment you mean.
wrong_credential_type
Status: 403 · Retryable: no
The credential is perfectly valid and is the wrong KIND for this route. An application key where an end-user token is required, or the reverse.
This has its own code rather than a generic forbidden because the two are different
facts and only one of them is fixable by the caller in a minute: "you lack a permission"
sends a developer to look for a permissions screen, and "you presented the wrong kind of
credential" sends them to the line of code that chose the header. The SRS singles this out
as the most common first-integration failure.
What to do: the message names the class the route expects and the class you presented. Present the other one.
wrong_credential_service
Status: 403 · Retryable: no
A platform credential was presented on a route belonging to a different internal service. The class is right and the service is not.
Internal routes only. A customer integrating against Relay cannot produce this error, and
it is documented because it appears in platform logs and because docs_url is derived
from the code — an undocumented code would ship a link to nothing.
What to do: internal callers should present the credential for the service that owns the route. The message names the service presented and the services permitted.
sender_not_permitted
Status: 403 · Retryable: no
An application credential named a person in user when sending a message. A key may speak
as software and not as any person: it acts for the tenant, not for one of the tenant's
users, so the only sender it may name is a bot user of that same tenant.
The third refusal in a family. wrong_credential_type is the wrong credential class,
wrong_credential_service the wrong service, and this one is the right class naming the
wrong kind of user.
The message names neither the person asked for nor the bots that would have been accepted. Which identifiers exist in a tenant is not something a refusal reveals — a message listing the acceptable senders would be an enumeration endpoint behind a 403.
What to do: create a bot user — a users upsert entry with kind: "bot" and a
description saying what the software is — and name it in user. A user token needs none
of this: its send is attributed to the token's subject, and naming user in the body is
refused.
not_message_author
Status: 403 · Retryable: no
The caller did not write this message. Only its author may change what it says, and only its author or a tenant API key may delete it.
This has its own code rather than a generic forbidden for a reason the generic entry
below states about itself: its client action is a change of credential or of permission,
and authorship is neither. No credential grants it, no permission screen confers it,
and no retry acquires it — a message belongs to whoever wrote it, permanently. A developer
sent to look for a permissions setting by a forbidden would not find one.
An end user editing or deleting somebody else's message and a tenant API key editing anybody's both arrive here, because the cause is the same in both cases and so is the answer. A tenant key may still delete any message in its environment — that is a different route and not a refusal.
What to do: nothing, for this message and this caller. If the operation is meant to be a moderation action rather than the author's own, use the deletion route with a tenant API key; editing on somebody's behalf is not offered, because a message rewritten by somebody else would say something its author never wrote with nothing on the wire to say so.
message_deleted
Status: 403 · Retryable: no
This message has been deleted. Its text cannot be changed; its edit history is unaffected.
Only the message's author ever receives this code. A caller who did not write the
message is refused with not_message_author first, whether or not the message still says
anything — so this answer cannot tell anybody that a message they could not otherwise see
exists.
It is a 403 rather than a 404 because the message is still there and the author can still
read it: deleted messages stay in history in their original position, so a 404 here would
deny the existence of a row the same caller can fetch through the history route. One
resource answering two ways depending on the verb is the defect the platform fixed for
channels and does not reintroduce for messages. It is not a 410 either — that says it
was here and it is gone, which is more than this API says anywhere.
What to do: stop offering an edit control for this message and re-read the channel's
history. The row is there with a null text and a deleted_at, which is the state the
client should be showing. Deleting it again succeeds and changes nothing.
forbidden
Status: 403 · Retryable: no
The credential is valid and is not permitted to perform this operation. The generic case:
where a more specific code exists — wrong_credential_type, wrong_credential_service,
not_message_author — that one is sent instead.
What to do: nothing the client can retry. This is a change of credential or of permission.
media_not_attachable
Status: 422 · Retryable: no
The attachment named {"type": "media", "media_id": …} and this sender cannot attach that
object. Three conditions answer with this code and they are deliberately
indistinguishable: the object belongs to another environment, it belongs to another user
of your environment, or no object has that id.
One answer for three reasons, on purpose. Telling you which one you hit would tell you whether somebody else's object exists — you could probe ids and learn which are real. The platform will not do that, so a media object you cannot attach and a media object that was never created look the same from outside.
This is not a mistake in your request, which is why it is not invalid_request. The id
is well-formed and the field is published in FR-MSG-11; what cannot be done is the thing
the request asks for, which is what a 422 means. A malformed media_id — anything that is
not a UUID — is a different answer: invalid_request with a 400 and
field: "attachments.<n>.media_id".
An API key's object can be attached by any user of that tenant. A slot taken by your backend records no uploader, which means the tenant uploaded it rather than nobody, and a user token of that tenant may attach it. The refusal is for another user's object, not for one your own server created.
Over a WebSocket the code is invalid_frame when the gateway's own schema refuses the
frame, and this code when the API does — the gateway forwards any 4xx the API names.
What to do: stop using that id. Request your own upload slot with
POST /v1/media, PUT the bytes to the URL it returns, and attach the media_id that
came back. Retrying this request unchanged will fail identically, however long you wait —
none of the three conditions resolves on its own.
unprocessable_request
Status: 422 · Retryable: no
The request was understood and cannot be carried out. This is the fallback a 422 gets when the thing that raised it did not name a more specific code, so it carries only what the status itself supports: your request was well-formed, the server read it, and what it asks for is not possible.
Nothing in the platform throws this today, and it exists for the same reason
service_unavailable does — so that the next refusal to forget its own code answers with
something a client can act on rather than with internal_error. A 422 calling itself an
internal error tells you the server broke when in fact it understood you perfectly.
If you are seeing this, the specific code is missing rather than you. The request is
not malformed — that would be invalid_request and a 400 — and it is not a permission
problem, which would be a 403. Something about the state of what you asked for makes it
impossible.
What to do: do not repeat the request unchanged; it will fail the same way. Read the
message, which carries whatever the thrower did say, and the request_id, which is what
support needs to tell you which refusal you hit.
webhook_endpoint_limit_reached
Status: 422 · Retryable: no, not until you delete one
You asked for another webhook endpoint and this environment already holds the maximum. The message names the limit and the count you are at, because "too many endpoints" leaves you counting.
The cap is per environment, not per organisation. A second environment has its own allowance, which is the shape most customers want anyway: staging and production endpoints that cannot exhaust each other.
What to do: delete an endpoint you no longer deliver to, or move the subscription into
another environment. If you are near the cap because you have one endpoint per event type,
consider one endpoint subscribed to several — event_types takes a list.
webhook_url_invalid
Status: 422 · Retryable: no · Field: url
The url you sent is not a valid absolute URL. Relay parses it with a real URL parser
before storing it, because an endpoint that cannot be parsed is an endpoint that will never
be called and you would not find out until a delivery was due.
A path is not enough. /hooks/relay has no host for Relay to connect to. Send the whole
thing: scheme, host, and path.
What to do: send an absolute https:// URL. If you are building it by concatenation,
check for a missing scheme or a doubled slash.
webhook_url_insecure
Status: 422 · Retryable: no · Field: url
The url uses a scheme other than https. Relay signs every delivery, and a signature
over a plaintext channel protects the body, not the reader — anyone on the path can read
the payload even though they cannot forge it.
This is refused rather than warned about, because a webhook carries your customers' message content and the decision to send that in the clear is not one to make by accident.
What to do: put TLS on the endpoint. For local development, use a tunnel that terminates
TLS for you rather than pointing Relay at http://localhost.
webhook_url_private_address
Status: 422 · Retryable: no · Field: url
The url resolves to a loopback, link-local, or private address, and Relay will not call
it. That includes 127.0.0.1, localhost, 10.x, 192.168.x, 172.16-31.x, and the
link-local range.
This is not about your network being unreachable. Relay's delivery workers run inside a network of their own, so an address that is private to you is either unreachable or — worse — something else entirely from where they stand. Refusing the address is how a request for your internal service stops being a request against ours.
What to do: give Relay a publicly resolvable HTTPS endpoint. In development, use a tunnelling service; the endpoint you register should be the tunnel's public URL.
webhook_event_types_empty
Status: 422 · Retryable: no · Field: event_types
event_types was absent, empty, or not a list. An endpoint subscribed to nothing would
never fire, and creating one silently is how a customer waits for deliveries that were
never going to come.
What to do: name at least one event type. The accepted set is in
webhook_event_type_unknown below, and the refusal for a
misspelling lists it too.
webhook_event_type_unknown
Status: 422 · Retryable: no · Field: event_types
One of the names in event_types is not an event type Relay declares. The message lists the
ones that are, so you can see which you meant.
A typo used to succeed. mesage.updated would be stored, the endpoint would be created,
and nothing would ever be delivered to it — a permanently silent endpoint with a 201 in
your logs. That is the failure this refusal exists to prevent.
Some declared types are not emitted yet, and those are accepted. Relay declares eight event types and currently emits five. Subscribing to one of the other three is not a mistake: the subscription is stored and starts delivering when the feature ships. You will not receive an error for it, and you will not receive deliveries until then either. The close-code table at the top of this page lists what Relay sends on a socket; for webhooks, the emitted set is what the delivery pipeline currently produces.
What to do: check the spelling against the list in the message. If the name is right and you are receiving nothing, the type is probably one of the declared-but-unbuilt three rather than a mistake on your side.
not_found
Status: 404 · Retryable: no
No such resource. And deliberately the same answer for a resource that belongs to
another tenant — same status, same body, byte for byte, with only request_id
differing.
That identity is a security property and not an implementation detail (FR-TEN-05, NFR-SEC-09). If a foreign channel answered differently from an absent one, the difference would be a way to enumerate other customers' resources: send ids, watch which answer comes back, keep the ones that answer "not yours". So the platform declines to tell you whether the thing exists, and a cross-tenant suite attacks every endpoint on every build to keep that true.
What to do: check the id, and check it belongs to the environment your credential belongs to.
channel_member_limit_exceeded
Status: 422 · Retryable: no — waiting changes nothing
The channel already holds its maximum members (1,000), and the request would have taken it past that. The message names the current count, the limit, and how many the request tried to add. Nobody was added: the check runs before any user is created, so a refused call leaves no rows behind.
Not the same as quota_exceeded, and the difference matters for what a client does
next. A quota is monthly and resets on a date; this is a structural limit on one channel
that no amount of waiting changes.
What to do: use more channels, or remove members. Retrying is futile.
not_a_member
Status: 403 · Retryable: no, until the user is a member
The user is not a member of this channel, and the operation is one that only a member can perform. In practice there is one operation like that: recording a read position, which is per-member state keyed by channel and user.
Where you will NOT see this, because both are more interesting than they look. A
private channel the caller cannot see answers 404 not_found — byte-identical to a
channel that does not exist, body and status, because a 403 naming the membership would
tell you the channel exists. And a public channel permits any authenticated user of the
tenant to read, send and join without being a member at all, so nothing there refuses.
What to do: add the user to the channel, or have them join it if it is public. The message never names the channel's contents or its other members.
channel_archived
Status: 403 · Retryable: yes, once the channel is unarchived
The channel is archived. Archiving stops new messages and keeps everything already written: history reads normally, the channel still appears in listings, and unarchiving restores sending with nothing lost.
Not the same as not_found, and you will only ever see this for a channel you can
already see. The refusal order is ban, then membership and visibility, then archive —
reversed, a non-member of a private archived channel would learn it exists from this code.
What to do: unarchive the channel, or write somewhere else. Retrying the same send against the same archived channel will keep failing.
user_banned
Status: 403 · Retryable: no, until the ban is lifted · on a WebSocket connect, close 4003
The user is banned in this environment. A ban is tenant-scope rather than per channel: they cannot open a socket and cannot send anywhere. Their existing messages stay exactly where they are, readable by everyone else and still attributed to them.
You will see this for every channel id, including ones that do not exist, and that is deliberate: the ban is checked before the channel is resolved, so this refusal cannot be used to find out which channels exist.
What to do: lift the ban if it was a mistake. There is nothing a client can retry into.
rate_limited
Status: 429 · Retryable: YES, after the window
Too many requests, frames or connection attempts in the current window. Retry-After is
set on the HTTP response.
On a socket, this arrives as an error frame and the connection stays open. Closing it
would make the client reconnect, and a reconnect spends the connection-establishment
allowance — a limiter that punishes the limited into hitting a second limit. The frame
refuses that one frame and nothing more.
What to do: back off and retry. Honour Retry-After where it is present; on a socket,
wait for the window to turn over and send again on the connection you still have.
quota_exceeded
Status: 402 · Retryable: yes, from the date in the message · on a WebSocket connect, close 4008
A monthly quota is exhausted. The message names the dimension (messages, active users or connection-minutes), the figure used, the figure allowed, and the date it resumes.
Three notification emails precede this at 80%, 90% and 100% of each quota, so it should never be the first anyone hears of it.
What to do: wait for the period to roll over, or raise the quota. Retrying before the date in the message will fail identically; the date is what makes it retryable at all.
connection_environment_conflict
Status: 409 · Retryable: no
A connection was reported for one environment and is now being reported for another. A connection belongs to one environment for its whole life, so this is a bug in the reporter rather than a state to reconcile — absorbing it would mean billing two tenants for one socket.
Internal routes only; a customer integration cannot produce it.
What to do: internal callers should report a connection under the environment it was established for.
invalid_frame
Status: WebSocket error frame · Retryable: no, not unchanged
A frame sent over the socket was not JSON, or was JSON that failed schema validation. The message carries the first schema complaint.
What to do: fix the frame. The connection stays open, so the next well-formed frame goes through.
unknown_frame_type
Status: WebSocket error frame, then close 4002 · Retryable: no
The frame's type names something a CLIENT may not send. Two frames are inbound —
message.send and typing.send — and every other member of the frame union is
server-to-client, so a client uttering one is a protocol violation rather than a malformed
frame.
The inbound set is named rather than listed by exception, and the spelling is the rule:
an inbound frame ends in .send. A frame whose type is outside that set is refused here
however well-formed it is.
This one closes the connection (4002) where invalid_frame does not, and the distinction
is deliberate: a bad frame is a bug in one message, and a client claiming to be the server
is a bug in the client's model of the protocol. The two are also reached in that order — a
type the union does not contain at all fails schema validation first and gets
invalid_frame with the socket left open.
What to do: send message.send to post a message, or typing.send to say you are
typing in a channel. Do not send events; receive them.
connection_limit_reached
Status: WebSocket error frame, then close 4004 · Retryable: no, not unchanged
A user may hold five concurrent connections in an environment (FR-RTM-09) and this one would have been the sixth. The count is per user per environment and is shared by every gateway instance, so opening the sixth against a different host does not help. The five already open are untouched — nothing is closed to make room, and they keep receiving while the refusal happens.
The count is a live one. A connection that ends frees its place immediately, with no waiting period. A connection whose gateway died frees its place when the 60-second lease on it expires, which is the one case where waiting is the answer.
This is the only refusal in the WebSocket set whose correct handling is not a retry. Reconnecting at once produces the same refusal; backing off produces it more slowly. Every other close code this platform sends — an expired token, a quota, a drain — is answered by trying again in some form. This one is answered by giving something up.
What to do: close one of the connections you already hold, then connect — immediately, with no delay. If you believe you hold fewer than five, count sockets rather than tabs: a page that opens a connection per component holds several, and a reconnect loop that does not close the old socket first holds two for as long as the old one takes to notice.
internal_error
Status: 500 · Retryable: yes, with backoff
The platform failed in a way it did not anticipate. No detail is returned, because a stack trace on the wire is an information leak and a message that guesses is worse than one that does not.
What to do: retry with backoff — many 500s are transient. If it persists, quote the
request_id from the response: it is in the log line for that request, and it is the
fastest way to the cause.
analytics_unavailable
Status: 503 · Retryable: yes, shortly
The analytics service did not answer within the deadline. Everything else works: messages send, sockets stay open, webhooks deliver. What is unavailable is the store the request log is read from, and this code exists so that you can tell that apart from an empty log.
An empty page would have been a claim about you — that your application made no requests in the window you asked about. This is a claim about the platform.
What to do: retry shortly. The deadline is two seconds on the server and three on the
client, so a request that fails this way failed quickly rather than hanging, and a retry a
few seconds later usually succeeds. Nothing about the query needs changing: if the window,
the filters and the cursor were wrong you would have had a 400 with the field named.
Not the same as internal_error. A malformed analytical query — our mistake, not
yours — answers 500 internal_error, because retrying it would never help.
media_type_not_allowed
Status: 415 · Retryable: no
The mime_type you declared when asking for an upload slot is not one Relay hosts. The
accepted set is ten types, and it is deliberately short: four image types (image/jpeg,
image/png, image/gif, image/webp), four audio (audio/mpeg, audio/mp4,
audio/ogg, audio/wav) and two video (video/mp4, video/webm).
The message names the type you sent, so a client that builds the value from a file
extension or from a browser's File.type can log the exact string that was refused rather
than guessing which of several uploads failed.
What to do: transcode, or do not offer the file. Retrying the same request will get the same answer, because nothing about the platform's state decides this one — the list is fixed in the build. If the type you need is a reasonable one to host, that is a request for the list, not a bug in your integration.
Not the same as media_too_large. That one is about the size of a type Relay does
accept, and the remedy there is to compress rather than to convert.
media_too_large
Status: 413 · Retryable: no
The bytes you declared exceeds the limit for that kind of media. The caps are per kind
rather than global: 10 MB for an image, 25 MB for audio, 100 MB for video. A 25 MB audio
file is accepted where a 25 MB image is not, and the kind is derived from the mime_type
in the same request.
The message names both figures — what you declared and what the cap is — because the remedy is to compress and a client cannot compress to an unknown target.
What to do: compress, re-encode at a lower bitrate, or resize. Retrying unchanged will fail identically; nothing on the platform side moves this limit for a single request.
Note that the figure is the one you declared. Relay refuses on your number before the file exists, so a client that under-declares gets a slot and then has to deal with the store's own limits at upload time. Declare the real size.
Not the same as media_storage_exhausted. That one is about your account's total, not
about this one file.
media_storage_exhausted
Status: 402 · Retryable: no
The bytes already committed for this environment, plus the size you declared, would exceed the environment's storage cap. The message names both figures: what is committed now and what the cap is.
What to do: delete media you no longer need, or raise the cap. Waiting does not help, and that is the part worth reading twice. Relay's other quotas are monthly flows — messages sent, active users, connection-minutes — and their refusals promise you a date when sending resumes. Storage is not a flow. It is a level: the figure falls when objects are deleted and the first of the month changes nothing about it.
Not the same as quota_exceeded. That code carries a resume date in its message, and
for this one that date would be a lie.
media_storage_unavailable
Status: 503 · Retryable: yes, shortly
Relay could not reach the object store when you asked for a slot. Nothing is wrong with your request — the type was accepted, the size was inside its cap, and no bytes were committed against your quota.
This is the only one of the four media refusals worth retrying, which is why it has its own code. The other three are permanent for the request that caused them: transcode, compress, free space. Retrying those wastes a round trip and, worse, a client that cannot tell them apart will either retry all four or abandon all four.
What to do: retry in a few seconds. Relay asks the store whether it is answering before it issues a slot, precisely so that you never receive an upload URL that cannot be used — the alternative is a signed URL that looks perfectly good and fails at upload time, which is much harder to diagnose from the client.
Not the same as service_unavailable. This code names the object store specifically.
service_unavailable
Status: 503 · Retryable: yes, shortly
Something the request needed did not answer. This is the general form: where Relay knows
which dependency failed it says so — analytics_unavailable for the analytical store,
media_storage_unavailable for object storage — and this code is what you get when the
status is all that is known.
What to do: retry in a few seconds, and treat a run of these as an incident rather than
as something to fix in your integration. Nothing about the request needs changing: a
malformed request answers 400 with the offending field named, and a request the platform
got wrong answers 500 internal_error, which retrying will not help.
Not the same as internal_error. A 500 means the platform did something wrong and
the same request will go wrong again. A 503 means the platform could not try.