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.

The gate and the fences

These can answer any proxied route, before anything reaches Chatfuel.

CodeHTTPCauseWhat to change
AuthSessionRequired401No 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.
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.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, 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.
AccountScopeBlocked403The 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.
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.
ProxyInternalError500A 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

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.Set the variable to a workspace this token can create bots in.
ProvisionUnreachable502workspaceCreateBot 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.
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 message and status; a 5xx is not, and becomes ProxyAuthUnavailable 503.

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.
bot_not_foundBotNotFoundNo such bot for this caller.
bad_name, name_too_longBadBotName
member_not_foundMemberNotFound
bot_already_attachedBotAlreadyAttached
bad_bot_idBadBotId
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_urlBadPost
caption_too_longCaptionTooLong
anything elseBotRequestRefusedThe fallback.
CodeHTTPCause
NotEnoughPermissions403The 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.
InvalidRequest400The body is not {"email": "<member email>"}, or the value has no @ in it.
RecoveryTargetNotMember403That email is not a member of this workspace.
RecoveryLinkFailed502GoTrue's admin/generate_link did not answer with a link.

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 invalidates every live session.
AdminBadPassword401Wrong password on POST /chatfuel/admin/session. Every attempt, right or wrong, pauses 250 ms first.
AdminThrottled429Too 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.
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.
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
ProxyInstagramMisconfigured500PUBLISHING_SECRET is not set, so this deployment cannot schedule.
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.
NotEnoughPermissions403Only owners and admins can turn scheduling on.
InvalidRequest400No 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.
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.
MediaTooLarge413Past the ceiling. Bytes past it are dropped as they arrive rather than collected and then rejected.
MediaTypeNotAllowed415The part's content type is not on the list.
MediaUploadFailed, MediaDeleteFailed502Storage 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.

CodeReason stringCause
4401AuthSessionRequiredThe gate refused the connection_init payload's authToken with a 401.
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 (5 s by default).
40004999Upstream's ownChatfuel's fatal application closes pass through unchanged, so the browser's graphql-ws correctly refuses to retry them.
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 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.

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).
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 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.

On this page