Register an endpoint
Add your URL in the dashboard under Developers → Webhooks, or with the API:whsec_…). It is shown once, so store it right away. Every active endpoint receives every event. To replace a secret, call POST /webhook-endpoints/{id}/rotate-secret: the old secret stops working at once.
What an event looks like
data.object is the full object, exactly as the REST API returns it.
Verify the signature
Anyone can send a request to your URL, so check that each one came from Bouclay. Every delivery carries a header:t is when it was sent (Unix seconds). v1 is an HMAC-SHA256 of "<t>.<raw body>" keyed with your signing secret, written in hex. To verify:
- Read the raw request body, before any JSON parsing.
- Compute the HMAC of
t + "." + bodywith your secret. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than five minutes old, so an old delivery can’t be replayed.
Handle each event once
Your endpoint must answer with a2xx within 10 seconds. Redirects are not followed. A delivery that fails is tried up to 5 times in all, waiting 1 minute, 5 minutes, 30 minutes and 2 hours between tries. So the same event can arrive more than once, and a retried one can arrive after a newer one.
- Answer fast. Return
200as soon as the signature checks out, then do the work in a background job. - Skip events you’ve seen. Store each
idyou handle and ignore repeats. - Skip stale events. Compare the event’s
createdtime with the last event you applied to that object, and ignore older ones.
Read the state, not the event name
Each object sends exactly two events:*.created when it first exists, and *.updated for every change after that. There is no invoice.paid or subscription.canceled. Read status and the other fields off data.object instead:
subscription.* events also carry data.object.customer.entitlements, the codes the customer holds right now, so you can update your paywall without calling back.
Event list
Prices, discounts, payments and refunds send no events of their own. A payment arrives on
invoice.updated as data.object.payment.
Missed something?
Every event is also kept in the API. List them withGET /events, newest first, to backfill after downtime.