Errors
Every error code the stack can produce — the proxy's HTTP refusals, the WebSocket close codes, the api-client's error classes — with what causes each one and what to change.
Classification is on errors[].extensions.code and never on the HTTP status, because a
401-equivalent arrives inside an HTTP 200. Every refusal the proxy writes itself uses the same
envelope shape as an answer from Chatfuel, on every route, and carries cache-control: no-store.
Before any route runs
Two checks stand in front of every route, and the WebSocket upgrade asks the same two. The values they read are on the proxy page; the reasoning is on the token boundary.
| Code | HTTP | Cause | What to change |
|---|---|---|---|
ProxyHostForbidden | 403 | The Host header names something this deployment does not answer to. Asked first, so a request to the wrong name collects no CORS header on its way out. | Add the name to ALLOWED_HOSTS, or reach the deployment by a name it knows. |
ProxyOriginForbidden | 403 | The Origin is neither this deployment's own nor one of ALLOWED_ORIGINS, and Fetch Metadata did not say same-origin. | List the origin that serves your app. |
ProxyRefusedToServe | 503 | The serverless host booted into a configuration it will not serve: open mode on a public hostname without CHATFUEL_OPEN_PROXY=1, or ALLOWED_ORIGINS='*'. The health route still answers; everything else is this. | Read the REFUSING TO SERVE: line in the deploy log — it names the three ways out. The Node server and the dev server refuse earlier, before the socket binds. |
InvalidRequest | 400 | On Vercel, the cfpath parameter was written twice, or decodes to a leading /, a . or .. segment, a ? or a #. | Nothing you send by hand. |
NotFound | 404 | On Vercel, a path under /chatfuel the core does not claim. On the other two hosts an unclaimed path falls through to the host itself. | — |
A refused upgrade gets plain HTTP 403 Forbidden before any socket exists.
The gate and the fences
These can answer any proxied route, before anything reaches Chatfuel.
| Code | HTTP | Cause | What to change |
|---|---|---|---|
AuthSessionRequired | 401 | No Authorization: Bearer <jwt>; a JWT whose unverified exp has passed; a JWT issued by somebody other than this deployment's Supabase project — all three refused with zero network — or PostgREST answered 401, which means the signature is bad. | Sign in again. The api-client turns this into ChatfuelSessionError. |
AuthTenantForbidden | 403 | The session is valid and its bot set is empty, and the request names a bot. Signed in, no workspace yet. | Call POST /chatfuel/auth/provision. This is the state provisioning exists to leave. |
ProxyAuthUnavailable | 503 | Supabase could not be reached, answered a non-200, or answered something that is not a JSON array — guessing would open the fence. Also the answer when this process has spent its minute's budget of gate misses. | Nothing in the app. Retry. |
ProxyAuthMisconfigured | 500 | Exactly one of VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY is set. The gate fails closed rather than running open. | Set both, or neither. |
ProxyTokenMissing | 500 | CHATFUEL_TOKEN is absent, or its value contains whitespace. | Run npx @chatfuel/wizard auth, or fix .env. The message names the variable and never its value. |
ProxyFenceUnavailable | 503 | The request names a bot or a resource, nothing is cached yet, and Chatfuel could not be asked which bots this deployment may use. | Retry. A stale answer is served on with a short retry window; only a cold failure refuses. |
BotNotAllowed | 403 | The request names a bot that is outside the fence. With the gate on the message is "Bot X belongs to another workspace"; without it, "Bot X is not one this deployment may use". | Name a bot this session or this deployment owns. |
ResourceNotAllowed | 403 | A flow, block, contact, conversation or task id the proxy handed out under another bot — "The <argument> in this request belongs to another workspace". In strict mode, also an id it has never seen: "This proxy cannot tell which bot that <argument> belongs to, and does not forward what it cannot check". | Ask for the resource under the bot it belongs to. Why the fence exists is on ceilings and fences. |
ProxyUpstreamUnavailable | 504 | Chatfuel timed out (the message says after how many ms) or was unreachable (the message names the host, and says so when an outbound proxy is configured). | Check egress. |
ProxyHandlerFailed | 500 | A route handler's promise rejected. The proxy logs the throw and answers rather than leaving the request hanging. | File it. |
ProxyInternalError | 500 | The same backstop inside the bot routes and the admin panel. | File it. Nothing you send causes this on purpose. |
An operation that names no bot and no resource passes every fence. The auth gate says what that means.
The ceilings
Every one of these is a value on the proxy page and a variable on environment variables.
| Code | HTTP | Cause |
|---|---|---|
RequestTooLarge | 413 | A GraphQL body over 2 MiB, a REST upload over 25 MiB, or a JSON route's body over 2 MiB. The bytes past the ceiling are dropped as they arrive, the refusal goes out with connection: close, and the socket is destroyed 250 ms later if the caller is still sending. |
BatchTooLarge | 413 | More operations in one GraphQL body than GRAPHQL_MAX_BATCH. Counted before anything is parsed. |
ProxyBusy | 503 | More GraphQL requests, or more REST uploads, in flight than this process allows. Carries retry-after: 5. |
TenantBusy | 429 | This tenant has spent its minute. Carries retry-after: 5. Felt only with a fence to key a tenant by. |
AdminBodyTooLarge | 413 | A body over 64 KiB on the admin panel, or over 4 KiB on /chatfuel/admin/session, which answers before any credential is checked. |
MediaTooLarge | 413 | A publish-queue media file over 25 MiB. |
What the proxy reads in a document
The GraphQL route and every subscribe frame on the socket run the same checks in the same order,
and the first refusal is the one you get.
| Code | HTTP | Cause |
|---|---|---|
UnsupportedMediaType | 415 | A GraphQL POST that is not application/json. A missing content-type is refused too: a POST with none is a request a page can send cross-site with no preflight. |
ProxyMalformedQuery | 400 | The body is not JSON with a string query in every entry; a document that does not parse; a walk the analyser could not finish; a bot named in a shape no fence can match; or more than 500 resource ids in one request. |
ProxyExtensionsUnsupported | 400 | An entry carried extensions. Refused rather than dropped in silence. |
OperationNotInRegistry | 403 | The document is not one the app ships. "<name> is not one of the GraphQL documents this app ships, and the proxy forwards no other." A batch is admitted whole or not at all. |
IntrospectionBlocked | 403 | A root field is __schema or __type. Refused with the gate on or off — the app is built against a bundled SDL snapshot and nothing introspects through the proxy. |
AccountOperationBlocked | 403 | A root field is an account-level operation, which names no bot and so passes every fence. Also the answer for POST /chatfuel/api/filestorage/upload/useraccount with the gate on. |
AccountScopeBlocked | 403 | With the gate on: the query reads the account through currentUser, or reaches it through a bot. |
AccountStructureBlocked | 403 | With the gate on: a root field changes which workspaces and bots this deployment has. |
OperationNotAllowed | 403 | The root field is not on the operation allowlist. "<field> is not one of the operations this app sends, and the proxy forwards no other." It runs after the two denylists, so an operation on both is refused with the reason that explains it. |
RestPathNotAllowed | 403 | A path under /chatfuel/api that is not one of the five upload endpoints. |
InvalidRequest | 400 | A bot-scoped REST upload with no ?botID=, or with two of them. |
TooManySubscriptions | — | 128 subscriptions already open on this socket. An error frame on that subscription's id, not a close. |
The sign-up and bot routes
| Code | HTTP | Cause | What to change |
|---|---|---|---|
ProxyAuthMisconfigured | 500 | On these routes it has three more causes: the gate is off, there is no service-role key, or CHATFUEL_WORKSPACE_ID is unset. It also covers a workspace id Chatfuel answers WorkspaceDoesNotExist or NotEnoughPermissions for — the message names the value it was given. | Set the variable to a workspace this token can create bots in. |
ProvisionUnreachable | 502 | workspaceCreateBot could not reach Chatfuel. The one place the proxy writes a log line, and it names the workspace variable, never a token. | Retry, then check egress. |
ProvisionRefused | 502 | Chatfuel answered and the proxy does not recognise what it said. The message carries Chatfuel's status when it was not 200 — a 401 or 403 there is a token that has expired or belongs to another account. | Check the token. |
WorkspaceFull | 409 | Chatfuel answered TooManyBotsInWorkspace. The plan behind the deployment's workspace allows no more bots. | The operator's to fix — upgrade the plan, or point at another workspace. |
BotNotFound | 404 | The <id> segment is empty or contains a slash, or the database says no such bot. | — |
BadBotName | 422 | The body carried no name, or a blank one. | Send {"name": "…"}. |
BotRenameFailed | 502 | The database renamed it and Chatfuel refused. The name is put back before this answer: a bot called one thing here and another there is worse than a rename that did not happen. | Retry. |
BotDeleteUnavailable | 503 | One of the two delete guards could not be checked with Chatfuel. Fails closed, because a retry is recoverable and a gone bot is not. | Retry. |
LastBotInWorkspace | 409 | The bot is the caller's last openable one, or the last in the deployment's Chatfuel workspace. Both refuse with the same sentence, because from where the person sits both mean the same thing. | Create another bot first. A bot that only needs a different name gets renamed. |
BotDeleteFailed | 502 | Chatfuel refused the delete. Nothing was removed on this side. | Retry. |
Codes the database writes
The cf_* functions raise a machine code in PostgREST's hint, and the proxy maps it. A 4xx from
the database is passed on with its own status and its own message; a 5xx is not, and becomes
ProxyAuthUnavailable 503. A 4xx whose hint is not one of these was written by PostgREST itself
— a constraint violation, a bad cast, a missing function — and its message would name tables and
columns this deployment does not publish, so it is replaced with "That is not allowed".
hint | Code | Means |
|---|---|---|
unauthenticated | AuthSessionRequired | The JWT did not reach the function. |
not_admin | BotAdminRequired | The caller is a member, not an owner or admin. |
tenant_not_found | BotWorkspaceMissing | The caller has no workspace row. |
tenant_ambiguous | BotWorkspaceAmbiguous | The caller stands in more than one workspace, so "theirs" names none. |
bot_not_found | BotNotFound | No such bot for this caller. |
bad_name, name_too_long | BadBotName | — |
member_not_found | MemberNotFound | — |
bot_already_attached | BotAlreadyAttached | — |
bot_still_upstream | BotStillUpstream | The row cannot be forgotten while Chatfuel still has the bot. |
bad_bot_id | BadBotId | — |
deployment_bot_cap, workspace_bot_cap | BotLimitReached | One of the two bot caps. Accounts has the numbers. |
post_not_found | PostNotFound | — |
not_claimed | PostNotClaimed | The scheduler's callback arrived for a post nothing had taken. |
bad_secret | NotAllowed | — |
bad_kind, bad_media, bad_patch, bad_status, bad_time, bad_url, insecure_url, bad_permalink | BadPost | — |
caption_too_long | CaptionTooLong | — |
not_member | RecoveryTargetNotMember | That email is not a member of this workspace. |
rank | NotEnoughPermissions | The target outranks the caller. |
cross_tenant | RecoveryTargetCrossTenant | The target also stands in another workspace, and a reset link resets the account rather than the membership. |
| anything else | BotRequestRefused | The fallback. |
The recovery-link route
| Code | HTTP | Cause |
|---|---|---|
ProxyAuthMisconfigured | 500 | No gate, or no service-role key. |
AuthSessionRequired | 401 | No bearer, or the caller has no workspace row. |
NotEnoughPermissions | 403 | The caller's role is neither owner nor admin. Also the fallback code for anything cf_recovery_authorize refuses without a mapped hint. |
RecoveryLinkNotEnabled | 501 | AUTH_RECOVERY_LINK_LOG is not on, so this deployment does not write reset links to its log at all. |
ProxyPublicUrlMissing | 409 | PUBLIC_URL is unset. The link carries a working reset token, and deriving its host from the request would let the requester choose where the token goes. |
InvalidRequest | 400 | The body is not {"email": "<member email>"}, or the value has no @ in it. |
RecoveryLinkFailed | 502 | GoTrue's admin/generate_link did not answer with a token. |
The admin panel
| Code | HTTP | Cause |
|---|---|---|
AdminMisconfigured | 500 | ADMIN_PASSWORD is set but shorter than 16 characters. The routes stay mounted and refuse, rather than vanishing. |
AdminSessionRequired | 401 | No x-cf-admin header, no cf_admin cookie, or a cookie that does not verify against the current password. Rotating the password, or ADMIN_COOKIE_SALT, invalidates every live session. |
AdminBadPassword | 401 | Wrong password on POST /chatfuel/admin/session. The body is read first and then every attempt pauses the same fixed interval, so the delay says nothing about the password. |
AdminThrottled | 429 | Too many attempts from this key. Carries retry-after. The counter is this process's memory, plus a shared row in Supabase where the deployment gave the proxy a service-role key — without one, a host that answers each request from a fresh instance is back to a count that lives as long as that instance. |
AdminRouteNotFound | 404 | No such path under /chatfuel/admin. |
AdminWorkspaceNotFound | 404 | No such workspace on this account — including an id that is not [A-Za-z0-9_-]{1,64}. |
AdminBotNotFound | 404 | No such bot on this account. |
AdminWorkspaceRequired | 422 | Creating a bot without saying which workspace. |
BadBotName | 422 | Creating or renaming a bot without a name. |
AdminGrantIncomplete | 422 | A grant needs both a bot and a person. |
AdminDatabaseUnavailable | 503 | Supabase did not answer. On a create it also means the bot was rolled back: it existed, nobody in the app could be given it, so Chatfuel's half was undone. |
ProxyAuthMisconfigured | 500 | /tenants and /grants need the auth module's database, and this deployment has none. |
AdminUpstreamFailed | 502 | Chatfuel did not answer this request. |
AdminUpstreamUnavailable | 503 | Chatfuel could not be asked about this bot before deleting it. |
AdminBotCreateFailed | 502 | Chatfuel refused to create the bot, for a reason the panel does not recognise. |
AdminWorkspaceForbidden | 403 | This token cannot create bots in that workspace. |
WorkspaceFull | 409 | That workspace's plan allows no more bots. |
LastBotInWorkspace | 409 | The bot is the last one in the workspace the app itself is built on. Refused outright, however it is asked for. |
WorkspaceGoesWithIt | 409 | The bot is the last one in some other workspace, and Chatfuel deletes a workspace when its last bot goes. Refused until it is asked for again with ?force=1. |
BotRenameFailed, BotDeleteFailed | 502 | As above. |
The publish queue
| Code | HTTP | Cause |
|---|---|---|
ProxyAuthMisconfigured | 500 | No service-role key, so nothing here can reach the queue's tables. |
ProxyInstagramMisconfigured | 500 | PUBLISHING_SECRET is not set, so this deployment cannot schedule. |
ProxyPublicUrlMissing | 409 | Registering the queue without PUBLIC_URL: the scheduler would be told to post the shared secret to an address the requester chose. |
ProxyTokenMissing | 500 | The callback arrived and there is no Chatfuel token to publish with. |
NotAllowed | 401 | The x-chatfuel-publish-key header is absent or does not match. Compared in constant time, both sides hashed first so the length of a guess leaks nothing either. |
InvalidRequest | 400 | No botID on a queue call, a body that is not a post, a callback body that is not {"id": "<post id>"}, or a media upload with no file part. |
PostNotFound | 404 | No such post — including an id that is not a UUID. Checked here rather than left to the database, which would answer a cast failure nobody can act on. |
BadBotId | 422 | The bot id is not key-shaped, so it has no media prefix of its own. |
MediaNotFound | 404 | The key is empty, outside this bot's own prefix, or not shaped like one this route writes. |
MediaTypeNotAllowed | 415 | The part's content type is not one of the five that can be published. |
MediaTypeMismatch | 415 | The header said what the file is and the bytes disagree. Same status as an unlisted type, because from the caller's side it is the same answer. |
MediaQuotaExceeded | 507 | This bot has filled its share of the media bucket. The per-file ceiling says nothing about how many files there are, and the bucket is billed by the byte. |
MediaUploadFailed, MediaDeleteFailed | 502 | Storage did not accept the write or the delete. |
Configuration problems, before any request
Four problems are recorded at startup, printed by name — never by value — and turn
/chatfuel/healthz into {"ok":false} with a 503.
| Problem | Means |
|---|---|
ProxyTokenMissing | No usable CHATFUEL_TOKEN. |
ProxyAuthMisconfigured | Exactly one of the two Supabase variables. |
ProxyWorkspaceMissing | The deployment would provision, and CHATFUEL_WORKSPACE_ID is unset, so nobody can finish signing up. |
ProxyAdminPasswordWeak | ADMIN_PASSWORD is shorter than 16 characters. |
Answers with no code at all
A 405 carries an allow header and an empty body — no envelope, because a wrong method is a
caller-side programming error and not an application answer. Every route answers it for a method
it does not take, and the Node server's static half for anything but GET and HEAD.
WebSocket close codes
The relay's admission is the HTTP sequence's twin in close-code vocabulary — a change to one is a change to both.
| Code | Reason string | Cause |
|---|---|---|
4401 | AuthSessionRequired | The gate refused the connection_init payload's authToken with a 401. |
4401 | Unauthorized | The first frame was not connection_init, the gate threw, or the admin panel took this socket's bot away. |
4401 | chatfuel proxy: token missing | No CHATFUEL_TOKEN, found when the upstream socket was about to open. |
4403 | AuthTenantForbidden | The gate answered 403, or the session's bot set is empty — refused at connect rather than opening an upstream socket that can only ever be told "not yours". |
4500 | ProxyAuthMisconfigured | Partial Supabase env, the fail-closed case. |
1013 | ProxyAuthUnavailable or ProxyFenceUnavailable | Whatever the gate answered 503 for, and — with the gate off — a deployment fence that could not be resolved. |
4408 | Connection initialisation timeout | No connection_init from the browser within wsInitTimeoutMs. |
4429 | Too many open sockets for this account | The per-tenant socket ceiling. |
4400 | A malformed-frame or overflow sentence | A frame that is not one of the protocol's, a subscribe or complete with no string id, more frames held behind a resource lookup than the queue takes, or more held before the upstream socket opened than the buffer takes. |
4000–4999 | Upstream's own | Chatfuel's fatal application closes pass through unchanged, so the browser's graphql-ws correctly refuses to retry them. |
1012 | chatfuel proxy: reconnect to be re-authorised | The socket reached its deadline — the session JWT's exp, or an hour, whichever is sooner. The client reconnects and is gated from scratch. |
1012 | chatfuel upstream disconnected | Any other upstream close. Non-fatal: the client reconnects through a fresh relay, and that reconnect is what makes onReconnect fire. |
Two more come from the client, not the proxy. graphql-ws closes with 4408 Request Timeout
when a ping it sent goes unanswered for 5 s on top of the 10 s keepalive — a dead socket after about
fifteen seconds. And a clean lazy idle close, 1000, is deliberately not treated as a lapse, so it
triggers no refetch.
A refused subscribe is not a close at all. It comes back as an in-band error frame on that
subscription's id, carrying the code the HTTP route would have answered with, so one bad
subscription does not take the shared socket down with it.
shouldRetryWsError adds 4403 to what graphql-ws already hard-fails on (4400, 4401, 4406, 4409,
4429); everything else, network errors included, stays retryable with jittered backoff — 5 s base,
60 s cap, resolved early when the browser comes back online.
The api-client's error classes
Every class extends ChatfuelApiError, which extends Error. The names all carry the Chatfuel
prefix.
| Class | Thrown when | Carries |
|---|---|---|
ChatfuelApiError | Never directly — the base. | — |
ChatfuelNetworkError | fetch rejected, the request timed out, or a WebSocket failed in a way that is not a close event. | cause. |
ChatfuelHttpError | A response with no parseable GraphQL envelope — and every 429, which is always surfaced this way so the throttle can retry it. | status, bodySnippet (the first 200 characters). The body stays off message, which a UI renders and a logger keeps. |
ChatfuelGraphQLError | errors[] was present, at any status including 200. data and errors can coexist, so partial data rides on the error. | errors, data, code, traceId, isPermissionDenied. |
ChatfuelAuthError | Some code is Unauthorized — the Chatfuel token needs rotation. Never retried. | Everything above. |
ChatfuelSessionError | Some code is in SESSION_ERROR_CODES, which is exactly ['AuthSessionRequired', 'AuthTenantForbidden'] — the caller's session, refused by the proxy before Chatfuel was reached. | reason, either 'sessionRequired' or 'forbidden'. |
toApiError decides in that order: session first, then auth, then plain GraphQL. The distinction
between the last two is the one that matters — ChatfuelAuthError means the deployment's token is
bad, ChatfuelSessionError means this person's sign-in is.
The throttle retries three times by default, with jittered backoff (1 s base, 30 s cap). A 429
is retried whatever the task was, because the server is saying it turned the request away before
running it. A network failure or a 5xx is retried only for a task marked idempotent: the
request may have reached Chatfuel, run, and lost its answer on the way back, and a retry there is a
second execution — a wasted round trip for a query, a second workspace on somebody's card for a
mutation. GraphQL and auth errors are never retried.
createClient's onSessionError fires once per lapse and re-arms on the next clean result. The
shell wires it to the auth module, which treats two of those rejections as not a lapse: nobody is
signed in (the topbar's bot-title query runs outside the gate, so a signed-out visitor always draws a
401), and a signed-in non-member on AuthTenantForbidden (the gate already shows /no-access, and
signing them out would throw away the account they have only just made).
Helpers worth knowing: isSessionError(err), hasErrorCode(err, code), nestedErrorCodes(err) —
which is duck-typed on .errors rather than instanceof, so a plain envelope-shaped object answers
the same — and errorMessageFor(err, messages, fallback), which looks a code up in a table the
caller owns.
The envelope
Two things make status-based error handling wrong here. A 401-equivalent (code: "Unauthorized")
arrives inside an HTTP 200. And Chatfuel's router wraps a subgraph failure, putting a useless
Failed to fetch from Subgraph 'bot'. on the outside and the code that says what actually went
wrong at errors[].extensions.errors[].extensions.code — a bad token arrives exactly that way.
So every code lookup reads both levels, on both sides of the wire: errorCodes in the api-client and
graphqlErrorCodes in the proxy. nestedErrorCodes puts the nested code first, because that is
the one worth acting on.
An entry looks like this:
{
"errors": [
{
"message": "…",
"path": ["bot", "flows"],
"extensions": {
"code": "NotEnoughPermissions",
"service": "bot",
"traceId": "…",
"errors": [{ "message": "…", "extensions": { "code": "Unauthorized" } }]
}
}
]
}One code on that list is the proxy's own: an error entry naming an internal upstream service has
that sentence replaced before it reaches the browser, and — when it carried no code of its own — is
tagged UpstreamServiceError, so a caller classifies it by code rather than by matching a message.
Chatfuel's own codes — what Unauthorized and NotEnoughPermissions mean upstream, and the rest of
that vocabulary — are on the API error reference.
The proxy
Every route the proxy mounts, which of them exist only when a module or a variable does, the ceilings and fences with their values, the options that move them, and the three hosts that serve the same core.
What a module receives
The ModuleClient a module root is handed by props, the options createChatfuelClient takes, and the helpers that ship in the same barrel.