Webhooks

Roster pushes every subscribed event to your endpoint as an HTTP POST. For activity events, the push channel and the pull feed (GET /v1/events) carry exactly the same envelope and fields — one contract, two channels. The financial events, donation.updated, donor.updated, refund.created and refund.updated, also carry sequence and come from the financial journal (GET /v1/financial_events), not from GET /v1/events. The event catalog lists every event type you can subscribe to and the payload fields of each.

The envelope

Each delivery body is one JSON envelope:

{
  "event_id": "evt_example",
  "event_type": "church.created",
  "occurred_at": "2026-10-03T14:25:47.968Z",
  "church_id": "ch_example",
  "entity_id": "ch_example",
  "livemode": false,
  "payload": { "church_id": "ch_example", "name": "Grace Chapel" }
}
  • event_id identifies the event. A retry repeats it, so deduplicate on it.
  • event_type is one of the types in the event catalog.
  • occurred_at is when the change happened, in UTC.
  • church_id is the church the event belongs to.
  • entity_id is the record that changed: the enrollment for enrollment.*, the donor session for partner_session.*, the church for church.*, the donation for donation.updated, the donor for donor.updated and the refund for refund.created and refund.updated.
  • livemode is true for an event from production and false for one from the sandbox. If the same receiver gets events from both environments, use livemode to keep them apart.
  • payload carries the fields of that event type.
  • sequence appears only on the financial events (donation.updated, donor.updated, refund.created and refund.updated): it is the event's position in your financial history (see Financial integration).

The test delivery, webhook.test, carries only event_id, event_type, occurred_at, livemode and payload (see Test events and duplicates).

payload carries only the fields documented for each event type. Enrollment, session and church events carry status, values and identifiers — no donor personal data. The two financial events are different by design: donation.updated and donor.updated carry the donor profile described in Financial integration when it is available, because that is what your reporting and ChMS handoff need. Treat those deliveries as personal data: keep them in your protected backend, out of URLs, analytics and diagnostic logs. refund.created and refund.updated carry the refund, with no donor profile; their metadata is what you sent.

Verifying the signature

Every delivery carries a Roster-Signature header, made with your endpoint's whsec_ secret:

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

The value is t=<digits> followed by one or more ,v1=<64 lowercase hex digits>, with no spaces. Today every delivery carries exactly one v1. Still, accept the delivery when any v1 matches: if Roster ever signs one delivery with more than one secret, for example during a secret rotation, a receiver built this way keeps working. The tested verifiers do both and reject any other form.

  • The key is the whole secret string exactly as returned, whsec_ prefix included, as UTF-8 bytes. Do not strip the prefix or base64-decode it.
  • The signed message is the digits of t, a literal ., then the raw body bytes. v1 is the HMAC-SHA256 digest in lowercase hex.
  • The header name is case-insensitive, so look it up that way. Node exposes it as req.headers["roster-signature"].
  • The time: t is when Roster signed and sent this attempt, not occurred_at. Every attempt is signed again when it is sent, so a retry hours later carries a fresh t with the same body and event_id. Reject the delivery when t is more than 300 seconds before or after your current time (five minutes in either direction), even when the HMAC matches. Keep your clock synchronized.
  • Which endpoint: every delivery, the test one included, carries Roster-Webhook-Endpoint with the we_ id of the endpoint it was sent to. It is not a secret. The signature covers only t and the raw body, never a header, so this one is not signed: use it to pick that endpoint's secret when two endpoints share a URL, and trust it only after the signature verifies with that secret.

Compute the HMAC over the raw request body, and parse the JSON only after the signature checks out. The verifiers below are the tested files; copy them as they are.

Node.js. Pass the raw body as a Buffer. Download: verify-webhook.mjs, https://docs.rostergiving.com/examples/verify-webhook.mjs.

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

/** Verify raw bytes before parsing JSON; deduplicate only after verification. */
export function verifyWebhook(
  secret,
  rawBody,
  signature,
  nowSeconds = Math.floor(Date.now() / 1000),
) {
  if (
    typeof secret !== "string" ||
    !secret ||
    !Buffer.isBuffer(rawBody) ||
    typeof signature !== "string" ||
    !Number.isFinite(nowSeconds)
  )
    return false;
  const match = /^t=(\d{1,16})((?:,v1=[0-9a-f]{64})+)$/.exec(signature);
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (
    !Number.isSafeInteger(timestamp) ||
    Math.abs(nowSeconds - timestamp) > 300
  )
    return false;
  const expected = createHmac("sha256", secret)
    .update(`${match[1]}.`)
    .update(rawBody)
    .digest();
  // One v1 today; accept the delivery when any v1 matches.
  return match[2]
    .split(",v1=")
    .slice(1)
    .some((hex) => timingSafeEqual(expected, Buffer.from(hex, "hex")));
}

