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

On this page

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

  1. Read the header. It holds a time and a signature: t=1761314520,v1=5257a869…
  2. Join the time, a dot, and the raw request body: 1761314520.{"id":"evt_…"}
  3. Make an HMAC-SHA256 of that text with your endpoint's signing secret (whsec_…, on the endpoint in Developers & Integrations).
  4. Compare it to v1 with a constant-time compare.
  5. Turn the event away if t is more than 5 minutes from the current time, in either direction. That stops replays.
TipUse the raw body exactly as it arrived. Parsing and re-encoding the JSON changes the bytes and the check fails.

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

TryAfterTryAfter
1Immediately52 hours after try 4
21 minute after try 165 hours after try 5
35 minutes after try 2710 hours after try 6
430 minutes after try 3824 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.

What Swum sendsPOST
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"
  }
}
Check it
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);
  });
}