Skip to content
ChatfuelSDK
Reference

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.

CodeHTTPCauseWhat to change
ProxyHostForbidden403The 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.
ProxyOriginForbidden403The 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.
ProxyRefusedToServe503The 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.
InvalidRequest400On Vercel, the cfpath parameter was written twice, or decodes to a leading /, a . or .. segment, a ? or a #.Nothing you send by hand.
NotFound404On 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.

CodeHTTPCauseWhat to change
AuthSessionRequired401No 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.
AuthTenantForbidden403The 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.
ProxyAuthUnavailable503Supabase 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.
ProxyAuthMisconfigured500Exactly one of VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY is set. The gate fails closed rather than running open.Set both, or neither.
ProxyTokenMissing500CHATFUEL_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.
ProxyFenceUnavailable503The 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.
BotNotAllowed403The 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.
ResourceNotAllowed403A 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.
ProxyUpstreamUnavailable504Chatfuel 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.
ProxyHandlerFailed500A route handler's promise rejected. The proxy logs the throw and answers rather than leaving the request hanging.File it.
ProxyInternalError500The 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.

CodeHTTPCause
RequestTooLarge413A 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.
BatchTooLarge413More operations in one GraphQL body than GRAPHQL_MAX_BATCH. Counted before anything is parsed.
ProxyBusy503More GraphQL requests, or more REST uploads, in flight than this process allows. Carries retry-after: 5.
TenantBusy429This tenant has spent its minute. Carries retry-after: 5. Felt only with a fence to key a tenant by.
AdminBodyTooLarge413A body over 64 KiB on the admin panel, or over 4 KiB on /chatfuel/admin/session, which answers before any credential is checked.
MediaTooLarge413A 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.

CodeHTTPCause
UnsupportedMediaType415A 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.
ProxyMalformedQuery400The 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.
ProxyExtensionsUnsupported400An entry carried extensions. Refused rather than dropped in silence.
OperationNotInRegistry403The 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.
IntrospectionBlocked403A 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.
AccountOperationBlocked403A 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.
AccountScopeBlocked403With the gate on: the query reads the account through currentUser, or reaches it through a bot.
AccountStructureBlocked403With the gate on: a root field changes which workspaces and bots this deployment has.
OperationNotAllowed403The 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.
RestPathNotAllowed403A path under /chatfuel/api that is not one of the five upload endpoints.
InvalidRequest400A 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

CodeHTTPCauseWhat to change
ProxyAuthMisconfigured500On 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.
ProvisionUnreachable502workspaceCreateBot 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.
ProvisionRefused502Chatfuel 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.
WorkspaceFull409Chatfuel 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.
BotNotFound404The <id> segment is empty or contains a slash, or the database says no such bot.—
BadBotName422The body carried no name, or a blank one.Send {"name": "…"}.
BotRenameFailed502The 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.
BotDeleteUnavailable503One 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.
LastBotInWorkspace409The 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.
BotDeleteFailed502Chatfuel 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".

hintCodeMeans
unauthenticatedAuthSessionRequiredThe JWT did not reach the function.
not_adminBotAdminRequiredThe caller is a member, not an owner or admin.
tenant_not_foundBotWorkspaceMissingThe caller has no workspace row.
tenant_ambiguousBotWorkspaceAmbiguousThe caller stands in more than one workspace, so "theirs" names none.
bot_not_foundBotNotFoundNo such bot for this caller.
bad_name, name_too_longBadBotName—
member_not_foundMemberNotFound—
bot_already_attachedBotAlreadyAttached—
bot_still_upstreamBotStillUpstreamThe row cannot be forgotten while Chatfuel still has the bot.
bad_bot_idBadBotId—
deployment_bot_cap, workspace_bot_capBotLimitReachedOne of the two bot caps. Accounts has the numbers.
post_not_foundPostNotFound—
not_claimedPostNotClaimedThe scheduler's callback arrived for a post nothing had taken.
bad_secretNotAllowed—
bad_kind, bad_media, bad_patch, bad_status, bad_time, bad_url, insecure_url, bad_permalinkBadPost—
caption_too_longCaptionTooLong—
not_memberRecoveryTargetNotMemberThat email is not a member of this workspace.
rankNotEnoughPermissionsThe target outranks the caller.
cross_tenantRecoveryTargetCrossTenantThe target also stands in another workspace, and a reset link resets the account rather than the membership.
anything elseBotRequestRefusedThe fallback.
CodeHTTPCause
ProxyAuthMisconfigured500No gate, or no service-role key.
AuthSessionRequired401No bearer, or the caller has no workspace row.
NotEnoughPermissions403The caller's role is neither owner nor admin. Also the fallback code for anything cf_recovery_authorize refuses without a mapped hint.
RecoveryLinkNotEnabled501AUTH_RECOVERY_LINK_LOG is not on, so this deployment does not write reset links to its log at all.
ProxyPublicUrlMissing409PUBLIC_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.
InvalidRequest400The body is not {"email": "<member email>"}, or the value has no @ in it.
RecoveryLinkFailed502GoTrue's admin/generate_link did not answer with a token.

