chatformdocs

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 two

Answer 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.

Signing the respondent in

A form with sign-in switched on asks for it before the first question: the session emits auth_required with the method it wants (google, phone or email). You collect the proof in your own UI and pass it through.

MethodCall
GooglePOST /v1/sessions/{id}/auth/google with { "idToken": … }, a Google ID token
PhonePOST /v1/sessions/{id}/auth/phone/token with { "idToken": … }, from Firebase phone sign-in in your page
EmailPOST /v1/sessions/{id}/auth/email/start with { "email": … }, then POST /v1/sessions/{id}/auth/email/verify with { "code": … }
curl -X POST https://api.chatform.in/v1/sessions/$SESSION_ID/auth/email/start \
  -H "x-api-key: $CHATFORM_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"email":"maya@northwind.co"}'

We send the email code ourselves: five sends per session, and it lasts ten minutes. Starting email sign-in on a form that does not sign in by email is a 400 with email_signin_off.

Success is { ok: true, identity, resumed } and the session emits auth_verified. resumed: true means the person had a response open and the conversation picks it up. A 409 with already_answered means this person has already responded and the form allows one response each; the error carries completedAt so you can say when.

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.

curl -X POST https://api.chatform.in/v1/sessions/$SESSION_ID/payments \
  -H "x-api-key: $CHATFORM_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"ref":"q_payment"}'

The reply is { recordId, launch, expiresAt }. The amount comes from the session, never from you. When the respondent returns from the gateway, POST /v1/sessions/{id}/payments/{recordId}/confirm checks with the gateway straight away instead of waiting for its webhook, and answers { recordId, status, settled }.

Verified payments →

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.

The block reference →

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.

Files →

Rotating a token

POST /v1/sessions/{id}/token/rotate

Issues a fresh respondent token and invalidates the old one immediately.

On this page