CheckCourt
App platform

UI extensions

Your own sections, tiles, badges, columns, hints, buttons and pages in CheckCourt, declarative or as an iframe

At the extension points from the manifest, your app appears directly in CheckCourt. There are two kinds:

  • Declarative: CheckCourt calls your URL server-side and renders the JSON you return with its own design system, in light and dark mode. None of your JavaScript runs in CheckCourt.
  • iframe: CheckCourt embeds your URL in a sandboxed iframe. Only allowed at the points that support it.

How your extension should look and sound is covered in the design guidelines.

PointWhereDeclarativeiframeObject
app.settingsAdmin → Apps → your app, below the settings formyesyesinstallation
booking.detail.panelBooking details dialogyesyesbooking
member.profile.sectionMember page in the admin area and the member's own settingsyesyesmember
dashboard.widgetDashboardyesnonone
booking.actionButton among the actions in the booking details (requires label, may appear more than once)yesnobooking
kiosk.tileHome screen of the kiosk terminal, with nobody signed inyesnonone
court.annotationShort badges in the court headers of the booking plan and on the kiosk court cardsyesnonone
member.list.columnAn extra column in the member list under Admin → Mitgliederyesnonone
member.settings.sectionA card on the member's own Einstellungen (settings) page, member apps onlyyesyesnone
booking.hintShort hint in the booking dialog, above the confirm buttonyesnonone
booking_plan.actionButton in the header of the booking plan (requires label)yesnobooking_plan
sidebar.actionEntry at the bottom of the sidebar and the mobile navigation (requires label)yesnonone
nav.pageEntry in the main navigation that opens a full page with your UI (requires label and icon)yesyesnone

The first six points are panels and buttons inside a card or a dialog. The next six are host surfaces: CheckCourt draws badges, table cells, hints, buttons and a settings card itself and places your content into its own layout. nav.page gives your app a page of its own.

Points that give you the ID of an object, or data about the club, require the matching scope: in tenantScopes for club installations, in userScopes for member installations. booking.detail.panel, booking.action and booking.hint require bookings:read, member.profile.section and member.list.column require members:read, court.annotation and booking_plan.action require courts:read. Without it the manifest is rejected, and an installation that doesn't hold the scope never sees the point.

When an extension appears

  • Only for installations that are allowed to run: active or with a pending update, not paused, app not suspended.
  • Extensions of a member app are only visible to their own member. Another member who opens the same booking doesn't see them.
  • Only on pages the person is allowed to open anyway. You get IDs, never the data itself. Whatever you need, you fetch through the API with your token.
  • Multiple apps are sorted by name, each in its own card. Court badges, member list columns, booking hints and sidebar entries have no card and show at most two apps per spot, again by app name; see Host surfaces.
  • One card per app, even if the app is installed twice for this person: by the club and as a member app, or by the club with the member's account linked under it (userLinking). If the viewer's own installation may render at this point, it replaces the club installation, so you get installation_kind: "user" and the member's real user ID. Otherwise the club installation renders, for example when the link lacks the point's scope in userScopes or when someone else opens the member's profile.

No app branding. CheckCourt shows extensions without your app's name, icon or an „App“ badge, so they fit in like a built-in part of the screen. A card's title is the label of your manifest entry; without one the card has no title. Dialogs are titled with the action's label, badges, cells, hints and buttons show only your content. Clubs see which app adds what under Admin → Apps. Because your content is not marked as coming from an app, it must never imitate CheckCourt's own controls to mislead people, such as a fake sign-in or payment prompt; the developer terms forbid this.

Signing key

Every request is signed with your app's signing secret, the same whsec_… as for webhooks. Every app has it from the moment it is created, with or without a webhook URL, so an iframe-only app can verify its context tokens too. Find it in the developer portal under your app → Webhooks → Signatur-Secret (signing secret): Anzeigen (show) reveals it once, Neu erzeugen (generate new) replaces it. If you save a webhook URL before you have seen the secret, it is shown then. Setting, changing or removing the webhook URL keeps the secret. A new one applies to webhooks and extensions immediately.

Context token

Every request carries a short-lived context token: a regular HS256 JWT whose key is the complete secret, including whsec_. It is sent in the CheckCourt-Context header and additionally as context in the body (declarative) or as the context query parameter (iframe).

