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_ididentifies the event. A retry repeats it, so deduplicate on it.event_typeis one of the types in the event catalog.occurred_atis when the change happened, in UTC.church_idis the church the event belongs to.-
entity_idis the record that changed: the enrollment forenrollment.*, the donor session forpartner_session.*, the church forchurch.*, the donation fordonation.updated, the donor fordonor.updatedand the refund forrefund.createdandrefund.updated. -
livemodeistruefor an event from production andfalsefor one from the sandbox. If the same receiver gets events from both environments, uselivemodeto keep them apart. payloadcarries the fields of that event type.-
sequenceappears only on the financial events (donation.updated,donor.updated,refund.createdandrefund.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.v1is 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:
tis when Roster signed and sent this attempt, notoccurred_at. Every attempt is signed again when it is sent, so a retry hours later carries a freshtwith the same body andevent_id. Reject the delivery whentis 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-Endpointwith thewe_id of the endpoint it was sent to. It is not a secret. The signature covers onlytand 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 — nohttp://, no custom port.-
A public domain name, not an IP address, and not an internal name such as
.local,.internalorlocalhost. 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_idand treat duplicates as a no-op. -
A retry goes only to the endpoints that have not accepted the event.
Roster records which endpoints answered
2xxfor each event, so one failing endpoint does not multiply deliveries to your healthy ones. An endpoint that answered2xxcan 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, fresht. -
The feed is your safety net. If your endpoint is down past the
retries, resume from your saved cursors: poll
GET /v1/eventsfor activity events andGET /v1/financial_eventsfordonation.updated,donor.updatedandrefund.*, each with its own stored cursor, and reconcile. Without a cursor,GET /v1/eventsstarts 90 days ago. Build reconciliation on the feeds, use webhooks for latency. -
Inspect deliveries.
GET /v1/webhook_endpoints/{id}/deliverieslists the last 50 attempts with outcome and status code, test calls included — your first stop when debugging. Itshealthobject summarizes your last 50 real event deliveries, not that list:webhook.testattempts count only while the endpoint has no real delivery yet, so a passing test call does not show that real deliveries work.health.statuslooks at both the failures in a row and how many of those 50 failed:brokenwhen the latest 3 attempts failed or 25 or more of the 50 failed,degradedwhen the latest attempt failed or 5 or more of the 50 failed,healthyotherwise, andunknownbefore any attempt. An endpoint that fails every other delivery never fails 3 in a row, and still readsbrokenonce 25 of its last 50 attempts failed. Next to it comeconsecutive_failures,delivered,failed,last_success_atandlast_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-Endpointheader, 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 andany(verify_webhook(s, raw_body, signature) for s in (new_secret, old_secret))in Python. -
Use
POST /v1/webhook_endpoints/{id}/testafter any change on your side; it exercises the exact signing and delivery path of real events. When the test doesn't deliver,reasonsays 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.