Verifying webhooks
Verify webhook signatures with the SDK and handle typed events
verifyWebhook checks the CheckCourt-Signature header exactly as described in
Webhooks: HMAC-SHA256 over
<t>.<raw body> keyed with the whole whsec_ secret, constant-time comparison, at most
five minutes of clock difference. It then returns the event as a type.
import express from "express";
import { SIGNATURE_HEADER, verifyWebhook, WebhookSignatureError } from "@checkcourt/sdk";
const app = express();
app.post("/webhooks/checkcourt", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await verifyWebhook({
secret: process.env.CHECKCOURT_WEBHOOK_SECRET!, // whsec_…
rawBody: req.body, // Buffer, untouched
signatureHeader: req.get(SIGNATURE_HEADER),
});
} catch (err) {
if (err instanceof WebhookSignatureError) return res.status(401).end();
throw err;
}
res.status(200).end(); // acknowledge first, then process
if (await alreadyHandled(event.id)) return;
switch (event.type) {
case "app.installed":
// store event.installation_id and event.tenant_id
break;
case "app.settings_updated":
// read the settings again, see getInstallation
break;
case "booking.created":
console.log(event.data.court_id, event.data.date, event.data.start_time); // typed
break;
}
});Pass the raw body
rawBody must be the body exactly as received: a string, Buffer, Uint8Array or
ArrayBuffer. Parsing the JSON and serializing it again produces a different string.
In Next.js route handlers, use await request.text().
In Next.js or on the edge
import { verifyWebhook } from "@checkcourt/sdk/webhooks";
export async function POST(request: Request) {
const event = await verifyWebhook({
secret: process.env.CHECKCOURT_WEBHOOK_SECRET!,
rawBody: await request.text(),
signatureHeader: request.headers.get("CheckCourt-Signature"),
}).catch(() => null);
if (!event) return new Response(null, { status: 401 });
await queue.push(event);
return new Response(null, { status: 200 });
}Options
| Option | Default | Meaning |
|---|---|---|
secret | required | The whole secret, including whsec_ |
rawBody | required | The untouched body |
signatureHeader | required | Value of CheckCourt-Signature; null or undefined gives missing_header |
toleranceSeconds | 300 | Allowed clock difference |
now | now | Your own clock, as a Date or unix seconds, for tests |
WebhookSignatureError.reason tells you what failed: missing_header,
malformed_header, timestamp_out_of_tolerance, signature_mismatch or
invalid_payload (not JSON, or not an event).
Duplicate deliveries
CheckCourt retries a delivery until you answer 2xx. A retry carries the same event.id
(equal to the CheckCourt-Event-Id header). Store the IDs of processed events, for
example as the primary key of a table, and skip known ones. See
Webhooks.
Types
WebhookEvent is a union of all events. After checking event.type, TypeScript knows
the fields of event.data. isEventType(event, "booking.created") does the same as a
function. The lists SUBSCRIBABLE_EVENT_TYPES (subscribable in the manifest) and
APP_LIFECYCLE_EVENT_TYPES (arrive without a subscription: app.installed,
app.uninstalled, app.upgraded, app.settings_updated) exist at runtime too.
Events from connections between apps have their own
helpers: isMetadataChangedEvent(event) narrows to app.metadata_changed, and
isAppEvent(event, "wingfield", "match_recorded") to an event another app published
(app.<slug>.<name>, built by appEventType(slug, name)).
Testing your receiver
signWebhookPayload creates valid signatures for tests:
import { signWebhookPayload } from "@checkcourt/sdk";
const body = JSON.stringify({ id: "evt_test", type: "booking.created", /* … */ });
const header = await signWebhookPayload("whsec_test", body);
await fetch("http://localhost:4000/api/checkcourt/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json", "CheckCourt-Signature": header },
body,
});