{
  "iss": "checkcourt",
  "installation_id": "inst_…",
  "installation_kind": "tenant",
  "app_id": "app_…",
  "tenant_id": "…",
  "point": "booking.detail.panel",
  "subject": { "type": "booking", "id": "…" },
  "viewer": {
    "user_id": "…",
    "capabilities": { "is_booker": true, "can_edit_bookings": false }
  },
  "iat": 1790000000,
  "exp": 1790000300,
  "nonce": "…"
}
  • exp is iat + 300, so the token is valid for five minutes. Reject expired tokens (at most 30 seconds of tolerance for clock skew) and tokens whose alg is not HS256.
  • installation_kind is "tenant" for club installations and "user" for member connections.
  • subject is null for dashboard.widget, kiosk.tile, court.annotation, member.list.column, member.settings.section, booking.hint, sidebar.action and nav.page. booking_plan.action gets { "type": "booking_plan", "id": "2026-10-07" }, the date of the plan the person is looking at.
  • viewer.user_id:
    • null at the kiosk, where nobody is signed in (kiosk.tile and court.annotation)
    • for member installations, always the member themselves
    • for club installations with members:read, the real user ID
    • otherwise a pseudonym (psn_…). It stays the same for this person and this installation. This lets you recognize returning people without learning who they are.
  • capabilities are booleans describing what the person is allowed to do in CheckCourt:
    • Bookings: is_booker, can_edit_bookings, can_cancel_foreign_bookings, can_see_booking_details
    • Members: is_self, can_see_member_details, can_edit_members
    • Dashboard: can_book, can_manage_club
    • Settings page: can_manage_app
    • Member list (member.list.column): can_edit_members
    • Booking plan (booking_plan.action): can_edit_bookings
    • The other host surfaces carry no capabilities: {}
    • At every point, if your manifest declares permissions: permissions, an object with every declared key and whether the person holds it through one of their roles, for example "permissions": { "manage_ladder": true, "view_stats": false }. Administrators hold all of them; at the kiosk all are false. Without declared permissions the field is absent.
  • Compare installation_id and tenant_id with what you know from app.installed.

Declarative requests additionally carry CheckCourt-Signature: t=<unix>,v1=<hex> over the raw body, as with webhooks. Verify it too: it binds action_id and values to the token.

Any JWT library works, for example jose in Node.js or PyJWT in Python:

import { jwtVerify } from "jose";

const key = new TextEncoder().encode(process.env.CHECKCOURT_WEBHOOK_SECRET); // whsec_…

export async function readContext(token) {
  const { payload } = await jwtVerify(token, key, {
    algorithms: ["HS256"],
    issuer: "checkcourt",
    clockTolerance: 30,
  });
  return payload;
}
import os

import jwt  # PyJWT

SECRET = os.environ["CHECKCOURT_WEBHOOK_SECRET"]  # whsec_…


def read_context(token: str) -> dict:
    return jwt.decode(token, SECRET, algorithms=["HS256"], issuer="checkcourt", leeway=30)

verifyExtensionRequest checks the signature, the token and that both agree, and returns typed claims. ui builds the response (more):

import express from "express";
import { ui, verifyExtensionRequest } from "@checkcourt/sdk";

app.post("/ext/dashboard", express.raw({ type: "application/json" }), async (req, res) => {
  const request = await verifyExtensionRequest({
    secret: process.env.CHECKCOURT_WEBHOOK_SECRET!, // whsec_…
    rawBody: req.body,
    headers: req.headers,
  }).catch(() => null);
  if (!request) return res.status(401).end();

  res.json(ui.doc([ui.heading("Platztür"), ui.stat("Öffnungen heute", "12"), ui.button("Tür öffnen", "door.open")]));
});

Declarative extensions

CheckCourt sends POST <your URL> with Content-Type: application/json:

{ "context": "<token>", "point": "dashboard.widget", "subject": null }

Limits: 2 seconds, at most 64 KB of response, redirects are not followed, any status other than 2xx counts as an error. 204 No Content hides the panel, see Hide a panel. On an error, CheckCourt shows „Dieser Inhalt ist gerade nicht verfügbar.“ (This content is currently unavailable.), and the rest of the page is not affected. Successful responses are cached, by default for 30 seconds per installation, point, object and person; see Caching.

Respond with a ui: "v1" document:

{
  "ui": "v1",
  "blocks": [
    { "type": "heading", "text": "Platztür", "level": 2 },
    { "type": "stat", "label": "Öffnungen heute", "value": "12" },
    { "type": "button", "label": "Tür öffnen", "action_id": "door.open" }
  ]
}

Blocks

BlockFields
texttext (up to 2,000 characters), tone?: "muted", icon?
headingtext (up to 200), level: 2 or 3, icon?
statlabel, value (up to 80), hint? (up to 200), icon?, size?: "md" (default) or "lg"
badgelabel (up to 40), variant?: "default", "secondary", "outline", "destructive", "warning"; icon?
listitems: [{ title, description?, icon? }] (up to 50)
key_valuepairs: [{ label, value }] (up to 30)
linklabel, url (https only, opens in a new tab)
buttonlabel (up to 40), action_id, variant?: "default", "secondary", "outline", "destructive"
formfields (up to 20), submit_label, action_id
dividernone
stackchildren, stacked vertically
rowchildren, side by side, wraps; justify?: "start" (default, packed to the left) or "between" (spread across the full width)
columnschildren (2 to 6), equal widths; dividers?: boolean, align?: "start" (default) or "center"

Form fields:

typeFields
textname, label, placeholder?, default?, required?, max_length?, multiline?, rows? (1 to 12)
numbername, label, default?, min?, max?, required?
selectname, label, options: [{ value, label }] (1 to 50), default?, required?
switchname, label, default?
datename, label, default?, min?, max?, required?
timename, label, default?, min?, max?, required?, step?

