The default webhook implementation is a route that parses the payload, updates some rows, and returns 200. It works until it does not, and when it does not the event is gone — the provider retried a few times, got errors, and stopped.
Accept, then process
Split the endpoint into two jobs it should never have been doing at once.
The endpoint’s only responsibility is to verify the signature, write the raw body to a table, and return 200 as fast as possible. No business logic, no joins, nothing that can be slow or throw.
webhook_events
id
provider
provider_event_id unique
signature_valid
raw_body jsonb
received_at
processed_at null until handled
attempts
last_error
A worker picks up unprocessed rows and does the actual work. If it fails, the row stays unprocessed with an incremented attempt count and the error recorded. Nothing is lost, and you can retry a year later against fixed code.
The unique index is the deduplication
Providers deliver twice. Not rarely — by design, because at-least-once is the only guarantee a network allows. A unique provider_event_id makes the second delivery a no-op insert conflict instead of a duplicated refund.
If your provider does not send a stable event id, hash the raw body and use that. It is weaker — two legitimate identical events collapse — but far better than nothing.
Verify before you store, not after
Signature verification happens on the raw bytes, before any parsing. Parse first and you have already let unverified input into a JSON decoder and possibly into your schema. Store the result of the check on the row, so a later audit can tell the difference between “we ignored it” and “it was forged”.
Ordering is not guaranteed
A success event can arrive after the refund event for the same object. Handlers must be written against the current state of the row, not against an assumed sequence — check what it says now, decide whether the event still applies, and ignore it if it does not.
What this buys
A provider outage becomes a backlog instead of data loss. A bug in the handler becomes a replay instead of an incident. And every event that ever arrived is still on disk, which is the only thing that makes “why is this account wrong” answerable.