The admin panel

CodeHTTPCause
AdminMisconfigured500ADMIN_PASSWORD is set but shorter than 16 characters. The routes stay mounted and refuse, rather than vanishing.
AdminSessionRequired401No 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.
AdminBadPassword401Wrong 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.
AdminThrottled429Too 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.
AdminRouteNotFound404No such path under /chatfuel/admin.
AdminWorkspaceNotFound404No such workspace on this account — including an id that is not [A-Za-z0-9_-]{1,64}.
AdminBotNotFound404No such bot on this account.
AdminWorkspaceRequired422Creating a bot without saying which workspace.
BadBotName422Creating or renaming a bot without a name.
AdminGrantIncomplete422A grant needs both a bot and a person.
AdminDatabaseUnavailable503Supabase 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.
ProxyAuthMisconfigured500/tenants and /grants need the auth module's database, and this deployment has none.
AdminUpstreamFailed502Chatfuel did not answer this request.
AdminUpstreamUnavailable503Chatfuel could not be asked about this bot before deleting it.
AdminBotCreateFailed502Chatfuel refused to create the bot, for a reason the panel does not recognise.
AdminWorkspaceForbidden403This token cannot create bots in that workspace.
WorkspaceFull409That workspace's plan allows no more bots.
LastBotInWorkspace409The bot is the last one in the workspace the app itself is built on. Refused outright, however it is asked for.
WorkspaceGoesWithIt409The 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, BotDeleteFailed502As above.

The publish queue

CodeHTTPCause
ProxyAuthMisconfigured500No service-role key, so nothing here can reach the queue's tables.
ProxyInstagramMisconfigured500PUBLISHING_SECRET is not set, so this deployment cannot schedule.
ProxyPublicUrlMissing409Registering the queue without PUBLIC_URL: the scheduler would be told to post the shared secret to an address the requester chose.
ProxyTokenMissing500The callback arrived and there is no Chatfuel token to publish with.
NotAllowed401The 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.
InvalidRequest400No 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.
PostNotFound404No 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.
BadBotId422The bot id is not key-shaped, so it has no media prefix of its own.
MediaNotFound404The key is empty, outside this bot's own prefix, or not shaped like one this route writes.
MediaTypeNotAllowed415The part's content type is not one of the five that can be published.
MediaTypeMismatch415The 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.
MediaQuotaExceeded507This 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, MediaDeleteFailed502Storage 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.

ProblemMeans
ProxyTokenMissingNo usable CHATFUEL_TOKEN.
ProxyAuthMisconfiguredExactly one of the two Supabase variables.
ProxyWorkspaceMissingThe deployment would provision, and CHATFUEL_WORKSPACE_ID is unset, so nobody can finish signing up.
ProxyAdminPasswordWeakADMIN_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.

CodeReason stringCause
4401AuthSessionRequiredThe gate refused the connection_init payload's authToken with a 401.
4401UnauthorizedThe first frame was not connection_init, the gate threw, or the admin panel took this socket's bot away.
4401chatfuel proxy: token missingNo CHATFUEL_TOKEN, found when the upstream socket was about to open.
4403AuthTenantForbiddenThe 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".
4500ProxyAuthMisconfiguredPartial Supabase env, the fail-closed case.
1013ProxyAuthUnavailable or ProxyFenceUnavailableWhatever the gate answered 503 for, and — with the gate off — a deployment fence that could not be resolved.
4408Connection initialisation timeoutNo connection_init from the browser within wsInitTimeoutMs.
4429Too many open sockets for this accountThe per-tenant socket ceiling.
4400A malformed-frame or overflow sentenceA 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–4999Upstream's ownChatfuel's fatal application closes pass through unchanged, so the browser's graphql-ws correctly refuses to retry them.
1012chatfuel proxy: reconnect to be re-authorisedThe socket reached its deadline — the session JWT's exp, or an hour, whichever is sooner. The client reconnects and is gated from scratch.
1012chatfuel upstream disconnectedAny 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.

ClassThrown whenCarries
ChatfuelApiErrorNever directly — the base.—
ChatfuelNetworkErrorfetch rejected, the request timed out, or a WebSocket failed in a way that is not a close event.cause.
ChatfuelHttpErrorA 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.
ChatfuelGraphQLErrorerrors[] was present, at any status including 200. data and errors can coexist, so partial data rides on the error.errors, data, code, traceId, isPermissionDenied.
ChatfuelAuthErrorSome code is Unauthorized — the Chatfuel token needs rotation. Never retried.Everything above.
ChatfuelSessionErrorSome 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.

On this page