Webhook là message, không phải callback chạy đúng một lần
Một webhook production có thể tới muộn, tới lặp, tới sai thứ tự hoặc không tới trong lúc dịch vụ của bạn gián đoạn. Handler đúng không dựa vào giả định “PayOS gọi đúng một lần”. Nó xác thực message, ghi nhận message và áp dụng thay đổi theo cách có thể chạy lại.
receive → verify signature → reserve event_id → apply transaction
→ return 2xx ↘ already processed → return 2xx
Bước 1: giữ raw payload và xác thực chữ ký
Không đọc trạng thái thanh toán trước khi signature hợp lệ. Checksum key là secret server-side; không đưa vào frontend, log hoặc analytics. Khi thay key, triển khai quy trình rotation có thời gian chuyển tiếp rõ ràng.
async function handleWebhook(rawBody: string, signature: string) {
const event = verifyWebhook(rawBody, signature, process.env.WEBHOOK_SECRET!)
await processOnce(event)
return { ok: true }
}
Hãy dùng đúng thuật toán và canonical payload theo tài liệu nhà cung cấp thay vì tự đoán cách sort hoặc stringify dữ liệu. PayOS mô tả signature và endpoint xác nhận webhook trong API reference.
Bước 2: deduplicate bằng unique constraint
Check-then-insert trong hai query riêng biệt vẫn có race condition. Tạo unique constraint trên event_id, insert trong transaction, rồi coi conflict là “đã xử lý”.
create table processed_webhook_events (
event_id uuid primary key,
event_type text not null,
processed_at timestamptz not null default now()
);
Trong cùng transaction, cập nhật subscription và entitlement. Chỉ commit event marker nếu thay đổi domain cũng commit. Nếu handler crash, retry có thể tiếp tục mà không mất sự kiện.
Bước 3: trả status có chủ đích
- Signature sai: trả
401hoặc400, không retry nội bộ. - Payload hợp lệ và đã xử lý: trả
2xx. - Lỗi tạm thời của database: trả
5xxđể upstream retry. - Event chưa hỗ trợ nhưng hợp lệ: log loại event và quyết định rõ có
2xxhay dead-letter; không silently grant access.
Không trả 2xx trước khi transaction bền vững. Ngược lại, đừng trả lỗi cho event đã xử lý vì điều đó tạo retry vô ích.
Đối soát đóng khoảng trống
Webhook không thể là cơ chế phục hồi duy nhất. Một job định kỳ nên đọc các Checkout/Payment đang pending quá lâu, hỏi lại provider và hoàn tất chúng bằng cùng billing engine như webhook.
pending checkout older than threshold
→ GET provider payment request
→ map provider state
→ complete through idempotent billing transaction
→ emit signed domain webhook
Điểm quan trọng là webhook và reconciliation không có hai bộ business logic khác nhau. Cả hai gọi chung một completion service để tránh trạng thái lệch.
Checklist production
- Có test signature hợp lệ, sai và key đã rotate.
- Gửi cùng event hai lần không tạo hai grants.
- Hai worker xử lý đồng thời vẫn chỉ commit một lần.
- Crash giữa insert và update không để trạng thái nửa vời.
- Job reconciliation sửa được payment bị bỏ lỡ.
- Log có request ID và event ID nhưng không có secret hoặc dữ liệu nhạy cảm.