Webhooks

Roster pushes every subscribed event to your endpoint as an HTTP POST. The push channel and the pull feed (GET /v1/events) carry exactly the same envelope and fields — one contract, two channels.

The envelope

{
  "event_id": "evt_...",
  "event_type": "enrollment.activated",
  "occurred_at": "2026-08-31T12:00:00.000Z",
  "church_id": "ch_...",
  "payload": { "enrollment_id": "en_..." }
}

payload carries only the fields documented for each event type — never donor PII.

Verifying the signature

Every delivery is signed with your endpoint's whsec_ secret:

Roster-Signature: t={unix_seconds},v1={hex hmac_sha256("{t}.{raw_body}", secret)}

Verify before trusting anything:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header, nowUnixSeconds) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!match) return false;
  const t = Number(match[1]);
  // Reject old timestamps even with a valid HMAC (replay protection).
  if (Math.abs(nowUnixSeconds - t) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(match[2], "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Compute the HMAC over the raw request body — parse the JSON only after the signature checks out.

Delivery semantics

  • At-least-once. A retry can re-deliver an event you already accepted — deduplicate by event_id and treat duplicates as a no-op.
  • Acknowledge fast. Return any 2xx quickly (enqueue and process async if needed). Anything else, or a timeout, counts as a failed attempt and will be retried a limited number of times.
  • The feed is your safety net. If your endpoint is down past the retries, nothing is lost: poll GET /v1/events with your stored cursor and you will see every event. Build reconciliation on the feed, use webhooks for latency.
  • Inspect deliveries. GET /v1/webhook_endpoints/{id}/deliveries lists the last 50 attempts with outcome and status code — your first stop when debugging.

Operational tips

  • Rotate an endpoint by creating a new one, confirming it receives events, then deleting the old — both stay active in between.
  • Use POST /v1/webhook_endpoints/{id}/test after any change on your side; it exercises the exact signing and delivery path of real events.