name matches ^[A-Za-z][A-Za-z0-9_]{0,39}$, action_id matches ^[A-Za-z0-9_.:-]{1,64}$. Set multiline: true on a text field for a free-text box (for example "Platzproblem melden"); rows sets its visible height and the value stays a string, with max_length still applied.

date and time render native pickers. For date, default, min and max are YYYY-MM-DD strings; for time they are HH:MM strings (24-hour) and step is the picker's granularity in minutes. Both submit their value as a string.

Rules: at most 100 blocks total, counting nested blocks (a stack with three children is four blocks), and nesting depth at most 3 (a block in a stack in a row is the deepest level). columns counts like stack and row. Texts are plain text, no HTML and no Markdown. Unknown blocks or fields invalidate the whole document. Write German texts in sentence case, without dashes, like the rest of CheckCourt. The review checks this.

Order: CheckCourt normalises the order of your blocks. At the top level and inside every stack, content comes first, then one row with all buttons, then one row with all links. A row keeps the order you gave it, and consecutive badges share a line. See Layout order.

Icons, columns, warnings and large numbers

Icons: text, heading, stat, badge and list items take an optional icon. CheckCourt draws it before the text in the text's own color and size; in a stat it sits before the value. Only these names (from Lucide) are allowed, any other name invalidates the document:

GroupIcons
Weathersun, moon, cloud, cloud-sun, cloud-moon, cloud-sun-rain, cloud-rain, cloud-drizzle, cloud-lightning, cloud-snow, cloud-fog, snowflake, wind, droplet, droplets, umbrella, thermometer, sunrise, sunset
Generalcheck, x, info, alert-triangle (also triangle-alert), alert-circle (also circle-alert), clock, calendar, map-pin, trophy, users, user, lock, unlock (also lock-open), lightbulb, zap, euro, star, heart, bell, flag, activity, timer, ticket, door-open

Columns: columns places 2 to 6 children in columns of equal width, for example one forecast day per column. dividers: true draws a vertical line between them, align: "center" centres each column's content. Two or three columns always share one line. On narrow cards, four columns wrap to two per row and five to three when the block is under 320 pixels wide, six wrap to three under 384 pixels, so nothing gets squeezed or scrolls sideways. Keep column content short: a label, an icon, a number.

Spreading a row: justify: "between" on a row puts the first child at the left edge and the last at the right edge, any others evenly in between, all centred vertically. Use it for a figure on the left and its status on the right, as in the weather widget below. When the card is too narrow for both, the children wrap and start again at the left.

Warning: the badge variant "warning" is a soft amber for things that need attention but aren't errors, such as „Regen möglich“ (rain possible) or „Platz nass“ (court wet). Use "destructive" for errors and overdue items. court.annotation and member.list.column accept "warning" too.

Large numbers: size: "lg" on a stat shows the value as a large figure, for the one number the panel is about. Use it at most once per panel.

A weather widget for dashboard.widget:

{
  "ui": "v1",
  "blocks": [
    {
      "type": "row",
      "justify": "between",
      "children": [
        { "type": "stat", "label": "Jetzt", "value": "24°", "hint": "Sonnig", "icon": "sun", "size": "lg" },
        { "type": "badge", "label": "Regen möglich", "variant": "warning", "icon": "droplets" }
      ]
    },
    { "type": "divider" },
    {
      "type": "columns",
      "dividers": true,
      "align": "center",
      "children": [
        { "type": "stat", "label": "Heute", "value": "24°", "icon": "sun" },
        { "type": "stat", "label": "Do", "value": "21°", "icon": "cloud-sun" },
        { "type": "stat", "label": "Fr", "value": "17°", "icon": "cloud-rain" },
        { "type": "stat", "label": "Sa", "value": "18°", "icon": "cloud-drizzle" },
        { "type": "stat", "label": "So", "value": "23°", "icon": "sun" }
      ]
    }
  ]
}
import { ui } from "@checkcourt/sdk";

const days = [
  ["Heute", "sun", "24°"],
  ["Do", "cloud-sun", "21°"],
  ["Fr", "cloud-rain", "17°"],
  ["Sa", "cloud-drizzle", "18°"],
  ["So", "sun", "23°"],
] as const;

ui.doc([
  ui.row(
    [
      ui.stat("Jetzt", "24°", { hint: "Sonnig", icon: "sun", size: "lg" }),
      ui.badge("Regen möglich", { variant: "warning", icon: "droplets" }),
    ],
    { justify: "between" },
  ),
  ui.divider(),
  ui.columns(
    days.map(([day, icon, temp]) => ui.stat(day, temp, { icon })),
    { dividers: true, align: "center" },
  ),
]);

Icons, columns, justify, "warning" and size need @checkcourt/sdk 0.10.0 or later.

Actions

Clicking a button or submitting a form sends a POST to the same URL:

{
  "context": "<new token>",
  "point": "dashboard.widget",
  "subject": null,
  "action_id": "door.open",
  "values": { "minutes": 10 }
}

values contains the form fields (strings, numbers, booleans; empty optional fields are omitted) and is {} for buttons. CheckCourt only forwards actions that appeared in the panel this person last saw, and checks values against the form's fields (names, types, required, min/max, max_length, select options). If the panel is no longer known (after about 30 minutes or on a different server), the person is asked to try again and your URL is not called.

