# Order Status Callback (Webhook)

> **Direction: GHN → your server.** When an order changes, GHN POSTs a JSON callback to the webhook URL you registered.

## Setup

You can turn webhooks on yourself right away — no need to wait for GHN to set them up for you.

1. Log in to the Developer Portal.
2. Open the account menu at the right end of the header and choose [Webhook configuration](/account/webhook).
3. On the **Order** tab, paste in your endpoint URL. That alone is enough to start — everything else already has a sensible default.
4. Press **Create webhook**. Done.

When you want to fine-tune, you also get:

- **Custom headers** — if your endpoint needs an auth token or routing metadata.
- **Timeout** (1–60 seconds) and **retry count** (1–5) — how long GHN waits, and how many times it tries again when your endpoint has not responded.
- **Permissions · Order** — pick exactly the events you want, instead of receiving all of them.

A few things people usually ask:

- **The client name and Client ID** are filled in from your account — you never type them.
- **Staging and Production are separate accounts.** A config on `developer.ghn.dev` does not carry over to `developer.ghn.vn` — once Staging works, remember to set it up on Production too.
- **A change needs ~15 minutes to take effect** because GHN caches it. If callbacks have not switched over yet, just give it a little longer.

## Delivery

- Method: **POST**, JSON body, to your registered URL.
- **Acknowledge with HTTP `2xx`.** Any non-2xx (or a timeout) is treated as a failure and retried (see Retry).
- Per-delivery timeout is configurable (default **5 seconds**).
- Make your endpoint **idempotent** — the same event can be delivered more than once. Deduplicate on `OrderCode` + `Type` + `Time`.

## Event types

One order change can fire **several** callbacks at once (except `create`, which fires alone). Each type is delivered only if GHN enabled it for your client.

| Type | Fires when | Payload Description (Vietnamese) |
| --- | --- | --- |
| `create` | The order was just created (fires alone) | Tạo đơn hàng |
| `switch_status` | Order status changed | Cập nhật trạng thái đơn hàng |
| `update_weight` | Weight/dimensions changed | Cập nhật kích thước khối lượng |
| `update_cod` | COD amount changed | Cập nhật COD |
| `update_fee` | Shipping fee changed | Cập nhật cước |
| `update_payment_type` | Fee payer changed | Cập nhật hình thức thanh toán |
| `cod` | COD money transferred to you | Chuyển tiền COD |
| `update_partial_return` | A partial-return was raised on the order | Cập nhật đơn giao 1 phần |

## Payload

Keys are **PascalCase** (exactly as below). **Every field is always present** — an unset field is at its zero value (`""`, `0`, `null`), not omitted.

```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": ""
}
```

_(Illustrative values; field names and types are exact.)_

| Field | Type | Meaning |
| --- | --- | --- |
| `ShopID` | Int | Shop that owns the order |
| `Time` | String | Order's last-updated time (ISO 8601) |
| `OrderCode` | String | GHN tracking code |
| `ClientOrderCode` | String | Your own order code |
| `Type` | String | Event type (see table above) |
| `Description` | String | Vietnamese label of the event |
| `Status` | String | New order status (see [Order Status Codes](https://developer.ghn.dev/en/docs/master-data/order-status.md)) |
| `Reason` | String | Failure reason text — set only for `ready_to_pick`, `delivery_fail`, `return_fail`, `damage`, `lost`, `cancel` |
| `ReasonCode` | String | Failure reason code — same statuses as `Reason`. Full catalogue: [Reason Codes](https://developer.ghn.dev/en/docs/master-data/reason-code.md) |
| `CODAmount` | Int | COD amount (VND) |
| `CODTransferDate` | String/null | When COD was transferred to you |
| `Weight` | Int | Declared weight (gram) |
| `ConvertedWeight` | Int | Volumetric weight (gram) |
| `Length`, `Width`, `Height` | Int | Dimensions (cm) |
| `PaymentType` | Int | Fee payer: `1` if the shop pays, `2` if the buyer pays |
| `IsPartialReturn` | Bool | Whether this order is itself a partial-return |
| `PartialReturnCode` | String | Set when a partial-return was raised against this order |
| `Fee` | Object | Fee breakdown — populated only if the `fee` event is enabled for you |
| `TotalFee` | Int | Total fee (VND) — with `fee` enabled |
| `Warehouse` | String | Current warehouse name — with `warehouse` enabled |
| `ShipperName`, `ShipperPhone` | String | Assigned driver — with `shipper` enabled |
| `PodURL` | String | Proof-of-delivery URL — with `pod` enabled, only on the transition into `delivered` |

## Retry

GHN retries failed deliveries **per order, per client**, on a fixed backoff curve.

**Which responses retry:**

| Response from your server | Outcome |
| --- | --- |
| `2xx` | Success — done |
| `4xx` (except 408, 429) | Permanent failure — **dropped, not retried** (fix your endpoint) |
| `5xx`, `408`, `429` | Retried |
| Timeout / no response | Retried |

**Backoff schedule** (wait before each retry):

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

- **Total attempts = 1 + `num_retry`** (your configured cap). After the cap is reached, the callback is **given up** (no more attempts).
- A queued retry becomes due within ~10 seconds of its backoff expiring.

## Ordering

- Deliveries are **FIFO per order** (`OrderCode` + your client). Callbacks for one order are delivered in the order the changes happened.
- **A failing callback blocks the ones behind it for the same order**: the queue drains head-first, so a callback that keeps failing holds up later callbacks for that same order until it succeeds, is permanently dropped, or is given up. Different orders are independent.
- When your endpoint recovers, the whole backlog for that order is flushed in one pass, in order.
