Webhooks — Tổng quan
Webhook là tín hiệu xác thực. Callback trình duyệt (onSuccess)
và các view của dashboard là tiện lợi; webhook là sự thật gốc.
Đảm bảo delivery
- At-least-once. Một event có thể được delivery tối đa 6 lần
nếu server của bạn không trả về 2xx trong timeout. Hãy làm handler
của bạn idempotent — dedup trên
X-Delivery(payload không có trườngevent_id; UUID delivery ổn định là khóa idempotency). - Một event mỗi HTTP request. Không batch.
- Cách ly theo từng endpoint. Nếu bạn có nhiều endpoint đã đăng ký, mỗi cái có track delivery + retry riêng. Một URL merchant chậm không thể bỏ đói các URL khác — mỗi host có circuit breaker riêng.
- Đã ký. Mọi payload mang header
X-Signature(và trong cửa sổ 24 giờ sau khi rotate, cũng cóX-Signature-Prev). Xác thực trước khi làm bất cứ điều gì với body. Xem Xác thực chữ ký.
Các loại event có thể subscribe
| Event | Phát khi… |
|---|---|
payment.settled | Giao dịch chuyển on-chain đã đạt số confirmation của chain. Dùng cái này để đánh dấu order đã thanh toán. |
payment.failed | Một thanh toán fiat bị provider từ chối tường minh (hiện tại: webhook Stripe báo hiệu failure). Không phát cho timeout crypto — những cái đó surface dưới dạng checkout.expired, và thanh toán crypto thiếu surface dưới dạng payment.underpaid. |
payment.underpaid | Vốn đã đến nhưng thiếu so với tổng order (điển hình: phí chuyển stablecoin trừ từ amount). |
payment.overpaid | Vốn đến vượt tổng order. Phần dư được ghi nhận nhưng không auto-refund. |
order.created | Một order mới được mở — hoặc bởi lệnh gọi B2B API của bạn hoặc bởi việc chuyển đổi checkout-session. |
order.canceled | Một order chuyển sang canceled. data.reason của payload phân biệt cancel thủ công với payment_timeout (order chưa thanh toán cũ bị worker sweep). |
order.resolved | Một order PARTIAL_PAID được resolve sang PAID — merchant chấp nhận phần thiếu. |
order.reopened | Một order trước đó auto-canceled (canceled_reason=payment_timeout) được merchant mở lại. |
checkout.created | Một người mua đã mở checkout cho một order. |
checkout.completed | Luồng phía người mua đã kết thúc (không có nghĩa là đã settle on-chain — dùng payment.settled cho điều đó). |
checkout.expired | Người mua đã từ bỏ và TTL của session đã hết. |
payment.refund.requested | Một bản ghi refund được tạo — hoặc từ lệnh gọi API do merchant khởi tạo hoặc từ form refund-request do khách hàng submit. |
payment.refund.approved | Một refund pending đã vượt qua workflow approve của bạn. |
payment.refund.rejected | Một refund pending bị từ chối. |
payment.refund.executed | Giao dịch chuyển on-chain của refund đã hoàn tất và bản ghi chuyển sang executed terminal. |
refund_request.created | Một token refund-request đã được phát hành. data.source là b2b / dashboard / renewal. Tùy chọn subscribe — hữu ích cho pipeline audit theo dõi token nào hiện active theo từng order. |
refund_request.renewal_requested | Một người mua bấm “Request new link” sau khi token của họ hết hạn. Khuyến nghị mạnh subscribe — đây là tín hiệu cho merchant rằng widget renewal có item mới để hành động. |
refund_request.renewed | Một renewal được approve và một token mới thay thế cái cũ. data.old_token / data.new_token tạo thành chuỗi audit. |
refund_request.canceled | Một merchant đã chuyển một token sang CANCELED từ dashboard (ví dụ từ chối một renewal request, kill một link đang chạy). Idempotent — chỉ transition đầu tiên phát event. data.reason là ghi chú tùy chọn của merchant. |
Dashboard fetch danh sách này từ GET /v1/webhooks/event-types để
form tạo / chỉnh sửa endpoint luôn khớp với những gì platform thực sự
phát. Subscribe đến một event chúng tôi không ship sẽ bị reject tại
thời điểm tạo với lỗi rõ ràng.
Event test không thể subscribe. Nút Send Test theo từng
endpoint của dashboard POST một envelope webhook.test.ping đồng bộ
đến đúng một endpoint đó (bỏ qua pipeline retry), và path “Send test
event” cấp merchant legacy fan-out một envelope webhook.test đến
mọi endpoint active bất kể filter của nó. Cả hai đều không xuất
hiện trong catalog ở trên — bạn nhận chúng nhờ có một endpoint đã
đăng ký, không phải bằng cách subscribe.
Chỉ subscribe đến các event bạn xử lý. Mỗi endpoint có filter event
riêng; wildcard "*" nghĩa là “mọi event, kể cả các event được
thêm trong tương lai”. Subscribe ít event hơn giữ handler của bạn
sạch hơn và giảm surface mà chúng tôi phải retry khi có lỗi.
Payload + headers
HTTP body chính là object data theo từng event trực tiếp. Không
có envelope ngoài kiểu Stripe — các trường như event type, delivery
ID, và timestamp phát sống trong header thay vào đó. Với
payment.settled body trông như:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* theo từng event */ }
}Các event khác mang bộ trường riêng — xem các publisher struct trong
payment-service/internal/domain/events.go cho hình dáng canonical
cho đến khi tài liệu theo từng event đáp đất. Tên trường ổn định
(lower snake_case); tx hash on-chain luôn là tx_hash (không phải
transaction_hash).
Header trên request inbound
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (chỉ trong cửa sổ grace rotate)| Header | Là gì |
|---|---|
X-Event | Loại event (ví dụ payment.settled). Route trên cái này tại lớp proxy nếu bạn muốn bỏ qua parse JSON. |
X-Delivery | UUID nhận diện row delivery. Ổn định qua mọi retry của cùng cặp (event, endpoint) — dùng làm khóa idempotency của bạn. |
Idempotency-Key | Mirror X-Delivery (cùng giá trị). Set trên mọi delivery — theo convention của Stripe / GitHub. |
X-Timestamp | Unix-seconds khi attempt được gửi. Được ký vào payload để một cặp (body, X-Signature) bị capture không thể replay vô hạn — reject delivery có timestamp ngoài cửa sổ tolerance của bạn. |
X-Signature | sha256=<hex> của HMAC-SHA256(secret, X-Timestamp + "." + raw_body). Xem Xác thực chữ ký. |
X-Signature-Prev | Cùng thuật toán với secret trước đó. Chỉ có trong cửa sổ 24 giờ sau khi bạn rotate — cho phép verifier chạy một trong hai key tiếp tục chấp nhận delivery trong cutover. Sau khi cửa sổ đóng, header ngừng được gửi. |
Schedule retry
Nếu endpoint của bạn không trả về 2xx trong timeout, chúng tôi retry
theo schedule này (timestamp tương đối với attempt đầu):
| Attempt | Delay | Tích lũy |
|---|---|---|
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hour | 1h 21m |
| 6 | +6 hours | 7h 21m |
Sau khi attempt 6 fail, delivery được chuyển vào dead letter và
email tài khoản merchant được thông báo. Event bị dead-letter có thể
replay từ panel Developers → Webhooks → Delivery history trong
dashboard, hoặc trực tiếp qua POST /v1/webhooks/deliveries/:id/replay.
Mỗi lần replay tạo row delivery mới với X-Delivery riêng — chuỗi
audit gắn lại với event gốc qua parent_delivery_id để retry-của-replay
không che event nguồn.
Đăng ký một endpoint
Từ merchant dashboard :
- Developers → Webhooks → + Add endpoint
- Paste URL của bạn — chỉ
https://…(HTTP thuần bị reject; form tạo cũng chặnlocalhost, các dải IP riêng, và URL mang userinfo) - Chọn các event để subscribe (hoặc
*cho tất cả) - Chọn môi trường — test hoặc live (mỗi cái có secret riêng; chúng không bao giờ qua lại)
- Save → dashboard hiển thị secret ký (
whsec_…) một lần. Lưu nó phía server; bạn sẽ cần nó cho hai tính năng tiếp theo.
Bạn có thể đăng ký tối đa 10 endpoint mỗi môi trường mỗi merchant (ví dụ một cho fulfillment production, một cho staging mirror, một cho notifier Slack). Mỗi cái duy trì trạng thái retry, secret, và circuit breaker theo từng host riêng.
Hành động vòng đời trên mỗi endpoint
Menu ⋮ trên thẻ mỗi endpoint surface:
- Edit — thay URL, mô tả, hoặc danh sách subscription. URL mới
được re-validate với cùng quy tắc
https:///SSRF như khi tạo. - Send Test — POST đồng bộ một envelope
webhook.test.pingký bằng secret hiện tại của bạn. Dashboard hiển thị HTTP status, latency, và snippet 512 byte của response. Bỏ qua pipeline RMQ nên câu trả lời tức thời. - Rotate Secret — sinh secret mới. Cái trước đó vẫn hợp lệ trong
24 giờ (delivery mang cả
X-SignaturevàX-Signature-Prevtrong cửa sổ để verifier chạy một trong hai key tiếp tục chấp nhận event trong khi bạn redeploy). - Reveal Secret — hiển thị lại secret hiện tại. Gate bởi xác thực 2FA mới và ghi vào audit log; chỉ dùng khi bạn mất copy và Rotate không chấp nhận được.
- Enable / Disable — flip
is_activemà không mất lịch sử delivery. Endpoint bị disable vẫn ở trong dashboard nhưng không nhận delivery mới. - Delete — vĩnh viễn. Dùng Disable nếu bạn có thể enable lại sau.
Mẹo cho handler
- Trả 2xx nhanh. Acknowledge với
200 OKtrước khi làm việc nặng — đẩy fulfillment vào background queue. Timeout mỗi attempt là 10 giây; giữ response lâu hơn sẽ kích hoạt retry. Timeout ở phía platform và không thể cấu hình theo merchant — liên hệ support nếu handler của bạn thực sự cần thêm thời gian. - Dedup trên
X-Delivery(hoặcIdempotency-Key— cùng giá trị). Kể cả khi bạn trả 2xx, một upstream proxy có thể drop kết nối và kích hoạt retry; delivery ID ổn định qua mọi retry của cùng row delivery, nên đó là key đúng. - Khoan dung với loại event không biết. Event mới có thể xuất hiện; trả 200 và no-op thay vì 4xx, nếu không bạn sẽ làm đầy retry queue.
- Log
X-Deliverycạnh business logic của bạn. Khi có gì đó sai, đó là khóa join giữa phía chúng tôi và phía bạn.
Tiếp theo
- Xác thực chữ ký — thuật toán chính xác + pattern chống replay.
- Khái niệm → Sessions — session ở trạng thái nào khi mỗi event được phát.