Enterprise: the API, webhooks and MCP server are live for schools on the Enterprise plan. The owner allows every key and AI connection. Ask about Enterprise
Webhooks / Verify Signatures
Verify a webhook signature
Every event Swum sends carries a Swum-Signature header. Check it before you act on the event, so nobody else can pretend to be Swum.
How it works
- Read the header. It holds a time and a signature:
t=1761314520,v1=5257a869… - Join the time, a dot, and the raw request body:
1761314520.{"id":"evt_…"} - Make an HMAC-SHA256 of that text with your endpoint's signing secret (
whsec_…, on the endpoint in Developers & Integrations). - Compare it to
v1with a constant-time compare. - Turn the event away if
tis more than 5 minutes from the current time, in either direction. That stops replays.
Answer fast, then work
Reply with any 2xx within 10 seconds, then do the slow part. Anything else counts as a failure and Swum tries again.
Retries
| Try | After | Try | After |
|---|---|---|---|
| 1 | Immediately | 5 | 2 hours after try 4 |
| 2 | 1 minute after try 1 | 6 | 5 hours after try 5 |
| 3 | 5 minutes after try 2 | 7 | 10 hours after try 6 |
| 4 | 30 minutes after try 3 | 8 | 24 hours after try 7 |
Each event keeps its id on every try, so store the ids you have handled and skip repeats. Event and delivery records remain for 30 days. A manual retry uses the same event id and a new delivery generation. Delays are measured after the previous failed attempt.
Rotating a secret
Roll the secret on the endpoint any time. For 24 hours Swum signs with both, so the header carries two v1 values and either one passing is enough.
POST /swum/events HTTP/1.1
Host: hooks.example.test
Content-Type: application/json
Swum-Event-Id: 8b04e26e…
Swum-Event-Type: lesson.booked
Swum-Signature: t=1792936800,v1=5257a869e7ec…
{
"id": "8b04e26e…",
"type": "lesson.booked",
"created": "2026-10-25T14:00:00.000Z",
"owner_id": "owner_example",
"data": {
"family_id": "family_example",
"lesson_id": "lesson_example",
"swimmer_ids": ["swimmer_example"],
"status": "booked",
"location_id": "pool_example"
}
}import crypto from 'node:crypto';
export function verifySwum(rawBody, header, secret) {
// rawBody is the original request body as a Buffer.
const pairs = header.split(',').map((part) => part.split('='));
const t = pairs.find(([key]) => key === 't')?.[1];
if (!/^\d+$/.test(t ?? '')) return false;
const seconds = Number(t);
if (!Number.isSafeInteger(seconds) ||
Math.abs(Date.now() / 1000 - seconds) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.').update(rawBody).digest();
return pairs.some(([key, value]) => {
if (key !== 'v1' || !/^[a-f0-9]{64}$/.test(value ?? '')) return false;
const signature = Buffer.from(value, 'hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(signature, expected);
});
}