Documentation

Webhooks

How the control plane tells you a payment went through, failed, or that someone opened a dispute.

Designed, not built

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

EventWhen it's sent
payment_intent.succeededThe payment completed. This is the one that activates or renews the service.
payment_intent.failedThe payment failed for good. Your dunning policy kicks in.
charge.refundedA full or partial refund was issued, with the fee adjustment already applied.
charge.dispute.createdThe customer opened a dispute. Consider suspending before you lose it.
account.updatedYour connected account's status changed, for example if Stripe stops allowing charges.
plan.changedYour 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-Timestamp more than 300 seconds off your clock.
  • If the signature doesn't match, respond 401 and 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 alert

And 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 h

Every 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.