Building your own interface
Run the conversation yourself — in your own UI, your own product, or somewhere that is not a browser.
The response API is a good fit when you have the answers already. This one is for when you are asking the questions: your server drives a conversation and the engine decides what comes next, what is valid, and where a branch goes.
Open a session
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"}}'{
"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?" }
}Everything the hosted form enforces applies here too: the close date, the response ceiling, the submission cap. Two things do not — the form password and the captcha — because an API key is stronger proof than a password typed into a box, and there is no browser to solve a captcha.
Give the browser the token, not the key
respondentToken is scoped to that one session and expires. Hand it to your
front end and keep the secret key on your server. That is the whole pattern:
// server
const { sessionId, respondentToken } = await openSession();
// browser gets only these twoAnswer a 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"}'Two shapes. structured is an answer to a specific question — what a form
control produces. text is free text, which the agent interprets against
whatever it just asked:
{ "type": "text", "text": "we're about a dozen people" }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" }
}events is the same stream a browser would have received, delivered over the
same request — one contract, two transports.
A rejected answer is not an error. You get accepted: true with a validation
object and the same question again, which is exactly what the conversation does.
Slow turns
An interview turn may involve a model call. Past a deadline you get 202 with
sinceSeq and a pollUrl, meaning "still running, resume from here" — not a
failure. Pass ?deadlineMs= to choose your own, up to 25 seconds.
Actions
POST /v1/sessions/{id}/actions
{ "action": "skip" }skip, stop, restart, edit (with a ref), submit, and — while a
verified answer is waiting on its code — resend_code and change_answer.
While a verified payment checkout is open, cancel_payment
drops it and retry_payment puts the question back for a fresh one.
submit matters more than it looks: forms default to showing a review step
before finishing, so without it such a form can never be completed. When
awaitingSubmit is true, that is what the session is waiting for.
Streaming
curl -N https://api.chatform.in/v1/sessions/$SESSION_ID/events \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "accept: text/event-stream"Server-sent events, with every event durably stored and replayed on reconnect.
Each carries a seq; keep the highest you have seen and you can resume exactly.
The event types are session_ready, user_message, message_start, token,
message_end, question, validation_error, upload_request,
upload_received, answer_recorded, branch_jump, escalate_ui, review,
auth_required, auth_verified, verify_required, verify_settled,
payment_required, payment_settled, payment_failed, ending, complete,
error, rate_limited and ping.
Without accept: text/event-stream, the same path takes ?since= and returns a
page of stored events instead — which is what you want after a dropped
connection or a 202:
curl "https://api.chatform.in/v1/sessions/$SESSION_ID/events?since=12" \
-H "x-api-key: $CHATFORM_SECRET_KEY"While a turn is in flight the JSON pull waits for it to land — sessions process one turn at a time. That makes it a long poll, which is usually what you want. The stream is the genuinely concurrent reader.
Verified answers
An email or phone question with verify on does not record the answer when
it is given. The session emits verify_required — { ref, channel, sentTo, sentAt } — and carries the same thing on the turn result and on
pendingVerification in the session state, for callers that poll rather than
stream. channel decides what you do next, because the two are proved in
different places.
channel: "email" — a code we sent
The code is already on its way to sentTo. Send it back as an ordinary message:
while a challenge is outstanding the session reads what arrives as the code and
nothing else, so there is no separate route and no token to carry.
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":"text","text":"483920"}'channel: "sms" — a number proved by Firebase
Nothing has been sent. Every SMS in this product is Firebase's, exactly as it is
for phone sign-in: run signInWithPhoneNumber in your own page against the
number in sentTo, let Firebase send and check the code, and post the ID token
it gives you.
curl -X POST https://api.chatform.in/v1/sessions/$SESSION_ID/verify/phone-token \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"idToken":"<firebase id token>"}'The token has to prove the number they answered with. One for any other number
is refused — it is a valid token that says nothing about this answer.
resend_code does not apply here; ask Firebase for another SMS instead.
Either way
A wrong, expired or over-tried code comes back as a validation_error with code
invalid_code, and the step stays open: try again, or change_answer to drop it
and ask the question afresh. On success the answer is recorded exactly as an
unverified one would have been, verify_settled fires, and the conversation
moves on.
A respondent who already signed in is not asked twice. If the gate verified them with Google and they type that same address, or verified their number and they type that same number, the answer is recorded straight away — a different value is a different claim, and is proved on its own.
Verified payments
A payment question on verified checkout is not answered with a value. Start its
checkout with POST /v1/sessions/{id}/payments and { "ref": … }, open the
launch it returns, and wait for payment_settled — the answer is written from
the gateway's confirmation and the conversation moves on by itself. Each turn
result carries pendingPayment while a checkout is open.
Rendering the questions
GET /v1/blocks returns every question type with its configuration schema, the
shape you receive, the shape you send and the errors it can produce. Build your
renderer against that and new block types will not surprise you.
Answering a file question
A file_upload question is not answered with a value — it is answered by
uploading. Register an intent, PUT the bytes, confirm; the session moves on when
the confirm lands.
Rotating a token
POST /v1/sessions/{id}/token/rotateIssues a fresh respondent token and invalidates the old one immediately.