CheckCourt

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 endpointsDeliveries 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 endpointThe ones under events in the manifest, for every installation
Address and secretPer endpointOne webhook URL per app, in the developer portal under Webhooks
PrerequisiteFeature webhooks for the club, enabled by supportA running installation of your app
Typical useZapier, n8n, your own club softwareApps 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"
  }
}
FieldMeaning
idEvent ID with the prefix evt_. Stays the same on every retry
typeEvent type, see the event catalog
created_atTime of the event (ISO 8601, UTC)
tenant_idThe club where it happened
installation_idOnly for deliveries to apps: the installation it is delivered for
objectType and ID of the affected object, such as booking, member, court or app_installation
dataThe fixed fields of the event type

These headers are also sent:

HeaderContent
CheckCourt-Signaturet=<Unix seconds>,v1=<HMAC-SHA256 as hex>
CheckCourt-Event-IdThe same ID as id in the body, unique per event
CheckCourt-Event-TypeThe same type as type in the body
CheckCourt-Installation-IdOnly for deliveries to apps
Content-Typeapplication/json
User-AgentCheckCourt-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.

  • t is the time of sending in Unix seconds.
  • v1 is an HMAC-SHA256 in hex over the text <t>.<body>, that is, the value of t, 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.

verify.js
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:

server.js
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);
});
verify.py
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:

app.py
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 "", 200

The TypeScript SDK checks signature and timestamp and returns the typed event:

server.ts
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:

AttemptDelay after the previous one
1immediately (sending runs once per minute)
25 minutes
31 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-Id and 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 200 and 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.updated can be followed by an older booking.created if its first attempt failed.

Event catalog

EventRequires scopeMeaningFields in data
booking.createdbookings:readA booking was createdbooking_id, court_id, date, start_time, end_time, type
booking.updatedbookings:readA booking was changed: court, date, time or playersas above
booking.cancelledbookings:readA booking was cancelledas above
booking.checked_inbookings:readA booking was checked inas above
member.joinedmembers:readA member joined the clubmember_id, user_id
member.updatedmembers:readA member's data was changedmember_id, user_id, changed_fields
member.leftmembers:readA member left the clubmember_id, user_id
court.lockedcourts:readA court was locked, entirely or for a time periodcourt_id, date, start_time, end_time
court.unlockedcourts:readA court lock was liftedas above

Notes on the fields:

  • court_id is a number, all other IDs are strings.
  • date has the format YYYY-MM-DD, times HH:MM:SS (for example "18:00:00").
  • For court.locked and court.unlocked, date, start_time and end_time are null if the whole court was locked or unlocked rather than a time period.
  • For member.updated, changed_fields lists which details changed, not the new values.

Only for specific recipients

EventGoes toFields in data
webhook.testOnly the endpoint for which someone chose Test senden (Send test)webhook_endpoint_id
app.installedOnly the app that was installed or connectedinstallation_id, app_id, version
app.upgradedOnly the app whose installation now runs a new versioninstallation_id, app_id, version
app.uninstalledOnly the app that was removed or disconnectedinstallation_id, app_id, version
app.settings_updatedOnly the club installation whose settings the club changedinstallation_id, app_id, version
app.metadata_changedOnly club apps the club connected to the writing app, for keys they declare under reads.metadataobject_type, object_id, app, key, deleted
app.<slug>.<name>Only club apps the club connected to the publishing app, for events they declare under subscribeswhat 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 as 10.…, 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/… or https://tuer.example.de/… in this documentation are placeholders. In production, use a public https address 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.

AddressProductionOutside production
Webhook URL, declarative extension URLshttps to a public host only. localhost, *.localhost and loopback, private or link-local IPs are refused when saving and when sendinghttps 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 URLsAs above, except http(s)://localhost, 127.0.0.1 and [::1], which load only in your organisation's sandbox clubAs for webhooks
OAuth redirect URIshttps to any host, plus http://localhost, 127.0.0.1 and [::1], which work only for consent in your sandbox clubhttps, 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.

StatusMeaning
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}/deliveries and POST /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.

On this page