Webhooks
We POST JSON to your URL when something happens, and sign it so you can tell it came from us.
Events
| order.completed | Someone paid. |
| order.refunded | You refunded them, in part or in full. |
| entitlement.granted | A buyer got access to one thing you sell. |
| entitlement.revoked | That access was taken away — refund, or a lost dispute. |
An order with two files in it produces one order.completed and two entitlement.granted. If you only want to know about sales, subscribe to the first and ignore the rest.
Payload
{
"event_id": "evt_order_completed_a1b2…",
"event_type": "order.completed",
"occurred_at": "2026-09-04T12:31:05.1234567Z",
"order_id": "a1b2c3d4-…",
"buyer_email": "buyer@example.com",
"product_id": "…",
"product_name": "Acme Synth",
"amount_cents": 2500,
"amount": "25.00",
"currency": "USD",
"price_type": "one_time",
"coupon_code": "SPRING20",
"refunded_amount_cents": 0,
"refunded_amount": "0.00"
}
Flat on purpose. Zapier turns nested objects into field names like data__order__id, and you should see order_id. Amounts come both ways — minor units to do arithmetic with, and a decimal string to put in an email.
Deduplicate on event_id. It is stable across retries, so the same event arriving twice is the same event_id and should be treated as one thing happening, not two.
Verifying the signature
Every request carries a Faerra-Signature header:
Faerra-Signature: t=1788483295,v1=5257a8695e…
v1 is HMAC-SHA256 over {timestamp}.{raw body}, keyed with your signing secret. This is the same scheme Stripe uses, so if you have verified one of theirs you have already written this.
// Node
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=')));
// Reject anything older than five minutes, or a request captured today
// can be replayed against you next week.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(age < 300)) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
// Constant time, so a wrong signature does not leak how wrong it was.
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1));
}
Verify against the raw body, before any JSON parsing. A framework that parses and re-serialises will change the bytes and the signature will not match.
Retries
Answer with any 2xx and we consider it delivered. Anything else, or no answer within ten seconds, and we try again after a minute, then five, then thirty, then two hours, six, and twelve — after which we stop and show it as failed on your integrations page.
Answer quickly. Ten seconds is the whole budget, so do the work after you have replied rather than before.
Using Zapier instead
Pick the Zapier integration rather than this one and paste a Catch Hook URL — no signature checking needed, because the hook URL is itself the secret. Everything above about the payload still applies, and Zapier will pick the fields up automatically once you send a test.