# Webhooks

> Get notified when orders update - no more checking your panel manually.

Source: https://api-doc.4nortes.app/webhooks

Get notified when something happens to your orders — like they get picked up or delivered. Instead of constantly checking your panel, we'll tell you.

<a id="overview"></a>

## How to set up

Here's what you need to do:

1. **Create an endpoint** in your app to receive notifications
2. **Register your URL** in your 4N NextDay panel and pick which events you want
3. **Verify signatures** to make sure they're really from us
4. **Respond quickly** with a 2xx status

> **Note:**
>
> Respond within 10 seconds. If you need to do something slow, save the payload and process it later.

> **Note:**
>
> Terminal events (`order.delivered`, `order.partially_delivered`, `order.delivery_failed`) are sent **after** the proof is captured, so every terminal payload carries the photos when they exist — under `delivery_proof` for successful deliveries, or `failure_proof` for failed ones (no recipient info on failures). The `order.status_changed` for `closed_with_return` is held back the same way, until the return to sender is confirmed with proof. Expect a short delay between a delivery happening on the ground and the webhook arriving — that's intentional.

<a id="event-types"></a>

## Event types

You'll get notified when:

- `order.received`: Your order was registered with us, and again each time one of our warehouses physically receives it. Tell the two apart by the presence of `data.reception`.
- `order.status_changed`: Order status changed (picked up, in transit, delivered, etc.).
- `order.delivered`: All packages were delivered successfully. Also fires `order.status_changed`.
- `order.partially_delivered`: Some packages were delivered, others failed. Also fires `order.status_changed`.
- `order.delivery_failed`: Every package in the attempt failed. Also fires `order.status_changed`.

### When each event fires

| Event                       | When it fires                                                                                                    |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `order.received`            | Once when the order is created (no `reception` key), then once per warehouse that scans it in (with `reception`) |
| `order.status_changed`      | Any delivery state update                                                                                        |
| `order.delivered`           | All packages were delivered successfully                                                                         |
| `order.partially_delivered` | At least one package was delivered and at least one failed                                                       |
| `order.delivery_failed`     | Every package in the attempt failed (a retry may follow)                                                         |

> **Note:**
>
> Terminal events (`order.delivered`, `order.partially_delivered`, `order.delivery_failed`) fire **in addition to** `order.status_changed`. Subscribe to whichever matches your integration — the terminal events carry the same base payload plus outcome-specific fields, so you don't need both if you only care about final states.

> **Note:**
>
> If a failed delivery is retried, you'll receive a new `order.status_changed` when the order goes back out for delivery, followed by a new terminal event for the new outcome. Keep your handlers idempotent — see [Handling duplicates](#handling-duplicates) for the deduplication key.

```json
{
  "event": "order.delivered",
  "timestamp": "2026-02-04T11:30:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "delivery_state": "delivered",
    "previous_state": "out_for_delivery",
    "delivery_date": "2026-02-04T11:30:00.000000Z",
    "timeline": ["..."],
    "delivery_attempts": ["..."],
    "delivery_proof": {"..."}
  }
}
```

---

<a id="delivery-states"></a>

## Delivery states

The `delivery_state` and `previous_state` fields in webhook payloads use these values — the same statuses you see in your panel:

- `pending`
- `picked_up`
- `in_transit`
- `out_for_delivery`
- `delivered`
- `partially_delivered`
- `failed`
- `closed_with_return`
- `nulled`

| State                 | Terminal | Description                                                                                         |
| --------------------- | -------- | --------------------------------------------------------------------------------------------------- |
| `pending`             | no       | Order created, awaiting pickup                                                                      |
| `picked_up`           | no       | Package collected from sender                                                                       |
| `in_transit`          | no       | In transport to destination area                                                                    |
| `out_for_delivery`    | no       | On delivery vehicle for final delivery                                                              |
| `delivered`           | yes      | All packages delivered successfully                                                                 |
| `partially_delivered` | yes      | Some packages delivered, others failed                                                              |
| `failed`              | yes      | Every package in the delivery attempt failed                                                        |
| `closed_with_return`  | yes      | Order closed after the undelivered packages were returned to the sender. Final — nothing follows it |
| `nulled`              | yes      | Order cancelled                                                                                     |