Respond with a new ui: "v1" document that replaces the panel. An optional "toast": { "kind": "success" | "error", "message": "…" } (up to 200 characters) confirms the action.

If the panel should go away after the action, answer with a hidden document (see below). CheckCourt shows the toast and removes the card. The card only disappears in response to the person's own click, so nothing jumps unexpectedly.

Buttons at the booking.action point send action_id: "invoke" when clicked. Your response opens in a dialog. With "blocks": [] or a hidden document and a toast, you only confirm, without a dialog.

Hide a panel

Not every panel is relevant everywhere: a door app has nothing to say about a booking on a court without a door, a fee app nothing about a member without open fees. Instead of an empty card, tell CheckCourt to show no card at all. Two equivalent responses:

{ "ui": "v1", "hidden": true }

or HTTP 204 No Content with an empty body.

  • Where: booking.detail.panel, member.profile.section, dashboard.widget, kiosk.tile and member.settings.section render nothing for this item. On the compact host surfaces it means no badges, an empty column or no hint, see Host surfaces. The rest of the slot, including other apps, is unaffected.
  • app.settings stays visible. It's your app's own page in the club administration; a vanishing card would leave the admin without any sign of your app. A hidden answer there shows the card with „Hier gibt es gerade nichts anzuzeigen.“ (nothing to show here right now).
  • No jumping: CheckCourt decides before it draws anything. Server-rendered pages wait for your answer, and slots loaded in the browser (booking dialog, profile, kiosk) show no card and no placeholder until your answer is known. A hidden panel never flashes.
  • Caching: a hidden answer is a successful render and is cached like one, see Caching. cache is allowed next to hidden: true, and a 204 answer follows its Cache-Control header.
  • Format: hidden: true must not be combined with blocks: "blocks" must be absent or empty, otherwise the document is invalid. An optional toast is allowed and shows after actions only.
  • Errors stay visible: a non-2xx status, a timeout, invalid JSON or an invalid document still show „Dieser Inhalt ist gerade nicht verfügbar.“ Hiding is a statement about the content, not a way to hide outages. Don't answer 204 when your backend fails.
  • booking.action buttons can't be hidden. They come from your manifest and are always shown, so the row of buttons in the booking dialog doesn't shift while it loads. If an action doesn't apply to a booking, answer the click with a short text that explains why.

Caching

CheckCourt keeps a successful render (including a hidden one) per installation, point, object, person and capabilities, so a page with many panels doesn't call your app on every load. You decide how long:

  1. cache.max_age in the document, in whole seconds. 0 means don't cache:

    { "ui": "v1", "blocks": [ … ], "cache": { "max_age": 120 } }

    With the SDK: ui.doc(blocks, { maxAge: 120 }) or ui.hidden({ maxAge: 0 }).

  2. The Cache-Control response header, if the document has no cache: no-store and no-cache mean don't cache, max-age=N caches for N seconds. Other directives such as private or s-maxage are ignored.

  3. Otherwise 30 seconds.

Every value is capped at 300 seconds. An invalid cache (negative, not a whole number, not an object) doesn't break the render; CheckCourt ignores it and uses the header or the default.

The cache is cleared earlier when:

  • Your app runs an action: all cached renders of the installation.
  • The object changes: a booking.* event drops every app's renders of that booking, and a member.* event those of that member, for all people. Other bookings and members stay cached. Points without an object (dashboard.widget, kiosk.tile) keep their lifetime, so choose a short max_age there if your content changes often.
  • The day, court or member changes: host surfaces are cached per day, court or member and dropped by the matching events, see Host surfaces.

In the sandbox club nothing is cached, and neither is anything that goes through checkcourt listen: every load calls your app, so you see each change at once. Expect production to be served from the cache.

iframe extensions

CheckCourt renders:

<iframe
  src="<your URL>?context=<token>&channel=<nonce>"
  sandbox="allow-scripts allow-forms allow-popups"
  referrerpolicy="no-referrer"
  loading="lazy"
></iframe>

Opaque origin: the sandbox has no allow-same-origin, so your page runs with the origin null, even when it loads files from its own domain. This has consequences:

  • No cookies or storage of your domain inside the frame. Sign in with the context token, for example by having your backend exchange it for a short session. The token expires after five minutes: then reload, or ask the person to open the page again.
  • No Referer. The frame is loaded with referrerpolicy="no-referrer", and requests from the frame carry Origin: null. Neither tells you that a request comes from CheckCourt; only a verified context token does.
  • CORS for fonts and modules. Browsers fetch web fonts and module scripts with CORS checks, and the frame's origin is null. Serve them with Access-Control-Allow-Origin: * (or inline fonts as data URLs), otherwise they are blocked inside the frame.
  • Popups only for links. allow-popups lets links with target="_blank" open a new tab. allow-modals is missing, so alert() and confirm() do nothing.

Initial height: the frame starts 240 pixels high; a nav.page frame fills the page instead. Set height on the manifest entry (whole pixels, 120 to 2,000) to start at another height:

