Webhooks
How the control plane tells you a payment went through, failed, or that someone opened a dispute.
The contract is locked in the architecture document; delivery isn't built yet.
1. Why your instance doesn't talk to Stripe
Your instance has no Stripe endpoint, and it never will. Stripe Connect events arrive at a single endpoint on the control plane, which works out which connected account they belong to from the event's account field, translates them into Payezz domain events and forwards them to you, signed with your own secret.
That buys three things:
- A single Stripe webhook secret for the whole system, instead of one per customer.
- Instances that don't need to be reachable from the internet by a third party.
- A retry policy that's ours, so we can fix it without depending on anyone.
2. Event catalog
| Event | When it's sent |
|---|---|
payment_intent.succeeded | The payment completed. This is the one that activates or renews the service. |
payment_intent.failed | The payment failed for good. Your dunning policy kicks in. |
charge.refunded | A full or partial refund was issued, with the fee adjustment already applied. |
charge.dispute.created | The customer opened a dispute. Consider suspending before you lose it. |
account.updated | Your connected account's status changed, for example if Stripe stops allowing charges. |
plan.changed | Your Payezz plan changed, and with it the applicable fee and limits. |
3. Delivery format
POST /webhooks/payezz HTTP/1.1
Host: store.acme.com
Content-Type: application/json
Payezz-Event-Id: evt_01J8Z3M4K7...
Payezz-Timestamp: 1789459260
Payezz-Signature: v1=9c40...11bd
Payezz-Api-Version: 2026-09-01
{
"id": "evt_01J8Z3M4K7...",
"type": "payment_intent.succeeded",
"created_at": "2026-10-03T09:14:20Z",
"data": {
"payment_intent_id": "pi_3Q...",
"amount_minor": 1499,
"currency": "eur",
"reference": { "kind": "service", "id": "svc_4821" },
"fee": { "total_minor": 48, "currency": "eur" },
"ledger_entry_id": "cle_01J..."
}
}4. Verifying the signature
The scheme is the same as for outgoing requests, in the opposite direction: HMAC-SHA256(secret, timestamp + "." + raw_body).
- Sign the raw body. If your framework hands you already-parsed JSON and you serialize it again, the signature won't match.
- Compare in constant time, never with a regular equality check.
- Reject anything with a
Payezz-Timestampmore than 300 seconds off your clock. - If the signature doesn't match, respond
401and process nothing.
5. What your instance should respond
Respond 200 as soon as you've persisted the event to your local queue, and process it afterwards. Don't do the work inside the handler.
The reason is concrete: if you process inline and something is slow, the delivery times out and gets retried, and you end up running the same job twice or losing the event, depending on how you handled idempotency. The queue separates receiving from processing, which are two different problems.
6. State-machine idempotency
Here's the most expensive mistake, and we're telling you because we made it: marking the event as processed before processing it. If processing fails and you return an error, the retry sees the ID already recorded, responds “duplicate” and does nothing. The event is lost forever, and a payment that went through may never activate the service.
Idempotency isn't solved by whether a row exists, but with a state machine:
received → INSERT events(id, status='received') with UNIQUE(id)
conflict and status='done' → 200, do nothing
conflict and status='processing' → 200, someone else is on it
conflict and status='failed' → retry
200 right away, BEFORE processing
queue → claim with SKIP LOCKED → status='processing', 60 s lease
process inside a transaction
success → status='done', processed_at
failure → status='failed', attempts++, growing run_after
after 8 attempts → dead-letter queue and alertAnd a warning about what makes it worse: the idempotency check must not fail open. If your database doesn't respond and you treat that failure as “never seen it”, the retry triggers a double activation. If you can't check, return an error and let it be retried.
7. Retries
If your instance doesn't respond with a 2xx, the control plane retries the delivery on this schedule:
0 s → 30 s → 2 min → 10 min → 1 h → 6 h → 24 hEvery delivery of the same event carries the same Payezz-Event-Id. That's the key to apply the previous section to. Once retries are exhausted, the event lands in the control plane's dead-letter queue and you get an email: it's never dropped silently.
8. Event retention
The received-events table grows forever if nobody cleans it up, and in a billing system that runs for years that becomes a real performance problem. Purge events in the done state older than ninety days. Those in failed aren't purged: they get reviewed.