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.
| Point | Where | Declarative | iframe | Object |
|---|---|---|---|---|
app.settings | Admin → Apps → your app, below the settings form | yes | yes | installation |
booking.detail.panel | Booking details dialog | yes | yes | booking |
member.profile.section | Member page in the admin area and the member's own settings | yes | yes | member |
dashboard.widget | Dashboard | yes | no | none |
booking.action | Button among the actions in the booking details (requires label, may appear more than once) | yes | no | booking |
kiosk.tile | Home screen of the kiosk terminal, with nobody signed in | yes | no | none |
court.annotation | Short badges in the court headers of the booking plan and on the kiosk court cards | yes | no | none |
member.list.column | An extra column in the member list under Admin → Mitglieder | yes | no | none |
member.settings.section | A card on the member's own Einstellungen (settings) page, member apps only | yes | yes | none |
booking.hint | Short hint in the booking dialog, above the confirm button | yes | no | none |
booking_plan.action | Button in the header of the booking plan (requires label) | yes | no | booking_plan |
sidebar.action | Entry at the bottom of the sidebar and the mobile navigation (requires label) | yes | no | none |
nav.page | Entry in the main navigation that opens a full page with your UI (requires label and icon) | yes | yes | none |
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 getinstallation_kind: "user"and the member's real user ID. Otherwise the club installation renders, for example when the link lacks the point's scope inuserScopesor 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": "…"
}expisiat + 300, so the token is valid for five minutes. Reject expired tokens (at most 30 seconds of tolerance for clock skew) and tokens whosealgis notHS256.installation_kindis"tenant"for club installations and"user"for member connections.subjectisnullfordashboard.widget,kiosk.tile,court.annotation,member.list.column,member.settings.section,booking.hint,sidebar.actionandnav.page.booking_plan.actiongets{ "type": "booking_plan", "id": "2026-10-07" }, the date of the plan the person is looking at.viewer.user_id:nullat the kiosk, where nobody is signed in (kiosk.tileandcourt.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.
capabilitiesare 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 arefalse. Without declared permissions the field is absent.
- Bookings:
- Compare
installation_idandtenant_idwith what you know fromapp.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
| Block | Fields |
|---|---|
text | text (up to 2,000 characters), tone?: "muted", icon? |
heading | text (up to 200), level: 2 or 3, icon? |
stat | label, value (up to 80), hint? (up to 200), icon?, size?: "md" (default) or "lg" |
badge | label (up to 40), variant?: "default", "secondary", "outline", "destructive", "warning"; icon? |
list | items: [{ title, description?, icon? }] (up to 50) |
key_value | pairs: [{ label, value }] (up to 30) |
link | label, url (https only, opens in a new tab) |
button | label (up to 40), action_id, variant?: "default", "secondary", "outline", "destructive" |
form | fields (up to 20), submit_label, action_id |
divider | none |
stack | children, stacked vertically |
row | children, side by side, wraps; justify?: "start" (default, packed to the left) or "between" (spread across the full width) |
columns | children (2 to 6), equal widths; dividers?: boolean, align?: "start" (default) or "center" |
Form fields:
type | Fields |
|---|---|
text | name, label, placeholder?, default?, required?, max_length?, multiline?, rows? (1 to 12) |
number | name, label, default?, min?, max?, required? |
select | name, label, options: [{ value, label }] (1 to 50), default?, required? |
switch | name, label, default? |
date | name, label, default?, min?, max?, required? |
time | name, 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:
| Group | Icons |
|---|---|
| Weather | sun, 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 |
| General | check, 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.tileandmember.settings.sectionrender 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.settingsstays 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.
cacheis allowed next tohidden: true, and a204answer follows itsCache-Controlheader. - Format:
hidden: truemust not be combined with blocks:"blocks"must be absent or empty, otherwise the document is invalid. An optionaltoastis allowed and shows after actions only. - Errors stay visible: a non-
2xxstatus, 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 answer204when your backend fails. booking.actionbuttons 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 shorttextthat 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:
-
cache.max_agein the document, in whole seconds.0means don't cache:{ "ui": "v1", "blocks": [ … ], "cache": { "max_age": 120 } }With the SDK:
ui.doc(blocks, { maxAge: 120 })orui.hidden({ maxAge: 0 }). -
The
Cache-Controlresponse header, if the document has nocache:no-storeandno-cachemean don't cache,max-age=Ncaches forNseconds. Other directives such asprivateors-maxageare ignored. -
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 amember.*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 shortmax_agethere 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 withreferrerpolicy="no-referrer", and requests from the frame carryOrigin: 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 withAccess-Control-Allow-Origin: *(or inline fonts as data URLs), otherwise they are blocked inside the frame. - Popups only for links.
allow-popupslets links withtarget="_blank"open a new tab.allow-modalsis missing, soalert()andconfirm()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 }, "*"):
| Message | Effect |
|---|---|
{ "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.
| Point | Who sees it | Scope | Extra request fields | You answer with | On failure |
|---|---|---|---|---|---|
court.annotation | Everyone who sees the booking plan, and the kiosk | courts:read | date, courts | annotations | nothing |
member.list.column | People allowed to see the member list | members:read | members | column | empty cells |
member.settings.section | The member who connected the app | none | none | a ui: "v1" document | calm text in the card |
booking.hint | The member in the booking dialog | bookings:read | draft | a restricted ui: "v1" document | nothing |
booking_plan.action | Everyone who sees the booking plan | courts:read | date, courts (subject is the date too) | a ui: "v1" document in a dialog | calm text in the dialog |
sidebar.action | Every signed-in member, or only the connected member | none | none | a ui: "v1" document in a dialog | calm 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.columnandbooking.hint, a timeout, a non-2xxstatus, 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 listenshows every request with its status. - Nothing to say:
{ "ui": "v1", "hidden": true }or204 No Contentmeans no badges, an empty column or no hint. An emptyannotationsorvalueslist does the same. - No toasts. Compact surfaces ignore
toast. - Caching follows the usual rules:
cache.max_age, thenCache-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/sdk0.5.0,verifyExtensionRequestalso returns the extra request fields (date,courts,members,draft), andui.annotations,ui.columnandui.hintbuild the answers.membersanddraftexist on render requests only, so checkrequest.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 thebadgeblock.- 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 avariantthe 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_memberstells 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
toastis 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:
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:
| Point | Also in the key | Dropped early when |
|---|---|---|
court.annotation | Date and court IDs | a booking.* event on that date, court.locked or court.unlocked for one of the courts |
member.list.column | Member IDs of the page | a member.* event for one of the members on the page |
booking.hint | The complete draft | a booking.*, court.locked or court.unlocked event on the draft's date |
member.settings.section | as for other panels | as for other panels |
booking_plan.action, sidebar.action | never 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:
declarativeoriframe. On its own page your UI is the content, so an iframe is allowed here, unlike on the dashboard or the sidebar. labelandiconare required. Thelabel(1 to 24 characters) is the text of the navigation entry and the page title. Theiconis 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.
subjectisnull; the request carries nothing beyondcontext,pointandsubject.viewer.capabilitiesis empty, apart frompermissionsif 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.
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:
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",
},
],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
2xxand a validui: "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.