VietBilling
All guides
VietBilling Engineering Guides

Safe PayOS webhooks: signatures, idempotency, and reconciliation

Build retry-safe webhook processing that never grants access twice and can recover when requests or callbacks are interrupted.

VietBilling EngineeringVerified 2026-08-3111 min read

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

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