Gifts into your church software
Every gift that reaches your church can be delivered to your own system, your ChMS. This page is for the developer wiring that up.
Push: your endpoint
Register an HTTPS endpoint in the dashboard (Integration Guide → Connect your
ChMS). From then on every gift is delivered there automatically, signed with
the Roster-Signature scheme below. Your webhook secret, which starts with
whsec_, appears once — store it in your secret manager.
The address starts with https://, carries no user name or password, and is on
the public internet: the dashboard doesn't save localhost or a private network
address, and a delivery fails when the name resolves to one.
-
gift.recordedis sent when the bank confirms the transfer of a gift to your church's account. A gift that has not reached you is not sent. -
gift.returnedis sent if the bank later returns that transfer. It carries the sameoccurrence_idand the same amount, so you reverse exactly what you recorded. -
giver.updatedis sent when a giver who has already given to your church updates their contact details.
How we deliver
-
Success is any
2xxanswer within 3 seconds. Anything else is a failed attempt, and so are a slower answer and a redirect: we never follow redirects, so register the final address. Store the delivery, answer, and create the gift in your software after that. - 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 can arrive shortly after that mark; then automatic retries stop.
-
Every attempt is signed again when it is sent: the same body and
event_id, with a newt. -
Headers. Every delivery carries
Content-Type: application/jsonandRoster-Signature. -
At least once. The same delivery can arrive more than once: deduplicate
on
event_id.
Manage connection, in the same step of the dashboard, lists the recent
deliveries — date, amount, donor, occurrence_id, event_id and whether your
endpoint accepted it, with Send again for one that failed. It also replaces
the address (you get a new secret, and the old one stops working) and
disconnects.
Pause deliveries, there too, stops sending until you select Resume deliveries. Gifts that arrive while deliveries are paused are not sent later: after you resume, read the pull list to reconcile them. The list holds only the 100 most recent gifts and returns, and it can't read further back: if more than 100 arrived while deliveries were paused, the oldest ones can't be read through the API. Resume deliveries before that happens.
Anyone on your team can connect your church software or create a read key, and every Owner gets an email naming who did it. When the person who set up the connection leaves your team, the connection is paused and any read key they created stops working. Check the address, then resume it; gifts from the pause are not sent later, as above. To avoid that pause, connect while signed in with an email your church keeps, such as the church office's, rather than a volunteer's own.
If your church software can't receive webhooks
Most church software (Planning Center, Breeze, Rock RMS and others) doesn't take a webhook from another service. Two ways in:
-
A small receiver. A serverless function you host receives our delivery,
verifies the signature (see Verifying the signature),
stores the gift and answers
2xx, then creates the donation through your software's API. Answer before calling your software: a call that takes more than 3 seconds turns the delivery into a failed attempt. Register the function's address in the dashboard. - The pull list. A scheduled job reads your gifts with a key and imports what is new. The list holds the 100 most recent gifts and returns: run the job often enough that fewer than 100 arrive between runs.
Where each field goes
| In your software | From the gift |
|---|---|
| The person | donor_id: save it on the person and match on it. The first time, find the person by giver.email or create them from giver. |
| Date received | occurred_at, when the bank confirmed the transfer to your church. |
| Amount | amount_minor divided by 100: the full gift, for the giver's receipt. |
| Fund | Not sent: donors don't choose a fund. Record the gifts in the fund you use for general giving. |
| Reference | occurrence_id. A gift.returned with the same occurrence_id reverses it. |
When donor_id is null, the gift has no identified giver: record it without a
person, as an anonymous gift, and match only on a donor_id that is not null.
Who gave
giver carries the giver's name, mailing address, email and phone, so you can
issue receipts. Donors agree to share these details with your church to
facilitate their donation, enable tax receipts and let your church know its
members. Keep them private.
When a giver updates their contact details, each church they have given to
gets its own giver.updated. That is why its event_id ends with your
church's ID: deduplicate on the whole value.
What we send
Amounts are integers in US cents (amount_minor, with currency), the full
gift before bank fees: 8753 means $87.53.
Headers
| Header | Meaning |
|---|---|
Content-Type |
Always application/json. |
Roster-Signature |
Proves the delivery came from Roster. It reads t={unix seconds},v1={signature}, where the signature is the HMAC-SHA256, in hex, of "{t}.{raw body}" with your webhook secret. Verify it against the raw bytes before parsing, and reject a timestamp older than five minutes. |
- Deliveries are at least once: the same notice can arrive more than once. Deduplicate on event_id.
- occurrence_id identifies the gift. A gift.returned carries the occurrence_id of the gift it reverses. If the bank sends the transfer again after a return, a new gift.recorded arrives for the same occurrence_id, with a new event_id: record it again.
webhook.test
Sent when you use Send a test delivery in the dashboard, to check that your endpoint receives and verifies our deliveries. It is not a gift.
| Field | Type | When present | Meaning |
|---|---|---|---|
event |
string | Required | Always webhook.test. |
event_id |
string | Required | Identifier of this test delivery; every test has a new one. |
church_id |
string | Required | Your church's identifier at Roster. One endpoint can serve several churches by reading it. |
occurred_at |
string | Required | When the test was sent, in UTC (ISO 8601). |
{
"event": "webhook.test",
"event_id": "evt_test_0d1c2b3a-4f5e-4a7b-8c9d-0e1f2a3b4c5d",
"church_id": "ch_8f3a2b1c9d4e",
"occurred_at": "2026-09-14T12:00:00.000Z"
}
gift.recorded
Sent when the bank confirms the transfer of a gift to your church's account. Record the gift.
| Field | Type | When present | Meaning |
|---|---|---|---|
event |
string | Required | Always gift.recorded. |
event_id |
string | Required | Identifier of this notice. The same event_id arriving again is a retry of the same notice, not a new gift. |
church_id |
string | Required | Your church's identifier at Roster. One endpoint can serve several churches by reading it. |
occurred_at |
string | Required | When the bank confirmed the transfer to your church, in UTC (ISO 8601). |
occurrence_id |
string | Required | Identifier of the gift. A later gift.returned for this gift carries the same occurrence_id. |
enrollment_id |
string | Required; may be null | Identifier of the giver's payroll giving sign-up with your church. Null when the gift is not linked to a sign-up. |
donor_id |
string | Required; may be null | Identifier of the giver at Roster. It stays the same across sign-ups. Null when the gift has no identified giver. |
currency |
string | Required | Currency of the amount. Always USD. |
amount_minor |
integer | Required | The full gift in US cents, before bank fees: the amount for the giver's receipt. 8753 means $87.53. |
giver |
object | Optional | Who gave, so you can issue receipts. Missing when we have no contact details on file for this giver. Treat it as your members' private information. |
giver.name |
string | Required when giver is provided | The giver's full name. |
giver.email |
string | Required when giver is provided; may be null | The giver's email address. Null when we do not have one. |
giver.phone |
string | Required when giver is provided; may be null | The giver's phone number. Null when we do not have one. |
giver.address |
object | Required when giver is provided | The giver's mailing address. |
giver.address.line1 |
string | Required when giver is provided | Street address. |
giver.address.city |
string | Required when giver is provided | City. |
giver.address.state |
string | Required when giver is provided | State. |
giver.address.zip |
string | Required when giver is provided | ZIP code. |
{
"event": "gift.recorded",
"event_id": "evt_9b2d4f6a8c0e1b3d5f7a9c2e4f1a3c5e",
"church_id": "ch_8f3a2b1c9d4e",
"occurred_at": "2026-09-14T13:22:05.123Z",
"occurrence_id": "occ_5b7e1d3c9a2f",
"enrollment_id": "enr_2c4e6a8b0d1f",
"donor_id": "dn_7a9c1e3b5d0f",
"currency": "USD",
"amount_minor": 8753,
"giver": {
"name": "Rebecca Dalia",
"email": "rebecca@example.com",
"phone": "+14255550100",
"address": {
"line1": "501 Elm St",
"city": "Austin",
"state": "TX",
"zip": "78701"
}
}
}
gift.returned
Sent when the bank returns the transfer of a gift your church had received. Reverse the gift you recorded for this occurrence_id.
| Field | Type | When present | Meaning |
|---|---|---|---|
event |
string | Required | Always gift.returned. |
event_id |
string | Required | Identifier of this notice. The same event_id arriving again is a retry of the same notice, not a new gift. |
church_id |
string | Required | Your church's identifier at Roster. One endpoint can serve several churches by reading it. |
occurred_at |
string | Required | When the bank reported the return, in UTC (ISO 8601). |
occurrence_id |
string | Required | Identifier of the gift being reversed: the occurrence_id of its gift.recorded. |
enrollment_id |
string | Required; may be null | Identifier of the giver's payroll giving sign-up with your church. Null when the gift is not linked to a sign-up. |
donor_id |
string | Required; may be null | Identifier of the giver at Roster. It stays the same across sign-ups. Null when the gift has no identified giver. |
currency |
string | Required | Currency of the amount. Always USD. |
amount_minor |
integer | Required | The same amount, in US cents, that the gift.recorded for this gift carried. Reverse exactly that amount. |
return_code |
string | Required | The bank's return code, for example R01. |
{
"event": "gift.returned",
"event_id": "evt_4c6e8a0b2d1f3a5c7e9b0d2f4a6c8e1b",
"church_id": "ch_8f3a2b1c9d4e",
"occurred_at": "2026-09-17T15:40:12.000Z",
"occurrence_id": "occ_5b7e1d3c9a2f",
"enrollment_id": "enr_2c4e6a8b0d1f",
"donor_id": "dn_7a9c1e3b5d0f",
"currency": "USD",
"amount_minor": 8753,
"return_code": "R01"
}
giver.updated
Sent when a giver who has already given to your church updates their contact details. Replace the details you keep for them.
| Field | Type | When present | Meaning |
|---|---|---|---|
event |
string | Required | Always giver.updated. |
event_id |
string | Required | Identifier of this notice for your church. The same event_id arriving again is a retry. |
church_id |
string | Required | Your church's identifier at Roster. One endpoint can serve several churches by reading it. |
occurred_at |
string | Required | When the giver updated their details, in UTC (ISO 8601). |
enrollment_id |
string | Required | Identifier of the giver's payroll giving sign-up with your church. |
donor_id |
string | Required | Identifier of the giver at Roster. It stays the same across sign-ups. |
giver |
object | Required | The giver's current details, complete. |
giver.name |
string | Required | The giver's full name. |
giver.email |
string | Required; may be null | The giver's email address. Null when we do not have one. |
giver.phone |
string | Required; may be null | The giver's phone number. Null when we do not have one. |
giver.address |
object | Required | The giver's mailing address. |
giver.address.line1 |
string | Required | Street address. |
giver.address.city |
string | Required | City. |
giver.address.state |
string | Required | State. |
giver.address.zip |
string | Required | ZIP code. |
{
"event": "giver.updated",
"event_id": "evt_6a8c0e2b4d1f3a5c7e9b1d3f5a7c9e0b:ch_8f3a2b1c9d4e",
"church_id": "ch_8f3a2b1c9d4e",
"occurred_at": "2026-09-20T09:15:00.000Z",
"enrollment_id": "enr_2c4e6a8b0d1f",
"donor_id": "dn_7a9c1e3b5d0f",
"giver": {
"name": "Rebecca Dalia",
"email": "rebecca.dalia@example.com",
"phone": "+14255550100",
"address": {
"line1": "501 Elm St",
"city": "Austin",
"state": "TX",
"zip": "78701"
}
}
}
Verifying the signature
Every delivery carries:
Roster-Signature: t=1756060925,v1=5f2c…
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; accept the
delivery when any v1 matches. The verifier below returns false for any
other form, such as a space after the comma or uppercase hex.
v1 is HMAC-SHA256 of {t}.{raw body} with your webhook secret. The key
is the whole secret string exactly as shown, whsec_ prefix included, as
UTF-8 bytes; do not strip the prefix or base64-decode it. Verify against the
raw bytes you received, before parsing. t is when this attempt was sent,
so a retry carries a new one. 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 checks out.
This is the tested verifier Roster publishes for its webhooks. Copy it as it
is, passing the raw body as a Buffer, or download it:
verify-webhook.mjs,
https://docs.rostergiving.com/examples/verify-webhook.mjs. A Python version
that takes the body as bytes is at
verify_webhook.py,
https://docs.rostergiving.com/examples/verify_webhook.py.
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")));
}
Send a test delivery from the dashboard (Integration Guide → Connect your
ChMS) to check your implementation before real gifts arrive. It sends one
webhook.test, with the fields shown in What we send, signed
the same way and with the same 3-second limit, and it is not retried. It
reports the status your endpoint returned, including errors.
Pull: read your gifts with a key
Your system can also read the gifts list on its own, with a read key. Create the key in the dashboard (Integration Guide → Connect your ChMS). It appears once; creating a new key turns the previous one off, and Turn it off ends it without a replacement.
GET /church/api/gifts
Authorization: Bearer ck_…
The response is { "gifts": [ … ] }: the same gift.recorded and
gift.returned bodies the push delivers, the 100 most recent, newest first. A
webhook that failed should not become a support ticket: read the list and
reconcile.
-
Up to 60 requests per minute per church. Above that you get
429with aRetry-Afterheader, in seconds. - A missing, malformed or turned-off key always gets the same
401.
When something is off
-
The dashboard says your system answered an error. Open Manage
connection first: each recent delivery shows whether your endpoint
accepted it, and Send again resends one that failed with the same
event_id. - A gift is missing in your system. Check the pull list — if it is there, the delivery failed on your side; if it is not, the bank has not confirmed the transfer to you yet.
-
A gift appears twice. Deliveries are at least once. Deduplicate on
event_id. Agift.recordedthat arrives again for the sameoccurrence_idafter agift.returnedhas a newevent_id: it is the transfer being sent again, so record it.