Python. Pass the raw body as bytes: request.get_data() in Flask, request.body in Django, with the header from request.headers.get("Roster-Signature"). Download: verify_webhook.py, https://docs.rostergiving.com/examples/verify_webhook.py.

"""Python 3.8+, standard library only. Same checks as verify-webhook.mjs."""
import hashlib
import hmac
import math
import re
import time

_SIGNATURE = re.compile(r"t=([0-9]{1,16})((?:,v1=[0-9a-f]{64})+)")
_MAX_T = 2**53 - 1  # Number.MAX_SAFE_INTEGER, the largest t verify-webhook.mjs accepts


def verify_webhook(secret, raw_body, signature, now_seconds=None):
    """Verify raw bytes before parsing JSON; deduplicate only after verification.

    secret: the whole whsec_ string, prefix included; its UTF-8 bytes are the key.
    raw_body: the request body exactly as received, as bytes.
    signature: the value of the Roster-Signature header.
    """
    now = int(time.time()) if now_seconds is None else now_seconds
    if (
        not isinstance(secret, str)
        or not secret
        or not isinstance(raw_body, (bytes, bytearray))
        or not isinstance(signature, str)
        or isinstance(now, bool)
        or not isinstance(now, (int, float))
        or (isinstance(now, float) and not math.isfinite(now))
    ):
        return False
    match = _SIGNATURE.fullmatch(signature)
    if not match:
        return False
    timestamp = int(match.group(1))
    if timestamp > _MAX_T or abs(now - timestamp) > 300:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        match.group(1).encode("ascii") + b"." + bytes(raw_body),
        hashlib.sha256,
    ).digest()
    # One v1 today; accept the delivery when any v1 matches.
    return any(
        hmac.compare_digest(expected, bytes.fromhex(candidate))
        for candidate in match.group(2).split(",v1=")[1:]
    )

Test vector

Check your implementation against this fixed delivery before real ones arrive. The secret is synthetic. The body is exactly the bytes shown, UTF-8, with no trailing newline.

Secret:   whsec_test_vector_not_a_real_secret
Body:     {"event_id":"evt_test_vector","event_type":"webhook.test","occurred_at":"2026-01-01T00:00:00.000Z","livemode":false,"payload":{"message":"Roster webhook test delivery"}}
Header:   Roster-Signature: t=1767225600,v1=a55ddcbb0b836b2d97c33f38a56ca9da8ae10ad7bda7a3149765f1ad3c55d0e1
Now:      1767225600

With the current time set to 1767225600, the signature is valid. It stays valid up to 300 seconds earlier or later and fails at 301. It also fails if you change any byte of the body or drop the whsec_ prefix from the secret.

Receiving a delivery

import { verifyWebhook } from "./verify-webhook.mjs";

// rawBody is a Buffer captured before the HTTP framework parses JSON.
// Node lowercases incoming header names.
const signatureHeader = req.headers["roster-signature"];
if (!verifyWebhook(endpointSecret, rawBody, signatureHeader)) {
  // Reject the request; do not enqueue or mark this event as processed.
  throw new Error("Invalid webhook signature");
}
const event = JSON.parse(rawBody.toString("utf8"));
// Atomically persist the event or enqueue it durably, deduplicating by event_id.
// Return 2xx only after durable acceptance; process business logic asynchronously.

Registering an endpoint

Your endpoint address has to be somewhere Roster can actually reach from the public internet, over an encrypted connection:

  • https:// only, on the standard port — no http://, no custom port.
  • A public domain name, not an IP address, and not an internal name such as .local, .internal or localhost. Names that resolve to a private network address are rejected too.
  • No username or password in the address — Roster signs every delivery instead (see above).
  • A name that doesn't resolve yet is accepted, so you can register before your DNS has propagated.
  • Up to 3 active endpoints per partner. Delete one you no longer use to add another.

When an address is rejected, the detail of the error says why and what to change.

The whsec_ secret is returned only in the creation response. Register endpoints from your backend and write the secret straight to your secret manager. Do not create endpoints through an AI assistant or the API MCP: the secret would stay in the chat transcript.