> **Note:**
>
> `closed_with_return` has **no dedicated terminal event** — it arrives only as `order.status_changed` with `delivery_state: "closed_with_return"`, once the return to sender is confirmed with proof. `delivery_proof` and `delivery_date` are `null` on that payload. If you only subscribe to the terminal events, you will not hear about returns.

> **Note:**
>
> `previous_state` uses the same values and tells you what the order was before this update. It is `null` when the prior state is unknown to us. `order.received` **never** includes `previous_state`.

---

<a id="payload-structure"></a>

## What you'll receive

### Headers we send

Every webhook request includes:

| Header                | Description                                                                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                                                                    |
| `X-4Nortes-Event`     | The event type (e.g., `order.status_changed`)                                                                                                         |
| `X-4Nortes-Signature` | HMAC-SHA256 signature of the raw body. Present whenever your account has a webhook secret (the panel creates one the first time webhooks are enabled) |

These are the only headers we add — there is **no per-delivery ID header**. See [Handling duplicates](#handling-duplicates) for how to deduplicate.

### Envelope

Every event uses the same top-level shape:

```json
{
  "event": "order.status_changed",
  "timestamp": "2026-02-04T11:30:00.000000Z",
  "data": { "..." }
}
```

- `event` — the event type, matches the `X-4Nortes-Event` header.
- `timestamp` — when the payload was built (ISO 8601). Events emitted for the same transition (e.g. `order.status_changed` and `order.delivered`) share it, and retries resend it unchanged.
- `data` — event payload, described below.

<a id="common-fields"></a>

### Common fields in `data`

These order-level fields are included on **every** order event. Event-specific fields (like `failure_info` or `packages[]`) are added on top.

- `tracking_number` (string): The order tracking number.
- `external_reference` (string|null): Your custom reference for the order.
- `cost_center` (string|null): Cost center associated with the order.
- `purchase_order` (string|null): Purchase order number.
- `bill_of_lading` (string|null): Bill of lading reference.
- `delivery_state` (string): Current delivery state. See [Delivery states](#delivery-states).
- `estimated_delivery_date` (string|null): Expected delivery date (ISO 8601). `null` when no estimate was set.
- `delivery_date` (string|null): When the whole order was delivered (ISO 8601). `null` until the order reaches `delivered` — it stays `null` on `partially_delivered`, `failed` and `closed_with_return`.
- `timeline` (array): Chronological list of state changes. See shape below.
- `delivery_attempts` (array): One entry per delivery proof captured at a completed stop, with recipient info, photos and per-package outcomes. Failed entries appear **only** when the driver recorded per-package outcomes (all failed) on a completed stop; a whole-stop failure ("could not deliver here") does not create an entry — use `failure_info.reason` for those. Empty until the first proof is captured. See shape below.

<a id="timeline-shape"></a>

#### `timeline[]` shape

Each entry marks a state transition or a failed delivery attempt:

```json
[
  { "state": "pending", "timestamp": "2026-02-03T10:00:00.000000Z" },
  { "state": "in_transit", "timestamp": "2026-02-03T18:00:00.000000Z" },
  {
    "state": "failed",
    "timestamp": "2026-02-04T14:00:00.000000Z",
    "type": "failure",
    "failure_info": {
      "type": "Intento de entrega fallido",
      "reason": "not_home",
      "notes": null
    }
  },
  { "state": "out_for_delivery", "timestamp": "2026-02-05T08:00:00.000000Z" },
  { "state": "delivered", "timestamp": "2026-02-05T11:30:00.000000Z" }
]
```

Entries with `type: "failure"` represent an individual failed attempt (the order may still complete successfully on a later attempt) and carry `failure_info`. Inside it, `reason` is the machine-readable failure code (see [Failure reasons](#failure-reasons)), while `type` and `notes` are human-readable Spanish text meant for display.

<a id="delivery-attempts-shape"></a>

#### `delivery_attempts[]` shape

Every delivery proof captured at a completed stop becomes an entry here:

- `attempt` (integer): 1-based attempt number for this order.
- `outcome` (string): `success` when at least one package in the attempt was delivered (partial attempts count as `success` — check `packages[]`), `failed` otherwise.
- `recipient_name` (string|null): Who received the packages. `null` when the driver didn't capture it (e.g. on failed attempts).
- `recipient_rut` (string|null): Recipient's Chilean RUT, when captured.
- `recipient_role` (string|null): Relationship of the recipient: `merchant_staff`, `recipient`, `operator` or `other`.
- `location` (object|null): `{ latitude, longitude }` captured at proof time.
- `notes` (string|null): Free-text notes from the driver.
- `photos` (array): Signed photo URLs (`url` and `preview_url`). They expire **2 hours after the payload was built**, not after you receive it — retries resend the same payload, so a late retry may arrive with expired links. Re-fetch the tracking endpoint when you need fresh URLs.
- `packages` (array): Per-package outcome for the attempt: `{ tracking_number, delivered, failure_reason }`. `failure_reason` uses the [machine-readable enum](#failure-reasons) on failed packages, `null` on successful ones.
- `created_at` (string): When the attempt was recorded (ISO 8601).

---

<a id="event-order-received"></a>

### `order.received`

Sent **1 + N times** over the life of an order: once when the order is registered with us (API, panel or import), and once more each time one of our warehouses physically scans it in. The payload shape is identical except for `reception`, which is present only on warehouse receptions.

Adds the following on top of the [common fields](#common-fields):

- `event` (string): Always `order.received`.
- `reception` (object): **Optional.** Present only when the event was triggered by a warehouse reception. Absent on the registration event.
- `reception.received_at` (string): When the package was scanned (ISO 8601).
- `reception.warehouse_id` (integer): ID of the warehouse that received the package.

> **Note:**
>
> Distinguish the two emissions by checking whether `data.reception` exists — don't assume this event means "scanned at our warehouse". `previous_state` is **never** included. `delivery_state` reflects the order at that moment: `pending` at registration, but possibly `picked_up` or `in_transit` on a later reception. `delivery_attempts` is normally `[]`, though an order re-entering a warehouse after a failed delivery may carry earlier attempts.

```json
{
  "event": "order.received",
  "timestamp": "2026-02-03T10:00:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "pending",
    "estimated_delivery_date": "2026-02-05T23:59:59.000000Z",
    "delivery_date": null,
    "timeline": [
      { "state": "pending", "timestamp": "2026-02-03T10:00:00.000000Z" }
    ],
    "delivery_attempts": []
  }
}
```

```json
{
  "event": "order.received",
  "timestamp": "2026-02-03T14:30:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "pending",
    "estimated_delivery_date": "2026-02-05T23:59:59.000000Z",
    "delivery_date": null,
    "timeline": [
      { "state": "pending", "timestamp": "2026-02-03T10:00:00.000000Z" }
    ],
    "delivery_attempts": [],
    "reception": {
      "received_at": "2026-02-03T14:30:00.000000Z",
      "warehouse_id": 1
    }
  }
}
```

<a id="event-order-status-changed"></a>

### `order.status_changed`

Sent on every delivery state transition — the same updates you'd see in your panel.

Adds the following on top of the [common fields](#common-fields):

- `event` (string): Always `order.status_changed`.
- `previous_state` (string|null): State the order was in before this update. `null` when the prior state is unknown.
- `delivery_proof` (object|null): Populated once the order reaches `delivered` or `partially_delivered`. `null` for intermediate transitions and for `failed`, `closed_with_return` and `nulled`.

> **Note:**
>
> For partial deliveries, this event carries `delivery_state: "partially_delivered"` and `delivery_proof` reflects the successful sub-delivery. If you need the per-package breakdown inline, subscribe to `order.partially_delivered` instead.

> **Note:**
>
> This event never carries top-level `failure_info` or `failure_proof` (the `timeline[]` entries keep theirs) — subscribe to `order.delivery_failed` for those. It is, however, the **only** way to learn about `closed_with_return` (see [Delivery states](#delivery-states)).

```json
{
  "event": "order.status_changed",
  "timestamp": "2026-02-04T11:30:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "delivered",
    "previous_state": "out_for_delivery",
    "estimated_delivery_date": "2026-02-04T23:59:59.000000Z",
    "delivery_date": "2026-02-04T11:30:00.000000Z",
    "timeline": ["..."],
    "delivery_attempts": ["..."],
    "delivery_proof": {
      "recipient_name": "Jane Doe",
      "recipient_rut": "12345678-9",
      "recipient_role": "recipient",
      "location": { "latitude": -33.4289, "longitude": -70.6093 },
      "notes": null,
      "photos": [
        {
          "url": "https://storage.example.com/photos/abc123.jpg",
          "preview_url": "https://storage.example.com/photos/thumb/abc123.jpg"
        }
      ],
      "created_at": "2026-02-04T11:30:00.000000Z"
    }
  }
}
```

<a id="event-order-delivered"></a>

### `order.delivered`

Sent when **all** packages in the order were delivered successfully. Fires **in addition to** `order.status_changed`.

Adds the following on top of the [common fields](#common-fields):

- `event` (string): Always `order.delivered`.
- `previous_state` (string|null): Previous delivery state (e.g., `out_for_delivery`). `null` when unknown.
- `delivery_proof` (object|null): Recipient info and signed photo URLs for the successful delivery. Same fields as one [`delivery_attempts[]`](#delivery-attempts-shape) entry (without `attempt`, `outcome` or `packages`). `null` only when the order was closed as delivered without a routed delivery (e.g. an operator confirmation).

```json
{
  "event": "order.delivered",
  "timestamp": "2026-02-04T11:30:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "delivered",
    "previous_state": "out_for_delivery",
    "estimated_delivery_date": "2026-02-04T23:59:59.000000Z",
    "delivery_date": "2026-02-04T11:30:00.000000Z",
    "timeline": [
      { "state": "pending", "timestamp": "2026-02-03T10:00:00.000000Z" },
      { "state": "in_transit", "timestamp": "2026-02-03T18:00:00.000000Z" },
      { "state": "out_for_delivery", "timestamp": "2026-02-04T08:00:00.000000Z" },
      { "state": "delivered", "timestamp": "2026-02-04T11:30:00.000000Z" }
    ],
    "delivery_attempts": [
      {
        "attempt": 1,
        "outcome": "success",
        "recipient_name": "Jane Doe",
        "recipient_rut": "12345678-9",
        "recipient_role": "recipient",
        "location": { "latitude": -33.4289, "longitude": -70.6093 },
        "notes": null,
        "photos": [
          {
            "url": "https://storage.example.com/photos/abc123.jpg",
            "preview_url": "https://storage.example.com/photos/thumb/abc123.jpg"
          }
        ],
        "packages": [
          { "tracking_number": "SH000000012345", "delivered": true, "failure_reason": null }
        ],
        "created_at": "2026-02-04T11:30:00.000000Z"
      }
    ],
    "delivery_proof": {
      "recipient_name": "Jane Doe",
      "recipient_rut": "12345678-9",
      "recipient_role": "recipient",
      "location": { "latitude": -33.4289, "longitude": -70.6093 },
      "notes": null,
      "photos": [
        {
          "url": "https://storage.example.com/photos/abc123.jpg",
          "preview_url": "https://storage.example.com/photos/thumb/abc123.jpg"
        }
      ],
      "created_at": "2026-02-04T11:30:00.000000Z"
    }
  }
}
```

<a id="event-order-partially-delivered"></a>

### `order.partially_delivered`

Sent when a delivery attempt ends with **some packages delivered and others failed**. Fires **in addition to** `order.status_changed`.

Adds the following on top of the [common fields](#common-fields):

- `event` (string): Always `order.partially_delivered`.
- `previous_state` (string|null): Previous delivery state (e.g., `out_for_delivery`). `null` when unknown.
- `packages_count` (integer): Total packages in the order.
- `delivered_packages_count` (integer): Packages delivered across all attempts so far.
- `delivery_progress` (string): Packages delivered vs. total, as a fraction string (e.g. `"2/3"`).
- `packages` (array): Per-package rollup: `{ tracking_number, delivery_state, failure_reason, delivered_at }`. `failure_reason` uses the [machine-readable enum](#failure-reasons); `delivered_at` is `null` for packages that haven't been delivered yet.
- `delivery_proof` (object|null): Recipient info and photos for the latest successful sub-delivery. Same shape as on `order.delivered`.

> **Note:**
>
> `delivery_date` stays `null` on this event — it is only set once **every** package has been delivered. Use `packages[].delivered_at` for per-package timing.

> **Note:**
>
> Use `packages[].failure_reason` when you need to drive business logic (automatic retries, reporting, routing). Use `delivery_attempts[].packages[]` when you need the same information split by attempt.

```json
{
  "event": "order.partially_delivered",
  "timestamp": "2026-02-04T11:30:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "partially_delivered",
    "previous_state": "out_for_delivery",
    "estimated_delivery_date": "2026-02-04T23:59:59.000000Z",
    "delivery_date": null,
    "packages_count": 3,
    "delivered_packages_count": 2,
    "delivery_progress": "2/3",
    "packages": [
      {
        "tracking_number": "SH000000012345",
        "delivery_state": "delivered",
        "failure_reason": null,
        "delivered_at": "2026-02-04T11:30:00.000000Z"
      },
      {
        "tracking_number": "SH000000012346",
        "delivery_state": "delivered",
        "failure_reason": null,
        "delivered_at": "2026-02-04T11:30:00.000000Z"
      },
      {
        "tracking_number": "SH000000012347",
        "delivery_state": "failed",
        "failure_reason": "refused",
        "delivered_at": null
      }
    ],
    "timeline": ["..."],
    "delivery_attempts": ["..."],
    "delivery_proof": {
      "recipient_name": "Jane Doe",
      "recipient_rut": "12345678-9",
      "recipient_role": "recipient",
      "location": { "latitude": -33.4289, "longitude": -70.6093 },
      "notes": "Customer refused one of the boxes",
      "photos": [
        {
          "url": "https://storage.example.com/photos/abc123.jpg",
          "preview_url": "https://storage.example.com/photos/thumb/abc123.jpg"
        }
      ],
      "created_at": "2026-02-04T11:30:00.000000Z"
    }
  }
}
```

<a id="event-order-delivery-failed"></a>

### `order.delivery_failed`

Sent when **every** package in a delivery attempt failed. Fires **in addition to** `order.status_changed`. A retry may follow — if it does, you'll receive a new `order.status_changed` when the order goes back out for delivery, and another terminal event for the new outcome.

Adds the following on top of the [common fields](#common-fields):

- `event` (string): Always `order.delivery_failed`.
- `previous_state` (string|null): Previous delivery state (e.g., `out_for_delivery`). `null` when unknown.
- `failure_info` (object|null): `{ type, reason, notes }`. **`reason`** is the machine-readable failure code (e.g. `not_home` — see [Failure reasons](#failure-reasons)). **`type`** and **`notes`** are human-readable Spanish text: `type` is `"Intento de entrega fallido"`, `"Intento de recogida fallido"`, `"manual"` (an operator changed the status from the panel) or `null`. `null` as a whole only in the rare case where no failure reason was recorded.
- `failure_proof` (object|null): Photos and context captured at the failed attempt — `{ location, notes, photos, created_at }`. **No recipient fields.** This is where the failure photos live. `null` when no photos were captured. Photo URLs are signed and expire 2 hours after the payload was built (see [`photos`](#delivery-attempts-shape)).

> **Note:**
>
> **`failure_info.reason` is the stable failure code for this event.** Failures recorded for the whole stop ("couldn't deliver here"), which produces **no** `delivery_attempts[]` entry — so `failure_info.reason` is the only place the code appears. When the driver instead recorded per-package outcomes, a `failed` attempt is added and `delivery_attempts[].packages[].failure_reason` carries the same codes per package.

> **Note:**
>
> A failed **pickup** attempt produces the same event, with `failure_info.type = "Intento de recogida fallido"` instead of `"Intento de entrega fallido"`. Filter on that field if your integration only cares about the delivery leg. Operator-driven status changes arrive with `type: "manual"` or `type: null`, and their `reason` may be free text rather than a catalogued code.

```json
{
  "event": "order.delivery_failed",
  "timestamp": "2026-02-04T14:00:00.000000Z",
  "data": {
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": "CC-001",
    "purchase_order": "PO-2026-100",
    "bill_of_lading": null,
    "delivery_state": "failed",
    "previous_state": "out_for_delivery",
    "estimated_delivery_date": "2026-02-04T23:59:59.000000Z",
    "delivery_date": null,
    "timeline": [
      { "state": "pending", "timestamp": "2026-02-03T10:00:00.000000Z" },
      { "state": "in_transit", "timestamp": "2026-02-03T18:00:00.000000Z" },
      { "state": "out_for_delivery", "timestamp": "2026-02-04T08:00:00.000000Z" },
      {
        "state": "failed",
        "timestamp": "2026-02-04T14:00:00.000000Z",
        "type": "failure",
        "failure_info": {
          "type": "Intento de entrega fallido",
          "reason": "not_home",
          "notes": null
        }
      }
    ],
    "delivery_attempts": [],
    "failure_info": {
      "type": "Intento de entrega fallido",
      "reason": "not_home",
      "notes": null
    },
    "failure_proof": {
      "location": { "latitude": -33.4289, "longitude": -70.6093 },
      "notes": null,
      "photos": [
        {
          "url": "https://storage.example.com/photos/fail-abc123.jpg",
          "preview_url": "https://storage.example.com/photos/thumb/fail-abc123.jpg"
        }
      ],
      "created_at": "2026-02-04T14:00:00.000000Z"
    }
  }
}
```

<a id="event-test"></a>

### `test`

Sent when an operator clicks **Test webhook** in your 4N NextDay panel. It is not an order event and does not depend on your event subscriptions — it only requires webhooks to be enabled with a URL. It is sent immediately and is **not retried**.

The payload does **not** include the [common fields](#common-fields) — there is no `tracking_number`. Treat `event: "test"` as a connectivity check: verify the signature, return a 2xx, and don't try to process it as an order update.

- `event` (string): Always `test`. The `X-4Nortes-Event` header is also `test`.
- `data.message` (string): Fixed text: `This is a test webhook from 4Nortes`.
- `data.merchant_id` (integer): Your merchant ID in our system.

> **Note:**
>
> Test requests are signed exactly like real events, so this is the easiest way to validate your signature check end to end.

```json
{
  "event": "test",
  "timestamp": "2026-02-03T09:15:00.000000Z",
  "data": {
    "message": "This is a test webhook from 4Nortes",
    "merchant_id": 42
  }
}
```

---

<a id="failure-reasons"></a>

## Failure reasons

Webhooks surface failure codes in two places, with the same vocabulary:

- **`failure_info.reason`** — at the top level of `order.delivery_failed` and inside `timeline[].failure_info`. This is the machine-readable code for the failure (e.g. `not_home`). The sibling `type` and `notes` fields are localized Spanish strings meant for display — don't write business logic against those two.
- **`failure_reason`** — on `delivery_attempts[].packages[]` (every event) and on `packages[]` (on `order.partially_delivered`). Same codes, scoped to one package.

### Machine-readable values

| Value                  | Meaning                                                                       |
| ---------------------- | ----------------------------------------------------------------------------- |
| `not_home`             | Nobody was at the address to receive the package                              |
| `refused`              | Recipient refused delivery                                                    |
| `wrong_address`        | Address is incorrect or cannot be located                                     |
| `inaccessible`         | Address exists but the driver couldn't reach it (locked gate, off-grid, etc.) |
| `business_closed`      | Destination is a business that was closed at delivery time                    |
| `pending_stock_break`  | Internal stock issue prevented delivery                                       |
| `no_cargo_ready`       | Cargo wasn't ready at the stop (typically a pickup attempt)                   |
| `no_cargo_to_ship`     | Nothing to ship at the stop (typically a pickup attempt)                      |
| `customer_unavailable` | Nobody was available to complete the handoff                                  |
| `other`                | Anything else (check `notes` on the attempt for context)                      |

> **Note:**
>
> New reason codes may be added over time, and failures recorded before a reason was catalogued (or entered as free text by an operator) pass through `failure_info.reason` as their original label, e.g. `"No se encontró al destinatario"`. Treat any value not in this table as `other` so your integration stays forward-compatible.

---

<a id="security"></a>

## Make sure it's really from us

Verify the signature in the `X-4Nortes-Signature` header to confirm we sent it.

### How to verify

We sign every webhook with your secret key: the signature is a hex-encoded HMAC-SHA256 of the **raw request body**, so verify against the exact bytes you received — don't re-serialize the JSON first. Check it like this:

```js
const crypto = require('crypto')

function verifyWebhook(payload, signature, secret) {
  if (typeof signature !== 'string' || signature.length === 0) {
    return false
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex')

  const expectedBuffer = Buffer.from(expected, 'utf8')
  const signatureBuffer = Buffer.from(signature, 'utf8')

  // timingSafeEqual throws on length mismatch — reject instead of crashing
  if (expectedBuffer.length !== signatureBuffer.length) {
    return false
  }

  return crypto.timingSafeEqual(expectedBuffer, signatureBuffer)
}

// Express.js example
app.post('/webhooks/nextday', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-4nortes-signature']
  const payload = req.body.toString()

  if (!verifyWebhook(payload, signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature')
  }

  const event = JSON.parse(payload)
  // Process the event...

  res.status(200).send('OK')
})
```

```python
import hmac
import hashlib
from flask import Flask, request

app = Flask(__name__)

def verify_webhook(payload: bytes, signature: str | None, secret: str) -> bool:
    if not signature:
        return False

    expected = hmac.new(
        secret.encode('utf-8'),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.route('/webhooks/nextday', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-4Nortes-Signature')
    payload = request.get_data()

    if not verify_webhook(payload, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    event = request.get_json()
    # Process the event...

    return 'OK', 200
```

```php
<?php

function verifyWebhook(string $payload, ?string $signature, string $secret): bool {
    if ($signature === null || $signature === '') {
        return false;
    }

    $expected = hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}

// Laravel example
Route::post('/webhooks/nextday', function (Request $request) {
    $signature = $request->header('X-4Nortes-Signature');
    $payload = $request->getContent();

    if (!verifyWebhook($payload, $signature, config('services.nextday.webhook_secret'))) {
        return response('Invalid signature', 401);
    }

    $event = json_decode($payload, true);
    // Process the event...

    return response('OK', 200);
});
```

> **Warning:**
>
> Always use constant-time comparison functions (like `crypto.timingSafeEqual` or `hash_equals`) to prevent timing attacks — and reject a missing header outright rather than letting the comparison throw.

> **Note:**
>
> Requests are only signed when your account has a webhook secret. The panel generates one automatically the first time webhooks are enabled (and shows it once — copy it then), so in practice every request you receive carries `X-4Nortes-Signature`. If the header is ever absent, treat the request as invalid.

> **Note:**
>
> We verify your endpoint's TLS certificate. Self-signed or otherwise invalid certificates make the delivery fail and go through the [retry cycle](#retry-policy), so use a publicly trusted certificate.

---

<a id="retry-policy"></a>

## If your server is down

Don't worry — we'll retry if you don't respond with a 2xx (or if the request times out after 10 seconds):

| Attempt   | Delay        |
| --------- | ------------ |
| 1st retry | \~10 seconds |
| 2nd retry | \~2 minutes  |
| 3rd retry | \~17 minutes |
| 4th retry | \~3 hours    |

After 5 total attempts (1 initial + 4 retries), we'll give up on that webhook. Retries resend the **exact same payload** — same `timestamp`, same signed photo URLs. Since the last retry lands roughly 3 hours after the first attempt and photo URLs expire 2 hours after the payload was built, a late retry can carry expired links; re-fetch them from the Tracking API if you need them.

<a id="handling-duplicates"></a>

### Handling duplicates

There is no per-delivery ID header, so build your own idempotency key from the payload: **`(event, data.tracking_number, timestamp)`**. All three parts matter — `order.status_changed` and its terminal twin (`order.delivered`, `order.partially_delivered` or `order.delivery_failed`) are emitted together and share both `tracking_number` and `timestamp`, so a key without `event` would drop one of them. Retries reuse the same `timestamp`, so this key collapses them correctly. The `test` event has no `tracking_number`; just acknowledge it.

### Tips

1. **Respond fast**: Return 2xx within 10 seconds
2. **Process later**: Save the data and handle it in the background
3. **Handle duplicates**: Deduplicate on `(event, data.tracking_number, timestamp)`
4. **Keep logs**: Save events for debugging
5. **Monitor failures**: Set up alerts if webhooks stop working

```js
app.post('/webhooks/nextday', async (req, res) => {
  // Acknowledge receipt immediately
  res.status(200).send('OK')

  // Process asynchronously
  const event = req.body
  await queue.add('processWebhook', event)
})
```
