# 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";