SDKs
A typed client for JavaScript, and React bindings.
Two packages. Neither is required — the API is plain HTTP and the reference is complete — but they handle the parts that are easy to get subtly wrong: retries, idempotency, cursor paging, stream reconnection and webhook signature verification.
npm i @chatformhq/js # the client
npm i @chatformhq/react # React bindingsBoth are ESM-only. That is deliberate for the parts that hold state: a session keeps its resume position in browser storage, and a package loaded twice — once as ESM, once as CommonJS — would mean two copies writing the same keys, which shows up as sessions randomly losing their place.
The client
import { createClient } from "@chatformhq/js";
const chatform = createClient({ apiKey: process.env.CHATFORM_SECRET_KEY! });chatform.forms, .responses, .sessions, .webhooks, .blocks, .exports,
.files, .templates, .ai, .paymentAccounts, chatform.import() and
chatform.me().
Five more hang off forms, because each has no meaning apart from the form it
belongs to: chatform.forms.versions, .knowledge, .integrations,
.settings and .payments.
Every type the package exports is generated from the API's own OpenAPI spec, so a field the reference documents is a field your editor knows about, with the same name and the same enum values.
// a whole response in one call
const response = await chatform.responses.create(formId, {
answers: { q_email: "maya@northwind.co" },
complete: true,
});
// or step by step
const draft = await chatform.responses.create(formId);
await chatform.responses.answer(draft.id, { ref: "q_email", value: "maya@northwind.co" });
await chatform.responses.complete(draft.id);Zero runtime dependencies — Web Crypto for signatures, fetch for transport —
so it runs unchanged on Node, Workers, Deno and Bun.
Building a form
// start from a template
const { id } = await chatform.templates.use("client-intake");
// or compose the document yourself, then publish it
const form = await chatform.forms.create({ title: "Speaker submissions" });
const { issues } = await chatform.forms.updateDocument(form.id, doc);
if (!issues.some((i) => i.level === "error")) await chatform.forms.publish(form.id);GET /v1/blocks publishes a JSON Schema for every block type, and
chatform.blocks.list() is how you read it. Build the document against that
rather than a hardcoded list, and a new question type will not surprise you.
forms.get() returns the published form's public config. A form that has never
been published answers with its draft document instead, with status saying
which you got. Use forms.getDocument() when you always want the working
document. forms.list() returns every form, drafts included, unless you ask for
status: "draft", "published" or "archived".
Versions
const versions = await chatform.forms.versions.list(formId);
const diff = await chatform.forms.versions.get(formId, 3, { compare: 2 });
await chatform.forms.versions.restore(formId, 2);restore writes the draft, not the live form, so respondents see nothing change
until you publish again. Each version carries the number of completed responses
recorded against it: a version nobody answered can be replaced freely, while one
with responses behind it is the schema those answers were recorded against.
Knowledge
await chatform.forms.knowledge.addText(formId, { title: "Refund policy", body });
await chatform.forms.knowledge.addLink(formId, { url: "https://example.com/faq" });
await chatform.forms.knowledge.upload(formId, { body: bytes, filename: "handbook.pdf" });Indexing happens after the call returns. knowledge.list() is where you watch a
source become usable, and where you see what the plan has left.
Generating with a model
const { questions } = await chatform.ai.clarifyForm({ prompt });
const { doc } = await chatform.ai.generateForm({ prompt, questionCount: 6 });
const form = await chatform.forms.create({ title: doc.title, doc });Nothing here saves anything — you get a document and decide what to do with it.
ai:generate is the only scope that costs money to use, so it is not in the
agent preset and has to be asked for; a 402 means the month's generations are
spent rather than that a payment failed. clarifyForm runs on the cheapest tier
and is not charged as a generation, so asking first is free.
Settings, payments and imports
// settings by key, the same list the builder shows
const { settings } = await chatform.forms.settings.get(formId);
const { changes, rejected } = await chatform.forms.settings.update(formId, {
changes: [{ key: "settings.closeRules.maxSubmissions", value: "500" }],
});
// a form from Google Forms, Typeform, Tally, Jotform, Youform or any public page
const { doc, report } = await chatform.import({ url });
if (report.notCopied.length === 0) await chatform.forms.create({ title: "Imported form", doc });
// payments the form's payment questions collected
const payments = await chatform.forms.payments.list(formId, { status: "paid" });settings.update applies what the plan allows and names the rest in
rejected, so read the result rather than only the status. import saves
nothing: check report.notCopied before you keep the document.
chatform.paymentAccounts lists and connects the organization's own Stripe,
Razorpay or Cashfree accounts; connecting returns a consent URL for a person to
open.
Paging
iterate() handles cursors so you do not have to:
for await (const response of chatform.responses.iterate(formId, { status: "completed" })) {
console.log(response.id);
}Files and exports
Both wrap something you would otherwise write yourself. files.upload hides the
three-step upload protocol:
await chatform.files.upload(sessionId, {
ref: "q_cv",
filename: "resume.pdf",
mime: "application/pdf",
body: await readFile("resume.pdf"),
});and exports.download queues an export, waits for it, and follows the signed
link:
const csv = await chatform.exports.download(formId, { format: "csv" });
await writeFile("responses.csv", await csv.text());Use exports.create() and exports.get() if you would rather not hold a
connection open while it runs.
Signed download URLs are followed without your API key — the signature is
the credential. They still go through whatever fetch you configured, so a
proxy or a test double sees them.
Errors
Every failure is a ChatformError with the code from the API, plus the
distinctions worth branching on:
import { ChatformError } from "@chatformhq/js";
try {
await chatform.responses.complete(id);
} catch (err) {
if (err instanceof ChatformError) {
if (err.isValidationError) console.log(err.issues); // which answers, and why
if (err.isRateLimited) await wait(err.retryAfter); // slow down
if (err.isQuotaExhausted) alertBilling(); // retrying will not help
console.log(err.requestId); // quote this to support
}
}Writes carry an Idempotency-Key automatically, because the client retries and a
timeout tells you nothing about whether the first attempt landed.
In a browser
Its own entrypoint, which throws on a secret key rather than warning:
import { createBrowserClient } from "@chatformhq/js/browser";
const chatform = createBrowserClient({ publishableKey: "pk_live_…" });It exposes sessions, blocks, stream() and files.upload, and nothing
that reads anyone's answers back, because a publishable key cannot do that
anyway. sessions.auth signs a respondent in with Google, a Firebase phone
token or an emailed code (auth.email.start, then auth.email.verify), and
sessions.startPayment opens the form's checkout for a payment question.
The safer pattern is usually to skip publishable keys entirely: open the session
on your server and hand the page the session-scoped respondentToken. A leaked
token is worth one half-finished response; a leaked secret key is worth your
account.
Verifying webhooks
import { verifyWebhook } from "@chatformhq/js/webhooks";
const event = await verifyWebhook({ body: rawBody, headers, secret });Throws rather than returning false — a caller who forgets to check a boolean
has written an unauthenticated endpoint, and that should not fail quietly. It
rejects a stale timestamp before computing the HMAC, compares in constant time,
and accepts several signatures so a secret can be rotated without downtime.
Pass the raw body. Parsing and re-serialising changes the bytes, and the signature is over the bytes.
React
import { ChatformEmbed } from "@chatformhq/react";
<ChatformEmbed
slug="team-onboarding"
hidden={{ plan: "trial" }}
onComplete={(e) => track("form_completed", { id: e.responseId })}
/>;No key needed — the frame talks to the API itself.
For your own interface:
import { useChatformSession } from "@chatformhq/react";
function Interview() {
const { messages, question, send, status } = useChatformSession({
formId: "frm_…",
publishableKey: "pk_live_…",
});
return (
<>
{messages.map((m) => <p key={m.id}>{m.content}</p>)}
{question && <YourInput block={question} onSubmit={send} />}
</>
);
}The engine decides what to ask, what is valid and where a branch goes. You own every pixel and none of the flow logic.
The hook also returns pendingVerification (a code the next message will be
read as) and pendingPayment (an open checkout to hand to the gateway). A turn
that runs past the API's deadline is followed up for you, so status goes from
thinking to ready with the same result a fast turn would give.
Or no SDK at all
Everything here is a thin wrapper over documented HTTP. If you would rather not
take a dependency, the reference has curl, JavaScript, Python and Go
for every endpoint, and https://api.chatform.in/openapi.json will generate a
client in most languages.