Events and reconciliation

Use webhooks for prompt notification. Recover activity events with GET /v1/events and the financial events (donation.updated, donor.updated, refund.created and refund.updated) with GET /v1/financial_events. The financial journal includes donor data for your existing reporting and ChMS integrations; see financial integration. Keep a separate cursor for each feed. Store events using event_id as a unique key so receiving an event through both channels does not apply it twice.

Identify the entity

{
  "event_id": "evt_example",
  "event_type": "enrollment.activated",
  "occurred_at": "2026-09-08T12:00:00.000Z",
  "church_id": "ch_example",
  "entity_id": "enr_example",
  "livemode": false,
  "payload": { "expected_amount": 25, "expected_cycle": "2026-09-30T12:00:00.000Z" }
}

entity_id identifies the enrollment for every enrollment.* event. livemode is true for production events and false for sandbox events, on both feeds and on webhooks: if the same consumer reads both environments, use it to keep them apart. Payload fields vary by event; consumers must tolerate optional fields and additions. Configured or expected amounts are not evidence of money received. The event catalog lists the fields exposed for each type.

Poll without losing your place

Set ROSTER_CURSOR to the last next_cursor you committed, or leave it empty on the first poll:

curl --fail-with-body --get "$ROSTER_BASE_URL/v1/events" \
  -H "Authorization: Bearer $ROSTER_KEY" \
  ${ROSTER_CURSOR:+--data-urlencode} ${ROSTER_CURSOR:+"since_cursor=$ROSTER_CURSOR"}

With ROSTER_CURSOR empty, the two ${ROSTER_CURSOR:+…} expansions add nothing, so no since_cursor is sent; an empty since_cursor= is rejected with 400. The expansions are written separately so the command works the same in bash and zsh. Pages contain at most 50 events. A non-empty page returns next_cursor, including the last page. Continue until a page has no events; do not wait for a null cursor on a non-empty page.

For every non-empty page, commit the deduplicated events and the new cursor in one database transaction. If processing fails, keep the previous cursor and retry. When an empty page arrives, retain the last committed cursor for the next scheduled poll. Use a bounded number of pages per job so a busy feed cannot occupy a worker indefinitely.

A cursor is opaque: do not decode, edit, or reconstruct it from a timestamp. The parameter name is since_cursor, not cursor. Keep separate checkpoints for separate filtered consumers and keep their filters stable. A general reconciliation consumer should read all event types.

Without a cursor, the first poll starts at the last 90 days. This is the initial query window, not a promise that a stored cursor expires after 90 days. Poll regularly and alert on a stale checkpoint. A malformed cursor returns 400; check that you persisted and URL-encoded the exact value supplied by the API. Do not silently discard a checkpoint after an error.

If your checkpoint is lost, pause the affected consumer, keep your stored events, and explicitly start a new reconciliation pass. The default pass only covers the last 90 days. Deduplicate against your existing event IDs and investigate any earlier gap rather than declaring a complete recovery.

Handle delayed and duplicate delivery

Do not use delivery order as business order, and do not let a delayed notification overwrite a newer enrollment state. Record the event for your audit trail, then use the enrollment read endpoint when you need the current state. An unknown future event type should be durably retained and surfaced to your integration team rather than crashing the whole consumer.

A tested JavaScript example, https://docs.rostergiving.com/examples/partner-client.mjs, provides syncEvents. Its commitPage callback is where your application atomically deduplicates and persists the page and cursor; the helper itself is not a database or a durable queue.