Every request your app's page makes to your own server carries the app session as
Authorization: Bearer <token>. Your server verifies it before doing anything, decides what the
person may do from session.level, and, when it needs platform data, calls the platform as
that person through appPlatform(). The platform re-checks everything on its side: an app
never gets more than the person has right now.
Both helpers come from @kwisple/sdk and run wherever Web Crypto exists: Node, edge runtimes,
Bun. The secret they need is the site's KWISPLE_WEBHOOK_SECRET (written to .env by
kwisple link; shown as Webhook-geheim under Website → Instellingen → Ontwikkeling).
appSessionFrom(request, { secret, app })
The session when the bearer is genuine, unexpired and meant for this app; null otherwise.
import { appLevelAtLeast, appSessionFrom } from "@kwisple/sdk";
export async function POST(request: Request) {
const session = await appSessionFrom(request, {
secret: process.env.KWISPLE_WEBHOOK_SECRET,
app: "golfgenerator",
});
if (!session) return new Response(null, { status: 401 });
if (!appLevelAtLeast(session.level, "edit")) return new Response(null, { status: 403 });
// … do the work
}| Parameter | Type | Meaning |
|---|---|---|
request | Request | The incoming request; the token is read from its Authorization: Bearer … header. |
secret | string | undefined | KWISPLE_WEBHOOK_SECRET. Missing or empty: every request fails verification (returns null). |
app | string | The app handle the token must have been issued for. A session of another app of the same site is rejected. |
Returns Promise<AppSession | null> (fields in Building the page).
null means: no Authorization header, a malformed token, a bad signature, an expired token
(exp reached), a token issued more than 60 s in the future, or a different app.
What it does under the hood (verifyAppSession in @kwisple/cms): splits the app1.<payload>.<sig>
token, recomputes the HMAC-SHA256 with a key derived from the secret for the purpose
kwisple-app-session, compares in constant time, then checks the claims. A webhook signature
(same secret, other key) can never pass as a session; see Security model.
appLevelAtLeast(level, needed)
true when level reaches needed on the scale view < suggest < approve < edit < manage.
Use it for every action that needs more than looking. Agency staff are manage; a read-only
subscription makes everyone view.
appPlatform(request, { secret, app, dashboardOrigin })
Verifies the session like appSessionFrom and returns an object that also acts on the platform
as the person using the app. null when the session is missing or not genuine (answer 401).
import { appPlatform } from "@kwisple/sdk";
const app = await appPlatform(request, {
secret: process.env.KWISPLE_WEBHOOK_SECRET,
app: "boekingen",
dashboardOrigin: process.env.KWISPLE_DASHBOARD_ORIGIN,
});
if (!app) return new Response(null, { status: 401 });
const open = await app.submissions("afspraak", { status: ["new", "waiting"] });
await app.updateSubmission(open[0].id, {
status: "waiting",
slot: "2026-11-05T14:00",
notify: { subject: "Een ander moment?", text: "Dag Lotte,\n\nPast donderdag om 14 u?" },
});| Option | Type | Meaning |
|---|---|---|
secret | string | undefined | KWISPLE_WEBHOOK_SECRET, as above. |
app | string | The app handle. |
dashboardOrigin | string | undefined | KWISPLE_DASHBOARD_ORIGIN: where the apps API lives. Checked lazily: a missing value throws KwispleError(500, "config", …) on the first call, not on construction. |
fetch | typeof fetch | Optional replacement for fetch (tests). |
AppPlatform
| Member | Meaning |
|---|---|
session: AppSession | The verified session. |
submissions(form, q?) | One form's submissions on this site, newest first, spam left out. q.status: a list of statuses to keep; q.limit: at most 500 (default 100). Needs the form-inbox right. |
updateSubmission(id, patch) | Follows a submission up. patch.status, patch.slot (a moment, or null to release it) and patch.notify ({ subject, text }, a mail to the visitor). Needs edit in the app. Returns { submission, mailed }. |
Both methods call the apps API on the dashboard with the same bearer the page sent you, and
cache: "no-store". A non-2xx answer throws KwispleError(status, code, message) with the
code and message from the response body (error, Platform antwoordde <status>. when the
body is not JSON). Catch it and map the statuses that matter to you: 409 (slot taken), 403
(not allowed), 404 (gone).
AppSubmission
interface AppSubmission {
id: string;
form: string; // the form handle, e.g. "afspraak"
values: Record<string, string | boolean>; // what the visitor typed
status: "new" | "waiting" | "accepted" | "declined" | "done";
slot: string | null; // the moment it claims, e.g. "2026-11-03T10:00"
createdAt: string; // ISO
statusChangedAt: string | null; // ISO
}SubmissionStatus and AppSubmission are exported from @kwisple/sdk.
Statuses and slot rules
The statuses are the ones the form inbox (Website → Formulieren, column Opvolging) shows and sets. An app and the inbox work on the same rows; whichever changes a status last wins.
| Status | Dashboard label | Meaning |
|---|---|---|
new | Nieuw | Just arrived, nobody acted. |
waiting | Wacht op antwoord | The business answered and expects a reply from the visitor (a proposed moment, a question). |
accepted | Aanvaard | Confirmed. |
declined | Afgewezen | Turned down. Always releases the slot. |
done | Afgehandeld | Closed. |
The typical path is new → waiting → accepted / declined → done, but no transition is enforced:
any status may follow any other.
Slot rules (setSubmissionStatus in the dashboard's forms service):
- A
slotis a string only one non-spam submission of the same form on the same site may hold (a unique index). Use a stable format such asYYYY-MM-DDTHH:mm; the platform only compares strings. Allowed characters: letters, digits and: _ . T -, at most 60. slot: "<moment>"moves the submission to that moment. If another submission holds it, the request fails with409 conflictand nothing changes.slot: nullreleases the moment.slotleft out keeps the current one, except withstatus: "declined", which releases it (slotbecomesnull) so a declined booking never blocks a moment.- A patch with neither
statusnorslotchanges nothing and returns the current submission. - Spam submissions (spam score ≥ 1) are invisible to apps: listing skips them and patching one
gives
404.
client.formSlots(form, { from, to }) on the delivery API lists the claimed slots, so your app
can offer only free moments before it proposes one; see Forms.
notify: mails to the visitor
notify: { subject, text } asks the platform to mail the visitor, in the business's identity
(name, logo, brand colour) and with the business's contact address as reply-to, the same way a
confirm at submit time does.
subject: 1–150 characters.text: 1–4000 characters of plain text; blank lines separate paragraphs. HTML is not interpreted.- The address is never yours to choose: the mail goes to the address the visitor typed in
the form (the first string field whose name contains
mailand looks like an address, else any value that looks like one). Without such a value the mail is skipped and the response saysmailed: false; the status change still happens. - The mail is sent by the worker (job
email.formConfirm, up to 5 retries).mailed: truemeans "queued", not "delivered". - On a staging site the subject is marked as a test.
The lead.updated webhook
When a patch sets status, the dashboard emits the outgoing webhook event lead.updated
for the client (only when an active endpoint listens to it). The payload carries the
submission's id, the new status, its outcome, value and currency, and a link to the
form inbox. A patch that only moves the slot emits nothing. Details in
Outgoing webhooks.
The apps API
What appPlatform calls. You can call it yourself from any server, with the app session as
bearer. Base URL: the dashboard origin (KWISPLE_DASHBOARD_ORIGIN). All answers carry
cache-control: no-store.
Authentication and the rights re-checked per request
Authorization: Bearer <app session> on every request; no cookies (the dashboard's proxy lets
/api/apps/ through without a session cookie). Each request is checked from scratch in
requireAppRequest() (apps/dashboard/src/lib/apps-api.ts), in this order:
- The token parses as an app session; the site in its claims exists, is not deleted and has a
webhook secret. → else
401 unauthorized. - The signature verifies with that site's secret, the token is unexpired, and the
workspace in the claims is the site's workspace. → else
401 unauthorized. - Rate limit: 240 requests per minute per person. → else
429 rate_limited. - The person still exists; their live access to the workspace is resolved exactly as the
dashboard does (
resolveWorkspaceAccess). → else401/403 forbidden. - The app is still one this person may open (
workspaceApps→appLevel()), and it is still declared by the same site as the token says. → else403 forbidden. - The person has the endpoint's permission (
site.forms, the form-inbox right: area Website-inhoud, level Bewerken) → else403 forbidden. - Both the app level the person has now and the
levelinside the session reach the endpoint's required level (viewfor reading,editfor writing). The session's level caps the request, so Bekijk als klant (viewing as a client role) holds on the API too. → else403 forbidden.
Error body, always: { "error": { "code": "<code>", "message": "<sentence in the dashboard language>" } }.
GET /api/apps/v1/submissions
One form's submissions on the app's site, newest first, spam left out.
| Query parameter | Required | Meaning |
|---|---|---|
form | yes | The form handle (^[a-zA-Z][a-zA-Z0-9_-]*$, max 60). Submissions are stored as form:<handle>. |
status | no | Comma-separated statuses to keep, e.g. new,waiting. |
limit | no | 1–500, default 100. |
Needs site.forms and app level view.
GET /api/apps/v1/submissions?form=afspraak&status=new,waiting&limit=300
Authorization: Bearer app1.eyJ2IjoxLCJhcHAiOiJib2VraW5nZW4i…
200 OK
{ "submissions": [ { "id": "…", "form": "afspraak", "values": { "naam": "Lotte", "datum": "2026-11-03", "tijd": "10:00", "email": "…" }, "status": "new", "slot": "2026-11-03T10:00", "createdAt": "2026-10-29T18:02:11.000Z", "statusChangedAt": null } ] }| Status | code | When |
|---|---|---|
| 400 | bad_request | Missing form, unknown status, limit out of range. |
| 401 | unauthorized | No or invalid session (see above). |
| 403 | forbidden | No form-inbox right, app not (or no longer) theirs, level below view. |
| 429 | rate_limited | More than 240 requests per minute for this person. |
PATCH /api/apps/v1/submissions/:id
Follows a submission up. Needs site.forms and app level edit. The body is strict: unknown
keys are a 400.
{
"status": "waiting",
"slot": "2026-11-05T14:00",
"notify": { "subject": "Een ander moment?", "text": "Dag Lotte,\n\n…" }
}| Body field | Type | Meaning |
|---|---|---|
status | "new" | "waiting" | "accepted" | "declined" | "done" | Optional new status. Emits lead.updated. |
slot | string | null | Optional: another moment (must be free) or null to release. Declining always releases. |
notify | { subject: string; text: string } | Optional mail to the visitor (1–150 / 1–4000 characters). |
200 OK
{ "submission": { …AppSubmission… }, "mailed": true }| Status | code | When |
|---|---|---|
| 400 | bad_request | Invalid body (unknown key, bad status, slot with other characters, texts too long) or malformed id. |
| 401 | unauthorized | No or invalid session. |
| 403 | forbidden | No form-inbox right, or the app level (now, or in the session) is below edit. A viewer gets this. |
| 404 | not_found | No such submission on this site (or it is spam). |
| 409 | conflict | Another submission holds that slot. Nothing changed. |
| 429 | rate_limited | Rate limit. |
There are no other endpoints under /api/apps/v1/ today.
Patterns
One helper per site
RANSON.N centralises the options once (src/lib/beheer.ts):
import { type AppPlatform, appPlatform } from "@kwisple/sdk";
export const DASHBOARD_ORIGIN = process.env.KWISPLE_DASHBOARD_ORIGIN?.replace(/\/+$/, "") ?? "";
const SECRET = process.env.KWISPLE_WEBHOOK_SECRET;
export function beheer(request: Request, app: "boekingen" | "golfgenerator"): Promise<AppPlatform | null> {
return appPlatform(request, { secret: SECRET, app, dashboardOrigin: DASHBOARD_ORIGIN });
}
export const nietAangemeld = () =>
Response.json({ fout: "Open dit via het dashboard." }, { status: 401 });Typing the app parameter as a union of the handles keeps a route from accepting the wrong
app's session.
Tell the page what it may do
Decide on the server and send the result along with the data, so the UI never guesses:
return Response.json(
{ boekingen, vrij, kanBeslissen: appLevelAtLeast(app.session.level, "edit") },
{ headers: { "cache-control": "no-store" } },
);Map platform errors to your own messages
try {
const r = await app.updateSubmission(id, { status: "accepted", slot });
} catch (e) {
if (e instanceof KwispleError && e.status === 409)
return fout(409, "Dat moment is net door een andere aanvraag genomen. Kies een ander.");
if (e instanceof KwispleError && e.status === 403)
return fout(403, "Je mag enkel kijken in deze app.");
throw e;
}Use the site's token for everything else
The session identifies the person; it does not replace the site's API token. Content, globals,
formSlots, submitForm go through createClient({ url, token }) from @kwisple/sdk as
anywhere else in the site (SDK client). Keep in mind that the site's
token has the site's rights, not the person's: gate those calls with session.level yourself.
Never serve the app routes without a check
Routes under the app's path that return data or HTML the dashboard should gate (a tool page,
an export) need a check too. When a request cannot carry the header (an <iframe src>, a
download link), mint your own short-lived ticket from a verified request and check that
instead, as the generator example does.