A webhook is a message, not an exactly-once callback
Production webhooks can be late, duplicated, out of order, or absent while your service is unavailable. A correct handler verifies the message, records it, and applies a change that is safe to repeat.
receive → verify signature → reserve event_id → apply transaction
→ return 2xx ↘ already processed → return 2xx
Preserve the raw payload and verify first
Never read payment state before signature verification. Keep checksum and signing keys server-side and out of logs, analytics, and browser bundles.
async function handleWebhook(rawBody: string, signature: string) {
const event = verifyWebhook(rawBody, signature, process.env.WEBHOOK_SECRET!)
await processOnce(event)
return { ok: true }
}
Use the provider's canonical payload rules rather than inventing sort or serialization behavior. PayOS documents signatures and webhook confirmation in its API reference.
Deduplicate with a database constraint
A separate check followed by an insert still races. Put a unique constraint on event_id, insert inside the same transaction as the domain change, and treat a conflict as already processed.
create table processed_webhook_events (
event_id uuid primary key,
event_type text not null,
processed_at timestamptz not null default now()
);
Commit the event marker only if subscription and entitlement changes also commit. After a crash, a retry can safely continue without losing the event.
Return status deliberately
- Invalid signature:
401or400. - Valid event already processed:
2xx. - Temporary database failure:
5xxso delivery can retry. - Unsupported valid event: record it and apply an explicit dead-letter policy.
Do not return 2xx before the transaction is durable, and do not return an error for an event that was already committed.
Reconciliation closes the gap
A scheduled job should find Checkouts or Payments pending beyond a threshold, read provider state, and complete them through the same billing service used by the webhook.
old pending Checkout → provider lookup → normalize state
→ idempotent completion → signed domain webhook
Two separate sets of completion rules inevitably drift. Webhook and reconciliation should share one transactional billing engine.
Production checklist
- Test valid, invalid, and rotated signatures.
- Replaying an event never creates two grants.
- Concurrent workers still commit once.
- A crash cannot leave a marker without the domain change.
- Reconciliation repairs a missed callback.
- Logs contain request and event IDs, never secrets or sensitive payload fields.