{ "point": "app.settings", "kind": "iframe", "url": "https://tuer.example.de/settings", "height": 480 }

height is allowed on iframe extensions only and has no effect on nav.page. Your first checkcourt:resize message replaces the initial height, except on a nav.page, which always fills the page.

channel is a random value per render. Read it from your URL on load and send it with every message. CheckCourt discards messages without the current channel. If your frame navigates to another page (a login redirect, an external link), that page doesn't know the channel and can no longer talk to CheckCourt.

Messages from your frame to CheckCourt, sent with window.parent.postMessage({ ...message, channel }, "*"):

MessageEffect
{ "type": "checkcourt:resize", "height": 420 }Sets the frame height (80 to 2,000 pixels), replacing the initial height
{ "type": "checkcourt:toast", "kind": "success", "message": "…" }Shows a notification (kind can also be "error", up to 200 characters)
{ "type": "checkcourt:navigate", "path": "/booking?date=2026-10-07" }Navigates within CheckCourt

Allowed targets for navigate start with /dashboard, /booking, /my-bookings, /club, /team, /trainer, /fees, /arbeitsstunden, /settings or /admin. CheckCourt only accepts messages that come from your frame and carry the correct channel. Everything else, including any other target, is ignored.

On load and on every color scheme change, CheckCourt sends:

{
  "type": "checkcourt:theme",
  "channel": "…",
  "mode": "light",
  "tokens": { "--background": "…", "--foreground": "…", "--primary": "…" }
}

mode is "light" or "dark". Apply the tokens as CSS variables and your frame matches its surroundings:

const channel = new URLSearchParams(location.search).get("channel");

window.addEventListener("message", (e) => {
  if (e.source !== window.parent || e.data?.channel !== channel) return;
  if (e.data.type === "checkcourt:theme") {
    for (const [name, value] of Object.entries(e.data.tokens)) {
      document.documentElement.style.setProperty(name, value);
    }
    document.documentElement.classList.toggle("dark", e.data.mode === "dark");
  }
});

new ResizeObserver(() => {
  window.parent.postMessage(
    { type: "checkcourt:resize", height: document.body.scrollHeight, channel },
    "*",
  );
}).observe(document.body);

connectExtensionFrame from @checkcourt/sdk/iframe reads token and channel, keeps the height in sync and applies the theme (more):

import { connectExtensionFrame } from "@checkcourt/sdk/iframe";

const frame = connectExtensionFrame();
// send frame.context to your backend and verify it there
frame.toast("success", "Gespeichert");
frame.navigate("/booking?date=2026-10-07");

Host surfaces

Six points sit directly in CheckCourt's own screens, and CheckCourt draws the element itself, in the place where it belongs: a badge in a court header, a cell in the member list, a hint in the booking dialog, a button in the plan header or in the sidebar, a card on the member's settings page. Technically they are declarative extensions: the same signature, the same context token, 2xx only, at most 64 KB, no redirects. They differ in what the request carries, what you answer and what happens when something goes wrong.

PointWho sees itScopeExtra request fieldsYou answer withOn failure
court.annotationEveryone who sees the booking plan, and the kioskcourts:readdate, courtsannotationsnothing
member.list.columnPeople allowed to see the member listmembers:readmemberscolumnempty cells
member.settings.sectionThe member who connected the appnonenonea ui: "v1" documentcalm text in the card
booking.hintThe member in the booking dialogbookings:readdrafta restricted ui: "v1" documentnothing
booking_plan.actionEveryone who sees the booking plancourts:readdate, courts (subject is the date too)a ui: "v1" document in a dialogcalm text in the dialog
sidebar.actionEvery signed-in member, or only the connected membernonenonea ui: "v1" document in a dialogcalm text in the dialog

Common rules

  • No layout shift. CheckCourt reserves the space before your answer arrives, but only when an installed app declares the point and may render for this person. Your answer fills that space and never pushes other content around. The details are listed per point below.
  • Compact surfaces fail silently. For court.annotation, member.list.column and booking.hint, a timeout, a non-2xx status, invalid JSON or an invalid answer show nothing: no badge, an empty cell, no hint. A court header has no room for an error message. CheckCourt logs the failure on its side. While developing, checkcourt listen shows every request with its status.
  • Nothing to say: { "ui": "v1", "hidden": true } or 204 No Content means no badges, an empty column or no hint. An empty annotations or values list does the same.
  • No toasts. Compact surfaces ignore toast.
  • Caching follows the usual rules: cache.max_age, then Cache-Control, otherwise 30 seconds, at most 300 seconds, nothing in the sandbox club. Which keys and events apply is listed under Caching on host surfaces.
  • SDK: from @checkcourt/sdk 0.5.0, verifyExtensionRequest also returns the extra request fields (date, courts, members, draft), and ui.annotations, ui.column and ui.hint build the answers. members and draft exist on render requests only, so check request.kind === "render" before you read them.

Court badges (court.annotation)

Short badges in the header of each court in the booking plan, such as „Flutlicht an“ (floodlight on) or „Halle beheizt“ (indoor court heated). Everyone who sees the booking plan sees them. The kiosk shows them too, read-only on today's court cards, with viewer.user_id set to null. Club installations only, and the installation needs courts:read.

