Your site renders its own forms; the platform stores what visitors send, filters spam, notifies the business, can mail the visitor a confirmation and can make sure a booking moment is taken only once. Submissions land in the client's form inbox, Website → Formulieren (forms), where they are read, answered and followed up.
The flow:
- The visitor submits a form on your site, to your own server route.
- Your route validates what it needs and calls
client.submitForm(). The token stays on your server. - The platform checks the submission against the form's declaration, scores it for spam, stores it and queues the notification (and the confirmation, if asked).
Declaring a form: defineForm
Declaring is optional: undeclared forms keep working untyped. Declare a form and you get a typed
submitForm, validation on the platform, and the form's name and field labels in the inbox and
in the notification mails.
// cms.config.ts
import { defineForm, defineSchema } from "@kwisple/sdk";
const contact = defineForm({
handle: "contact",
name: "Contactformulier",
fields: {
name: { kind: "text", label: "Naam", required: true },
email: { kind: "email", label: "E-mail", required: true },
phone: { kind: "phone", label: "Telefoon" },
topic: { kind: "select", label: "Onderwerp", options: ["offerte", "vraag"] },
message: { kind: "textarea", label: "Bericht", required: true, maxLength: 2000 },
newsletter: { kind: "checkbox", label: "Nieuwsbrief" },
},
});
export default defineSchema({ sections: [/* … */], forms: [contact] });Push the model (kwisple push) for the platform to know the form.
Form options
| Key | Type | Meaning |
|---|---|---|
handle | string | What the site sends as form. Letters, digits, - and _, starting with a letter. Unique per site. |
name | string | The form's name in the inbox and the notification mail. |
fields | Record<string, FormFieldDef> | The fields by handle (letters, digits and _). At least one. |
Field options
| Key | Type | Meaning |
|---|---|---|
kind | "text" | "textarea" | "email" | "phone" | "number" | "date" | "select" | "checkbox" | What the value must look like. |
label | string | The label in the dashboard, also used in validation messages. |
required | boolean | Must be filled (a checkbox: must be true). |
options | string[] | select only, required there: the allowed values. |
maxLength | number | Text kinds: at most this many characters. Default 5000. |
Validation on the platform
| Kind | Accepted value | Message when wrong |
|---|---|---|
any, required | non-empty after trimming | <Label> is verplicht. |
text, textarea, phone, … | a string, at most maxLength | <Label> moet tekst zijn. / <Label> is te lang. |
email | an address | <Label> is geen geldig e-mailadres. |
phone | digits, spaces, + ( ) . / -, 6 to 30 characters | <Label> is geen geldig telefoonnummer. |
number | a number, comma or dot as decimal mark | <Label> moet een getal zijn. |
date | YYYY-MM-DD, optionally with a time | <Label> moet een datum zijn. |
select | one of options | <Label>: "<value>" is geen van de opties. |
checkbox | true or false | <Label> moet aan of uit zijn. |
Values are trimmed. Fields the form does not declare are dropped. When a check fails the
platform answers 400 with all sentences in message (and as a problems array in the body);
the SDK throws KwispleError(400, "bad_request", "<sentences>").
The model validation adds its own checks on push: a valid handle, no duplicate forms, at least
one field, known kinds, options for every select.
Submitting from your site
Typed submitForm
submitForm<H extends FormHandle<S>>(input: {
form: H;
values: FormValues<S, H & string>;
entryId?: string;
website?: string;
startedAt?: number;
slot?: string;
confirm?: { to: string; subject: string; text: string };
clientIp?: string;
}): Promise<{ ok: true; id?: string }>For a declared form, values is typed from its fields: required fields required, checkboxes as
boolean, every other kind as string. For an undeclared handle it is
Record<string, string | boolean>.
| Input | Meaning |
|---|---|
form | The form handle. For undeclared forms any handle matching the same pattern (max 60 characters). |
values | Field values, at most 50 fields of at most 5000 characters. |
entryId | Optional: the entry the form is on. Must belong to the site, else 404 Entry niet gevonden. |
website | The honeypot: the value of a hidden field humans leave empty. |
startedAt | When the visitor opened the form, in milliseconds since the epoch. |
slot | A moment only one submission may claim (see Bookings with slots). |
confirm | A confirmation mail to the visitor (see Confirmation mails). |
clientIp | The visitor's IP. Sent as the x-kwisple-client-ip header, so rate limits apply per visitor. |
A route handler (Next.js)
// src/app/api/contact/route.ts
import { KwispleError } from "@kwisple/sdk";
import { client } from "@/lib/cms";
export async function POST(req: Request) {
const data = await req.formData();
const str = (k: string) => String(data.get(k) ?? "").trim();
try {
await client.submitForm({
form: "contact",
values: {
name: str("name"),
email: str("email"),
message: str("message"),
newsletter: data.get("newsletter") === "on",
},
website: str("website"),
startedAt: Number(str("startedAt")) || undefined,
clientIp: req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
});
return Response.json({ ok: true });
} catch (e) {
if (e instanceof KwispleError && e.status === 400)
return Response.json({ ok: false, error: e.message }, { status: 400 });
console.error("[contact]", e instanceof KwispleError ? `${e.status} ${e.code}` : e);
return Response.json({ ok: false, error: "Er liep iets mis. Probeer opnieuw." }, { status: 500 });
}
}In the form, render the honeypot as a hidden text input named website and put the moment the
page rendered the form in a hidden startedAt input. Read the visitor's IP from the header your
host or proxy sets; RANSON.N's visitorIp(headers) takes the first address of X-Forwarded-For
behind its proxy.
Never call submitForm from the browser: the token would be exposed.
Answers and errors
| Platform answer | SDK | What to do |
|---|---|---|
201 { ok: true, id } | resolves { ok: true, id } | Thank the visitor. |
202 { ok: true } | resolves { ok: true } without id | Dropped as spam; thank the visitor anyway. |
400 bad_request | KwispleError(400, "bad_request") | Show e.message (one sentence per field, using the labels). |
404 Site staat niet live. | KwispleError(404, "not_found") | The production site is offline. Staging sites take forms. |
409 Dit moment is net door iemand anders gekozen. | KwispleError(409, "conflict") | The slot is taken; ask for another moment. |
429 | KwispleError(429, "rate_limited") | Too many submissions from this visitor: a burst of 5, then one per 20 s. |
Without clientIp the limit counts your server's IP, so all visitors of the site share it.
Spam protection
The platform scores every submission:
| Signal | Score |
|---|---|
website (honeypot) is not empty | +2 |
sent less than 3 seconds after startedAt | +1 |
- 2 or more: dropped silently. The answer is still
202 { ok: true }, so a bot learns nothing. - 1: stored, but kept apart in the inbox under … berichten mogelijk spam (possible spam), without a notification, a webhook or a claimed slot. Geen spam (not spam) moves it into the inbox; it then counts as newly received.
- 0: a normal submission.
Bookings with slots
slot makes a form a booking form: only one submission of that form may hold a given slot. The
key is a string of letters, digits and : _ . T -, at most 60 characters, for example
2026-11-03T10:00.
await client.submitForm({
form: "afspraak",
values: { name, email, phone },
slot: "2026-11-03T10:00",
clientIp,
});- A second submission with the same slot gets
409 conflict(Dit moment is net door iemand anders gekozen.). Two visitors clicking at once cannot both get it: a unique index decides. - A spam-scored submission never claims a slot, so a bot cannot block a real visitor.
- Slots are per form:
contactandafspraakcan both hold2026-11-03T10:00.
Showing taken slots: formSlots
const taken = await client.formSlots("afspraak", { from: "2026-11", to: "2026-11" });
// ["2026-11-03T10:00", "2026-11-05T14:30", …]GET /api/v1/forms/{form}/slots returns the claimed slots, sorted, at most 2000, never cached.
from and to are string bounds on the key (to includes every key that starts with it), so
with ISO-style keys a month prefix selects a month. Check a slot against your own rules (opening
hours, closed days) on your server before you submit; the platform only guarantees uniqueness.
Releasing a slot
A slot stays claimed while the submission holds it. It is released when:
- someone sets the submission to Afgewezen (declined) in the inbox: declining always releases the moment;
- an app sets
slot: null(orstatus: "declined") through the apps API; - the submission is deleted.
An app can also move a booking to another free moment; see Server side.
Confirmation mails
confirm asks the platform to mail the visitor:
await client.submitForm({
form: "afspraak",
values: { name, email },
slot,
confirm: {
to: email,
subject: "We ontvingen je aanvraag",
text: `Dag ${name},\n\nWe bekijken je aanvraag voor ${moment} en laten snel iets weten.`,
},
});| Rule | Detail |
|---|---|
to | Must equal (case-insensitively) one of the submitted values. Otherwise no mail is sent and the submission is still stored; the endpoint can never relay mail. |
subject | 1 to 150 characters. |
text | Plain text, 1 to 4000 characters. Blank lines separate paragraphs. |
| Identity | The business's name, logo and brand colour. Replies go to the business's contact address. |
| Footer | Je kreeg deze mail omdat je iets doorgaf via de website van <business>. |
| Staging | The subject starts with [Testversie]. |
| Spam | Never sent for a spam-scored submission. |
The mail is sent by the platform's worker, so submitForm does not wait for it.
Notifications to the business
Every clean submission notifies:
- In the dashboard: everyone who may read the form inbox gets a notification
(Nieuw bericht via
<site>). - By mail (
Nieuw bericht via <site>: <form>): to the business's contact address (Instellingen → Gegevens), or when there is none to everyone who may read the inbox. The mail lists each field with its label and has Bekijk alle berichten (view all messages). Replying to it goes to the visitor: the reply-to is the first value that looks like an e-mail address, preferring a field whose handle containsmail.
Submissions from a staging site arrive in the staging site's own inbox, marked as a test.
Submissions in the dashboard
Website → Formulieren lists every message of the site, newest first, with an unread count in the menu. Who sees it: people with at least Bewerken (edit) on Website-inhoud (website content), and the agency.
| Action | Label |
|---|---|
| Open a message (marks it read) | click it |
| Mark everything read | Alles gelezen |
| Reply by mail | Antwoorden |
| Rescue possible spam | Geen spam |
| Delete | Verwijderen (permanent) |
| Several at once | Alles selecteren, Markeer als gelezen, Verwijderen |
| Download | Exporteren (CSV) |
Follow-up status
Every submission has a status, set under Opvolging (follow-up) in the inbox or by an app:
| Status | Label | Meaning |
|---|---|---|
new | Nieuw | nothing done yet (default) |
waiting | Wacht op antwoord | an answer from the visitor is expected, e.g. another moment was proposed |
accepted | Aanvaard | going ahead, e.g. a confirmed booking |
declined | Afgewezen | not going ahead; releases the slot |
done | Afgehandeld | finished |
The usual path is new → waiting → accepted or declined → done, but any status can be set at any
time. A booking's slot shows as Moment: <moment>.
Outcome and value
For enquiries, Resultaat (outcome) records what the lead brought: Nog geen resultaat,
Klant geworden (won) or Geen klant geworden (lost), and for a won lead an optional
Waarde (€) (value). People type amounts as they write them (1.500, 1 500,50, € 2.000).
Won leads and their value show in the statistics under Klanten via de website and in the
monthly report, counted on the day the outcome was set; correcting only the value does not move
that day.
Webhooks to other systems
Agencies can send submissions to a CRM with outgoing webhooks:
| Event | When | Carries |
|---|---|---|
form.submitted | a clean submission arrives, or one is rescued with Geen spam | the submission (id, form name, fields, labels, createdAt), the site and a link to the inbox |
lead.updated | the status or the outcome changes, in the inbox or through an app | id, status, outcome, value (euro) and currency: "EUR", and a link to the inbox |
Submissions on a staging site never send form.submitted. See
Outgoing webhooks.
Apps that handle submissions
A booking desk or another tool in the client's dashboard can list the site's submissions and set
their status, move or release slots and mail the visitor, with the rights of the person using it
(site.forms, at least edit in the app). See Server side and
Example: a booking desk.
Checklist
- Declare forms with
defineFormand push the model. - Post to your own route; call
submitFormthere withwebsite,startedAtandclientIp. - Show
e.messageon400; ask for another moment on409. - Use
slotfor anything that can be booked once; grey outformSlots()in the calendar. - Only set
confirm.toto the address the visitor typed. - Mention the form in the site's privacy statement.