CheckCourt
TypeScript SDK

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

app/webhooks/checkcourt/route.ts
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

OptionDefaultMeaning
secretrequiredThe whole secret, including whsec_
rawBodyrequiredThe untouched body
signatureHeaderrequiredValue of CheckCourt-Signature; null or undefined gives missing_header
toleranceSeconds300Allowed clock difference
nownowYour 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,
});

On this page