# Forms

> Put a Kanorio form on your own front end, with bot protection, file uploads, consent and contact capture handled for you.

> Markdown version of https://kanorio.com/en/developers/guides/forms. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Build the form in the dashboard under **Marketing › Online forms**, then show it on your front end. Kanorio checks for bots and spam, stores the submission, creates or updates the contact, sends confirmation and team emails, and fires the `customers.form.submitted` webhook.

Before you start, copy the form id from the dashboard. If the browser calls the API from your own domain, add that domain to **Allowed origins** under [Site settings › Developer](https://app.kanorio.com/settings/developer). `localhost` is always allowed.

## Option 1: the form element

`<KanorioForm>` renders every question type, file uploads and the consent checkbox, and runs bot protection for you. It is the fastest way and gets new protection rules without an upgrade.

```tsx
import { KanorioProvider, KanorioForm } from "@kanorio/elements";
import "@kanorio/elements/elements.css";

export function ContactSection() {
  return (
    <KanorioProvider siteUrl="https://s-xxxxxxxxxxxx.kanorio.com">
      <KanorioForm formId="cform12345678901234567890" siteName="Sourdough Studio" />
    </KanorioProvider>
  );
}
```

Without React, use `<kanorio-form site-url="…" form-id="…">`. See [Elements](https://kanorio.com/en/developers/elements).

## Option 2: your own markup

Use the Content Delivery API directly when you want full control of the HTML.

### 1. Load the form

`GET /api/v1/forms/{id}` returns the questions, the consent settings and whether the form is accepting answers. If `availability.accepting` is `false`, show a closed message instead of the inputs.

### 2. Get the bot check when the form is shown

`GET /api/v1/forms/{id}/challenge` returns the bot check for this submission. Its `provider` is set by the site owner under **Site settings › Bot protection**:

- `kanorio` (the default): a built-in proof-of-work challenge in `altcha`. The SDK solves it for you (about half a second on a mid-range phone) and sends the result as `_tk`.
- `turnstile`, `recaptcha` or `hcaptcha`: the owner's own keys. Render that provider's widget with `siteKey` and `action` (for Turnstile also `cData`), and pass its token as `captchaToken`. The server decides which provider to verify from the site settings, so a token from any other provider is rejected.

Fetch a new challenge for every submission: challenges and widget tokens are single-use.

### 3. Submit

Send the answers, keyed by question id, with the protection fields. The honeypot fields must be empty.

```ts
import { forms } from "@kanorio/sdk/forms";

const SITE_URL = "https://s-xxxxxxxxxxxx.kanorio.com";
const FORM_ID = "cform12345678901234567890";
const client = forms.client({ siteUrl: SITE_URL });

// when the form is shown
const renderedAt = Date.now();
const challenge = await client.challenge(FORM_ID);

// when the visitor submits
// built-in: the SDK solves the challenge; third-party: pass the widget token
const protection = await forms.protectionFromChallenge(FORM_ID, challenge, renderedAt, {
  captchaToken: challenge.provider === "kanorio" ? undefined : widgetToken,
});

const result = await client.submit(FORM_ID, {
  answers: { name: "Ada", email: "ada@example.com", message: "Do you ship?" },
  consent: "checked",
  pagePath: window.location.pathname,
  protection,
});

if (!result.ok) {
  // result.error.code, and result.error.issues for INVALID_ANSWERS
}
```

Without the SDK, `POST /api/v1/forms/{id}/submit` with this body (built-in check shown; with a third-party provider, send `captchaToken` instead of `_tk`):

```json
{
  "answers": { "name": "Ada", "email": "ada@example.com" },
  "consent": "checked",
  "pagePath": "/contact",
  "_hp": "",
  "_ts": 1793520000000,
  "_tk": "<solved ALTCHA payload>",
  "_ctx": "cform12345678901234567890"
}
```

A successful submission returns `200` with `{}`. If the form closed in the meantime, the body is `{ "state": "closed" }`, `"not_open"` or `"full"`.

| Error code | What to do |
|---|---|
| `INVALID_ANSWERS` | Show the messages in `details.issues`, keyed by question id |
| `VERIFICATION_EXPIRED` | Fetch a new challenge (and reset the widget) and submit again |
| `DUPLICATE_SUBMISSION` | Tell the visitor the form was already sent |
| `RATE_LIMITED` | Wait for `Retry-After`, then retry |

### File uploads

For a file question, ask for an upload ticket first with `POST /api/v1/forms/{id}/upload-request`, sending the question id, file name, type and size with protection fields from `forms.uploadProtectionFromChallenge(FORM_ID, challenge, renderedAt, solved)`: with the built-in check this is the same solved challenge you submit with; with a third-party provider it uses the challenge's `uploadToken`, because widget tokens are single-use and are checked on submit. Upload the file with `PUT` to `uploadUrl` using the returned `headers`, then send the `uploadId` as that question's answer. Files over the question's limit fail with `413 FILE_TOO_LARGE`.

## After a submission

- The submission appears in the dashboard and in the Management API under `submissions`.
- The contact is created or updated from the email or phone answer.
- Confirmation and team emails go out as configured on the form.
- Webhook subscribers receive `customers.form.submitted`.

## Forwarding from your server

You can submit from your own server, for example a Next.js server action. Rate limits apply per visitor, so submitting directly from the browser protects your quota best. If you forward, keep the browser's protection fields unchanged.