CheckCourt sends one request per installation and plan view, with all visible courts of that day:

{
  "context": "<token>",
  "point": "court.annotation",
  "subject": null,
  "date": "2026-10-07",
  "courts": [
    { "id": 1, "name": "Platz 1" },
    { "id": 2, "name": "Platz 2" },
    { "id": 3, "name": "Platz 3" }
  ]
}

Answer with one entry per court you have something to say about:

{
  "ui": "v1",
  "annotations": [
    { "court_id": 1, "label": "Flutlicht an", "variant": "secondary" },
    { "court_id": 3, "label": "Gesperrt bis 14:00", "variant": "outline" }
  ],
  "cache": { "max_age": 120 }
}
  • label: 1 to 24 characters, plain text.
  • variant (optional): "default", "secondary", "outline", "destructive" or "warning", as for the badge block.
  • At most one entry per court. A second entry for the same court makes the whole answer invalid.
  • At most 200 entries. Court IDs that weren't in the request are dropped; they may be courts this person can't see.
  • CheckCourt shows at most two badges per court across all apps, by app name. Further badges are not shown.

No layout shift: if at least one app declares court.annotation and may render for this person, every court header gets one fixed badge line. The badges appear in it when the answers arrive. Without such an app, the header stays as it is.

const request = await verifyExtensionRequest({ secret, rawBody: req.body, headers: req.headers });
if (request.point === "court.annotation") {
  const lit = await floodlightCourts(request.date!); // Set of court IDs
  return res.json(
    ui.annotations(
      request.courts!
        .filter((court) => lit.has(court.id))
        .map((court) => ({ court_id: court.id, label: "Flutlicht an", variant: "secondary" })),
      { maxAge: 120 },
    ),
  );
}

Member list column (member.list.column)

An extra column in the member list under Admin → Mitglieder (members), for example the fee status from your bookkeeping. Only people who may see the member list see it. Club installations only, and the installation needs members:read. CheckCourt shows at most two app columns, from the first two apps by name.

CheckCourt sends one request per installation and list page, with the members on the visible page only (at most 20):

{
  "context": "<token>",
  "point": "member.list.column",
  "subject": null,
  "members": [
    { "member_id": "…", "user_id": "…" },
    { "member_id": "…", "user_id": "…" }
  ]
}

member_id is the club membership, the same member_id as in the member.* webhook events. user_id is the person's user ID. Answer with the column:

{
  "ui": "v1",
  "column": {
    "title": "Beitrag",
    "values": [
      { "member_id": "…", "text": "Offen", "variant": "destructive" },
      { "member_id": "…", "text": "Bezahlt" }
    ]
  }
}
  • title: 1 to 20 characters.
  • text: 1 to 24 characters. With a variant the cell shows a badge, without one plain text.
  • At most one value per member. A second value for the same member makes the whole answer invalid.
  • At most 200 values. Values for members that weren't in the request are dropped.
  • Members without a value get an empty cell. That's fine and often the right answer.
  • viewer.capabilities.can_edit_members tells you whether the person may edit members.

No layout shift: the column is there from the start. Until your answer arrives, the header shows the label from your manifest entry, or stays empty if there is none, and the cells fill in afterwards. Use the same text for label and column.title, so the header doesn't change. If your app fails, the header stays and the cells stay empty.

if (request.point === "member.list.column" && request.kind === "render") {
  const open = await openFees(request.members!.map((m) => m.member_id));
  return res.json(
    ui.column({
      title: "Beitrag",
      values: request.members!.map((m) =>
        open.has(m.member_id)
          ? { member_id: m.member_id, text: "Offen", variant: "destructive" }
          : { member_id: m.member_id, text: "Bezahlt" },
      ),
    }),
  );
}

Member settings card (member.settings.section)

A card on the member's own Einstellungen (settings) page, right after Verbundene Apps (connected apps), one card per connected app. Only for member apps ("user" in installTargets), and only the member who connected the app sees it. viewer.user_id is always that member. No scope is needed.

It works like the other panels: declarative or iframe, a card titled with your label if you set one, buttons and forms, hiding and caching as usual. The request is a normal render:

{ "context": "<token>", "point": "member.settings.section", "subject": null }

If your app fails, the card shows the calm „Dieser Inhalt ist gerade nicht verfügbar.“ (this content is currently unavailable), like every other panel.

if (request.point === "member.settings.section") {
  const prefs = await reminderSettings(request.context.viewer.user_id);
  return res.json(
    ui.doc([
      ui.form({
        actionId: "reminders.save",
        submitLabel: "Speichern",
        fields: [ui.field.switch("reminders", "Erinnerung vor jeder Buchung", { default: prefs.enabled })],
      }),
    ]),
  );
}

Booking hint (booking.hint)

A short hint in the booking dialog, such as „Ab 19:30 brennt das Flutlicht, das kostet 4,50 € pro Stunde.“ (from 19:30 the floodlight is on, at 4.50 € per hour). The member who is booking sees it. Club installations only, and the installation needs bookings:read.

