UI extensions
Verify context tokens and requests, build ui documents and connect iframe extensions with the SDK
How extensions work, where they appear and which limits apply is described in UI extensions. The SDK helps in three places: verifying incoming requests, building the response, and inside the iframe.
Declarative extensions
verifyExtensionRequest checks a request completely:
- the
CheckCourt-Signatureover the raw body, - the context token (
HS256only,iss,iatandexpwith 30 seconds of leeway), - that body,
CheckCourt-Contextheader and token agree, i.e.pointandsubjectin the body match the token.
It then tells you whether it is a render or an action.
import express from "express";
import { verifyExtensionRequest, ui, toast, ExtensionVerificationError } from "@checkcourt/sdk";
const SECRET = process.env.CHECKCOURT_WEBHOOK_SECRET!; // whsec_…
app.post("/ext/booking", express.raw({ type: "application/json" }), async (req, res) => {
let request;
try {
request = await verifyExtensionRequest({ secret: SECRET, rawBody: req.body, headers: req.headers });
} catch (err) {
if (err instanceof ExtensionVerificationError) return res.status(401).end();
throw err;
}
const { context } = request;
if (!(await isKnownInstallation(context.installation_id, context.tenant_id))) {
return res.status(403).end();
}
if (context.point !== "booking.detail.panel") return res.status(404).end();
if (request.kind === "action" && request.actionId === "door.open") {
await openDoor(context.subject.id, Number(request.values.minutes ?? 10));
return res.json(ui.doc([ui.text("Die Tür ist offen.")], { toast: toast.success("Tür geöffnet") }));
}
res.json(
ui.doc([
ui.heading("Platztür"),
ui.row([ui.stat("Öffnungen heute", "12"), ui.badge("Online", "secondary")]),
ui.form({
actionId: "door.open",
submitLabel: "Tür öffnen",
fields: [ui.field.number("minutes", "Minuten offen", { default: 10, min: 1, max: 30 })],
}),
]),
);
});After checking context.point, TypeScript knows context.subject and
context.viewer.capabilities exactly, for example capabilities.is_booker for bookings.
If your manifest declares permissions, capabilities.permissions holds every declared key
with whether the viewer holds it, at every point (from @checkcourt/sdk 0.11.0):
if (context.viewer.capabilities.permissions?.manage_ladder) { … }.
UI copy stays German: CheckCourt's interface is German.
CheckCourt shows toasts only after actions; on the first render they are dropped.
booking.action buttons send the actionId "invoke" (BOOKING_ACTION_INVOKE); answer
with ui.doc([], { toast }) to just confirm.
When a panel has nothing relevant to show, answer with ui.hidden() (or res.status(204).end())
and CheckCourt renders no card for it. After an action, ui.hidden({ toast }) confirms and
removes the panel. app.settings keeps its card. See
Hide a panel.
if (context.point === "booking.detail.panel" && !(await courtHasDoor(context.subject.id))) {
return res.json(ui.hidden());
}CheckCourt caches a render for 30 seconds by default. Pass maxAge in seconds (at most
300) to change that per answer, or 0 for a panel that must always be fresh. It's emitted
as cache: { max_age } and wins over a Cache-Control header. A maxAge that isn't a
whole number of seconds >= 0 throws a RangeError. See
Caching.
return res.json(ui.doc([ui.stat("Akku", `${level} %`)], { maxAge: 0 }));Verifying only the token
For the context token alone, for example in your iframe backend:
import { verifyExtensionContext } from "@checkcourt/sdk";
const context = await verifyExtensionContext(token, process.env.CHECKCOURT_WEBHOOK_SECRET!);ExtensionVerificationError.reason is then malformed_token, unsupported_algorithm,
invalid_signature, invalid_claims, expired or not_yet_valid.
UI builder
Each function creates exactly one block of the ui: "v1" format. Whatever the builder
produces passes CheckCourt's validation. It does not check lengths; the limits are in the
block catalog.
| Function | Block |
|---|---|
ui.doc(blocks, { toast?, maxAge? }) | The whole document; maxAge adds cache: { max_age } |
ui.hidden({ toast?, maxAge? }) | { ui: "v1", hidden: true }: no card for this item |
ui.heading(text, level = 2) or ui.heading(text, { level?, icon? }) | heading, level 2 or 3 |
ui.text(text, { tone?, icon? }) | text; tone: "muted" for side notes |
ui.stat(label, value, { hint?, icon?, size? }) | stat; size: "lg" for a large figure |
ui.badge(label, variant?) or ui.badge(label, { variant?, icon? }) | badge; variants in BADGE_VARIANTS, including "warning" |
ui.list([{ title, description?, icon? }]) | list |
ui.keyValue(pairs) | key_value, from [{ label, value }] or an object |
ui.link(label, url) | link, https only |
ui.button(label, actionId, { variant? }) | button |
ui.form({ actionId, submitLabel, fields }) | form |
ui.field.text(name, label, { placeholder?, default?, required?, max_length?, multiline?, rows? }) | Text field; multiline makes it a text box (rows 1 to 12), the value stays a string |
ui.field.number(name, label, { default?, min?, max?, required? }) | Number field |
ui.field.select(name, label, options, { default?, required? }) | Select |
ui.field.switch(name, label, { default? }) | Switch |
ui.field.date(name, label, { default?, min?, max?, required? }) | Native date picker; default/min/max are YYYY-MM-DD strings, the value stays a string |
ui.field.time(name, label, { default?, min?, max?, required?, step? }) | Native time picker; default/min/max are HH:MM (24-hour) strings, step is in minutes |
ui.divider() | divider |
ui.stack(children) | stack, vertical |
ui.row(children, { justify? }) | row, horizontal; justify: "between" spreads the children across the full width (ROW_JUSTIFICATIONS). Throws on any other value |
ui.columns(children, { dividers?, align? }) | columns: 2 to 6 equal-width columns, align is "start" or "center". Throws on any other count |
toast.success(message), toast.error(message) | Toast for ui.doc and ui.hidden |
ui.annotations(list, { maxAge? }) | Answer for court.annotation: [{ court_id, label, variant? }] |
ui.column({ title, values }, { maxAge? }) | Answer for member.list.column: values is [{ member_id, text, variant? }] |
ui.hint(blocks, { maxAge? }) | Answer for booking.hint: only text, badge, key_value and link blocks |
The three answers for the host surfaces
need @checkcourt/sdk 0.5.0 or later. The request also carries the point's extra fields:
request.date and request.courts for court.annotation, request.members for
member.list.column and request.draft for booking.hint. members and draft exist on
render requests only, so TypeScript wants request.kind === "render" checked first:
if (request.point === "booking.hint" && request.kind === "render") {
const { court_id, date, start_time, end_time } = request.draft!;
// …
}A booking_plan.action request (both the click and any action inside the dialog) carries
request.date and the plan's request.courts; the date/time form fields and these
courts need @checkcourt/sdk 0.6.0 or later.
icon takes a name from UI_ICONS (type UiIcon, see the
icon list); the
builders throw a TypeError on any other name. Icons, ui.columns, justify on ui.row, the
"warning" variant and size need @checkcourt/sdk 0.10.0 or later.
At most 100 blocks and three levels. Strings are plain text, no HTML or Markdown.
iframe extensions
In the browser, connect your page with connectExtensionFrame. It reads context and
channel from the URL, sends every message with the channel and applies CheckCourt's
theme.
import { connectExtensionFrame } from "@checkcourt/sdk/iframe";
const frame = connectExtensionFrame(); // height and theme are handled automatically
// verify the token in your backend and exchange it for a short session
const session = await fetch("/api/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context: frame.context }),
}).then((r) => r.json());
document.querySelector("#save")!.addEventListener("click", async () => {
await save(session);
frame.toast("success", "Gespeichert");
});
document.querySelector("#plan")!.addEventListener("click", () => {
frame.navigate("/booking?date=2026-10-07");
});| Member | Meaning |
|---|---|
frame.context | The context token from the URL. Verify it in your backend only, never in the browser |
frame.channel | The channel of this render |
frame.resize(height?) | Set the height; without a value, the document's height (80 to 2,000 pixels) |
frame.toast(kind, message) | Toast, "success" or "error", 1 to 200 characters |
frame.navigate(path) | Navigate within CheckCourt. Disallowed targets throw a RangeError |
frame.onTheme(callback) | Called with { mode, tokens } right away and on every change. Returns an unsubscribe function |
frame.theme | The last theme received |
frame.disconnect() | Remove listeners and observers |
Options of connectExtensionFrame:
autoResize(defaulttrue): keeps the height in sync. It measures right away, after the current task, onloadand on everyResizeObservercallback. It does not wait for animation frames, which browsers skip in cross-origin frames outside the visible area.applyTheme(defaulttrue): sets the tokens as CSS variables on<html>, toggles thedarkclass andcolor-scheme. Withfalse, do it yourself inonTheme.
Without channel in the URL, connectExtensionFrame throws: the page was not opened by
CheckCourt, or it is a follow-up page after navigating inside the frame. The browser
entry point contains no secrets and no Node.js APIs.