Skip to content
ChatfuelSDK
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.

One core is assembled from per-concern modules and imported from one place by all three hosts, so the route table is the same in dev, on a Node server and on Vercel.

The routes

Paths are the defaults; every one of them is an option.

RouteMountedWhat it does
POST /chatfuel/graphqlAlwaysForwards the body to {upstream}/graphql with authorization: Bearer <CHATFUEL_TOKEN>. Every bot the body names — the botID variable, and the argument of a root bot(id: $x) field, read out of the query text rather than trusted to a convention — is checked against the fence first. Any other method is a 405 with an allow header and a bare body: a wrong method is a caller-side programming error, not an application answer.
WS /chatfuel/graphqlAlwaysThe graphql-transport-ws relay. Same path as the POST route; the Upgrade disambiguates. An upgrade on any other path is a no-op, which is what lets Vite's own HMR socket share the server.
/chatfuel/api/*AlwaysPassthrough to {upstream}/api/* — the REST file uploads — with the same token injection. Any method; a botID query parameter is fenced the same way a GraphQL variable is.
POST /chatfuel/auth/provisionGate on and a service-role keyThe second half of signing up: the account's workspace and its bots, adding the first bot when it has none. Idempotent — somebody who already has a workspace gets it back. Without CHATFUEL_WORKSPACE_ID it stops rather than creating a bot that bills nowhere.
POST /chatfuel/auth/botsSameAnother bot for the caller's workspace. Body {name}.
PATCH /chatfuel/auth/bots/<id>SameRenames it, in the database first and then in Chatfuel. Body {name}.
DELETE /chatfuel/auth/bots/<id>SameDeletes it, in Chatfuel first and then in the database.
POST /chatfuel/auth/recovery-linkGate on and a service-role keyBody {email} from an owner or admin; answers { tokenHash, url } so an operator can reset a member's password before custom SMTP exists.
/chatfuel/admin/*ADMIN_PASSWORD is setThe operator panel — its own table below.
/chatfuel/publishing/*Gate on and a service-role keyThe Instagram publish queue — its own table below.

POST /chatfuel/auth/provision and the three bots routes are mounted on the gate and the service-role key; CHATFUEL_WORKSPACE_ID is checked inside the handler, so a deployment missing it gets an answer that names the variable rather than a 404.

What decides whether a route exists at all

Resolved flagTrue whenRoutes it mounts
provisionRouteauthMode === 'on' and a service-role key is available/chatfuel/auth/provision, /chatfuel/auth/bots, /chatfuel/auth/bots/<id>
recoveryLinkRouteThe same condition/chatfuel/auth/recovery-link
publishingQueueRouteThe same conditionEverything under /chatfuel/publishing
adminRouteadminMode is not off — that is, ADMIN_PASSWORD is set at allEverything under /chatfuel/admin

A route that is not mounted is not claimed: the dispatcher returns false, the host answers its own 404, and that 404 is the signal. The app reads a 404 on the publishing routes as "this deployment keeps its queue in the browser" and offers no schedule control. A deployment that never asked for a panel answers 404 on /chatfuel/admin and the app reads that as "no panel".

The one exception is a password that is set but shorter than 16 characters: adminMode becomes misconfigured, the routes stay claimed, and they answer 500 — a panel that quietly is not there teaches the operator nothing.

The publish queue has a second, quieter half. With the routes mounted but PUBLISHING_SECRET unset, the queue still stores posts and never fires them, and the scheduler's callback route refuses every request.

Under /chatfuel/admin

Every path except /session requires a signed cf_admin cookie and an x-cf-admin request header — a form posted from another origin can send a cookie but cannot set a header. The cookie is minted from ADMIN_PASSWORD, lasts 12 hours, and is the whole authorization: this module never calls admitRequest, never consults the auth gate and never applies the workspace fence, because the panel's purpose is the account-wide view those exist to withhold.

RouteMethodsWhat it does
/chatfuel/admin/sessionGET, POST, DELETERead the session, unlock with the password, sign out.
/chatfuel/admin/overviewGETThe account behind the master token: its workspaces and their bots.
/chatfuel/admin/healthGETWhether the token still works and what this deployment is missing.
/chatfuel/admin/tenantsGETThe app's own accounts, where the auth module is installed.
/chatfuel/admin/grantsPOST, DELETEHand one bot to one of the app's accounts, and take it back.
/chatfuel/admin/botsPOSTCreate a bot.
/chatfuel/admin/bots/<id>GET, PATCH, DELETEOne bot's details, rename, delete.
/chatfuel/admin/workspaces/<id>GETOne workspace. An id that is not [A-Za-z0-9_-]{1,64} is a 404 before anything runs.

Anything else under the prefix is 404 AdminRouteNotFound.

Under /chatfuel/publishing

RouteMethodsWhat it does
/chatfuel/publishing/configGETWhether this deployment can schedule at all.
/chatfuel/publishing/registerPOSTRecords where this app answers, so the scheduler knows where to knock. Owner or admin only.
/chatfuel/publishing/postsGET, POSTThe bot's queued posts, and a new one. ?botID= is required and fenced.
/chatfuel/publishing/posts/<id>PATCH, DELETEEdit or drop one. The id must be a UUID, or it names no post and the answer is a 404.
/chatfuel/publishing/mediaPOST, DELETEDurable storage for what a scheduled post is made of — the platform's own uploads expire two hours after they arrive.
/chatfuel/publishing/publish-duePOSTThe scheduler's callback. Authorised by x-chatfuel-publish-key against the shared secret, never by a session.

The options

createChatfuelProxy(options, env) takes these; each falls back to the env bag, then to a default.

OptionDefaultWhat it changes
upstreamCHATFUEL_API_BASE, then https://panel.chatfuel.comWhere every forwarded call goes. Trailing slashes are trimmed.
httpPath/chatfuel/graphqlThe GraphQL POST path.
wsPathSame as httpPathThe upgrade path. POST and Upgrade disambiguate, so they can share.
apiPath/chatfuel/apiForwarded to {upstream}/api.
authPath/chatfuel/authPrefix for provision, bots and recovery-link.
publishingPath/chatfuel/publishingPrefix for the publish queue.
adminPath/chatfuel/adminPrefix for the operator panel.
adminPasswordADMIN_PASSWORDUnset unmounts the panel; shorter than 16 characters makes it answer 500.
publishingSecretPUBLISHING_SECRETThe secret the scheduler and this server share. Without it nothing is scheduled.
bypassSecretVERCEL_AUTOMATION_BYPASS_SECRETRecorded at registration so the scheduler's callback is not turned away at the edge before any of this code runs.
publicUrlPUBLIC_URLWhere this deployment answers from the outside. Anything that is not an absolute http(s) URL is dropped rather than patched up — this value decides where a credential is posted every minute, and deriving it from a request means the requester chooses it.
tokenEnv'CHATFUEL_TOKEN'Which variable holds the Chatfuel token.
tokenenv[tokenEnv]Explicit override, for tests. A value containing whitespace is treated as missing.
workspaceIdCHATFUEL_WORKSPACE_IDThe Chatfuel workspace provisioned bots are created in — the billing container.
timeoutMs30_000Upstream HTTP budget.
slowTimeoutMs290_000The budget for the four instagramAccountPublish* fields, which block while Instagram transcodes. Separate rather than a raised default, so a dead upstream is still felt in thirty seconds everywhere else, and a little under five minutes because that is where a serverless function is killed.
allowedBotIdsUnset — the request-time fenceA list freezes the fence at startup; 'any' turns it off entirely.
fencettlMs (60 s), retryMs (5 s), timeoutMs (10 s) and test injection for the request-time fence. It exists only when a token does.
authResolved from envAn object forces the gate on with those values; false never gates.
wsInitTimeoutMs5_000How long the relay waits for the browser's connection_init before closing.

auth resolved from env has three outcomes: both VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY set is on, neither is off, and exactly one is misconfigured — which fails closed, so every proxied request answers 500. See the auth gate for what the gate then does, and the environment for where the variables come from.

The three hosts

HostFileServesWhat differs
Vite dev servervite.tsThe proxy, mounted as middleware plus an upgrade listenerThe only file in the package that imports vite. It builds the env bag as loadEnv(mode, envDir, '') — an empty prefix, so unprefixed secrets are read from .env without ever reaching import.meta.env — then lets process.env win. A configuration problem is logged, never fatal: requests answer with a synthetic envelope so the problem is visible in the app.
Node serverserver.ts/healthz, then the proxy, then the built dist/Reads process.env only. Static serving is GET/HEAD, contained inside distDir before any filesystem call, immutable under /assets/, no-cache for index.html, and an unknown path that is not asset-shaped falls back to index.html. basePath mounts all of it under a sub-path. An upgrade that is not the relay's path is destroyed. Not re-exported from the package entry — importing it by path is what keeps vite out of the server bundle.
Vercel functionapps/shell/api/chatfuel.tsThe proxy only — Vercel serves dist/ itselfThe module's default export is an http.Server, which is what makes incoming WebSocket upgrades work. vercel.json rewrites /chatfuel/:cfpath* to /api/chatfuel, and restoreUrl turns ?cfpath=auth/provision back into /chatfuel/auth/provision before the core sees it, so the route table needs no per-host variant. Env is read at module scope, once per cold start. /chatfuel/healthz answers 200 or 503 and lists the configuration problems by name. An unclaimed path is a 404 NotFound envelope.

A rewrite straight to panel.chatfuel.com would be cheaper and cannot work: a rewrite forwards the browser's headers and has no way to add one, so the token would have to be sent by the client — the one thing the proxy exists to prevent. The headers block in vercel.json sets response headers, not request ones.

Nothing the client sent is forwarded

Outgoing headers are built from scratch rather than merged, so the browser's Authorization and its cookies are stripped by construction:

  • The GraphQL forward sends exactly content-type: application/json and authorization: Bearer <token>.
  • The REST passthrough sends authorization plus the request's content-type when there is one, because a multipart upload needs its boundary. Nothing else, cookies included, travels.
  • The WebSocket relay waits for the browser's connection_init, gates it, and then sends its own connection_init {authToken: "Bearer …"} upstream. The browser's payload — the user's session JWT — is consumed by the gate and never leaves the server.

Coming back the other way, the upstream status and body pass through untouched, because a GraphQL refusal arrives as an envelope under HTTP 200 and rewriting it would lose the answer. The proxy's own refusals are no-store: a cached 403 would keep saying "not your bot" about a bot that has since become the caller's. Which codes those are is on the errors page.

On this page