A hint is purely informational. It never blocks a booking and never changes it. The member can always confirm, whatever your hint says, and your app can't prevent or alter the booking from here.

CheckCourt asks as soon as the member has picked court, date, start and end time, 300 milliseconds after the last change:

{
  "context": "<token>",
  "point": "booking.hint",
  "subject": null,
  "draft": {
    "court_id": 3,
    "date": "2026-10-07",
    "start_time": "18:00",
    "end_time": "19:00",
    "type": "regular"
  }
}

type is "regular", "training" or "mannschaft" (team). The draft isn't a booking yet, so there is no booking ID.

Timeout: 1 second instead of 2. A hint that arrives later is no longer useful.

Answer with a regular ui: "v1" document, restricted to these top-level blocks: text, badge, key_value and link, at most 6 of them. Any other block (button, form, heading, stack, row and so on) makes the answer invalid, and nothing is shown.

{
  "ui": "v1",
  "blocks": [
    { "type": "text", "text": "Ab 19:30 brennt das Flutlicht, das kostet 4,50 € pro Stunde." }
  ]
}

CheckCourt shows hints from at most two apps, the first two by name.

No layout shift: if an app declares booking.hint, the dialog reserves a fixed-height area above the confirm button. Taller content scrolls inside that area. The confirm button never moves.

if (request.point === "booking.hint" && request.kind === "render") {
  const { court_id, date, end_time } = request.draft!;
  if (!(await needsFloodlight(court_id, date, end_time))) return res.status(204).end();
  return res.json(ui.hint([ui.text("Ab 19:30 brennt das Flutlicht, das kostet 4,50 € pro Stunde.")]));
}

Booking plan button (booking_plan.action)

A button in the header of the booking plan, next to the date navigation, for everyone who sees the plan. Club installations only, and the installation needs courts:read.

The button is a static action: CheckCourt draws it from your manifest, with its label (required, up to 24 characters) and optional icon, when the page renders. Your app is not called for that, so the button never jumps in and can't be hidden per day or person. Only a click calls your app:

{
  "context": "<token>",
  "point": "booking_plan.action",
  "subject": { "type": "booking_plan", "id": "2026-10-07" },
  "date": "2026-10-07",
  "courts": [
    { "id": 1, "name": "Platz 1" },
    { "id": 2, "name": "Platz 2" }
  ]
}

subject.id is the date the plan shows. The request also carries date and the plan's courts ([{ id, name }], the same shape as court.annotation), so a form can offer a court select without a separate GET /courts. Answer with a regular ui: "v1" document with any blocks; CheckCourt opens it in a dialog. Buttons and forms in it send normal action requests with the same subject, date and courts. Every click asks your app afresh: the answer to the click is never cached. viewer.capabilities.can_edit_bookings tells you whether the person may edit bookings.

  • If your app fails, the dialog shows „Dieser Inhalt ist gerade nicht verfügbar.“
  • A hidden document closes the dialog (or opens none). A toast is shown, both in the answer to the click and after an action inside the dialog, so a click can also just confirm something with a toast and a hidden document.

Sidebar entry (sidebar.action)

An entry at the bottom of the sidebar and of the mobile navigation, above Abmelden (sign out) and Hilfe (help). For club installations every signed-in member sees it, for member apps only the member who connected the app. No scope is needed. CheckCourt shows at most two app entries, the first two by app name.

It is a static action like booking_plan.action: label (required, up to 24 characters) and optional icon come from the manifest, and a click sends { "context": "<token>", "point": "sidebar.action", "subject": null }. The answer opens in a dialog with the same rules.

Both static actions in the manifest, and a handler for the plan button:

manifest.ts
extensions: [
  {
    point: "booking_plan.action",
    kind: "declarative",
    url: "https://wetter.example.de/ext/plan",
    label: "Wetter ansehen",
    icon: "cloud-rain",
  },
  {
    point: "sidebar.action",
    kind: "declarative",
    url: "https://wetter.example.de/ext/sidebar",
    label: "Platzwetter",
    icon: "sun",
  },
],
if (request.point === "booking_plan.action" && request.kind === "render") {
  const day = request.subject!.id; // "2026-10-07"
  const forecast = await forecastFor(day);
  return res.json(
    ui.doc([ui.keyValue(forecast.hours), ui.link("Vorhersage öffnen", forecast.url)]),
  );
}

Caching on host surfaces

Besides installation and person, the cache key of a host surface contains:

PointAlso in the keyDropped early when
court.annotationDate and court IDsa booking.* event on that date, court.locked or court.unlocked for one of the courts
member.list.columnMember IDs of the pagea member.* event for one of the members on the page
booking.hintThe complete drafta booking.*, court.locked or court.unlocked event on the draft's date
member.settings.sectionas for other panelsas for other panels
booking_plan.action, sidebar.actionnever cached

As everywhere, an action of your app drops all cached renders of the installation, and the sandbox club caches nothing.

Pages in the navigation (nav.page)

