Receive billing events
Billing pushes every state change your module needs to know about — a payment succeeded, an entitlement was granted or revoked, a subscription changed — to your webhook. React to these instead of polling.
1. Set your webhook URL
Delivery targets your Service.webhookUrl. Two constraints that trip people up:
- It must be a public HTTPS host. The outbox has an SSRF guard that rejects
localhost, loopback, private, and link-local addresses — so alocalhostURL will silently never receive events. For local development, use a tunnel (e.g. an ngrok-style public URL). - There is currently no admin API to set
Service.webhookUrl— it's set directly in the database (or a seed). This is a deliberate flag in these docs; don't look for aPUT /v1/admin/servicesfield for it (that endpoint setscode/name/isActive/metadataonly; itswebhookUrlbelongs to payment providers, a different object).
2. The envelope
Every event is the same JSON shape, with three headers:
x-service-code: <your service.code>
x-event-type: <event type, e.g. billing.payment.succeeded>
x-event-id: <unique event id — use for idempotency>
{
"type": "billing.payment.succeeded",
"data": { "order_id": "…", "user_id": "user-123", "amount": "20.0000", "currency": "USD" },
"aggregate": {},
"eventId": "…uuid",
"timestamp": "2026-07-26T12:00:00.000Z"
}
data keys are snake_case. aggregate is present for envelope symmetry but is empty today.
3. Event catalog
The events you'll act on most, with their data:
type | data |
|---|---|
billing.payment.succeeded | { order_id, user_id, amount, currency } |
billing.payment.refunded | { order_id, user_id, amount, currency } |
billing.entitlement.granted | { user_id, key, source, order_id? | subscription_id?, expires_at?, trial? } |
billing.entitlement.revoked | { user_id, key, … } |
billing.subscription.updated / .cancelled / .ended | subscription id + status |
billing.subscription.payment_failed | dunning signal for a renewal |
billing.subscription.trial_started | a trial began |
billing.payment.status_changed | { payment_intent_id, order_id, status, previous_status } |
For access control you mostly need entitlement.granted / entitlement.revoked (see
Gate access with entitlements).
4. Verify and de-duplicate
Two properties to design for:
- No signature. Outbound webhooks are not signed — the trust signals are TLS and the
x-service-codeheader. Terminate TLS, and treat the endpoint as unauthenticated: don't act on anything you can't re-verify against the API if it matters. (You can also firewall the endpoint to billing's egress.) - At-least-once delivery. A failed POST is retried, so the same
eventIdcan arrive more than once. Make your handler idempotent — dedupe oneventId(store processed ids; ignore repeats).
app.post('/billing/webhook', async (req, res) => {
const eventId = req.header('x-event-id')
if (await seen(eventId)) return res.sendStatus(200) // idempotent
const { type, data } = req.body
switch (type) {
case 'billing.entitlement.granted': await grant(data.user_id, data.key, data.expires_at); break
case 'billing.entitlement.revoked': await revoke(data.user_id, data.key); break
case 'billing.payment.succeeded': await recordPayment(data); break
}
await markSeen(eventId)
res.sendStatus(200) // 2xx = delivered; non-2xx makes billing retry
})
Return 2xx once you've durably accepted the event. A non-2xx (or timeout — the POST has a 10s
budget) marks the delivery failed and it will be retried.