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.confirmedcomes when a booking is confirmed: paid online, confirmed to be paid later (bank transfer, on site), or confirmed by a payment staff record.payment.paidcovers online payments, payments staff record (cash, card terminal) and payments marked paid in an import (gateway: "external"). A pass or subscription bought online hasplanPurchaseIdandcustomerIdinstead ofbookingId; a paid fee hasfeeId.- 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
- Read the raw request body, before any JSON parsing.
- Split
Plek-Signatureon,intotandv1. - Compute
HMAC-SHA256(secret, t + "." + body)as lowercase hex. - Compare it with
v1in constant time, and reject the request whentis 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.