A nav.page adds an entry to CheckCourt's main navigation, in the sidebar and in the mobile menu, that opens a full page showing your app. Use it for things a member comes back to on their own: a training plan, a ladder, a court diary. It is not a place for one-off actions; those fit sidebar.action better.

  • Targets: club apps and member apps.
  • Kinds: declarative or iframe. On its own page your UI is the content, so an iframe is allowed here, unlike on the dashboard or the sidebar.
  • label and icon are required. The label (1 to 24 characters) is the text of the navigation entry and the page title. The icon is one of the allowed icon names. CheckCourt draws the entry from the manifest alone, so your app is not called until someone opens the page.
  • One per app. A manifest may contain at most one nav.page.
  • No scope and no object. subject is null; the request carries nothing beyond context, point and subject. viewer.capabilities is empty, apart from permissions if your manifest declares permissions.
  • requires (optional): the key of one of your permissions. Then only people holding it see the entry and can open the page and run its actions; administrators always do.

Who sees the entry: for a club installation every member of the club (with requires, only those holding the permission), for a member app only the member who connected it. The entries sit after CheckCourt's own entries and the club's own menu links, before Admin, ordered by app name. They disappear as soon as the installation is paused, uninstalled or the app is suspended.

Just a link? No app needed

Clubs can add up to eight links of their own to the navigation under Admin → Grundeinstellungen → Menüpunkte (basic settings, menu items), for example to the club website or a tournament calendar. Such a link opens the address and nothing else. Build a nav.page only when your app has a page of its own to show.

The page lives at /apps/<installation ID>/<index>, where <index> is the position of the nav.page entry in your manifest's extensions list. A declarative page looks like one of CheckCourt's own pages: your label as the page title and your UI directly below it, without a card or your app's name. Anyone who may not see the entry gets a „not found“ page instead, including members of other clubs and, for a member app, every other member.

A declarative page is rendered and cached like any other declarative extension: buttons and forms send action requests with "point": "nav.page", and the answer replaces the page content. A hidden document does not remove the page; CheckCourt shows „Hier gibt es gerade nichts anzuzeigen.“ (nothing to show here right now) instead. If your app fails or times out, the page shows „Dieser Inhalt ist gerade nicht verfügbar.“ (this content is currently unavailable).

An iframe page follows the iframe rules but is shown full-bleed: the frame takes the whole content area next to CheckCourt's navigation, without a title, card, border or padding, on phones too. Your page scrolls inside the frame; height and checkcourt:resize are ignored here. Design it like a full page of your own.

manifest.ts
extensions: [
  {
    point: "nav.page",
    kind: "iframe",
    url: "https://rangliste.example.de/checkcourt/page",
    label: "Rangliste",
    icon: "trophy",
  },
],

With permissions and requires, only people holding the right see the entry. Since an app has one nav.page, this replaces the public page, for example when the page is only for the ladder's organizers:

manifest.ts
permissions: [{ key: "manage_ladder", label: "Rangliste verwalten" }],
extensions: [
  {
    point: "nav.page",
    kind: "iframe",
    url: "https://rangliste.example.de/checkcourt/admin",
    label: "Rangliste verwalten",
    icon: "list-checks",
    requires: "manage_ladder",
  },
],
manifest.ts
extensions: [
  {
    point: "nav.page",
    kind: "declarative",
    url: "https://training.example.de/ext/page",
    label: "Trainingsplan",
    icon: "calendar",
  },
],

A minimal declarative page with a form:

if (request.point === "nav.page") {
  if (request.kind === "action" && request.actionId === "note") {
    await saveNote(request.context.viewer.user_id, String(request.values.text));
    return res.json(ui.doc([ui.text("Notiz gespeichert.")], { toast: toast.success("Gespeichert") }));
  }
  return res.json(
    ui.doc([
      ui.heading("Diese Woche"),
      ui.keyValue([
        { label: "Dienstag", value: "18:00 Jugendtraining" },
        { label: "Donnerstag", value: "19:00 Herren 40" },
      ]),
      ui.form({
        actionId: "note",
        submitLabel: "Speichern",
        fields: [ui.field.text("text", "Notiz an den Trainer", { multiline: true, max_length: 500 })],
      }),
    ]),
  );
}

@checkcourt/sdk knows nav.page from version 0.8.0 and height from version 0.9.0.

Test in the sandbox

Install the app in the sandbox club and open the place where the point appears: a booking in the booking calendar, the dashboard, the app's page under Admin → Apps, the member page in the admin area, the kiosk, the booking plan (court badges and the plan button), the member list, the booking dialog, your own settings page, the sidebar or your app's entry in the navigation. Every new version takes effect in the sandbox immediately. To see what an ordinary member sees, use Als dieses Mitglied ansehen (view as this member) on one of the demo members, see the sandbox club. If you only see „nicht verfügbar“, check:

  • Do you verify with the current signing secret? It exists from the app's creation on; if you generated a new one, only the new one is valid.
  • Does your URL respond within 2 seconds with 2xx and a valid ui: "v1" document under 64 KB?
  • Does your URL redirect? Redirects are not followed.
  • Does the installation hold the scope the point requires?

On the compact host surfaces there is no „nicht verfügbar“: a badge, cell or hint that doesn't appear failed in the same way. booking.hint has only 1 second.

On this page