Webhooks

Get an HTTPS POST the moment a booking is confirmed, a payment comes in or a session is cancelled, signed so you know it came from Plek.

Instead of polling the API, let Plek tell you. An Admin registers an HTTPS URL under Settings > API & webhooks in the admin app and picks the events to send. The signing secret (whsec_…) is shown once, when the webhook is created or its secret is rotated.

Events

Event data
booking.confirmed bookingId, bookingType, participantId, sessionId, seriesId, price, confirmedAt
booking.cancelled the same, plus previousStatus, cancelledAt
customer.created customerId, email, firstName, lastName, source (registration, booking, staff, sso, import)
payment.paid paymentId, bookingId, planPurchaseId, feeId, customerId, amount, method, gateway, paidAt
payment.refunded paymentId, bookingId, refunded, totalRefunded, fullyRefunded, refundedAt
session.cancelled sessionId, activityId, locationId, startTime, endTime, bookedCount, reason, cancelledAt
session.coach_changed sessionId, coachIds, previousCoachIds, changedAt
fee.created feeId, kind (late_cancellation, no_show), customerId, bookingId, sessionId, amount, dueAt, createdAt
hire.* venue hire requests, quotes and payments
ping only from the "Send a test event" button
  • booking.confirmed comes when a booking is confirmed: paid online, confirmed to be paid later (bank transfer, on site), or confirmed by a payment staff record.
  • payment.paid covers online payments, payments staff record (cash, card terminal) and payments marked paid in an import (gateway: "external"). A pass or subscription bought online has planPurchaseId and customerId instead of bookingId; a paid fee has feeId.
  • Payloads carry ids and essentials, never children's or medical details. Fetch the rest through the Staff API with an API key.
  • Amounts are in the smallest currency unit: {"amount": 1500, "currency": "EUR"} is €15.00.

The delivery

Each delivery is a POST with a JSON body:

{
  "id": "5d0c…",
  "type": "booking.confirmed",
  "createdAt": "2026-10-01T10:00:00+00:00",
  "organisationId": "…",
  "data": { "bookingId": "…" }
}
Header Value
Plek-Signature t=<unix timestamp>,v1=<hex HMAC-SHA256>
Plek-Event the event type
Plek-Event-Id the event id, the same for every endpoint and every retry
Plek-Delivery the delivery id, as shown in the delivery log
Plek-Timestamp the same timestamp as in the signature

Answer with any 2xx within 10 seconds; do the real work afterwards. Redirects are not followed. Anything else, or no answer, is retried after 1 minute, 5 minutes, 25 minutes, about 2 hours and about 10 hours; after 6 attempts the delivery is marked failed. Every attempt shows in the delivery log in the admin app, where a delivery can be sent again. Deliveries are kept for 30 days.

Delivery is at least once: the same event can arrive twice, and events can arrive out of order. Deduplicate on Plek-Event-Id, and read the current state from the API when order matters.

Webhook URLs must be HTTPS on a public address; private, loopback and link-local addresses are refused, also when a public host name resolves to one.

Verifying the signature

  1. Read the raw request body, before any JSON parsing.
  2. Split Plek-Signature on , into t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + body) as lowercase hex.
  4. Compare it with v1 in constant time, and reject the request when t is more than 5 minutes from now.

PHP:

function plekSignatureIsValid(string $secret, string $header, string $body, int $tolerance = 300): bool
{
    parse_str(str_replace(',', '&', $header), $parts);
    $timestamp = (int) ($parts['t'] ?? 0);
    $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

    return abs(time() - $timestamp) <= $tolerance
        && hash_equals($expected, (string) ($parts['v1'] ?? ''));
}

$valid = plekSignatureIsValid(
    getenv('PLEK_WEBHOOK_SECRET'),
    $_SERVER['HTTP_PLEK_SIGNATURE'] ?? '',
    file_get_contents('php://input')
);

Node.js (Express: use express.raw({ type: 'application/json' }) so the body stays raw):

import crypto from 'node:crypto';

function plekSignatureIsValid(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  return Math.abs(Date.now() / 1000 - timestamp) <= toleranceSeconds
    && given.length === expected.length / 2
    && crypto.timingSafeEqual(given, Buffer.from(expected, 'hex'));
}

Python:

import hashlib, hmac, time

def plek_signature_is_valid(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts.get("t", "0"))
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - timestamp) <= tolerance and hmac.compare_digest(expected, parts.get("v1", ""))

Rotating the secret takes effect from the next attempt, retries of earlier events included.

Testing

"Send a test event" in the admin app sends a ping to the URL. To receive webhooks on your own computer, expose it with a tunnel (for example cloudflared tunnel --url http://localhost:3000) and register the tunnel's HTTPS address.