Each new endpoint needs a new Idempotency-Key. Repeating an earlier key with the same request returns the endpoint that key created, active or deleted, without its secret, and creates nothing. POST /v1/webhook_endpoints/{id}/test also requires an Idempotency-Key.

If a secret leaks, create a new endpoint with a new Idempotency-Key (a new path on your receiver is enough), have your receiver verify that path with the new secret, then delete the old endpoint. If you already have three, delete the leaked one first: deliveries to it stop at once. Every activity event from the gap is still in GET /v1/events, and every financial event (donation.updated, donor.updated and refund.*) in GET /v1/financial_events; catch up from each stored cursor.

Roster never follows redirects. Register the final URL — a 3xx answer is a failed delivery, in the test call and in regime alike. Every call also has a deadline of 3 seconds, for the test call and in regime alike: persist or enqueue durably within the deadline, return 2xx, then process asynchronously.

Every delivery, the test call included, identifies itself with User-Agent: Roster-Webhooks/1.0; allow it if a firewall filters the calls to your receiver. It is not proof of origin, since any client can send that header: accept a delivery only when Roster-Signature verifies.

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.
  • A retry goes only to the endpoints that have not accepted the event. Roster records which endpoints answered 2xx for each event, so one failing endpoint does not multiply deliveries to your healthy ones. An endpoint that answered 2xx can still get a duplicate, for example when its answer did not reach Roster within the deadline; keep deduplicating.
  • Acknowledge fast. Return any 2xx quickly (enqueue and process async if needed). Anything else, or a timeout, counts as a failed attempt.
  • Retries. A failed delivery is retried within minutes, then in a new round about once an hour. No new round starts later than 24 hours after the first attempt, so the last attempts of the final round can arrive shortly after that mark; then automatic retries stop. Each retry is signed again: same body and event_id, fresh t.
  • The feed is your safety net. If your endpoint is down past the retries, resume from your saved cursors: poll GET /v1/events for activity events and GET /v1/financial_events for donation.updated, donor.updated and refund.*, each with its own stored cursor, and reconcile. Without a cursor, GET /v1/events starts 90 days ago. Build reconciliation on the feeds, use webhooks for latency.
  • Inspect deliveries. GET /v1/webhook_endpoints/{id}/deliveries lists the last 50 attempts with outcome and status code, test calls included — your first stop when debugging. Its health object summarizes your last 50 real event deliveries, not that list: webhook.test attempts count only while the endpoint has no real delivery yet, so a passing test call does not show that real deliveries work. health.status looks at both the failures in a row and how many of those 50 failed: broken when the latest 3 attempts failed or 25 or more of the 50 failed, degraded when the latest attempt failed or 5 or more of the 50 failed, healthy otherwise, and unknown before any attempt. An endpoint that fails every other delivery never fails 3 in a row, and still reads broken once 25 of its last 50 attempts failed. Next to it come consecutive_failures, delivered, failed, last_success_at and last_attempt_at. The email to your workspace owners follows the failures in a row only: it goes out at the third failure in a row, and again at the first delivery after that. In the partner portal, Deliveries offers a UTC date range within the last 14 calendar days and pages of 50 attempts. Delivered and Failed count all recorded attempts in that range, including retries, not unique events. Channel health remains based on the latest real deliveries, independently of the dates selected. Older delivery logs expire; this is not a lifetime event archive.

Operational tips

  • To replace an endpoint or its secret, create a new endpoint, confirm it receives events, then delete the old one; both stay active in between. Each endpoint has its own secret, and both receive every event they subscribe to. During the overlap, give the new endpoint a different path, pick the secret by the Roster-Webhook-Endpoint header, or accept a signature made with either secret. With the tested verifiers, accepting either secret is [newSecret, oldSecret].some((secret) => verifyWebhook(secret, rawBody, signatureHeader)) in Node and any(verify_webhook(s, raw_body, signature) for s in (new_secret, old_secret)) in Python.
  • Use POST /v1/webhook_endpoints/{id}/test after any change on your side; it exercises the exact signing and delivery path of real events. When the test doesn't deliver, reason says in plain words what happened (redirect, timeout, connection refused, non-2xx).

Test events and duplicates

The endpoint test sends webhook.test, a diagnostic envelope. It does not represent an enrollment event and does not appear in the event feed. Verify its signature and acknowledge it without requiring a church or enrollment ID. For every other event, entity_id identifies the record that changed (see The envelope) and event_id is the deduplication key. See events and reconciliation for recovery and handling delayed notifications.