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.
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>, or a JWT whose unverified exp has passed (refused with zero network), or PostgREST answered 401 — 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. | 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, 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. |
AccountScopeBlocked | 403 | The query selects something under currentUser other than id, botRole or __typename — including a fragment spread, which could hide fields. | Ask about a bot, not about the account. |
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. |
ProxyInternalError | 500 | A backstop: something escaped a handler in the bots routes or the admin panel that was supposed to have answered already. | File it. Nothing you send causes this on purpose. |
An operation that names no bot passes every fence. Most do: they address a flow, a contact or a conversation by an id that is not derivable from a bot id, and the auth gate page says what that means.
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. | Set the variable to a workspace this token can create bots in. |
ProvisionUnreachable | 502 | workspaceCreateBot could not reach Chatfuel. The only place the proxy writes a log line, and it names the workspace variable, never a token. | Retry, then check egress. |
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 message and status; a 5xx is not, and becomes
ProxyAuthUnavailable 503.
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. |
bot_not_found | BotNotFound | No such bot for this caller. |
bad_name, name_too_long | BadBotName | — |
member_not_found | MemberNotFound | — |
bot_already_attached | BotAlreadyAttached | — |
bad_bot_id | BadBotId | — |
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 | BadPost | — |
caption_too_long | CaptionTooLong | — |
| anything else | BotRequestRefused | The fallback. |
The recovery-link route
| Code | HTTP | Cause |
|---|---|---|
NotEnoughPermissions | 403 | The caller has no workspace, or their role is neither owner nor admin. Checked twice: before the body is read, and again after the members lookup. |
InvalidRequest | 400 | The body is not {"email": "<member email>"}, or the value has no @ in it. |
RecoveryTargetNotMember | 403 | That email is not a member of this workspace. |
RecoveryLinkFailed | 502 | GoTrue's admin/generate_link did not answer with a link. |
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 invalidates every live session. |
AdminBadPassword | 401 | Wrong password on POST /chatfuel/admin/session. Every attempt, right or wrong, pauses 250 ms first. |
AdminThrottled | 429 | Too many attempts from this address. Carries retry-after. The counter lives in one process's memory, so on per-request functions the length of the password is what actually stands between the panel and a guess. |
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. |
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 |
|---|---|---|
ProxyInstagramMisconfigured | 500 | PUBLISHING_SECRET is not set, so this deployment cannot schedule. |
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. |
NotEnoughPermissions | 403 | Only owners and admins can turn scheduling on. |
InvalidRequest | 400 | No botID on a posts 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. |
MediaTooLarge | 413 | Past the ceiling. Bytes past it are dropped as they arrive rather than collected and then rejected. |
MediaTypeNotAllowed | 415 | The part's content type is not on the list. |
MediaUploadFailed, MediaDeleteFailed | 502 | Storage did not accept the write or the delete. |
Answers with no code at all
Two, and both are deliberate.
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. The GraphQL route answers it for
anything but POST, and the Node server's static half for anything but GET and HEAD.
The Vercel function answers 404 NotFound in envelope shape for a path under /chatfuel that the
core does not claim. On the other two hosts an unclaimed path falls through to the host itself,
which is what makes an unmounted route readable as "this deployment does not have that".
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 | 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 (5 s by default). |
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 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 subscribe frame for a bot outside the fence is not a close at all. It comes back as an in-band
error frame on that subscription's id, carrying AuthTenantForbidden or BotNotAllowed, 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). |
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 ChatfuelNetworkError, and ChatfuelHttpError with a 429 or a 5xx, up to
three times with jittered backoff (1 s base, 30 s cap). 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 now 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" } }]
}
}
]
}The proxy's own refusals use the same shape on every route, GraphQL or not, so one error path reads
every answer — and they are sent cache-control: no-store, because a cached 403 would keep saying
"not your bot" about a bot that has since become the caller's.
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 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.