Kwisple
Browse the docs

Apps: custom modules per client

Server side: sessions, rights and platform data

Verifying the app session on your server with appSessionFrom, acting on the platform as the person with appPlatform, and the apps API on the dashboard behind it.

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
}
ParameterTypeMeaning
requestRequestThe incoming request; the token is read from its Authorization: Bearer … header.
secretstring | undefinedKWISPLE_WEBHOOK_SECRET. Missing or empty: every request fails verification (returns null).
appstringThe 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?" },
});
OptionTypeMeaning
secretstring | undefinedKWISPLE_WEBHOOK_SECRET, as above.
appstringThe app handle.
dashboardOriginstring | undefinedKWISPLE_DASHBOARD_ORIGIN: where the apps API lives. Checked lazily: a missing value throws KwispleError(500, "config", …) on the first call, not on construction.
fetchtypeof fetchOptional replacement for fetch (tests).

AppPlatform

MemberMeaning
session: AppSessionThe 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.

StatusDashboard labelMeaning
newNieuwJust arrived, nobody acted.
waitingWacht op antwoordThe business answered and expects a reply from the visitor (a proposed moment, a question).
acceptedAanvaardConfirmed.
declinedAfgewezenTurned down. Always releases the slot.
doneAfgehandeldClosed.

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 slot is 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 as YYYY-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 with 409 conflict and nothing changes.
  • slot: null releases the moment.
  • slot left out keeps the current one, except with status: "declined", which releases it (slot becomes null) so a declined booking never blocks a moment.
  • A patch with neither status nor slot changes 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 mail and looks like an address, else any value that looks like one). Without such a value the mail is skipped and the response says mailed: false; the status change still happens.
  • The mail is sent by the worker (job email.formConfirm, up to 5 retries). mailed: true means "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:

  1. The token parses as an app session; the site in its claims exists, is not deleted and has a webhook secret. → else 401 unauthorized.
  2. 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.
  3. Rate limit: 240 requests per minute per person. → else 429 rate_limited.
  4. The person still exists; their live access to the workspace is resolved exactly as the dashboard does (resolveWorkspaceAccess). → else 401 / 403 forbidden.
  5. The app is still one this person may open (workspaceApps → appLevel()), and it is still declared by the same site as the token says. → else 403 forbidden.
  6. The person has the endpoint's permission (site.forms, the form-inbox right: area Website-inhoud, level Bewerken) → else 403 forbidden.
  7. Both the app level the person has now and the level inside the session reach the endpoint's required level (view for reading, edit for writing). The session's level caps the request, so Bekijk als klant (viewing as a client role) holds on the API too. → else 403 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 parameterRequiredMeaning
formyesThe form handle (^[a-zA-Z][a-zA-Z0-9_-]*$, max 60). Submissions are stored as form:<handle>.
statusnoComma-separated statuses to keep, e.g. new,waiting.
limitno1–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 } ] }
StatuscodeWhen
400bad_requestMissing form, unknown status, limit out of range.
401unauthorizedNo or invalid session (see above).
403forbiddenNo form-inbox right, app not (or no longer) theirs, level below view.
429rate_limitedMore 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 fieldTypeMeaning
status"new" | "waiting" | "accepted" | "declined" | "done"Optional new status. Emits lead.updated.
slotstring | nullOptional: 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 }
StatuscodeWhen
400bad_requestInvalid body (unknown key, bad status, slot with other characters, texts too long) or malformed id.
401unauthorizedNo or invalid session.
403forbiddenNo form-inbox right, or the app level (now, or in the session) is below edit. A viewer gets this.
404not_foundNo such submission on this site (or it is spam).
409conflictAnother submission holds that slot. Nothing changed.
429rate_limitedRate 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.

Last updated 2026-10-30