# Callback trạng thái đơn (Webhook)

> **Chiều: GHN → server của bạn.** Khi đơn hàng thay đổi, GHN POST một callback JSON đến webhook URL bạn đã đăng ký.

## Thiết lập

Webhook do GHN cấu hình, theo từng client. **Liên hệ bộ phận hỗ trợ/admin của GHN** và cung cấp:

- **Client ID** của bạn
- **URL callback** (và HTTP method, mặc định `POST`)
- **môi trường** (Staging hoặc Production)
- **xác thực** endpoint của bạn cần (GHN có thể gửi Basic auth hoặc header API-key)
- các **loại sự kiện** bạn muốn nhận, và số lần **retry** (`num_retry`)

GHN lưu cấu hình này theo từng client. **Thay đổi cấu hình có thể mất tới ~15 phút để có hiệu lực** (cấu hình được cache).

## Gửi (Delivery)

- Method: **POST**, body JSON, đến URL bạn đăng ký.
- **Xác nhận bằng HTTP `2xx`.** Bất kỳ mã non-2xx (hoặc timeout) đều bị coi là thất bại và sẽ retry (xem Retry).
- Timeout mỗi lần gửi có thể cấu hình (mặc định **5 giây**).
- Hãy làm endpoint **idempotent** — cùng một sự kiện có thể được gửi nhiều lần. Khử trùng lặp theo `OrderCode` + `Type` + `Time`.

## Loại sự kiện

Một thay đổi đơn có thể kích hoạt **nhiều** callback cùng lúc (trừ `create`, gửi một mình). Mỗi loại chỉ được gửi nếu GHN đã bật cho client của bạn.

| Type | Kích hoạt khi | Giá trị Description |
| --- | --- | --- |
| `create` | Đơn vừa được tạo (gửi một mình) | Tạo đơn hàng |
| `switch_status` | Trạng thái đơn thay đổi | Cập nhật trạng thái đơn hàng |
| `update_weight` | Khối lượng/kích thước thay đổi | Cập nhật kích thước khối lượng |
| `update_cod` | Số tiền COD thay đổi | Cập nhật COD |
| `update_fee` | Cước phí thay đổi | Cập nhật cước |
| `update_payment_type` | Bên trả phí thay đổi | Cập nhật hình thức thanh toán |
| `cod` | Tiền COD đã chuyển cho bạn | Chuyển tiền COD |
| `update_partial_return` | Có yêu cầu giao 1 phần trên đơn | Cập nhật đơn giao 1 phần |

## Payload

Khóa dạng **PascalCase** (đúng như bên dưới). **Mọi trường luôn có mặt** — trường không được đặt sẽ ở giá trị mặc định (`""`, `0`, `null`), không bị bỏ.

```json
{
  "ShopID": 196560,
  "Time": "2026-07-15T04:49:47.811Z",
  "OrderCode": "LADFYR",
  "ClientOrderCode": "SHOP-2026-00193",
  "Type": "switch_status",
  "Description": "Cập nhật trạng thái đơn hàng",
  "Status": "delivered",
  "Reason": "",
  "ReasonCode": "",
  "CODAmount": 285000,
  "CODTransferDate": null,
  "Weight": 600,
  "ConvertedWeight": 800,
  "Length": 25,
  "Width": 20,
  "Height": 8,
  "PaymentType": 2,
  "IsPartialReturn": false,
  "PartialReturnCode": "",
  "Fee": {},
  "TotalFee": 0,
  "Warehouse": "",
  "ShipperName": "",
  "ShipperPhone": "",
  "PodURL": ""
}
```

_(Giá trị minh họa; tên trường và kiểu là chính xác.)_

