Skip to content
ChatfuelSDK
ReferenceAPI client

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.

A module root is handed a ModuleClient through props and never builds one, so nothing in module code knows about tokens, proxy prefixes or which host it is running inside.

What a module may call

MemberShapeWhat it does
query(doc, variables, opts?) => Promise<TData>Runs a generated document over HTTP. Throws when the envelope carries errors[]; partial data rides on the error.
mutate(doc, variables, opts?) => Promise<TData>The same function under a second name — kept separate for intent, identical in behaviour. Variables are stripped of __typename either way.
subscribe(doc, variables, observer) => () => voidOpens a subscription and returns the unsubscribe, which maps 1:1 onto a useEffect cleanup. One lazy shared WebSocket serves every subscription in the app.
onReconnect(cb) => () => voidFires after that socket re-establishes following an abnormal close — refetch every query backing a live view, because the server does not replay what you missed. A clean idle close does not fire it.
uploadFile?(botId, file, fileType, pluginId?) => Promise<UploadedFile>REST upload, attached by the host when there is an upload path. Absent means no upload UI — GraphQL never accepts file bytes.
proxyFetch?(path, init?) => Promise<Response>An authenticated call to one of the proxy's own routes, not to Chatfuel. path is relative to the proxy prefix.

The last two are optional because the host decides whether to attach them. proxyFetch being present does not promise that a given route exists either — the answer to that is the 404 the route itself gives. A module offers the half of itself that answers and does without the other half; it never asks which modules the deployment installed.

execute (the envelope without the throw), iterate (the async-iterable form of subscribe) and dispose are on the full ChatfuelClient and deliberately not on ModuleClient. The only document type a module ever names is TypedDoc, so the generated shape can change — it has already gone from an AST to a printed string — without a module noticing.

Building one

createChatfuelClient is the factory, and the host app is the only place that calls it.

Prop

Type

This is the whole of the shell's factory — the file every scaffolded app carries, and the one place that knows where the proxy is mounted:

content/shell/src/client.ts
export function createAppClient(options: AppClientOptions = {}): ModuleClient {
  const getAuthHeader = async (): Promise<string | undefined> => {
    const value = await options.getAccessToken?.();
    return value ? `Bearer ${value}` : undefined;
  };
  const client = createChatfuelClient({
    url: GRAPHQL_PATH,
    wsUrl: GRAPHQL_PATH,
    token: options.getAccessToken,
    onSessionError: options.onSessionError,
  });
  // Where the session bearer is allowed to go, taken off the same path the
  // client was given. Both are relative here, so in the browser this is the
  // page's own origin and an absolute `basePath` from anywhere else is refused.
  const pinnedOrigin = credentialOrigin(GRAPHQL_PATH);
  return {
    query: client.query,
    mutate: client.mutate,
    subscribe: client.subscribe,
    onReconnect: client.onReconnect,
    uploadFile: (botId, file, fileType, pluginId) =>
      uploadFile({ botId, file, fileType, pluginId, getAuthHeader, pinnedOrigin }),
    proxyFetch: async (path, init) => {
      const auth = await getAuthHeader();
      const headers = new Headers(init?.headers);
      if (auth) headers.set('authorization', auth);
      return fetch(`${PROXY_PREFIX}${path.startsWith('/') ? path : `/${path}`}`, { ...init, headers });
    },
  };
}

Where a credential may go

url and wsUrl are configuration, and a mistake in either is not a failed request — it is a token leaving over a channel everyone on the path can read. Both are judged at construction, so a misconfigured deployment fails at startup rather than on the wire, and url is judged first because it is what the deployment named: wsUrl and the upload path are then held to its origin.

RefusedWhy
A url with whitespace the URL parser strips or deletes" http://evil.example" and "ht\rtp://evil.example" name a foreign host by the time fetch sees them. The guard normalises the same way and refuses any url that needed it, so a broken .env line is a message and not a silent redirect.
A scheme-relative url — //host, and every \ spelling new URL folds into itIt looks relative and has no scheme to check, but it names a host of its own; what it inherits from the page is the scheme, not the origin.
Plaintext http/ws to a host that is not loopbackEverything sent there is readable on the path, and the answers acted on are anyone's to write.
An absolute url at an origin other than the pinned oneA host one label off — panel.chatfuel.com.evil.example — is https and not loopback, so only the base url can catch it.

A relative url passes: it inherits the page's origin, and how that page is served is the host's decision. executeHttp checks once more against the header it actually resolved, which is the only place that knows whether a credential is going out.

The token in the browser

token takes a string or a getter, and the getter is resolved per request and per socket connect, so a rotating credential works without rebuilding the client. A getter that answers undefined sends no Authorization header at all.

In the browser that value is never the Chatfuel token. It is the user's own session token, sent so the proxy's gate can check it; the proxy strips it and attaches the Chatfuel Authorization server-side, and the WebSocket relay reads it out of the connection-init payload and opens its own upstream connection. See the token boundary.

