Business systems
Forms
Put a Kanorio form on your own front end, with bot protection, file uploads, consent and contact capture handled for you.
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. 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.
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.
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 inaltcha. The SDK solves it for you (about half a second on a mid-range phone) and sends the result as_tk.turnstile,recaptchaorhcaptcha: the owner's own keys. Render that provider's widget withsiteKeyandaction(for Turnstile alsocData), and pass its token ascaptchaToken. 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.
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):
{ "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.