| Trường | Kiểu | Ý nghĩa |
| --- | --- | --- |
| `ShopID` | Int | Shop sở hữu đơn |
| `Time` | String | Thời điểm cập nhật cuối của đơn (ISO 8601) |
| `OrderCode` | String | Mã vận đơn GHN |
| `ClientOrderCode` | String | Mã đơn riêng của bạn |
| `Type` | String | Loại sự kiện (xem bảng trên) |
| `Description` | String | Nhãn tiếng Việt của sự kiện |
| `Status` | String | Trạng thái đơn mới (xem [Thông tin đơn](https://developer.ghn.vn/vi/docs/order/info.md) để biết danh sách trạng thái) |
| `Reason` | String | Text lý do thất bại — chỉ có với `ready_to_pick`, `delivery_fail`, `return_fail`, `damage`, `lost`, `cancel` |
| `ReasonCode` | String | Mã lý do thất bại — cùng các trạng thái với `Reason` |
| `CODAmount` | Int | Số tiền COD (VND) |
| `CODTransferDate` | String/null | Thời điểm COD đã chuyển cho bạn |
| `Weight` | Int | Khối lượng khai báo (gram) |
| `ConvertedWeight` | Int | Khối lượng quy đổi thể tích (gram) |
| `Length`, `Width`, `Height` | Int | Kích thước (cm) |
| `PaymentType` | Int | Bên trả phí: là `1` nếu shop trả, là `2` nếu người mua trả |
| `IsPartialReturn` | Bool | Đơn này có phải là đơn giao 1 phần hay không |
| `PartialReturnCode` | String | Có giá trị khi có yêu cầu giao 1 phần trên đơn này |
| `Fee` | Object | Chi tiết phí — chỉ có khi sự kiện `fee` được bật cho bạn |
| `TotalFee` | Int | Tổng phí (VND) — khi bật `fee` |
| `Warehouse` | String | Tên kho hiện tại — khi bật `warehouse` |
| `ShipperName`, `ShipperPhone` | String | Tài xế được gán — khi bật `shipper` |
| `PodURL` | String | URL bằng chứng giao hàng — khi bật `pod`, chỉ khi chuyển sang `delivered` |

## Retry

GHN retry các lần gửi thất bại **theo từng đơn, từng client**, trên một đường cong backoff cố định.

**Response nào sẽ retry:**

| Response từ server của bạn | Kết quả |
| --- | --- |
| `2xx` | Thành công — xong |
| `4xx` (trừ 408, 429) | Thất bại vĩnh viễn — **bỏ, không retry** (hãy sửa endpoint) |
| `5xx`, `408`, `429` | Retry |
| Timeout / không phản hồi | Retry |

**Lịch backoff** (chờ trước mỗi lần retry):

| Retry # | Chờ | Retry # | Chờ |
| --- | --- | --- | --- |
| 1 | 30s | 7 | 1h |
| 2 | 2m | 8 | 2h |
| 3 | 5m | 9 | 3h |
| 4 | 10m | 10 | 6h |
| 5 | 20m | 11 | 12h |
| 6 | 30m |  |  |

- **Tổng số lần gửi = 1 + `num_retry`** (mức bạn cấu hình). Sau khi đạt mức này, callback bị **bỏ cuộc** (không gửi nữa).
- Một retry trong hàng đợi trở nên "đến hạn" trong khoảng ~10 giây sau khi backoff hết hạn.

## Thứ tự (Ordering)

- Gửi theo **FIFO cho từng đơn** (`OrderCode` + client của bạn). Các callback của một đơn được gửi theo đúng thứ tự thay đổi đã xảy ra.
- **Một callback thất bại sẽ chặn các callback phía sau của cùng đơn đó**: hàng đợi rút từ đầu, nên một callback liên tục thất bại sẽ giữ lại các callback sau của cùng đơn cho tới khi nó thành công, bị bỏ vĩnh viễn, hoặc bị bỏ cuộc. Các đơn khác nhau độc lập với nhau.
- Khi endpoint của bạn phục hồi, toàn bộ tồn đọng của đơn đó được đẩy hết trong một lượt, đúng thứ tự.
