Webhooks
Receive events from CheckCourt, verify signatures and trace deliveries
Webhooks report events from a club the moment they happen: a new booking, a
cancellation, a member joining the club. CheckCourt sends a signed POST request to an
address of your choice.
There are two ways to receive events. Format, signature and retries are the same for both.
| Club webhook endpoints | Deliveries to apps | |
|---|---|---|
| Who sets them up? | Club admins under Admin → Automatisierungen (automations) or via the API (/api/v1/webhooks) | You as the developer, through your app's manifest |
| Which events? | The ones checked on the endpoint | The ones under events in the manifest, for every installation |
| Address and secret | Per endpoint | One webhook URL per app, in the developer portal under Webhooks |
| Prerequisite | Feature webhooks for the club, enabled by support | A running installation of your app |
| Typical use | Zapier, n8n, your own club software | Apps for many clubs |
How club admins create endpoints in the UI is described in the administrator guide.
Request structure
Every delivery is a POST with a JSON body:
{
"id": "evt_k1x9q2…",
"type": "booking.created",
"created_at": "2026-05-04T08:15:00.000Z",
"tenant_id": "…",
"object": { "type": "booking", "id": "…" },
"data": {
"booking_id": "…",
"court_id": 3,
"date": "2026-05-04",
"start_time": "18:00:00",
"end_time": "19:00:00",
"type": "regular"
}
}| Field | Meaning |
|---|---|
id | Event ID with the prefix evt_. Stays the same on every retry |
type | Event type, see the event catalog |
created_at | Time of the event (ISO 8601, UTC) |
tenant_id | The club where it happened |
installation_id | Only for deliveries to apps: the installation it is delivered for |
object | Type and ID of the affected object, such as booking, member, court or app_installation |
data | The fixed fields of the event type |
These headers are also sent:
| Header | Content |
|---|---|
CheckCourt-Signature | t=<Unix seconds>,v1=<HMAC-SHA256 as hex> |
CheckCourt-Event-Id | The same ID as id in the body, unique per event |
CheckCourt-Event-Type | The same type as type in the body |
CheckCourt-Installation-Id | Only for deliveries to apps |
Content-Type | application/json |
User-Agent | CheckCourt-Webhooks/1 |
Thin events
An event deliberately contains only IDs and non-sensitive facts, never names, email
addresses or other personal data. You fetch everything else through the
REST API, where permissions apply. A lighting or door
system can switch using court_id, date, start_time and end_time without fetching
anything. And after a delayed attempt, reading via the API always gives you the current
state.
Verify the signature
Verify every request before you process it. That way you know it really comes from CheckCourt and was not modified in transit.
tis the time of sending in Unix seconds.v1is an HMAC-SHA256 in hex over the text<t>.<body>, that is, the value oft, a period and the unmodified body.- The key is the complete secret, including the
whsec_prefix. - Reject requests whose timestamp differs from your clock by more than five minutes (300 seconds). This protects against replay of old requests.
- Compare the signature in constant time.
Use the raw body
Compute the signature over the body exactly as it arrived. If you parse the JSON first and then serialize it again, you get a different string and the verification fails.
import crypto from "node:crypto";
// rawBody: the unmodified body as a string, not re-serialized
function isFromCheckCourt(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const given = String(parts.v1 ?? "");
return (
given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
);
}With Express, you get the raw body by using express.raw instead of express.json for
this route:
import express from "express";
const app = express();
const SECRET = process.env.CHECKCOURT_WEBHOOK_SECRET; // whsec_…
app.post("/webhooks/checkcourt", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!isFromCheckCourt(SECRET, rawBody, req.get("CheckCourt-Signature") ?? "")) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
res.status(200).end(); // acknowledge first, then process
handleEvent(event).catch(console.error);
});import hashlib
import hmac
import time
def is_from_checkcourt(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = parts.get("t", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))With Flask, request.get_data() returns the raw body:
import json
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["CHECKCOURT_WEBHOOK_SECRET"] # whsec_…
@app.post("/webhooks/checkcourt")
def checkcourt_webhook():
raw = request.get_data()
if not is_from_checkcourt(SECRET, raw, request.headers.get("CheckCourt-Signature", "")):
return "", 401
event = json.loads(raw)
enqueue(event) # acknowledge first, then process
return "", 200The TypeScript SDK checks signature and timestamp and returns the typed event:
import express from "express";
import { SIGNATURE_HEADER, verifyWebhook } from "@checkcourt/sdk";
const app = express();
app.post("/webhooks/checkcourt", express.raw({ type: "application/json" }), async (req, res) => {
const event = await verifyWebhook({
secret: process.env.CHECKCOURT_WEBHOOK_SECRET!, // whsec_…
rawBody: req.body, // Buffer, untouched
signatureHeader: req.get(SIGNATURE_HEADER),
}).catch(() => null);
if (!event) return res.status(401).end();
res.status(200).end(); // acknowledge first, then process
handleEvent(event).catch(console.error);
});Responses, retries and duplicates
Respond within 10 seconds with a 2xx status. Anything else counts as a failure: a
different status, a timeout, a connection error. CheckCourt does not follow redirects
(3xx). CheckCourt stores at most 2,048 characters of your response for the delivery
log.
After a failure, CheckCourt retries up to twice, so three attempts in total over about one hour:
| Attempt | Delay after the previous one |
|---|---|
| 1 | immediately (sending runs once per minute) |
| 2 | 5 minutes |
| 3 | 1 hour |
If the third attempt also fails, the delivery is considered permanently failed. You can resend it later from the delivery log.
Handle duplicate deliveries
The same event can arrive more than once, for example if your response gets lost in transit and CheckCourt retries. The order is not guaranteed. Therefore:
- Store
CheckCourt-Event-Idand process each ID only once. The simplest way is a table whose primary key is the event ID. A second insert with the same ID then fails in the database, and you know you have already seen the event. - Acknowledge first, then process. Store the event, respond with
200and handle it afterwards. Otherwise long processing leads to timeouts and unnecessary retries. - Read the current state via the API instead of inferring state from the order of
events. A
booking.updatedcan be followed by an olderbooking.createdif its first attempt failed.
Event catalog
| Event | Requires scope | Meaning | Fields in data |
|---|---|---|---|
booking.created | bookings:read | A booking was created | booking_id, court_id, date, start_time, end_time, type |
booking.updated | bookings:read | A booking was changed: court, date, time or players | as above |
booking.cancelled | bookings:read | A booking was cancelled | as above |
booking.checked_in | bookings:read | A booking was checked in | as above |
member.joined | members:read | A member joined the club | member_id, user_id |
member.updated | members:read | A member's data was changed | member_id, user_id, changed_fields |
member.left | members:read | A member left the club | member_id, user_id |
court.locked | courts:read | A court was locked, entirely or for a time period | court_id, date, start_time, end_time |
court.unlocked | courts:read | A court lock was lifted | as above |
Notes on the fields:
court_idis a number, all other IDs are strings.datehas the formatYYYY-MM-DD, timesHH:MM:SS(for example"18:00:00").- For
court.lockedandcourt.unlocked,date,start_timeandend_timearenullif the whole court was locked or unlocked rather than a time period. - For
member.updated,changed_fieldslists which details changed, not the new values.
Only for specific recipients
| Event | Goes to | Fields in data |
|---|---|---|
webhook.test | Only the endpoint for which someone chose Test senden (Send test) | webhook_endpoint_id |
app.installed | Only the app that was installed or connected | installation_id, app_id, version |
app.upgraded | Only the app whose installation now runs a new version | installation_id, app_id, version |
app.uninstalled | Only the app that was removed or disconnected | installation_id, app_id, version |
app.settings_updated | Only the club installation whose settings the club changed | installation_id, app_id, version |
app.metadata_changed | Only club apps the club connected to the writing app, for keys they declare under reads.metadata | object_type, object_id, app, key, deleted |
app.<slug>.<name> | Only club apps the club connected to the publishing app, for events they declare under subscribes | what the publishing app sent |
You cannot subscribe to these events, and they don't belong in the manifest. Apps
receive their lifecycle events automatically, regardless of their scopes, as soon as a
webhook URL is set. app.uninstalled still arrives even though the installation was just
removed: it tells you that you must delete the data of that club or member. After
app.settings_updated, read the new values via GET /api/v1/app/installation. Events
between apps are described in
Connections between apps.
Who receives which event?
Club webhook endpoints receive every checked event in the club. An endpoint can only subscribe to events whose scope is held by the person who creates or changes it. Events that happened before the endpoint existed are not delivered retroactively.
Club apps receive the events from events in the manifest of their installed
version, as long as the installation holds the required scope.
Member apps only receive events that concern their member: for bookings, when the member booked or is listed as a player; for member events, only their own. Court locks don't concern an individual person and are therefore not sent to member apps. In addition, the member must still hold the scope themselves.
For all apps:
- Events from before the installation are not delivered retroactively.
- While an installation is paused, it receives no events, and they are not delivered after resuming either. Sync the state via the API afterwards.
- If an installation is paused or removed, or the app is suspended, deliveries already in the queue are dropped as well.
- Without a webhook URL, the app receives no events. Set the URL before a club installs
the app, otherwise you miss
app.installed.
Addresses
- Only
https://addresses. Credentials in the URL (https://user:pass@…) are not allowed, and the address can be at most 2,048 characters long. - Addresses in internal networks are blocked:
localhost, private and local IP ranges (such as10.…,192.168.…,169.254.…) and their IPv6 counterparts. CheckCourt checks this when saving and again when sending, using the resolved IP address, so a hostname cannot point to an internal target either. - Redirects are not followed.
- Addresses such as
https://example.com/…orhttps://tuer.example.de/…in this documentation are placeholders. In production, use a publichttpsaddress of your own service.
What is allowed where
"Production" is app.checkcourt.de. "Outside production" means a CheckCourt instance that
does not run with NODE_ENV=production, for example a local checkout of the platform.
| Address | Production | Outside production |
|---|---|---|
| Webhook URL, declarative extension URLs | https to a public host only. localhost, *.localhost and loopback, private or link-local IPs are refused when saving and when sending | https to any host, plus http:// to localhost, 127.0.0.1, [::1] and *.localhost. Loopback is reachable when sending; private and link-local IPs are still refused when sending |
| iframe extension URLs | As above, except http(s)://localhost, 127.0.0.1 and [::1], which load only in your organisation's sandbox club | As for webhooks |
| OAuth redirect URIs | https to any host, plus http://localhost, 127.0.0.1 and [::1], which work only for consent in your sandbox club | https, plus http://localhost, 127.0.0.1 and [::1] (not *.localhost) |
To receive webhooks and declarative extension requests on your own machine from
app.checkcourt.de, use the CLI: checkcourt listen forwards
the sandbox club's requests to localhost, without a public URL or tunnel. Versions and
listings that still contain local addresses cannot be submitted for review.
Secret and rotation
The signing secret starts with whsec_ and is shown only once. For a club endpoint
that is when the endpoint is created. An app has its secret from the moment it is
created, with or without a webhook URL. In the developer portal under your app →
Webhooks → Signatur-Secret (signing secret), Anzeigen (show) reveals it once; if
you save a webhook URL before that, it is shown then instead. Store it securely right
away.
When a new secret is generated, the old one stops working immediately. Deliveries still waiting for a retry are signed with the new one too. So update the new secret at your receiver promptly. For apps, the same secret also signs the UI extensions, including the context tokens of iframe extensions. Neu erzeugen (generate new) works with or without a webhook URL. Setting, changing or removing your app's webhook URL keeps the secret.
Delivery log and resending
Every delivery appears in the delivery log, newest first: time, event, status, number of attempts, HTTP status, your receiver's response and error message. Entries are deleted after 30 days.
| Status | Meaning |
|---|---|
| Ausstehend (Pending) | Not attempted yet |
| Neuer Versuch geplant (Retry scheduled) | At least one attempt failed, the next one is due |
| Zugestellt (Delivered) | Your receiver responded with 2xx |
| Fehlgeschlagen (Failed) | All attempts failed or the delivery was aborted |
Erneut senden (Resend) puts a delivery back into the queue; it then goes out within the next minute. You can find the log:
- for club endpoints under Admin → Automatisierungen → endpoint, or via the API with
GET /api/v1/webhooks/{id}/deliveriesandPOST /api/v1/webhook-deliveries/{id}/redeliver - for apps in the developer portal on the Webhooks tab under Zustellungen (Deliveries), for all installations of your app
Persistent failures
Club endpoints are disabled automatically if their deliveries have been failing for five days and there was another failure in the last 24 hours. Open deliveries are then aborted. The person who created the endpoint gets an email, as long as they are still allowed to Webhooks verwalten (manage webhooks); otherwise everyone with that permission does. The endpoint shows Automatisch deaktiviert (Automatically disabled) and can be switched back on once the problem is fixed.
Apps are not switched off. If deliveries to an installation have been failing for five days, CheckCourt aborts the open deliveries with the note „Die App war seit 5 Tagen nicht erreichbar“ (the app has been unreachable for 5 days) and records this in the club's activity log. All members of your developer organization get one email per failure streak with the app's name, the number of affected clubs and a link to the app's Webhooks tab. The installations stay active and the clubs keep the app. New events continue to be delivered. After an outage, sync the state via the API.
Manage webhooks via the API
With an API key that has webhooks:read or webhooks:manage, you can also manage
endpoints via the REST API. All endpoints are listed in the
API reference. Apps cannot create webhook
endpoints: they receive events only through their manifest, which the club has agreed
to.
curl -X POST "https://app.checkcourt.de/api/v1/webhooks" \
-H "Authorization: Bearer ck_mgmt_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/checkcourt",
"description": "Belegung an die Hallensteuerung",
"eventTypes": ["booking.created", "booking.cancelled"]
}'The response contains the secret. This is the only place it appears. A club can create at
most 10 endpoints. As long as the webhooks feature is off for the club, all webhook
endpoints respond with 422 BUSINESS_RULE.