CheckCourt
TypeScript SDK

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:

  1. the CheckCourt-Signature over the raw body,
  2. the context token (HS256 only, iss, iat and exp with 30 seconds of leeway),
  3. that body, CheckCourt-Context header and token agree, i.e. point and subject in 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.

FunctionBlock
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.

frame.ts
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");
});
MemberMeaning
frame.contextThe context token from the URL. Verify it in your backend only, never in the browser
frame.channelThe 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.themeThe last theme received
frame.disconnect()Remove listeners and observers

Options of connectExtensionFrame:

  • autoResize (default true): keeps the height in sync. It measures right away, after the current task, on load and on every ResizeObserver callback. It does not wait for animation frames, which browsers skip in cross-origin frames outside the visible area.
  • applyTheme (default true): sets the tokens as CSS variables on <html>, toggles the dark class and color-scheme. With false, do it yourself in onTheme.

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.

On this page