Docs · Build
Webhooks
Register an endpoint, verify the signature in your language, handle retries and ordering, and replay what you missed.
Folio sends a signed POST to an https endpoint you register whenever something happens: a teaching finishes, a note is published, a customer church changes plan. You do not have to poll. The event catalogue lists every event with its fields and an example body, generated from the API.
Set up an endpoint#
You can manage endpoints in the console or with a key that has webhooks:write.
curl -X POST "$FOLIO_API_URL/v1/webhook-endpoints" \
-H "Authorization: Bearer $FOLIO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: endpoint-sunday-pipeline" \
-d '{
"name": "Sunday pipeline",
"url": "https://hooks.example.org/folio",
"event_types": [
"teaching.ready",
"note.published"
],
"mode": "live"
}'event_types takes types from the catalogue, or * for every event, including ones added later. The response carries the signing secret (whsec_…) once. Store it. After you rotate (POST /v1/webhook-endpoints/{endpoint}/rotate) both secrets sign for 24 hours so you can deploy the new one without dropping events.
Send a signed test event to check your receiver. The delivery carries the header Folio-Test: true:
curl -X POST "$FOLIO_API_URL/v1/webhook-endpoints/whe_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012/test" \
-H "Authorization: Bearer $FOLIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "note.published"
}'What arrives#
POST /your/endpoint
content-type: application/json
webhook-id: msg_01JC8M2X9R5V0K4T7E3Q6B1ZDN
webhook-timestamp: 1793541609
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2/3Wb9g5N1q0n1k=
folio-api-version: 2026-12-01
folio-attempt: 1The body is one JSON object, an event. This example is a published note, trimmed to its main fields:
{
"id": "evt_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012",
"object": "event",
"api_version": "2026-12-01",
"created": "2026-11-01T12:31:00Z",
"type": "note.published",
"livemode": true,
"request": { "id": "req_5f3c9a1e7b2d4c68a0e1f2d3c4b5a697", "idempotency_key": null },
"actor": { "id": "usr_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012", "object": "user" },
"data": {
"object": {
"id": "note_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012",
"object": "note",
"status": "published",
"summary": "Nothing in all creation can separate us from the love of God in Christ.",
"teaching": { "id": "tch_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012", "object": "teaching", "title": "Nothing can separate us" }
},
"previous_attributes": { "status": "approved" }
}
}| Field | Meaning |
|---|---|
id | The event id, evt_…. It is the same on every retry and on every endpoint that receives the event. Deduplicate on it. |
object | Always event. |
api_version | The event schema version. The shape of each event is fixed for a version. |
created | When it happened, RFC 3339 in UTC. Every timestamp in an event is an RFC 3339 string. |
type | The event type from the catalogue. |
livemode | false for sandbox churches and for test events. |
request | The API request that caused the event (its id is the request_id of that response, plus the Idempotency-Key the caller sent), or null when Folio caused it, such as a job. |
actor | The team member who caused it, or null. |
church, partner, ordering_key | Present on partner endpoints for customer churches. |
test | true on test events. |
data.object | The whole resource as it was at that moment, in the shape the REST API returns for it, with related resources embedded as small references. |
data.previous_attributes | On update events only: the earlier value of each field that changed. |
Every event, with its fields, privacy class and a complete example, is in the catalogue. Build against it, and ignore fields you do not recognise, because fields are added without a new version.
Rules every object follows#
- Every object has
objectandid. Ids are typed:note_…,tch_…. - Related resources are embedded as references, not bare ids. A note carries its teaching with
id,objectandtitle. - Collections are lists:
{"object":"list","data":[…],"has_more":false,"url":"/v1/…"}. Each list is capped, at 10 to 25 items. Whenhas_moreis true, read the rest fromurlwith the API. - Nesting stops three levels below
data.object. - Money is an integer in the smallest unit with its currency, such as
{"amount_minor":7900,"currency":"USD"}. - A value that can be missing is
null, not absent, unless the catalogue marks the field optional.
Privacy classes#
Each event is marked metadata_only (ids, kinds, states and times, never text or contact details), content (what the REST read of the resource returns), account (facts about the church, its keys and connections) or financial (minor units, and at most a card brand and last four digits). No event carries a signing secret, a key, a token or a full card number. Subscribe each endpoint only to what it needs.
Verify the signature#
Always verify before you trust a body. The signed content is <webhook-id>.<webhook-timestamp>.<raw body>. The signature is the base64 HMAC-SHA256 of that content. The HMAC key is the full secret string as bytes, including the whsec_ prefix; it is not base64-decoded. webhook-signature holds one or two space-separated v1,<signature> values: accept the request if any matches. Compare in constant time, use the raw body bytes (not a re-serialised object), and reject timestamps more than five minutes from your clock.
SECRET='whsec_...'
ID='msg_01JC8M2X9R5V0K4T7E3Q6B1ZDN'; TS='1793541609'
printf '%s.%s.%s' "$ID" "$TS" "$(cat body.json)" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64
jq -r '[.id, .type, .data.object.id] | @tsv' body.jsonTwo ids, two jobs#
idin the body (evt_…) names the event. It is the same on every retry, on every endpoint and on a replay. Deduplicate on it, so a retry or a replay of something you already handled does nothing twice.webhook-idin the headers (msg_…) names one delivery. A retry reuses it; a replay is a new delivery with a new one.
Filters on an endpoint#
An endpoint can narrow what it receives. Filters and payload shapes address fields as data.object.<field>, for example only events where data.object.status is published. Set them when you create or update the endpoint; see the endpoint operations in the API reference.
Answer fast, process later#
Answer with any 2xx within the timeout, then do the work. Skip events whose id you have already handled. Any other answer, or a timeout, is retried on a backoff; the webhook-will-retry-after header on a failed attempt tells you the next delay in seconds.
An endpoint that fails 20 times in a row is disabled (disabled_reason is consecutive_failures) and shows as Disabled in the console; enable it again from the endpoint page after you fix the receiver. A payload over 256 KB is not sent and the delivery fails with payload too large.
Ordering#
Deliveries for one church share an ordering_key (the church id). Within a key, order events by created. Different churches have no order relative to each other, and retries can arrive out of order, so write handlers that tolerate that: fetch the current state with the API when order matters.
See what happened, and replay#
curl -X GET "$FOLIO_API_URL/v1/webhook-endpoints/whe_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012/deliveries?status=failed" \
-H "Authorization: Bearer $FOLIO_API_KEY"GET …/deliveries/{delivery}/attempts shows every attempt: the exact request (with the signature shortened), your receiver's status, latency and the first 2 KB of its answer. Payloads and answers are kept for 30 days.
curl -X POST "$FOLIO_API_URL/v1/webhook-endpoints/whe_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012/deliveries/whd_0192f7a8-3b1c-7d2e-9a40-5c6d7e8f9012/replay" \
-H "Authorization: Bearer $FOLIO_API_KEY"A replay sends the event again as a new delivery with a new webhook-id.
Partners: every church you run#
A partner endpoint can subscribe to events for all of its customer churches. Set child_scope to none for the partner's own events, all for every customer church including ones added later, or selected with church_ids. Each event says which church it is about, so one endpoint can tell churches apart. partner.church.deleted is sent twice for a church: once as scheduled when deletion is requested and once as purged when its data is removed. See deletion and purged events and partners.
Verified against API version 0.0.0-dev. Generated from the live OpenAPI document.