DEV Community

Md Ayan bag
Md Ayan bag

Posted on AI-assisted

Build a chat-style intake flow from your server with six curl calls

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 ref like q_email; that is how you address it.
  • A secret key from Settings → API keys. It starts with sk_live_ (or sk_test_, which writes rows flagged as test data and never fires webhooks). Keep it on your server.
  • curl and jq. The Node snippet at the end uses the built-in fetch, so Node 18 or newer.

Export two variables so the commands below paste cleanly:

export CHATFORM_SECRET_KEY=sk_test_...
export FORM_ID=...
Enter fullscreen mode Exit fullscreen mode

1. Check the key

curl https://api.chatform.in/v1/me -H "x-api-key: $CHATFORM_SECRET_KEY"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

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?" }
}
Enter fullscreen mode Exit fullscreen mode

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_...
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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" }
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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, ... }
}
Enter fullscreen mode Exit fullscreen mode

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 only form:read cannot 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_request with issues[] naming each bad field, or malformed_json for 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 202 with sinceSeq and a pollUrl. Pass ?deadlineMs= to pick your own deadline, up to 25 seconds.
  • 429 rate_limited with Retry-After in seconds. Retry it. 402 limit_reached means the monthly quota is spent and retrying is futile.

Where to go next

  • GET /v1/blocks returns 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, PUT the bytes, confirm.
  • Forms with sign-in emit auth_required and 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)