Webhooks

Captive POSTs each new guest signup to your endpoint as JSON, signed with a per-endpoint secret. Add endpoints under Dashboard → Webhooks (up to 5), where you can also send test events and watch recent deliveries.

The signup.created event

POST <your endpoint>
Content-Type: application/json
X-Captive-Event: signup.created
X-Captive-Delivery: 6b1f6a2e-...
X-Captive-Signature: t=1751830000,v1=5f3a...

{
  "id": "6b1f6a2e-...",
  "event": "signup.created",
  "createdAt": "2026-07-06T18:00:00.000Z",
  "data": {
    "id": "…",
    "email": "guest@example.com",
    "clientMac": "aa:bb:cc:dd:ee:ff",
    "apMac": "11:22:33:44:55:66",
    "ssid": "Lake Cabin Guest",
    "authorized": true,
    "siteId": "…",
    "siteSlug": "lake-cabin",
    "siteName": "Lake Cabin",
    "createdAt": "2026-07-06T18:00:00.000Z"
  }
}

Signups are delivered whether or not device authorization succeeded — check data.authorized if you only want guests who got online.

Verifying signatures

The X-Captive-Signature header is t=<unix seconds>,v1=<hex HMAC> where the HMAC is SHA-256 over `${t}.${rawBody}` keyed with your endpoint's secret. Verify with the raw request body, before any JSON parsing:

import { createHmac, timingSafeEqual } from "crypto";

function verify(secret, signatureHeader, rawBody) {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(signatureHeader);
  if (!match) return false;
  const [, t, given] = match;
  // reject stale timestamps to prevent replay (5 minutes is plenty)
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}

Delivery semantics

  • Respond 2xx quickly (within 10 seconds) — anything else counts as a failure.
  • At-least-once: retries mean you may see the same delivery twice. Dedupe on the id field (also in X-Captive-Delivery).
  • Retries: immediately, ~30s, ~2min, then on a backoff schedule (5m → 15m → 1h → 4h → 12h) up to 8 attempts total.
  • Auto-disable: an endpoint whose last 20 deliveries all failed is disabled and you're emailed. Re-enable it from the dashboard once fixed.
  • Ordering is not guaranteed.