Kwisple
Browse the docs

Content and data

Forms, bookings and confirmations

Declare forms with defineForm, submit them from your site's server, take bookings with slots, send confirmation mails, and follow submissions up in the dashboard.

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:

  1. The visitor submits a form on your site, to your own server route.
  2. Your route validates what it needs and calls client.submitForm(). The token stays on your server.
  3. 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

KeyTypeMeaning
handlestringWhat the site sends as form. Letters, digits, - and _, starting with a letter. Unique per site.
namestringThe form's name in the inbox and the notification mail.
fieldsRecord<string, FormFieldDef>The fields by handle (letters, digits and _). At least one.

Field options

KeyTypeMeaning
kind"text" | "textarea" | "email" | "phone" | "number" | "date" | "select" | "checkbox"What the value must look like.
labelstringThe label in the dashboard, also used in validation messages.
requiredbooleanMust be filled (a checkbox: must be true).
optionsstring[]select only, required there: the allowed values.
maxLengthnumberText kinds: at most this many characters. Default 5000.

Validation on the platform

KindAccepted valueMessage when wrong
any, requirednon-empty after trimming<Label> is verplicht.
text, textarea, phone, …a string, at most maxLength<Label> moet tekst zijn. / <Label> is te lang.
emailan address<Label> is geen geldig e-mailadres.
phonedigits, spaces, + ( ) . / -, 6 to 30 characters<Label> is geen geldig telefoonnummer.
numbera number, comma or dot as decimal mark<Label> moet een getal zijn.
dateYYYY-MM-DD, optionally with a time<Label> moet een datum zijn.
selectone of options<Label>: "<value>" is geen van de opties.
checkboxtrue 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>.

InputMeaning
formThe form handle. For undeclared forms any handle matching the same pattern (max 60 characters).
valuesField values, at most 50 fields of at most 5000 characters.
entryIdOptional: the entry the form is on. Must belong to the site, else 404 Entry niet gevonden.
websiteThe honeypot: the value of a hidden field humans leave empty.
startedAtWhen the visitor opened the form, in milliseconds since the epoch.
slotA moment only one submission may claim (see Bookings with slots).
confirmA confirmation mail to the visitor (see Confirmation mails).
clientIpThe 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 answerSDKWhat to do
201 { ok: true, id }resolves { ok: true, id }Thank the visitor.
202 { ok: true }resolves { ok: true } without idDropped as spam; thank the visitor anyway.
400 bad_requestKwispleError(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.
429KwispleError(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:

SignalScore
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: contact and afspraak can both hold 2026-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 (or status: "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.`,
  },
});
RuleDetail
toMust 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.
subject1 to 150 characters.
textPlain text, 1 to 4000 characters. Blank lines separate paragraphs.
IdentityThe business's name, logo and brand colour. Replies go to the business's contact address.
FooterJe kreeg deze mail omdat je iets doorgaf via de website van <business>.
StagingThe subject starts with [Testversie].
SpamNever 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 contains mail.

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.

ActionLabel
Open a message (marks it read)click it
Mark everything readAlles gelezen
Reply by mailAntwoorden
Rescue possible spamGeen spam
DeleteVerwijderen (permanent)
Several at onceAlles selecteren, Markeer als gelezen, Verwijderen
DownloadExporteren (CSV)

Follow-up status

Every submission has a status, set under Opvolging (follow-up) in the inbox or by an app:

StatusLabelMeaning
newNieuwnothing done yet (default)
waitingWacht op antwoordan answer from the visitor is expected, e.g. another moment was proposed
acceptedAanvaardgoing ahead, e.g. a confirmed booking
declinedAfgewezennot going ahead; releases the slot
doneAfgehandeldfinished

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:

EventWhenCarries
form.submitteda clean submission arrives, or one is rescued with Geen spamthe submission (id, form name, fields, labels, createdAt), the site and a link to the inbox
lead.updatedthe status or the outcome changes, in the inbox or through an appid, 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 defineForm and push the model.
  • Post to your own route; call submitForm there with website, startedAt and clientIp.
  • Show e.message on 400; ask for another moment on 409.
  • Use slot for anything that can be booked once; grey out formSlots() in the calendar.
  • Only set confirm.to to the address the visitor typed.
  • Mention the form in the site's privacy statement.

Last updated 2026-10-30