# Authentication
Source: https://chatform.in/docs/authentication
---
title: Authentication
description: Four kinds of key, and which one belongs where.
---
Every request to `/v1` carries a key, in either header:
```bash
curl https://api.chatform.in/v1/me -H "x-api-key: sk_live_…"
curl https://api.chatform.in/v1/me -H "authorization: Bearer sk_live_…"
```
Never in a query string. Keys in URLs end up in access logs, proxy logs and
`Referer` headers, so `/v1` does not accept one there at all.
## The four kinds
| Prefix | Where it belongs | What it can do |
| --- | --- | --- |
| `sk_live_` | Your server | Everything its scopes allow, against real data |
| `sk_test_` | Your server | The same, but everything it writes is marked test data |
| `pk_live_` | A browser | Open and drive sessions only, from origins you list |
| `pk_test_` | A browser | The same, marked as test data |
Keys belong to your organization, not to the person who created them — so a
teammate can see and revoke a key after its author has left.
## Secret keys never go in a browser
If a request carries a secret key *and* an `Origin` header, it is refused with
`secret_key_in_browser` and a message telling you to rotate it. That request came
from a page, which means the key is already readable by everyone who loaded it.
This is the single most common way an API key leaks, and a silent success here
would be worse than an error.
## Publishable keys, for the browser
A `pk_` key is safe to ship in a page because it is pinned to origins and can do
almost nothing: open a session, answer questions, upload a file. It cannot read
responses, manage webhooks or touch a form.
You must list its origins when you create it — a publishable key with no
allowlist is refused, because without one it is just a secret key in public.
```
https://acme.example
https://*.preview.acme.example
```
Wildcards match one label deep and are matched on the host, so
`https://evil-example.com` never satisfies `https://*.example.com`.
## The safer browser pattern
You usually do not need a publishable key at all. Have your server open the
session and hand the browser the **respondent token** it gets back:
```js
// your server
const res = await fetch(`https://api.chatform.in/v1/forms/${formId}/sessions`, {
method: "POST",
headers: { "x-api-key": process.env.CHATFORM_SECRET_KEY },
});
const { sessionId, respondentToken } = await res.json();
// hand only these two to the page
```
That token is scoped to one session and expires. A leaked one is worth a single
half-finished response; a leaked secret key is worth your whole account.
## Test mode
A `sk_test_` key hits your real forms and writes real rows — but everything it
creates is flagged as test data:
- excluded from analytics, the drop-off funnel and your response counts
- never billed, and never counted against your monthly quota
- no webhooks fire
- hidden in the dashboard unless you turn on "show test data"
- deleted after thirty days
So you can rehearse an integration against the form you actually ship, without a
second copy of it and without polluting anything.
## Scopes
Keys carry scopes, and they are enforced. A key with `form:read` cannot start a
session, and no key of any kind can mint another key or change your plan.
[Scopes in detail →](/docs/scopes)
## Rotation
`POST /api/keys/{id}/rotate` in the dashboard mints a replacement and puts the
old key on a clock — 24 hours by default. A deploy is not atomic, and "revoke,
then create" means downtime in between.
The old key keeps working through the window, then stops.
## Errors
| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `unauthorized` | No key sent |
| 401 | `invalid_api_key` | Not a key we know |
| 401 | `api_key_disabled` | Revoked |
| 401 | `api_key_expired` | Past its expiry |
| 403 | `secret_key_in_browser` | A secret key arrived from a page |
| 403 | `origin_not_allowed` | Publishable key, unlisted origin |
| 403 | `insufficient_scope` | The key lacks the scope named in `error.required` |
| 429 | `rate_limited` | Too fast — see [rate limits](/docs/rate-limits) |
| 402 | — | Plan quota or a feature your plan does not include |
401 responses are deliberately identical in wording. The code tells you what
happened; the message never confirms whether a guessed key ever existed.
---
# Embedding
Source: https://chatform.in/docs/embed
---
title: Embedding
description: A script tag or an iframe. No backend, no keys.
---
The fastest way to put a form somewhere is not to use the API at all.
## Popup or side tab
```html
```
`data-mode` takes `popup`, `side-tab`, `inline` or `fullpage`.
The loader injects the iframe lazily — on intent, not on load — so it does not
compete with your own page for the first paint.
## Where it sits, and what it looks like
```html
```
| Attribute | Takes | Default |
| --- | --- | --- |
| `data-position` | `bottom-right`, `bottom-left`, `top-right`, `top-left` | `bottom-right` |
| `data-offset` | px between the launcher and the edges | `20` |
| `data-width` | panel width in px | `400` (`440` for a side tab) |
| `data-height` | panel height in px; inline takes `auto` | `600` |
| `data-color` | launcher colour | `#f97316` |
| `data-label` | launcher text; `""` for an icon-only bubble | `Questions?` |
| `data-icon` | `chat` or `none` | `chat` |
| `data-theme` | `light`, `dark`, `auto` | `auto` |
| `data-open-on` | `click`, `load`, `exit-intent`, `scroll:` | `click` |
Below 520px wide the panel goes full screen whatever you set, because a 400px
panel inset from the corner of a phone is a form nobody can fill in.
Two forms can sit on one page in two different corners — each script tag gets
its own placement rules, and `window.Chatform.get("")` reaches each one.
## Inline
```html
```
`?embed=1` renders the form without the standalone page's chrome.
## Prefilling
Anything you already know, pass in — the respondent is not asked for it twice.
```html
```
Or as query parameters on an iframe: `?plan=trial&utm_source=pricing-page`. Only
the hidden fields the form declares are accepted; anything else is ignored.
## Reacting to it
The frame posts messages to your page:
```js
window.addEventListener("message", (event) => {
if (event.origin !== "https://chatform.in") return;
const message = event.data;
if (message?.source !== "chatform") return;
switch (message.type) {
case "ready": break;
case "resize": /* message.height */ break;
case "question": /* message.ref */ break;
case "answer": /* message.ref — the value is deliberately not included */ break;
case "complete": analytics.track("form_completed", { id: message.responseId }); break;
case "close": break;
}
});
```
`answer` carries the question's `ref` and type, never the value. Putting a
respondent's answers into the embedding page's JavaScript by default is not a
default anyone asked for — use [webhooks](/docs/webhooks) or the responses API if
you need the data.
Always check `event.origin`. Any page can post a message to yours.
## Controlling it
```js
window.Chatform.open();
window.Chatform.close();
window.Chatform.toggle();
window.Chatform.prefill({ plan: "pro" });
window.Chatform.on("complete", (e) => console.log(e.responseId));
```
Calls made before the script loads are queued and replayed, so you do not have to
wait for it.
## Restricting where a form can be framed
A form can list the origins allowed to embed it. The allowlist is enforced when a
session is opened — a page that is not on it cannot start one, whatever it does
in the browser — and it also produces a `frame-ancestors` policy so the browser
refuses the frame in the first place.
Leave it empty and the form embeds anywhere, which is usually what a public form
wants.
## Content Security Policy
If your site sets a CSP, the embed needs:
```
frame-src https://chatform.in;
script-src https://chatform.in;
```
The loader adds no inline script and evaluates nothing. If your policy uses
nonces, put yours on the script tag as `data-nonce` and it will be copied onto
the styles it injects.
---
# Errors
Source: https://chatform.in/docs/errors
---
title: Errors
description: One envelope, and what each status actually means.
---
Every error has the same shape:
```json
{
"error": {
"code": "invalid_answer",
"message": "One or more answers were rejected",
"issues": [
{ "ref": "q_phone", "code": "invalid_phone", "message": "Please enter a valid phone number with country code." }
],
"request_id": "req_9f2c…",
"doc_url": "https://chatform.in/docs/errors#invalid-answer"
}
}
```
`request_id` is on every response, error or not, as the `X-Request-Id` header.
Quote it and we can find the request. If you send your own, we keep it.
## Statuses
| Status | Meaning | What to do |
| --- | --- | --- |
| 400 | Malformed — bad JSON, a bad cursor | Fix the request |
| 401 | No key, or not a valid one | Check the key |
| 402 | A plan limit or a feature you do not have | Upgrade; retrying will not help |
| 403 | The key is valid but not allowed to do this | Check scopes and origins |
| 404 | Not found — or not yours | Cross-tenant access looks identical to not found, deliberately |
| 409 | State conflict — already completed, already responded | Re-read before retrying |
| 413 | Too large | |
| 422 | Understood, but wrong — a rejected answer, an invalid document | Read `issues` |
| 429 | Too fast | Back off; see `Retry-After` |
| 507 | Storage is full | |
The 402/429 distinction matters: **429 means slow down**, and **402 means the
month is spent** and retrying is futile.
## Answer validation
A 422 from an answer endpoint carries one issue per rejected answer, each with
the block's `ref` and a code. The codes are per block type and are documented on
each type's page — [rating](/docs/blocks/rating) can return `out_of_range`,
[email](/docs/blocks/email) can return `freemail`, and so on.
The full set:
`required` · `type` · `too_short` · `too_long` · `pattern` · `invalid_email` ·
`freemail` · `invalid_phone` · `invalid_url` · `not_integer` · `too_small` ·
`too_large` · `invalid_date` · `invalid_time` · `time_out_of_range` ·
`past_date` · `too_early` · `too_late` · `invalid_option` · `too_few` ·
`too_many` · `out_of_range` · `incomplete_ranking` · `invalid_ranking` ·
`invalid_row` · `invalid_column` · `incomplete_matrix` · `too_many_files` ·
`file_too_large` · `name_required` · `payment_pending` · `incomplete` ·
`consent_required` · `unsupported`
The messages are the same ones a respondent would have seen, so you can show
them as-is.
## Flow errors
| Code | Meaning |
| --- | --- |
| `block_not_reachable` | The form has not asked this question yet |
| `block_not_visible` | A visibility rule hides it for these answers |
| `unknown_block` | No question with that `ref` |
| `incomplete` | Required questions are unanswered; `issues` names them |
| `response_not_open` | Already completed or abandoned |
## Key errors
See [authentication](/docs/authentication#errors). 401 responses are worded
identically on purpose — the code tells you what happened, and the message never
confirms whether a guessed key ever existed.
---
# Exports
Source: https://chatform.in/docs/exports
---
title: Exports
description: Ask for every response as one file, and collect it when it is ready.
---
The [read API](/docs/responses#reading-responses-back) pages through responses
25 or 100 at a time, which is the right shape for syncing and the wrong shape
for "give me everything as a CSV". Exports are the second shape.
They are asynchronous on purpose. A worker has a wall-clock budget and you have
a timeout, so above a few thousand rows the only honest answer is a receipt and
a link that appears shortly.
## Request one
```bash
POST /v1/forms/{formId}/exports
```
```json
{
"format": "csv",
"status": ["completed"],
"created_after": 1759276800000
}
```
```json
{
"id": "exp_4b1e9c07a2d5f83b6c14",
"object": "export",
"status": "queued",
"download_url": null
}
```
`202`, immediately — nothing has been read yet. Needs `response:export`.
| Field | Default | |
| --- | --- | --- |
| `format` | `csv` | `csv` or `json` |
| `status` | `["completed"]` | any statuses, or `["all"]` |
| `source` | all | `chat`, `api` or `embed` |
| `mode` | `live` | `live`, `test` or `all` |
| `created_after` / `created_before` | — | epoch milliseconds |
Send an `Idempotency-Key` and a retried request returns the same export rather
than queueing a second run of the same query.
## Collect it
```bash
GET /v1/exports/{exportId}
```
```json
{
"id": "exp_4b1e9c07a2d5f83b6c14",
"status": "ready",
"row_count": 4812,
"bytes": 918273,
"download_url": "https://api.chatform.in/d/export/exp_4b1e...?exp=1764...&sig=...",
"download_expires_at": 1764000600000,
"expires_at": 1764086400000
}
```
Poll until `status` is `ready`. A few seconds is plenty for most forms; there is
no webhook for this yet. A `failed` export carries the reason in `error`.
`GET /v1/exports` lists recent ones, newest first, optionally filtered by
`form_id`.
`download_url` carries no API key — the signature is the credential, and it
expires in ten minutes. Read the export again to mint a fresh one rather than
storing the URL. The reasoning is the same as for [files](/docs/files#about-that-url).
## What is in the file
CSV gets one column per answerable question, named by the question with its ref
in brackets — `Email? (q_email)` — so a column can be matched back to the
document without the file being an export of our primary keys.
```csv
response_id,status,source,started_at,completed_at,"Email? (q_email)","Rate us (q_rating)"
sbm_...,completed,chat,2026-09-01T10:14:22.000Z,2026-09-01T10:16:05.000Z,ada@example.com,5
```
Values are the labels a person would recognise, not internal ids: an option
comes out as its text, a matrix as readable pairs. An unanswered cell is empty
rather than `(skipped)` — a spreadsheet already has a way to say nothing is
there.
`format: "json"` gives JSON Lines instead — one response object per line, with
`answers` keyed by ref and values in their canonical form. That is the one to
use when something downstream is going to parse it.
## Partial responses
Exporting what you finished collecting is never behind the paywall. Asking for
unfinished responses — `in_progress`, `abandoned`, or `all` — needs a plan that
includes partial responses, and an over-limit request is refused with a `402`
rather than quietly narrowed to completed. An export that silently contains
something other than what you asked for is worse than one that says no.
## Retention
An export is a full copy of respondent data sitting in a bucket, so it is not
kept indefinitely: the object and its record are deleted 24 hours after the
request. Re-request rather than re-download later — the query is stored on the
export record, so asking again means the same thing.
The ceiling on a single export is 100,000 responses. Above that, filter by date
range, or page the read API.
---
# Files
Source: https://chatform.in/docs/files
---
title: Files
description: Upload a file into a response, and read one back out.
---
A `file_upload` question is answered with a file, which means two things this
API has to do well: accept bytes from a headless caller, and hand them back to
you afterwards without turning your API key into a URL you paste into a browser.
## Uploading
Three steps, in this order. They are the same three steps the hosted chat uses —
the same rows, the same limits, the same validation — reached with an API key
instead of a respondent token.
### Register the intent
```bash
POST /v1/sessions/{sessionId}/uploads/intent
```
```json
{
"ref": "q_cv",
"filename": "resume.pdf",
"mime": "application/pdf",
"size": 248193
}
```
```json
{
"fileId": "file_9f2c1a4b8e07d3f6",
"uploadUrl": "/v1/sessions/chs_.../uploads/file_9f2c1a4b8e07d3f6"
}
```
The type and size are checked here, before any bytes move — including the form
owner's per-file limit and their remaining storage. There is no way to un-upload
something, so everything that can be refused is refused first.
### PUT the bytes
```bash
curl -X PUT "https://api.chatform.in{uploadUrl}" \
-H "Authorization: Bearer $CHATFORM_API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @resume.pdf
```
The raw body — not multipart, not base64. The size must match what you declared
within a kilobyte, which is what stops an intent for a 2KB text file from
becoming a 25MB upload.
### Confirm
```bash
POST /v1/sessions/{sessionId}/uploads/{fileId}/confirm
```
Until this call the file is `pending` and invisible to the form. Confirming
verifies the object really landed, flips it to `confirmed`, and tells the
session — which is what lets the conversation move to the next question.
Needs `file:write`. Publishable keys hold it by default, because uploading from
a browser is exactly the case they exist for.
The two auth paths are alternatives, never a fallback. A respondent token will
not work on `/v1`, and an API key will not work on `/p`. If a key could stand in
for a missing respondent token, a leaked session id plus any key in your
organization would be a way into someone else's upload.
## Reading a file back
An answer to a `file_upload` question carries a `fileId`. Resolve it:
```bash
GET /v1/files/{fileId}
```
```json
{
"id": "file_9f2c1a4b8e07d3f6",
"object": "file",
"form_id": "frm_...",
"response_id": "sbm_...",
"filename": "resume.pdf",
"mime": "application/pdf",
"size_bytes": 248193,
"download_url": "https://api.chatform.in/d/file/file_9f2c...?exp=1764...&sig=...",
"download_expires_at": 1764000600000
}
```
Needs `file:read` — which publishable keys deliberately do not have. A key that
ships in your page must not be able to read what other people uploaded.
### About that URL
`download_url` carries no API key. The signature is the credential: it names one
file, it expires in ten minutes, and leaking it costs you that one file rather
than your organization.
That is why it is signed rather than authenticated in the usual way. Putting an
`sk_live_` key in a URL would write it into access logs, proxy logs and
`Referer` headers — so `/v1` never accepts a key in a query string, and this is
the alternative. Mint a fresh URL by reading the file again; do not store one.
Every failure answers `404`, including an expired link, so a stale URL cannot be
used to learn that a file id exists.
Downloads always arrive as `application/octet-stream` with an `attachment`
disposition, whatever was uploaded. These are bytes from strangers, and nothing
we serve should render them.
## Limits
| | |
| --- | --- |
| Max size per file | 25 MB |
| Types | images, PDF, plain text, CSV, Word, Excel, MP3, WAV, MP4, WebM |
| Per-file and storage limits | set by the form owner's plan |
A file larger than the plan allows is `413`; an organization out of storage is
`507`. Neither message mentions billing to the person filling in the form —
that is the form owner's business to read in their dashboard.
---
# Building your own interface
Source: https://chatform.in/docs/headless
---
title: Building your own interface
description: 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
```bash
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"}}'
```
```json
{
"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:
```js
// server
const { sessionId, respondentToken } = await openSession();
// browser gets only these two
```
## Answer a question
```bash
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:
```json
{ "type": "text", "text": "we're about a dozen people" }
```
The reply is the whole turn:
```json
{
"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
```bash
POST /v1/sessions/{id}/actions
{ "action": "skip" }
```
`skip`, `stop`, `restart`, `edit` (with a `ref`), and `submit`.
`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
```bash
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`, `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:
```bash
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.
## 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 →](/docs/blocks)
## 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 →](/docs/files)
## Rotating a token
```bash
POST /v1/sessions/{id}/token/rotate
```
Issues a fresh respondent token and invalidates the old one immediately.
---
# Idempotency
Source: https://chatform.in/docs/idempotency
---
title: Idempotency
description: Retry a write safely, when you cannot tell whether the first one landed.
---
A timeout tells you nothing about whether the request succeeded. Send an
`Idempotency-Key` and retrying is safe.
```bash
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/responses \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "idempotency-key: $(uuidgen)" \
-H "content-type: application/json" \
-d '{"answers": {"q_email": "maya@northwind.co"}, "complete": true}'
```
Use a fresh UUID per logical operation and reuse it across retries of that
operation.
## What happens
- **First request**: processed normally, and its result is stored.
- **Same key, same body**: the stored response is returned unchanged, with
`Idempotency-Replayed: true`. Nothing is created twice.
- **Same key, different body**: **422** `idempotency_key_reuse`. Replaying the
first response would hide a bug in your code behind a plausible success.
- **Same key while the first is still running**: **409**
`idempotency_in_progress` with `Retry-After`.
- **The first request failed with a 5xx**: the key is released. A 5xx is not an
outcome, so a retry is a real retry.
Keys are kept for 24 hours.
## Where it applies
`POST /v1/forms/{id}/responses`, `POST /v1/responses/{id}/complete`,
`POST /v1/forms/{id}/sessions`, `POST /v1/forms`, `POST /v1/forms/{id}/publish`.
Recording an answer is naturally idempotent — the same answer to the same
question overwrites rather than duplicates — so it needs no key.
## Webhooks, in the other direction
Deliveries are at-least-once, so your endpoint needs the same property. Record
`webhook-id` and skip ones you have already handled.
---
# Chatform for developers
Source: https://chatform.in/docs
---
title: Chatform for developers
description: Embed a form, drive one from your backend, or build your own interface on the same engine.
---
Chatform forms are conversations. A respondent is asked one question at a time,
their answer is validated as they give it, and the next question depends on what
they said. Everything the hosted experience does is available over HTTP.
There are three ways in, and they are genuinely different jobs. Start with the
one that matches yours.
## Put a form on your site
A script tag or an iframe. No backend, no keys, nothing to deploy.
```html
```
[Embedding →](/docs/embed)
## Drive it from your backend
Create responses, record answers, read them back. This is the path for
importing existing data, wiring a form into a workflow, or collecting answers
from somewhere that is not a browser at all.
```bash
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/responses \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"answers": {"q_email": "maya@northwind.co"}, "complete": true}'
```
[The response lifecycle →](/docs/responses)
## Build your own interface
Open a conversation, stream it, and render it however you like — a chat, a
classic form, a voice agent, a Slack bot. The engine decides what to ask next;
you decide what it looks like.
```bash
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/sessions \
-H "x-api-key: $CHATFORM_SECRET_KEY"
```
[Building your own UI →](/docs/headless)
---
## Start here
- [Quickstart](/docs/quickstart) — a key to a stored answer, in five minutes.
- [Authentication](/docs/authentication) — key types, and which one belongs in a browser.
- [Blocks](/docs/blocks) — all 26 question types, what each accepts and returns.
- [API reference](/docs/api) — every endpoint, generated from the spec.
- [SDKs](/docs/sdk) — a typed client for JavaScript, and React bindings.
- [Files](/docs/files) — answering a file question, and reading the bytes back.
- [Exports](/docs/exports) — every response as one CSV or JSONL file.
## What this API is not
Worth knowing before you build against it.
- **Payments are not verified.** A payment block hands the respondent to your
own checkout or a UPI app and records that they said they paid. Nothing here
talks to a gateway, so `verified` is always `false`. Reconcile against your
processor.
- **Scheduling is a hand-off.** A scheduling block records the booking link and,
if you pass it, the slot. It does not hold a calendar.
- **Answers are re-validated server-side, always.** Whatever your interface
accepts, the engine applies the same rules the hosted experience does. That is
deliberate: it is what lets you build a UI without reimplementing validation.
---
# Pagination
Source: https://chatform.in/docs/pagination
---
title: Pagination
description: Cursors, and why they are not page numbers.
---
List endpoints return a page and a cursor:
```json
{
"data": [ /* … */ ],
"has_more": true,
"next_cursor": "eyJvcmRlciI6ImNyZWF0ZWQi…"
}
```
Ask for the next page by handing it back:
```bash
curl "https://api.chatform.in/v1/forms/$FORM_ID/responses?limit=50&cursor=$NEXT" \
-H "x-api-key: $CHATFORM_SECRET_KEY"
```
Stop when `has_more` is false. `limit` defaults to 25 and caps at 100.
## Why not offsets
Responses arrive while you are reading them. With `?page=2`, a new response
pushes everything down by one — so you see a row twice and miss another, and
nothing tells you it happened.
A cursor is anchored to a row rather than a position, so a page boundary means
the same thing however much has changed. It is signed, so a modified one is
rejected rather than quietly returning the wrong window.
## Ordering
`order=created` (default) or `order=updated`. A cursor belongs to the ordering it
was issued for; using one with the other is a 400.
`order=updated` with `updated_since` is how you poll for changes without
re-reading everything:
```bash
curl "https://api.chatform.in/v1/forms/$FORM_ID/responses?order=updated&updated_since=$LAST_SEEN" \
-H "x-api-key: $CHATFORM_SECRET_KEY"
```
Though if you are polling for new responses, [a webhook](/docs/webhooks) is
better than both.
## Iterating
```js
async function* allResponses(formId) {
let cursor;
do {
const url = new URL(`https://api.chatform.in/v1/forms/${formId}/responses`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, {
headers: { "x-api-key": process.env.CHATFORM_SECRET_KEY },
}).then((r) => r.json());
yield* page.data;
cursor = page.next_cursor;
} while (cursor);
}
```
---
# Quickstart
Source: https://chatform.in/docs/quickstart
---
title: Quickstart
description: From an API key to a stored answer in about five minutes.
---
You need a published form and a secret key. Create the key at
[chatform.in/settings/api-keys](https://chatform.in/settings/api-keys) — it is shown once.
Secret keys start with `sk_live_` and belong on a server. Sending one from a
browser exposes it to every visitor of that page, and the API refuses such
requests outright rather than letting it happen quietly.
## 1. Check the key works
```bash
curl https://api.chatform.in/v1/me -H "x-api-key: $CHATFORM_SECRET_KEY"
```
That returns which organization the key belongs to, what it is allowed to do,
and what is left of your plan. If this fails, nothing below will work.
## 2. Find a form
```bash
curl https://api.chatform.in/v1/forms -H "x-api-key: $CHATFORM_SECRET_KEY"
```
Take an `id` from the response. You can also read the form's questions:
```bash
curl https://api.chatform.in/v1/forms/$FORM_ID -H "x-api-key: $CHATFORM_SECRET_KEY"
```
Each block has a `ref` — a stable name like `q_email`. That is how you address a
question when answering it.
## 3. Open a response
```bash
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/responses \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{}'
```
You get back a response `id` and, in `next`, the question the form is waiting on.
## 4. Answer it
```bash
curl -X POST https://api.chatform.in/v1/responses/$RESPONSE_ID/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_email", "value": "maya@northwind.co"}'
```
The reply carries the updated response, including the *next* question. Repeat
until `next` is an ending rather than a block.
## 5. Complete it
```bash
curl -X POST https://api.chatform.in/v1/responses/$RESPONSE_ID/complete \
-H "x-api-key: $CHATFORM_SECRET_KEY"
```
The response now shows up in your dashboard alongside every conversational one,
and any webhooks you have configured fire.
## In one call
If you already have all the answers — importing a spreadsheet, say — send them
together:
```bash
curl -X POST https://api.chatform.in/v1/forms/$FORM_ID/responses \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"answers": {"q_email": "maya@northwind.co", "q_role": "opt_founder1"}, "complete": true}'
```
This writes the same per-answer records the step-by-step path does, so your
drop-off funnel and per-question summaries stay correct. It is a shortcut
through the API, not around the data.
## Next
- [Authentication](/docs/authentication) — test keys, browser keys, rotation.
- [The response lifecycle](/docs/responses) — partials, editing, abandonment.
- [Blocks](/docs/blocks) — what each question type accepts.
- [SDKs](/docs/sdk) — if you would rather not write the HTTP yourself.
---
# Rate limits and quotas
Source: https://chatform.in/docs/rate-limits
---
title: Rate limits and quotas
description: Three limits, and how to tell them apart.
---
Three separate things can slow you down, and they want different responses.
## 1. Burst
A short-window ceiling that absorbs runaway loops and key-guessing. You will not
meet it in normal use.
## 2. Your key's limit
Each key has a sustained rate, and it is on the key — you can raise or lower it
per key in the dashboard, within your plan's ceiling.
| Plan | Default |
| --- | --- |
| Pro | 120 requests/minute |
| Business | 600 requests/minute |
Publishable keys get more headroom, because one window per respondent on a busy
page is legitimately bursty.
Every response tells you where you stand:
```
RateLimit-Limit: 120
RateLimit-Remaining: 108
RateLimit-Reset: 42
RateLimit-Policy: 120;w=60
```
Exceeding it is **429** with `Retry-After` in seconds:
```json
{ "error": { "code": "rate_limited", "message": "Too many requests for this API key", "scope": "key" } }
```
`scope` tells you which limit you hit — `burst` or `key`.
## 3. Your monthly quota
`api_requests` is metered per month against your plan. Running out is **402**,
not 429, and it says when it resets:
```json
{
"error": {
"code": "limit_reached",
"limitKey": "api_requests_per_month",
"used": 50000,
"limit": 50000,
"resetsAt": 1790000000000
}
}
```
Retrying a 429 makes sense. Retrying a 402 does not.
Requests are counted on the way in, including ones that end in an error — an
error loop costs us the same work a successful call does. Requests made with a
test key are counted separately and never against your quota.
## Backing off
```js
async function withRetry(request, attempts = 4) {
for (let i = 0; i < attempts; i++) {
const res = await request();
if (res.status !== 429) return res;
// Retry-After is authoritative. Jitter so a fleet does not retry in lockstep.
const wait = Number(res.headers.get("retry-after") ?? 2 ** i);
await new Promise((r) => setTimeout(r, wait * 1000 + Math.random() * 500));
}
throw new Error("rate limited");
}
```
Watch `RateLimit-Remaining` and slow down before you hit zero — that costs
nothing and is better than being told.
## If you are being throttled a lot
Usually it means polling. Use [webhooks](/docs/webhooks) instead of asking
whether anything has arrived, and `updated_since` instead of re-reading pages you
already have.
---
# The response lifecycle
Source: https://chatform.in/docs/responses
---
title: The response lifecycle
description: Open a response, answer into it, complete it — and why partial answers are first-class.
---
A response is created the moment someone starts, not when they finish. Every
answer is stored as it arrives. That is what makes a half-finished response
visible in your dashboard, and it is why this API has a lifecycle rather than a
single submit endpoint.
A single "post the whole form" endpoint would create finished records with no
per-question history — so the drop-off funnel, the per-question summaries and
every partial count would mean something different for API traffic than for
conversations. The convenience path exists, but it runs through the same three
steps internally.
## Open
```bash
POST /v1/forms/{formId}/responses
```
```json
{
"hiddenFields": { "utm_source": "docs" },
"expiresIn": 86400
}
```
Returns the response, with `next` pointing at the first question. This counts as
a *start*, exactly as opening a conversation does.
`expiresIn` is how long an unfinished response stays open before it is marked
abandoned. Default is 24 hours; the range is five minutes to thirty days.
## Answer
```bash
POST /v1/responses/{id}/answers
```
One at a time:
```json
{ "ref": "q_email", "value": "maya@northwind.co" }
```
Or several:
```json
{
"answers": [
{ "ref": "q_email", "value": "maya@northwind.co" },
{ "ref": "q_role", "value": "Founder" }
]
}
```
A batch is all-or-nothing. If one answer is rejected, none are written — a
partial write would leave you unable to tell what landed.
The reply carries the updated response including the next question, so a client
never has to ask separately.
### Answers are normalised
What you send is not always what gets stored. `" Maya@Northwind.CO "` becomes
`maya@northwind.co`; `"Founder"` becomes the option's id; `"98123 45678"` on a
phone block with an Indian country hint becomes `+919812345678`. The block
reference documents this per type.
### Order is enforced
By default you can only answer the question the form is waiting on, or re-answer
one already behind it. Answering ahead returns
`block_not_reachable`.
That is deliberate: accepting a jump to question nine would record eight
abandonments at questions nobody was ever asked. If you are importing data that
was collected elsewhere, open the response with `"mode": "free"` and the check is
relaxed — the answers are still validated, and the response records that its flow
was not enforced.
A question hidden by its own visibility rule is refused with
`block_not_visible`, whatever the mode.
## Complete
```bash
POST /v1/responses/{id}/complete
```
Refuses with `incomplete` if a required question that was actually *asked* is
unanswered, and names them:
```json
{
"error": {
"code": "incomplete",
"message": "Required questions are unanswered",
"issues": [{ "ref": "q_role", "code": "required", "message": "\"Role?\" is required" }]
}
}
```
Required questions inside a branch nobody took are not asked for — that is what
makes conditional logic usable.
The reply carries the resolved ending, including its redirect and call to action
if the form has them.
## Abandon
```bash
POST /v1/responses/{id}/abandon
```
There is deliberately no "complete anyway" flag. "I want the data but they did
not finish" already has a name and a status, and letting completion mean two
things would make your completion rate unreadable. Abandoned responses keep every
answer that was given.
## Reading responses back
```bash
GET /v1/forms/{formId}/responses?limit=50&include=answers
```
Cursor-paginated. Filters: `status`, `created_after`, `created_before`,
`updated_since`, `ending_ref`, `source`, `q` (full-text over answers), and
`mode` for test data.
Omitting `status` means completed only. Asking for `in_progress` or `abandoned`
needs a plan that includes partial responses; if yours does not, you get a 402
rather than a quietly narrowed result set.
[Pagination →](/docs/pagination)
For everything at once rather than a page at a time, ask for an
[export](/docs/exports).
## Editing
```bash
DELETE /v1/responses/{id}/answers/{ref}
```
Retracts one answer and moves the flow back to it. Later answers are kept — one
of them may still be valid, and re-asking everything is a worse experience than
re-asking what actually changed.
## Idempotency
Creating and completing accept an `Idempotency-Key` header. A network timeout
tells you nothing about whether the write landed, and this is what makes the
retry safe.
[Idempotency →](/docs/idempotency)
---
# Scopes
Source: https://chatform.in/docs/scopes
---
title: Scopes
description: What a key is allowed to do, and how to give it as little as possible.
---
A key carries scopes as `resource: [actions]`. They are checked on every request,
and a missing one is a 403 naming exactly what was needed:
```json
{
"error": {
"code": "insufficient_scope",
"message": "This API key lacks the session:create scope",
"required": "session:create"
}
}
```
## The vocabulary
| Resource | Actions | What it covers |
| --- | --- | --- |
| `form` | `read`, `write`, `publish` | Reading forms, editing documents, publishing versions |
| `response` | `read`, `read_partial`, `write`, `delete`, `export` | Creating and reading responses |
| `session` | `create`, `write`, `read` | Conversations |
| `webhook` | `read`, `write` | Delivery endpoints |
| `file` | `read`, `write` | Uploads and downloads |
| `analytics` | `read` | Aggregates |
## Defaults
A new secret key gets `form:read`, `response:read` and all of `session` — enough
for most integrations, and nothing destructive. Ask for more explicitly.
Publishable keys have a **ceiling**, not a default: `form:read`, `session:*` and
`file:write`, and no amount of asking will widen it. That is what makes them safe
to ship in a page.
## What no key can do
Some things are unreachable by any key, whatever its scopes say: minting keys,
changing your plan, reading the audit log, managing team members. Those need a
signed-in person with the right role.
This is deny-by-default — a capability is unreachable until it is deliberately
mapped — so a new dashboard feature is never accidentally exposed to every
existing key.
## Pinning a key to specific forms
A key can be restricted to a list of forms. Anything outside that list returns
404, not 403, so a restricted key cannot be used to discover which form ids
exist.
Useful when you are handing a key to a contractor, a client, or a single
deployment that only has business with one form.
## Least privilege in practice
- A script that exports responses nightly: `response:read`, `response:export`.
- A signup page: a publishable key, pinned to the signup form.
- A form-authoring pipeline: `form:read`, `form:write`, `form:publish` — and
nothing that can read anyone's answers.
---
# SDKs
Source: https://chatform.in/docs/sdk
---
title: SDKs
description: A typed client for JavaScript, and React bindings.
---
Two packages. Neither is required — the API is plain HTTP and the
[reference](/docs/api) is complete — but they handle the parts that are easy to
get subtly wrong: retries, idempotency, cursor paging, stream reconnection and
webhook signature verification.
```bash
npm i @chatformhq/js # the client
npm i @chatformhq/react # React bindings
```
Both 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
```ts
import { createClient } from "@chatformhq/js";
const chatform = createClient({ apiKey: process.env.CHATFORM_SECRET_KEY! });
```
`chatform.forms`, `.responses`, `.sessions`, `.webhooks`, `.blocks`, `.exports`,
`.files`, and `chatform.me()`.
```ts
// 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.
### Paging
`iterate()` handles cursors so you do not have to:
```ts
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:
```ts
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:
```ts
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:
```ts
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:
```ts
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.
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
```ts
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
```tsx
import { ChatformEmbed } from "@chatformhq/react";
track("form_completed", { id: e.responseId })}
/>;
```
No key needed — the frame talks to the API itself.
For your own interface:
```tsx
import { useChatformSession } from "@chatformhq/react";
function Interview() {
const { messages, question, send, status } = useChatformSession({
formId: "frm_…",
publishableKey: "pk_live_…",
});
return (
<>
{messages.map((m) =>
{m.content}
)}
{question && }
>
);
}
```
The engine decides what to ask, what is valid and where a branch goes. You own
every pixel and none of the flow logic.
## Or no SDK at all
Everything here is a thin wrapper over documented HTTP. If you would rather not
take a dependency, [the reference](/docs/api) has curl, JavaScript, Python and Go
for every endpoint, and `/openapi.json` will generate a client in most languages.
---
# Google Sheets & Excel
Source: https://chatform.in/docs/spreadsheets
---
title: Google Sheets & Excel
description: A file when you want a snapshot, a URL when you want a sheet that stays right.
---
"Put the responses in a spreadsheet" is two different requests, and answering
only the first is why exported data is always a week old.
## A file
From **Integrations → Google Sheets & Excel**, or directly:
```bash
GET /api/forms/{formId}/submissions/export # CSV
GET /api/forms/{formId}/submissions/export.xlsx # Excel workbook
```
Add `?includePartials=true` to include unfinished responses. Both go through the
browser session, so the download link works from the dashboard with no key.
The workbook is a real `.xlsx`, not a renamed CSV: typed cells, a frozen bold
header, filters, and columns sized to their contents. It also keeps the leading
zeros on things that look like numbers and are not — `007`, `0044 7700 900123` —
which is the single most common way an exported phone column is destroyed.
## A sheet that stays right
Create a feed URL in **Integrations → Google Sheets & Excel → Create feed URL**,
then in Google Sheets put this in cell A1:
```
=IMPORTDATA("https://api.chatform.in/p/feed/cff_….csv")
```
Sheets re-reads it about hourly. New responses appear on their own; nobody has
to remember to export anything.
In Excel: **Data → From Web**, paste the same URL, and set the refresh interval
in the query properties.
### What the feed is
A stable CSV of this form's responses, with the same columns as the export —
one per question, titled with the question rather than its `ref`.
Cells that a spreadsheet would execute rather than display (`=`, `+`, `@`, and a
leading `-` that is not a number) are prefixed with an apostrophe. Respondents
type these rows, and a spreadsheet treats a leading `=` as a program.
### The URL is the credential
A spreadsheet cannot send a cookie or an `Authorization` header — it fetches a
URL on a schedule and nothing else. So the token in the path is the whole
credential, the same trade the [signed download links](/docs/exports) make:
- 192 bits of CSPRNG output, looked up by hash, never guessable.
- Anyone holding the link can read the responses. Treat it like a password.
- **Rotate** issues a new URL and kills the old one; **Revoke** kills it with no
replacement. Both are immediate, and both leave any sheet using the old URL
showing an error in the cell rather than stale data.
The feed serves the 5,000 most recent responses. Past that, use the
[exports API](/docs/exports), which is built for the whole history.
Unfinished responses in the feed need the same plan as unfinished responses in
the CSV. What you finished collecting is yours on every plan.
## Why there is no "Connect Google account"
Appending rows through the Sheets API needs an OAuth consent screen, refresh
tokens held on your behalf, a sync worker and a reconciliation story for every
row someone edits by hand — and it would still only serve people who use Google
Sheets.
`=IMPORTDATA(url)` is one cell, works the same in Excel, keeps no credential of
yours on our side, and you can revoke it without going near an account settings
page. If you need rows pushed into something the moment they arrive, that is
what [webhooks](/docs/webhooks) are for.
---
# Versioning
Source: https://chatform.in/docs/versioning
---
title: Versioning
description: What can change under you, and what cannot.
---
The API is versioned in the path. Everything is under `/v1`, and `/v1` will keep
meaning what it means today.
## What we may change without warning
- **New endpoints.**
- **New fields in a response.** Ignore what you do not recognise; do not assert
on an exact object shape.
- **New optional request fields.**
- **New enum values** — new block types, new webhook events, new error codes.
Handle the unknown case rather than exhaustively matching.
- **Error messages.** The `code` is the contract; the `message` is for people.
## What we will not change
- Removing or renaming a field.
- Changing what a field means.
- Making an optional request field required.
- Changing a status code for the same condition.
- Removing an endpoint.
Any of those would be `/v2`, announced ahead of time, with the old version
supported for a stated window.
## Old names keep working
Where something has been renamed, both names work. `/v1/forms/{id}/chat/sessions`
still opens a session; webhooks subscribed to `submission.completed` still fire
on `response.completed`. Integrations written against the first shape of this API
do not need rewriting for a tidier URL.
## Deprecations
If we ever deprecate something, the response says so:
```
Deprecation: Wed, 01 Apr 2026 00:00:00 GMT
Sunset: Wed, 01 Oct 2026 00:00:00 GMT
Link: ; rel="deprecation"
```
## Advice
- Treat the response as data, not as a schema. Read the fields you need.
- Branch on `error.code`, never on the message.
- Store `request_id` in your logs. It is what makes a support conversation
short.
---
# Webhooks
Source: https://chatform.in/docs/webhooks
---
title: Webhooks
description: Get told when something happens, instead of asking.
---
Point us at a URL and we will POST to it. Configure endpoints per form or across
your whole organization.
## Events
| Event | When |
| --- | --- |
| `response.completed` | Someone finished |
| `response.abandoned` | A response was given up on or timed out |
| `response.partial` | A response has stalled part-way, with answers in it |
| `session.started` | A conversation opened |
| `form.published` | A new version went live |
`response.partial` is the one people miss. Until it existed, a half-finished lead
was invisible until it timed out — which is exactly as long as it was worth
following up on. It fires once a response has stopped changing for a minute, and
again only if more answers arrive.
Subscriptions written against the old `submission.completed` and
`submission.abandoned` names keep working. Both names match the same event.
## The payload
```json
{
"event": "response.completed",
"formId": "frm_…",
"timestamp": 1788505749473,
"submission": { "id": "sbm_…", "status": "completed", "duration_ms": 48210 },
"answers": [
{ "ref": "q_email", "type": "email", "value": "maya@northwind.co" }
]
}
```
## Verifying a delivery
Every delivery is signed. Verify it — an unverified webhook endpoint is a public
API that writes to your database.
Headers follow the [Standard Webhooks](https://www.standardwebhooks.com) spec:
```
webhook-id: whd_…
webhook-timestamp: 1788505749
webhook-signature: v1,
```
The signed content is `{id}.{timestamp}.{body}`, so a replayed body with a fresh
id fails.
```js
import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"];
// Reject anything older than five minutes, before doing real work.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return signature
.split(" ")
.some((part) =>
crypto.timingSafeEqual(Buffer.from(part.replace("v1,", "")), Buffer.from(expected)),
);
}
```
```python
import hmac, hashlib, base64, time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
wid, ts, sig = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
return False
expected = base64.b64encode(
hmac.new(secret.encode(), f"{wid}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
).decode()
return any(hmac.compare_digest(p.removeprefix("v1,"), expected) for p in sig.split(" "))
```
```ts
async function verify(rawBody: string, headers: Headers, secret: string) {
const id = headers.get("webhook-id")!;
const ts = headers.get("webhook-timestamp")!;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${ts}.${rawBody}`));
const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));
return (headers.get("webhook-signature") ?? "").split(" ").includes(`v1,${expected}`);
}
```
Verify against the **raw** body. Parsing and re-serialising changes the bytes,
and the signature is over the bytes.
## Retries
A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes and 2
hours, then marked dead. Any 2xx counts as success.
Endpoints that fail twenty times in a row are switched off. That is a courtesy —
an endpoint gone that long is not coming back on its own, and retrying every
event forever turns a dead integration into an incident.
## Handling them well
- **Be idempotent.** Deliveries are at-least-once. `webhook-id` is unique per
delivery; recording the ones you have processed is the simplest way.
- **Answer quickly.** We wait ten seconds. Acknowledge, then do the work.
- **Do not infer order.** A retried event can arrive after a newer one. Use
`timestamp`, or re-read the response.
---
# Address
Source: https://chatform.in/docs/blocks/address
---
title: Address
description: "A postal address."
generated: true
family: contact
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
A postal address.
**How it gets answered** — extracted from free text by the agent, then re-validated.
## Configuration
Fields specific to `address`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="address configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "address"
},
"fields": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"street",
"city",
"state",
"postal",
"country"
]
}
},
"countryWhitelist": {
"type": "array",
"items": {
"type": "string",
"minLength": 2,
"maxLength": 2
}
}
},
"required": [
"id",
"ref",
"title",
"type",
"fields"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `fields=`.
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_addr0001",
"ref": "q_address",
"type": "address",
"title": "Where do we ship?",
"required": true,
"imageKey": null,
"media": null,
"fields": [
"city",
"country"
]
}
```
## What you send
A map of the block's address fields to their values.
```ts
type Answer = Record<'street' | 'city' | 'state' | 'postal' | 'country', string>;
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_address", "value": {"city":"Bengaluru","country":"IN"}}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_address",
value: {"city":"Bengaluru","country":"IN"},
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_address", "value": {"city":"Bengaluru","country":"IN"}},
)
```
### What gets stored
The value is normalised before it is saved, so what you read back is not always what you sent.
| You send | Stored | |
| --- | --- | --- |
| `{"city":"Bengaluru","country":"IN"}` | `{"city":"Bengaluru","country":"IN"}` | |
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | an empty answer on a required block | This question needs an answer. |
| `type` | `"Bengaluru"` | Please fill in the details. |
| `incomplete` | `{"city":"Bengaluru"}` | Please provide country. |
---
# Common fields
Source: https://chatform.in/docs/blocks/common-fields
---
title: Common fields
description: The fields every block has, whatever it collects.
generated: true
---
{/* GENERATED by tooling/gen-block-docs.ts. Do not edit. */}
Every block carries these, so each type's own page documents only what is specific to it.
```json title="Shared block fields"
{
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 6,
"maxLength": 32
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
},
"title": {
"type": "string",
"maxLength": 2000
},
"description": {
"type": "string",
"maxLength": 5000
},
"required": {
"default": false,
"type": "boolean"
},
"visibility": {
"default": null,
"anyOf": [
{
"$ref": "#/$defs/__schema0"
},
{
"type": "null"
}
]
},
"image_key": {
"default": null,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"agentHints": {
"default": null,
"anyOf": [
{
"type": "object",
"properties": {
"askStyle": {
"type": "string",
"maxLength": 500
},
"retryHint": {
"type": "string",
"maxLength": 500
},
"whyWeAsk": {
"type": "string",
"maxLength": 500
},
"examples": {
"default": [],
"maxItems": 5,
"type": "array",
"items": {
"type": "string",
"maxLength": 200
}
}
}
},
{
"type": "null"
}
]
},
"media": {
"default": null,
"anyOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"image",
"video",
"file"
]
},
"key": {
"default": null,
"anyOf": [
{
"type": "string",
"maxLength": 500
},
{
"type": "null"
}
]
},
"url": {
"default": null,
"anyOf": [
{
"type": "string",
"maxLength": 1000
},
{
"type": "null"
}
]
},
"filename": {
"type": "string",
"maxLength": 300
},
"mime": {
"type": "string",
"maxLength": 120
},
"sizeBytes": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"alt": {
"type": "string",
"maxLength": 300
},
"caption": {
"type": "string",
"maxLength": 300
}
},
"required": [
"kind"
]
},
{
"type": "null"
}
]
},
"prefillParam": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
}
}
```
`ref` is the one to pay attention to: it is how you address a block when
answering, and it is stable across edits in a way `id` is not meant to be.
---
# Contact info
Source: https://chatform.in/docs/blocks/contact_info
---
title: Contact info
description: "Name, email and phone collected together in one step."
generated: true
family: contact
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
Name, email and phone collected together in one step.
**How it gets answered** — extracted from free text by the agent, then re-validated.
## Configuration
Fields specific to `contact_info`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="contact_info configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "contact_info"
},
"fields": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"first_name",
"last_name",
"email",
"phone"
]
}
}
},
"required": [
"id",
"ref",
"title",
"type",
"fields"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `fields=`.
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_contact1",
"ref": "q_contact",
"type": "contact_info",
"title": "Your details",
"required": true,
"imageKey": null,
"media": null,
"fields": [
"first_name",
"email"
]
}
```
## What you send
A map of the block's fields to their values.
```ts
type Answer = Record<'first_name' | 'last_name' | 'email' | 'phone', string>;
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_contact", "value": {"first_name":" Maya ","email":"maya@northwind.co"}}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_contact",
value: {"first_name":" Maya ","email":"maya@northwind.co"},
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_contact", "value": {"first_name":" Maya ","email":"maya@northwind.co"}},
)
```
### What gets stored
The value is normalised before it is saved, so what you read back is not always what you sent.
| You send | Stored | |
| --- | --- | --- |
| `{"first_name":" Maya ","email":"maya@northwind.co"}` | `{"first_name":"Maya","email":"maya@northwind.co"}` | |
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | an empty answer on a required block | This question needs an answer. |
| `type` | `"Maya"` | Please fill in the details. |
| `incomplete` | `{"first_name":"Maya"}` | Please provide email. |
| `invalid_email` | `{"first_name":"Maya","email":"nope"}` | That doesn't look like a valid email address. |
---
# Date
Source: https://chatform.in/docs/blocks/date
---
title: Date
description: "A date, or a date and time they choose — an arrival time, a preferred day. This is the right type when YOU are asking them when; `scheduling` is for booking against a calendar you own."
generated: true
family: number
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
A date, or a date and time they choose — an arrival time, a preferred day. This is the right type when YOU are asking them when; `scheduling` is for booking against a calendar you own.
**How it gets answered** — extracted from free text by the agent, then re-validated.
## Configuration
Fields specific to `date`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="date configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "date"
},
"min": {
"type": "string"
},
"max": {
"type": "string"
},
"disablePast": {
"default": false,
"type": "boolean"
},
"dateFormat": {
"default": "YYYY-MM-DD",
"type": "string",
"enum": [
"YYYY-MM-DD",
"DD/MM/YYYY",
"MM/DD/YYYY"
]
},
"includeTime": {
"default": false,
"type": "boolean"
},
"timeStepMinutes": {
"default": 30,
"type": "integer",
"minimum": 5,
"maximum": 120
},
"timeMin": {
"default": "09:00",
"type": "string",
"pattern": "^\\d{2}:\\d{2}$"
},
"timeMax": {
"default": "18:00",
"type": "string",
"pattern": "^\\d{2}:\\d{2}$"
}
},
"required": [
"id",
"ref",
"title",
"type"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `disablePast=true to refuse dates already gone`.
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_date0001",
"ref": "q_start",
"type": "date",
"title": "Start date?",
"required": true,
"imageKey": null,
"media": null,
"minDate": "2026-01-01",
"maxDate": "2026-12-31",
"disablePast": false,
"dateFormat": "YYYY-MM-DD",
"includeTime": false,
"timeStepMinutes": 30,
"timeMin": "09:00",
"timeMax": "18:00"
}
```
## What you send
A date as `YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` when the block includes a time.
```ts
type Answer = string;
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_start", "value": "2026-06-01"}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_start",
value: "2026-06-01",
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_start", "value": "2026-06-01"},
)
```
### What gets stored
The value is normalised before it is saved, so what you read back is not always what you sent.
| You send | Stored | |
| --- | --- | --- |
| `"2026-06-01"` | `"2026-06-01"` | |
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | an empty answer on a required block | This question needs an answer. |
| `type` | `20260601` | Please provide a date. |
| `invalid_date` | `"01/06/2026"` — always ISO on the wire, whatever the display format | Please provide a date in YYYY-MM-DD format. |
| `invalid_time` | — | |
| `time_out_of_range` | — | |
| `past_date` | — | |
| `too_early` | `"2025-12-31"` | Date must be on or after 2026-01-01. |
| `too_late` | `"2027-01-01"` | Date must be on or before 2026-12-31. |
---
# Dropdown
Source: https://chatform.in/docs/blocks/dropdown
---
title: Dropdown
description: "Pick one from a long list — a country, a plan."
generated: true
family: choice
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
Pick one from a long list — a country, a plan.
Requires `options`.
**How it gets answered** — matched exactly — never sent to a model.
## Configuration
Fields specific to `dropdown`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="dropdown configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "dropdown"
},
"options": {
"minItems": 1,
"maxItems": 500,
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 6,
"maxLength": 32
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"description": {
"type": "string",
"maxLength": 1000
},
"image_key": {
"default": null,
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"score": {
"type": "number"
}
},
"required": [
"id",
"label"
]
}
}
},
"required": [
"id",
"ref",
"title",
"type",
"options"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_drop0001",
"ref": "q_country",
"type": "dropdown",
"title": "Country?",
"required": true,
"imageKey": null,
"media": null,
"options": [
{
"id": "opt_in000001",
"label": "India",
"imageKey": null
},
{
"id": "opt_us000001",
"label": "United States",
"imageKey": null
}
]
}
```
## What you send
The chosen option's id.
```ts
type Answer = string;
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_country", "value": "India"}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_country",
value: "India",
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_country", "value": "India"},
)
```
### What gets stored
The value is normalised before it is saved, so what you read back is not always what you sent.
| You send | Stored | |
| --- | --- | --- |
| `"India"` | `"opt_in000001"` | |
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | an empty answer on a required block | This question needs an answer. |
| `invalid_option` | `"Atlantis"` | Please pick one of the available options. |
---
# Email
Source: https://chatform.in/docs/blocks/email
---
title: Email
description: "An email address, validated as one."
generated: true
family: contact
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
An email address, validated as one.
**How it gets answered** — extracted from free text by the agent, then re-validated.
## Configuration
Fields specific to `email`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="email configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "email"
},
"unique": {
"default": false,
"type": "boolean"
},
"businessOnly": {
"default": false,
"type": "boolean"
}
},
"required": [
"id",
"ref",
"title",
"type"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `businessOnly=true to refuse gmail and the other free providers; unique=true to refuse a value another respondent already gave — a team name, a username, a seat number`.
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_email001",
"ref": "q_email",
"type": "email",
"title": "Work email?",
"required": true,
"imageKey": null,
"media": null
}
```
## What you send
An email address, lowercased and trimmed.
```ts
type Answer = string;
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_email", "value": " Maya@Northwind.CO "}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_email",
value: " Maya@Northwind.CO ",
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_email", "value": " Maya@Northwind.CO "},
)
```
### What gets stored
The value is normalised before it is saved, so what you read back is not always what you sent.
| You send | Stored | |
| --- | --- | --- |
| `" Maya@Northwind.CO "` | `"maya@northwind.co"` | |
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | `""` | This question needs an answer. |
| `type` | `1` | Please enter an email address. |
| `invalid_email` | `"maya"` | That doesn't look like a valid email address. |
| `freemail` | `"maya@gmail.com"` — businessOnly rejects free providers | Please use your work email address. |
| `duplicate` | `unique` is on and another response already gave this answer | Someone has already used that — please try a different one. |
---
# File upload
Source: https://chatform.in/docs/blocks/file_upload
---
title: File upload
description: "A file or image — a CV, a screenshot, a receipt."
generated: true
family: advanced
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
A file or image — a CV, a screenshot, a receipt.
**How it gets answered** — arrives out of band (an upload, a payment, or a booking).
## Configuration
Fields specific to `file_upload`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="file_upload configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "file_upload"
},
"accept": {
"minItems": 1,
"maxItems": 20,
"type": "array",
"items": {
"type": "string"
}
},
"maxFiles": {
"default": 1,
"type": "integer",
"minimum": 1,
"maximum": 10
},
"maxSizeMB": {
"default": 10,
"type": "number",
"minimum": 0.1,
"maximum": 100
}
},
"required": [
"id",
"ref",
"title",
"type",
"accept"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `accept=, maxFiles=<1-10>, maxSizeMB=<0.1-100>`.
## What you receive
`GET /v1/forms/{id}` and every "next question" projects blocks through `toPublicBlock`. For the example above:
```json title="PublicBlock"
{
"id": "blk_file0001",
"ref": "q_resume",
"type": "file_upload",
"title": "Upload your CV",
"required": true,
"imageKey": null,
"media": null,
"accept": [
"application/pdf"
],
"maxFiles": 1,
"maxSizeMB": 1
}
```
## What you send
File descriptors, as returned by the upload confirm step.
```ts
type Answer = { fileId: string; filename: string; mime: string; size: number; r2Key: string }[];
```
```bash
curl -X POST https://api.chatform.in/v1/responses/{RESPONSE_ID}/answers \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"ref": "q_resume", "value": [{"fileId":"fil_1","filename":"cv.pdf","mime":"application/pdf","size":1000,"r2Key":"uploads/cv.pdf"}]}'
```
```ts
await chatform.responses.answer(responseId, {
ref: "q_resume",
value: [{"fileId":"fil_1","filename":"cv.pdf","mime":"application/pdf","size":1000,"r2Key":"uploads/cv.pdf"}],
});
```
```python
requests.post(
f"https://api.chatform.in/v1/responses/{response_id}/answers",
headers={"x-api-key": os.environ["CHATFORM_SECRET_KEY"]},
json={"ref": "q_resume", "value": [{"fileId":"fil_1","filename":"cv.pdf","mime":"application/pdf","size":1000,"r2Key":"uploads/cv.pdf"}]},
)
```
## Errors
| Code | When | Message |
| --- | --- | --- |
| `required` | an empty answer on a required block | This question needs an answer. |
| `type` | `"cv.pdf"` | Please upload a file. |
| `too_many_files` | — | |
| `file_too_large` | `[{"fileId":"fil_1","filename":"big.pdf","mime":"application/pdf","size":5000000,"r2Key":"uploads/big.pdf"}]` | "big.pdf" exceeds the 1MB limit. |
---
# Blocks
Source: https://chatform.in/docs/blocks
---
title: Blocks
description: Every question type, what it accepts, and what it returns.
generated: true
---
{/* GENERATED by tooling/gen-block-docs.ts. Do not edit. */}
A form is a list of blocks. Each one has a `type` that decides what it collects,
how it is validated, and what shape the answer takes on the wire.
Everything on these pages is generated from the same schemas the API validates
against, so if a page says a value is valid, it is.
| Type | Family | How it is answered | Answer |
| --- | --- | --- | --- |
| [Welcome](/docs/blocks/welcome) `welcome` | Content | matched exactly — never sent to a model | `never` |
| [Message](/docs/blocks/statement) `statement` | Content | matched exactly — never sent to a model | `never` |
| [Short text](/docs/blocks/short_text) `short_text` | Text | extracted from free text by the agent, then re-validated | `string` |
| [Long text](/docs/blocks/long_text) `long_text` | Text | extracted from free text by the agent, then re-validated | `string` |
| [Email](/docs/blocks/email) `email` | Contact | extracted from free text by the agent, then re-validated | `string` |
| [Phone](/docs/blocks/phone) `phone` | Contact | extracted from free text by the agent, then re-validated | `string` |
| [Website](/docs/blocks/url) `url` | Contact | extracted from free text by the agent, then re-validated | `string` |
| [Number](/docs/blocks/number) `number` | Numbers & dates | extracted from free text by the agent, then re-validated | `number` |
| [Date](/docs/blocks/date) `date` | Numbers & dates | extracted from free text by the agent, then re-validated | `string` |
| [Yes / No](/docs/blocks/yes_no) `yes_no` | Choice | matched exactly — never sent to a model | `boolean` |
| [Single select](/docs/blocks/single_select) `single_select` | Choice | matched exactly — never sent to a model | `string` |
| [Multi select](/docs/blocks/multi_select) `multi_select` | Choice | matched exactly — never sent to a model | `string[]` |
| [Dropdown](/docs/blocks/dropdown) `dropdown` | Choice | matched exactly — never sent to a model | `string` |
| [Picture choice](/docs/blocks/picture_choice) `picture_choice` | Choice | matched exactly — never sent to a model | `string[]` |
| [Rating](/docs/blocks/rating) `rating` | Scale | matched exactly — never sent to a model | `number` |
| [NPS](/docs/blocks/nps) `nps` | Scale | matched exactly — never sent to a model | `number` |
| [Opinion scale](/docs/blocks/opinion_scale) `opinion_scale` | Scale | matched exactly — never sent to a model | `number` |
| [Ranking](/docs/blocks/ranking) `ranking` | Choice | extracted from free text by the agent, then re-validated | `string[]` |
| [Matrix](/docs/blocks/matrix) `matrix` | Scale | extracted from free text by the agent, then re-validated | `Record` |
| [File upload](/docs/blocks/file_upload) `file_upload` | Advanced | arrives out of band (an upload, a payment, or a booking) | `{ fileId: string; filename: string; mime: string; size: number; r2Key: string }[]` |
| [Signature](/docs/blocks/signature) `signature` | Advanced | arrives out of band (an upload, a payment, or a booking) | `{ fileId: string; r2Key: string; signedName?: string }` |
| [Payment](/docs/blocks/payment) `payment` | Advanced | arrives out of band (an upload, a payment, or a booking) | `{ status: "pending" \| "paid"; method?: "link" \| "upi"; reference?: string; amount?: number }` |
| [Scheduling](/docs/blocks/scheduling) `scheduling` | Advanced | arrives out of band (an upload, a payment, or a booking) | `{ provider: string; url: string; slotIso?: string; confirmedAt?: number }` |
| [Contact info](/docs/blocks/contact_info) `contact_info` | Contact | extracted from free text by the agent, then re-validated | `Record<'first_name' \| 'last_name' \| 'email' \| 'phone', string>` |
| [Address](/docs/blocks/address) `address` | Contact | extracted from free text by the agent, then re-validated | `Record<'street' \| 'city' \| 'state' \| 'postal' \| 'country', string>` |
| [Consent](/docs/blocks/legal_consent) `legal_consent` | Advanced | matched exactly — never sent to a model | `boolean` |
The same information is available as JSON at `GET /v1/blocks`, which is the right
source if you are building a UI that renders every type.
---
# Consent
Source: https://chatform.in/docs/blocks/legal_consent
---
title: Consent
description: "Agreeing to terms, a waiver, a code of conduct. Put the wording in `description`. By default the only answer is yes; add decline=true when a refusal has to be a real answer you can route on — an eligibility gate, a policy someone may decline."
generated: true
family: advanced
---
{/* GENERATED by tooling/gen-block-docs.ts from @repo/form-schema. Do not edit.
Regenerate with `pnpm gen:blocks`; `pnpm blocks:verify` fails when stale. */}
Agreeing to terms, a waiver, a code of conduct. Put the wording in `description`. By default the only answer is yes; add decline=true when a refusal has to be a real answer you can route on — an eligibility gate, a policy someone may decline.
**How it gets answered** — matched exactly — never sent to a model.
## Configuration
Fields specific to `legal_consent`. The [fields every block has](/docs/blocks/common-fields) — `id`, `ref`, `title`, `required`, `visibility`, `media`, `agentHints`, `prefillParam` — are documented once.
```json title="legal_consent configuration"
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"buttonLabel": {
"type": "string",
"maxLength": 60
},
"type": {
"type": "string",
"const": "legal_consent"
},
"consentText": {
"type": "string",
"minLength": 1,
"maxLength": 10000
},
"allowDecline": {
"default": false,
"type": "boolean"
},
"agreeLabel": {
"default": "I agree",
"type": "string",
"maxLength": 60
},
"declineLabel": {
"default": "I do not agree",
"type": "string",
"maxLength": 60
}
},
"required": [
"id",
"ref",
"title",
"type",
"consentText"
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"op": {
"type": "string",
"enum": [
"and",
"or"
]
},
"conditions": {
"default": [],
"type": "array",
"items": {
"type": "object",
"properties": {
"left": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "ref"
},
"ref": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,40}$"
}
},
"required": [
"kind",
"ref"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "variable"
},
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{0,40}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "hidden"
},
"name": {
"type": "string",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,60}$"
}
},
"required": [
"kind",
"name"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "literal"
}
},
"required": [
"kind"
]
}
]
},
"op": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"contains",
"not_contains",
"starts_with",
"ends_with",
"matches_regex",
"is_empty",
"is_not_empty",
"is_checked",
"is_not_checked",
"includes",
"not_includes",
"ranked_above",
"ranked_below"
]
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"left",
"op"
]
}
},
"groups": {
"default": [],
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": [
"op"
]
}
}
}
```
In a generated draft these arrive as `config` pairs: `decline=true to offer an explicit refusal; agree=