Every "conversational" intake form I have built from scratch ended up the same way: a state machine in a route handler, a switch on the current question, hand-written validation for each field, and a branching table nobody wanted to touch after launch. Then product asks for "ask a follow-up if the answer is vague", and now there is a model call in the middle of the state machine too.
The alternative is to let a form engine drive the conversation and keep only the UI. This tutorial walks through chatform's headless API: you open a session on a published form, post answers, and the engine tells you what to ask next, what was rejected and when the conversation is complete. Everything below comes from the docs at chatform.in/docs; I have not invented any response fields.
What you need
- A published chatform form. Every question has a
reflikeq_email; that is how you address it. - A secret key from Settings → API keys. It starts with
sk_live_(orsk_test_, which writes rows flagged as test data and never fires webhooks). Keep it on your server. -
curlandjq. The Node snippet at the end uses the built-infetch, so Node 18 or newer.
Export two variables so the commands below paste cleanly:
export CHATFORM_SECRET_KEY=sk_test_...
export FORM_ID=...
1. Check the key
curl https://api.chatform.in/v1/me -H "x-api-key: $CHATFORM_SECRET_KEY"
This returns the organization the key belongs to, its scopes, and what is left of the plan. If this call fails, nothing else will work, so start here. A 401 with code invalid_api_key means the key is not one the API knows. You can also send the key as authorization: Bearer sk_..., but never in a query string; /v1 refuses keys in URLs.
2. Read the form's questions
curl https://api.chatform.in/v1/forms/$FORM_ID -H "x-api-key: $CHATFORM_SECRET_KEY"
Note the ref of each block. If the form is not live you get not_published; the draft is readable with ?view=document, but it takes no responses until you publish it.
3. Open a session
A session is a conversation on a published form, driven by you instead of the hosted page.
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/sessions \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"hiddenFields": {"plan": "trial"}}'
Response:
{
"sessionId": "chs_…",
"respondentToken": "…",
"expiresAt": 1788592149473,
"streamUrl": "/v1/sessions/chs_…/events",
"greeting": "Hi! Let's get started.",
"question": { "ref": "q_email", "type": "email", "title": "What's your email?" }
}
The body also accepts externalId (your own id for the respondent, up to 200 characters) and expiresIn in seconds, between 300 and 2,592,000. Save sessionId:
export SESSION_ID=chs_...
Two things worth knowing. The close date, response ceiling and submission cap of the hosted form all apply here. The form password and captcha do not, because an API key is stronger proof than a password and there is no browser to solve a captcha.
The respondentToken is the piece that makes a browser front end safe. It is scoped to this one session and expires, so you hand it to the page and keep the secret key on the server. If a request carrying a secret key arrives with an Origin header, the API refuses it with secret_key_in_browser and tells you to rotate the key.
4. Answer the question
curl -X POST https://api.chatform.in/v1/sessions/$SESSION_ID/messages \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"type": "structured", "ref": "q_email", "value": "maya@northwind.co"}'
There are two message shapes. structured is an answer to a specific question, what a form control would produce. text is free text the engine interprets against whatever it just asked:
{ "type": "text", "text": "we're about a dozen people" }
Either way the reply is the whole turn:
{
"accepted": true,
"assistantMessages": ["Got it. And what's your role?"],
"question": { "ref": "q_role", "…": "…" },
"validation": null,
"complete": false,
"awaitingSubmit": false,
"events": [{ "seq": 7, "type": "answer_recorded", "…": "…" }],
"answers": { "q_email": "maya@northwind.co" }
}
Your loop is: render assistantMessages, render question, collect an answer, post it, repeat. A rejected answer is not an HTTP error. You still get accepted: true, plus a validation object with ref, code and message, and the same question again. The message is the same text a respondent would see, so you can show it as is.
When the engine reaches an ending, question is null and ending carries ref, title, bodyMd and a kind of success or screen_out.
5. Submit
Forms default to showing a review step before finishing. When awaitingSubmit is true, the session is waiting for exactly one thing:
curl -X POST https://api.chatform.in/v1/sessions/$SESSION_ID/actions \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"action": "submit"}'
Without this, such a form can never complete. The same endpoint takes skip, stop, restart and edit (with a ref), so back buttons and "start over" links are one call each.
6. Read state, or catch up after a drop
curl https://api.chatform.in/v1/sessions/$SESSION_ID \
-H "x-api-key: $CHATFORM_SECRET_KEY"
Session state includes status, currentRef, answers, awaitingSubmit, completedAt and a summary array of { ref, title, display }, which is handy for a review screen.
The events endpoint has two modes. With accept: text/event-stream it is a server-sent event stream, and every event carries a seq. Without that header it returns a JSON page:
curl "https://api.chatform.in/v1/sessions/$SESSION_ID/events?since=12" \
-H "x-api-key: $CHATFORM_SECRET_KEY"
That returns { events, latest_seq, has_more }. Keep the highest seq you have seen and you can resume exactly where you left off.
The same loop in Node
const BASE = "https://api.chatform.in/v1";
const headers = {
"x-api-key": process.env.CHATFORM_SECRET_KEY,
"content-type": "application/json",
};
async function openSession(formId) {
const res = await fetch(`${BASE}/forms/${formId}/sessions`, {
method: "POST", headers, body: JSON.stringify({}),
});
return res.json(); // { sessionId, respondentToken, question, ... }
}
async function answer(sessionId, ref, value) {
const res = await fetch(`${BASE}/sessions/${sessionId}/messages`, {
method: "POST", headers,
body: JSON.stringify({ type: "structured", ref, value }),
});
return res.json(); // { accepted, validation, question, complete, awaitingSubmit, ... }
}
Hand sessionId and respondentToken to the page; keep everything else behind this module.
Errors you will actually hit
Every error uses one envelope: { "error": { "code", "message", "issues", "request_id" } }. The request_id is also the X-Request-Id header on every response, so log it.
-
401
unauthorized,invalid_api_key,api_key_disabled,api_key_expired. Wording is identical on purpose; read the code. -
403
secret_key_in_browser,origin_not_allowed,insufficient_scope. A key with onlyform:readcannot start a session. - 403 on opening a session means the form is closed, capped, or over its response ceiling.
- 404 is also what you get for a form that belongs to another tenant. That is deliberate.
-
400
invalid_requestwithissues[]naming each bad field, ormalformed_jsonfor a JSON content type with a body that is not JSON. An empty body counts. -
202 on a message is not a failure. An interview turn may involve a model call. Past the deadline you get
202withsinceSeqand apollUrl. Pass?deadlineMs=to pick your own deadline, up to 25 seconds. -
429
rate_limitedwithRetry-Afterin seconds. Retry it. 402limit_reachedmeans the monthly quota is spent and retrying is futile.
Where to go next
-
GET /v1/blocksreturns every question type with its config schema, the shape you receive and the shape you send. Build your renderer against that and new block types will not surprise you. - File questions are answered by uploading: register an intent,
PUTthe bytes, confirm. - Forms with sign-in emit
auth_requiredand accept Google, phone or email proof on dedicated routes. - If you would rather not write the HTTP, there is a typed JavaScript client and React bindings.
Disclosure: I work on chatform. This post was drafted with AI assistance and checked against the docs by hand. The full headless reference, including streaming, verified answers and payments, is at chatform.in/docs/headless.
Top comments (0)