<!-- Source: https://docs.infraio.xyz/vi/webhooks/overview -->
<!-- Last updated: 2026-10-04 -->

# 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ường `event_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 và retry riêng. Một endpoint chậm
  không làm chậm các endpoint khác.
- **Đã 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ý](https://docs.infraio.xyz/vi/webhooks/signature-verification).

## 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ị payment provider từ chối. 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` (một order chưa thanh toán đã hết thời gian). |
| `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. |

### Dự kiến (Sắp ra mắt)

> **Note:**
>
> **Sắp ra mắt.** Các event này thuộc về hóa đơn định kỳ và thuê bao,
> hiện chưa khả dụng. Chúng **không** nằm trong bảng có thể subscribe ở
> trên và hiện chưa thể đăng ký. Xem
> [Hóa đơn định kỳ](https://docs.infraio.xyz/vi/guides/recurring-invoices).

| Event dự kiến | Phát khi… |
| --- | --- |
| `subscription.created` | Một subscription được tạo. |
| `invoice.created` | Một hóa đơn cho chu kỳ thanh toán được tạo. |
| `invoice.paid` | Một hóa đơn được thanh toán. |
| `subscription.past_due` | Một hóa đơn chưa thanh toán sau ngày đến hạn. |
| `subscription.canceled` | Một subscription bị hủy. |

Form endpoint của dashboard liệt kê cùng các event này. Subscribe đến
một event không tồn tại sẽ bị reject khi bạn lưu endpoint.

> **Note:**
>
> **Event test không thể subscribe.** Nút **Send Test** theo từng
> endpoint của dashboard gửi một event `webhook.test.ping` đến đúng một
> endpoint đó ngay lập tức, không retry. Nó không nằm trong catalog ở
> trên: bạn nhận nó vì bạn có một endpoint đã đăng ký, không phải vì
> bạn đã subscribe.

> **Note:**
>
> 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
> đơn giản hơn và có ít retry hơn khi endpoint của bạn gặp 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ư:

```json
{
  "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. Tên trường ổn định
(lower snake_case); tx hash on-chain luôn là `tx_hash`.

`tx_hash` là mã định danh giao dịch theo định dạng riêng của network (`0x…` trên các chain EVM; hash hoặc chữ ký native trên TRON, Solana và TON). `deposit_address` có thể vắng mặt với TRON, Solana và TON vì người mua trả thẳng vào ví Treasury của bạn, còn `confirmations` tuân theo [Chains & tài sản](https://docs.infraio.xyz/vi/concepts/chains).

### Header trên request inbound

```http
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. |
| `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ý](https://docs.infraio.xyz/vi/webhooks/signature-verification). |
| `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 đánh dấu **Failed** và
email tài khoản của bạn được thông báo. Bạn có thể replay các event
failed từ panel **Developers → Webhooks → Delivery history** trong
dashboard. Mỗi lần replay là một delivery mới với `X-Delivery` riêng.

## Đăng ký một endpoint

Từ [merchant dashboard](https://app.infraio.xyz):

1. **Developers → Webhooks** → **+ Add endpoint**
2. Paste URL của bạn — chỉ `https://…` (HTTP thuần bị reject; form
   tạo cũng chặn `localhost`, các dải IP riêng, và URL mang userinfo)
3. Chọn các event để subscribe (hoặc `*` cho tất cả)
4. 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)
5. 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 có trạng thái retry và secret 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.ping` ký
  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. Test ping không được retry,
  nên bạn nhậ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-Signature` và `X-Signature-Prev`
  trong 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** — bật hoặc tắt endpoint mà 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

1. **Trả 2xx nhanh.** Acknowledge với `200 OK` trước khi làm việc nặng
   — chuyển fulfillment sang background job. 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.
2. **Dedup trên `X-Delivery`** (hoặc `Idempotency-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.
3. **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 các delivery đó sẽ cứ retry
   mãi.
4. **Log `X-Delivery` cạ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ý](https://docs.infraio.xyz/vi/webhooks/signature-verification) — thuật toán
  chính xác + pattern chống replay.
- [Khái niệm → Sessions](https://docs.infraio.xyz/vi/concepts/sessions) — session ở trạng thái
  nào khi mỗi event được phát.