When the session lapses

onSessionError fires when the gate answers AuthSessionRequired (the session is missing or expired) or AuthTenantForbidden (the caller is not a member of this deployment's tenant). It fires once per lapse — the next successful call re-arms it — and it fires from the HTTP and the WebSocket path alike, so the shell can refresh the session or send the user to sign-in without every module handling it. Neither code means the Chatfuel token needs rotating; that one is Unauthorized, and the whole family is on the errors page.

Timeouts and retries

The default HTTP timeout is 30 000 ms. opts.timeoutMs overrides it for one request, which a handful of Chatfuel mutations need: instagramAccountPublishReel sits on Instagram's transcoder for up to five minutes, and raising the client-wide default so four operations can finish would mean a dead upstream is felt five minutes late everywhere. The timeout signal and your own opts.signal are AbortSignal.any-ed together, so an abort stays an abort.

A response body is read to a cap of 32 MiB and abandoned past it — a declared content-length over the cap never starts. Time bounds how long an answer may take, not how large it may be.

Uploads get a budget shaped to the file instead: 30 seconds plus 20 seconds per MiB, capped at ten minutes, because thirty seconds is generous for a query and not enough for a 25 MiB video on a phone.

Retries only exist when you pass throttle, and the rule is not the same for every failure. A 429 is retried whatever the request was — the server is saying it turned it away before running it, so a repeat cannot double anything, and 429 is always surfaced as an HTTP error rather than an envelope so the throttle can see it. A network failure or a 5xx may mean the request reached Chatfuel, ran, and lost its answer on the way back, so those are retried only when replaying is safe. The client reads that off the document itself: anything that is not a mutation is replayable. A GraphQL error is never retried. Three attempts, backing off from 1 s to 30 s.

Helpers in the same barrel

Everything ~api exports is in content/api-client/src/index.ts; these are the ones worth knowing before you write the same code yourself.

HelperWhat it is for
paginate(client, doc, baseVars, select)An async generator over a Relay-ish connection, one page of nodes per yield, stopping when hasNextPage is false. Sequential by construction — never fetch a page while one is in flight. baseVars already carries the page-size variable; the cursor variable is after unless you say otherwise. It also stops at maxPages (1 000 by default) and throws when a cursor repeats, because hasNextPage: true with a cursor that does not advance is a walk that would never end.
isInvalidCursorError(err)Whether a failure looks like a stale cursor, so you can refetch from the start. Heuristic: the server's exact shape is undocumented.
stripTypename(value)Recursively drops __typename, non-mutating. query and mutate already do this to variables — reach for it when you are building an input by hand out of a fetched object. Fields are written with defineProperty, so an own __proto__ key out of JSON.parse cannot swap the object's prototype and take the field out of the request in silence.
newClientId()A fresh UUID for an outgoing message. Must be unique across every client of the account, so generate one per send rather than per component.
backoffDelay(attempt, opts?)min(base * 2^attempt, cap) * (0.5 + random/2). Defaults are the WebSocket reconnect constants: 5 s base, 60 s cap.
createThrottle(opts)A token bucket with concurrency and retries, wrapping any promise-returning task. BATCH_THROTTLE is the preset for bulk work; Chatfuel's hard limit is 25 requests per second per token. An rps of zero or a concurrency below one is refused rather than hanging.
stableUuid(key)A v4-shaped UUID derived from a key you choose, same key to same value every time. Segment and filter ids are validated as UUIDs — a readable slug fails the whole query with a generic subgraph error — and a fresh crypto.randomUUID() per render would make a list refetch forever. isUuid and UUID_RE check the shape. It is not a secret: the derivation is public and reversible by search, so anything whose safety rests on being unguessable uses crypto.randomUUID().
fetchRoleGates(client, botId, spec) / closedGates(spec)Permission gates over MyBotRole. A gate opens when the role holds any of its (object, action) pairs; closedGates is what a caller shows before the answer arrives. fetchRoleGates never rejects and closes everything it cannot decide — the proxy talks upstream under one master token, so the API enforces the token owner's role and not the signed-in person's. A gate decides what is offered; what is allowed is the server's.
createUserStorage(client, id)The only persistence the Chatfuel API offers a client: one arbitrary string under one id, scoped to the signed-in user. No team scope and no sharing — a colleague opening the same bot reads their own items. An id never written reads back as null, not as an error.
upsertBy, sortByDesc, removeByList merges for live updates: de-dupe by key rather than appending, and re-sort client-side because a live update carries no position. Merge messages by clientId, never by the nullable Message.id.
uploadFile(options)The multipart POST behind ModuleClient.uploadFile. Two endpoints — /filestorage/upload/bot, and /filestorage/upload/plugin when a pluginId is given. A non-2xx throws ChatfuelHttpError, whose body snippet carries the platform code (FileTooBig, FileContentTypeNotSupported) for the UI to match on.

On this page