# Orders API

> Sync orders from your system, download labels, and manage shipments - all the panel actions, automated.

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

Sync orders from your system, download shipping labels, and manage shipments. Everything you do in your panel, now automated.

> **Note:**
>
> Successful responses and business-rule errors use the `success` / `message` / `data` envelope shown below (errors also carry `errors` and a `timestamp`). Two cases come straight from the framework and look different: a missing or invalid token returns `401` with just `{"message": "Unauthenticated."}` (a valid token that isn't a merchant API token gets a `401` in the envelope with `Authentication required.`), and failed input validation returns `422` with `{"message": "...", "errors": {"field": ["..."]}}` — no `success`, no `timestamp`, and messages may be in Spanish. Always branch on the HTTP status code, never on the presence of `success`. See [Error handling](https://api-doc.4nortes.app/errors.md).

> **Note:**
>
> Every `{tracking_number}` path parameter below must match `^(4N|SH)\d{12}$`. A malformed value returns a `422` validation error (`The tracking number format is invalid.`), not a `404`. A well-formed number that doesn't belong to your account returns `404` with `Order not found.`.

---

<a id="create-order"></a>

## Sync an order

`POST /orders/create`

Send order details from your system. This is the same as filling out the "Create Order" form in your panel. Returns `201 Created`.

### Request Body

- `sender_name` (string): Name of the sender or business. Max 255 characters. **Required.**
- `sender_company` (string): Sender company name. Max 255 characters. Defaults to your account name.
- `sender_phone` (string): Sender phone number. Max 50 characters.
- `sender_email` (string): Sender email address. Must be a valid email. Max 255 characters.
- `recipient_name` (string): Name of the recipient. Max 255 characters. **Required.**
- `recipient_company` (string): Recipient company name. Max 255 characters.
- `recipient_phone` (string): Recipient phone number. Max 50 characters.
- `recipient_email` (string): Recipient email address. Must be a valid email. Max 255 characters.
- `destination_address` (string): Destination street address. Max 500 characters. **Required.**
- `destination_locality` (string): Destination locality (comuna). Send either the locality **name** (matched against our catalog) or its **numeric ID** from the [References API](https://api-doc.4nortes.app/api/references.md). Max 100 characters. **Required.** An unknown locality returns `404`; a locality outside your coverage returns `422`.
- `destination_reference` (string): Reference or landmark for the destination. Max 500 characters.
- `destination_building_number` (string): Building or apartment number at destination. Max 50 characters.
- `external_reference` (string): Your own order code. Max 255 characters. Must be **unique within your account** — reusing one returns `422`. Orders created with it can be looked up by this code in [Order events](#order-events).
- `cost_center` (string): Cost center for internal accounting. Max 255 characters.
- `purchase_order` (string): Purchase order number. Max 255 characters.
- `bill_of_lading` (string): Bill of lading reference. Max 255 characters.
- `service_id` (integer): ID of one of your **active** delivery services (see [Services](https://api-doc.4nortes.app/services.md)). If omitted, your first active service is used (excluding the on-route service reserved for driver-created cargo); if your account has none, the request fails with `422`. A service that exists but belongs to another account also returns `422`.
- `weight` (number): Package weight in kilograms. Must be >= 0. Default: `0`. Not allowed when `type` is `pouch`.
- `valuation` (integer): Declared value of the package. Must be >= 0. Default: `0`.
- `packages_count` (integer): Number of packages in this order. Must be >= 1. Default: `1`.
- `type` (string): Order type: `standard` (default) or `pouch`. A pouch carries no weight — don't send `weight` with a pouch.
- `custom_note_1` (string): Free-text note for your own use. Max 200 characters. `custom_note_2` through `custom_note_5` are also available.

### Errors

- `404` — `Locality not found: {value}` when `destination_locality` matches nothing.
- `422` (envelope) — `Merchant {name} has no coverage for locality: {locality}`, `The specified service is not available for this merchant.`, or `There are no active services available for this merchant.`
- `422` (validation) — missing required fields, invalid email, `external_reference` already used (`The external reference has already been taken for this merchant.`), a `service_id` that doesn't exist (`The selected service does not exist.`), or `weight` sent with a `pouch`.

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/create \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "sender_name": "John Store",
    "sender_phone": "+56912345678",
    "sender_email": "store@example.com",
    "recipient_name": "Jane Doe",
    "recipient_phone": "+56987654321",
    "recipient_email": "jane@example.com",
    "destination_address": "Calle Secundaria 456",
    "destination_locality": "Providencia",
    "destination_reference": "Near the plaza",
    "weight": 2.5,
    "external_reference": "ORDER-001",
    "service_id": 1,
    "packages_count": 1
  }'
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/create',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      sender_name: 'John Store',
      sender_phone: '+56912345678',
      recipient_name: 'Jane Doe',
      recipient_phone: '+56987654321',
      destination_address: 'Calle Secundaria 456',
      destination_locality: 'Providencia',
      weight: 2.5,
      external_reference: 'ORDER-001',
      service_id: 1,
      packages_count: 1,
    }),
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Order created successfully",
  "data": {
    "id": 12345,
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": null,
    "purchase_order": null,
    "bill_of_lading": null,
    "merchant_id": 42,
    "service_id": 1,
    "destination": {
      "address_line": "Calle Secundaria 456",
      "locality_id": 2,
      "locality_name": "Providencia",
      "reference": "Near the plaza"
    },
    "recipient": {
      "name": "Jane Doe",
      "phone": "+56987654321",
      "email": "jane@example.com"
    },
    "sender": {
      "name": "John Store",
      "phone": "+56912345678",
      "email": "store@example.com"
    },
    "delivery_state": "pending",
    "packages_count": 1,
    "weight": 2.5,
    "valuation": 0,
    "created_at": "2025-02-03T10:30:00.000000Z"
  },
  "timestamp": "2025-02-03T10:30:00.000000Z"
}
```

```json
{
  "message": "The recipient name is required. (and 1 more error)",
  "errors": {
    "recipient_name": ["The recipient name is required."],
    "external_reference": [
      "The external reference has already been taken for this merchant."
    ]
  }
}
```

```json
{
  "success": false,
  "message": "Locality not found: Providencai",
  "errors": null,
  "timestamp": "2025-02-03T10:30:00.000000Z"
}
```

---

<a id="get-order"></a>

## Get order details

`GET /orders/{tracking_number}`

Fetch order details using the tracking number. Same info you see when clicking an order in your panel.

### Path Parameters

- `tracking_number` (string): The order tracking number (e.g., `4N000000012345`). **Required.**

### Response Fields

- `delivery_state` (string): Current delivery state. See [Delivery states](https://api-doc.4nortes.app/orders.md#delivery-states).
- `internal_state` (string | null): The courier-side status name as it appears in the 4 Nortes panel (free text, in Spanish). It is managed by our operations team and can change over time — **do not branch on it**. Use `delivery_state` for logic.
- `packages_count` (integer): Total packages in the order.
- `picked_up_packages_count` (integer | null): Packages already scanned at pickup. `null` until per-package scanning has started for this order.
- `pending_pickup_packages_count` (integer | null): Packages not yet scanned at pickup. `null` until per-package scanning has started.
- `delivered_packages_count` (integer): Packages delivered so far.
- `failed_packages_count` (integer): Packages whose last delivery attempt failed.
- `returned_packages_count` (integer): Packages returned to sender.
- `pending_packages_count` (integer): Packages neither delivered nor returned (`packages_count - delivered - returned`).
- `delivery_progress` (string): Delivered vs. total as a fraction string, e.g. `"2/3"`.
- `packages` (array): One entry per package: `tracking_number`, `delivery_state` (`pending`, `delivered`, `failed` or `returned`), `picked_up_at`, `delivered_at`, `returned_at` (ISO 8601 or `null`).
- `timeline` (array): Delivery-state changes in chronological order, each with `state` and `timestamp`. For the full step-by-step history use [Order events](#order-events).
- `estimated_delivery_date` (string | null): Expected delivery date.
- `delivery_date` (string | null): Actual delivery date, once delivered.

```bash
curl https://api.4nortes.app/api/v1/orders/4N000000012345 \
  -H "Authorization: Bearer {token}"
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345',
  {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Operation successful",
  "data": {
    "id": 12345,
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": null,
    "purchase_order": null,
    "bill_of_lading": null,
    "delivery_state": "in_transit",
    "internal_state": "En bodega destino",
    "sender": {
      "name": "John Store",
      "phone": "+56912345678",
      "email": "store@example.com"
    },
    "recipient": {
      "name": "Jane Doe",
      "phone": "+56987654321",
      "email": "jane@example.com"
    },
    "destination": {
      "address_line": "Calle Secundaria 456",
      "locality_id": 2,
      "locality_name": "Providencia",
      "reference": "Near the plaza"
    },
    "packages_count": 2,
    "picked_up_packages_count": 2,
    "pending_pickup_packages_count": 0,
    "delivered_packages_count": 0,
    "failed_packages_count": 0,
    "returned_packages_count": 0,
    "pending_packages_count": 2,
    "delivery_progress": "0/2",
    "packages": [
      {
        "tracking_number": "4N000000012345-305",
        "delivery_state": "pending",
        "picked_up_at": "2025-02-03T14:30:00.000000Z",
        "delivered_at": null,
        "returned_at": null
      },
      {
        "tracking_number": "4N000000012345-418",
        "delivery_state": "pending",
        "picked_up_at": "2025-02-03T14:30:00.000000Z",
        "delivered_at": null,
        "returned_at": null
      }
    ],
    "weight": 2.5,
    "valuation": 0,
    "timeline": [
      {
        "state": "pending",
        "timestamp": "2025-02-03T10:30:00.000000Z"
      },
      {
        "state": "picked_up",
        "timestamp": "2025-02-03T14:30:00.000000Z"
      },
      {
        "state": "in_transit",
        "timestamp": "2025-02-03T18:00:00.000000Z"
      }
    ],
    "estimated_delivery_date": "2025-02-05T00:00:00.000000Z",
    "delivery_date": null,
    "created_at": "2025-02-03T10:30:00.000000Z"
  },
  "timestamp": "2025-02-04T08:00:00.000000Z"
}
```

```json
{
  "success": false,
  "message": "Order not found.",
  "errors": null,
  "timestamp": "2025-02-04T08:00:00.000000Z"
}
```

```json
{
  "message": "The tracking number format is invalid.",
  "errors": {
    "tracking_number": ["The tracking number format is invalid."]
  }
}
```

---

<a id="order-events"></a>

## Order events

`GET /orders/{reference}/events`

The step-by-step history of an order — every step we took, oldest first. Where [Get order](#get-order) tells you where the order is now, this tells you how it got there: when it was created, each delivery-state change, every failed attempt and, if enabled for your account, the operator-side movements between our warehouses and routes.

> **Warning:**
>
> This endpoint speaks [JSON:API](https://jsonapi.org): successful responses have `Content-Type: application/vnd.api+json` and are **not** wrapped in the `success` / `data` envelope used by the other endpoints. Errors are different again: `403` and `404` **do** use the envelope, and `422` uses the raw validation shape. Branch on the HTTP status and only parse `2xx` bodies as JSON:API — don't feed error bodies to a strict JSON:API client library.

### Path Parameters

- `reference` (string): Either our tracking number (e.g. `4N000000012345`) **or** your own code, as sent in `external_reference` when the order was created. Only your account's orders are searched. If a value matches both a tracking number and an `external_reference`, the tracking number wins. Looking up by your code only works for orders created with an `external_reference`. **Required.**

### Query Parameters

- `filter[scope]` (string): `public` (default) or `internal`. `internal` adds operator-side movements (warehouse receptions, route assignments, internal status). It must be enabled for your account by 4 Nortes — contact your account manager. Without it, `internal` returns `403`.

### Response

A JSON:API document. `data` is the full, unpaginated list of events, oldest first. Each item is `{ "id", "type": "order-events", "attributes": {...} }` with these attributes:

- `event` (string): Event name (see the table below). New names may appear over time — **ignore events you don't recognise**.
- `from` (mixed): Value before the change, or `null`. Type depends on the event.
- `to` (mixed): Value after the change, or `null`. Type depends on the event.
- `failure` (object | null): Only on `delivery_attempt_failed`: `type` (human-readable, Spanish — don't branch on it), `reason` (machine-readable, see below) and `notes` (driver notes or `null`).
- `source` (string): Who caused the step: `system` (automatic), `api` (a request to this API — your integration or the 4 Nortes driver app), `user` (4 Nortes staff in the panel), `webhook` (an inbound carrier webhook) or `agent` (an automated 4 Nortes operations assistant acting on an approved decision). Staff identities are never exposed.
- `occurred_at` (string): When it happened, ISO 8601 (UTC).

Top-level `meta` carries the order's `tracking_number`, `external_reference`, current `delivery_state` and the `scope` that was applied.

### Events

| Event                             | Scope    | `from` / `to`                                                                                                              |
| --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `order_created`                   | public   | `from` is `null`, `to` is the tracking number                                                                              |
| `delivery_state_changed`          | public   | [Delivery state](https://api-doc.4nortes.app/orders.md#delivery-states) values, e.g. `out_for_delivery` → `delivered`      |
| `delivery_attempt_failed`         | public   | Both `null`; details are in `failure`. Also emitted when a pickup visit fails                                              |
| `internal_state_changed`          | internal | Objects with `slug` and `name` (the courier-side status), or `null`                                                        |
| `warehouse_changed`               | internal | Warehouse names. Emitted whenever the order moves to a different warehouse, i.e. on each warehouse reception that moves it |
| `received_at_warehouse`           | internal | `to` is the date of the **first** warehouse reception (ISO 8601); emitted once                                             |
| `route_assignment_changed`        | internal | Route codes, e.g. `4N-R-494681`; `null` when unassigned or removed from a route                                            |
| `estimated_delivery_date_changed` | internal | ISO 8601 dates                                                                                                             |
| `delivery_attempts_changed`       | internal | Integer attempt counts                                                                                                     |
| `package_added`                   | internal | `to` is the new package's tracking number                                                                                  |
| `package_removed`                 | internal | `from` is the removed package's tracking number                                                                            |

`failure.reason` is one of `not_home`, `refused`, `wrong_address`, `inaccessible`, `business_closed`, `pending_stock_break`, `no_cargo_ready`, `no_cargo_to_ship`, `customer_unavailable`, `other`.

> **Note:**
>
> Delivery evidence (recipient name, photos, GPS) is not part of the event history. Fetch it from the [Tracking API](https://api-doc.4nortes.app/api/tracking.md).

### Errors

- `401` — no merchant token.
- `403` (envelope) — `filter[scope]=internal` without the internal-events option enabled: `Internal events are not enabled for this account.` This check runs before the order lookup.
- `404` (envelope) — `Order not found.` when no order of yours matches `{reference}`. There is no format check on `{reference}`, so a malformed value is simply a `404`.
- `422` (validation) — an unknown `filter[scope]` value.

```bash
# -g stops curl from treating [scope] as a URL glob
curl -g "https://api.4nortes.app/api/v1/orders/288698844/events?filter[scope]=internal" \
  -H "Authorization: Bearer {token}"
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/288698844/events?filter[scope]=internal',
  {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  }
)

if (!response.ok) {
  // 403/404 use the standard envelope, 422 the validation shape
  throw new Error((await response.json()).message)
}

const { data: events, meta } = await response.json()
for (const { attributes } of events) {
  console.log(attributes.occurred_at, attributes.event, attributes.from, '→', attributes.to)
}
```

```json
{
  "data": [
    {
      "id": "981201",
      "type": "order-events",
      "attributes": {
        "event": "order_created",
        "from": null,
        "to": "4N202609146790",
        "failure": null,
        "source": "api",
        "occurred_at": "2026-09-14T13:02:11.000000Z"
      }
    },
    {
      "id": "981377",
      "type": "order-events",
      "attributes": {
        "event": "internal_state_changed",
        "from": null,
        "to": { "slug": "en-transito", "name": "En tránsito" },
        "failure": null,
        "source": "user",
        "occurred_at": "2026-09-14T23:36:02.000000Z"
      }
    },
    {
      "id": "981378",
      "type": "order-events",
      "attributes": {
        "event": "received_at_warehouse",
        "from": null,
        "to": "2026-09-14T23:36:00.000000Z",
        "failure": null,
        "source": "system",
        "occurred_at": "2026-09-14T23:36:02.000000Z"
      }
    },
    {
      "id": "981379",
      "type": "order-events",
      "attributes": {
        "event": "warehouse_changed",
        "from": "00-Postalchile-RM",
        "to": "Bodega Central 4 Norte Logística",
        "failure": null,
        "source": "system",
        "occurred_at": "2026-09-14T23:36:02.000000Z"
      }
    },
    {
      "id": "982540",
      "type": "order-events",
      "attributes": {
        "event": "route_assignment_changed",
        "from": null,
        "to": "4N-R-494681",
        "failure": null,
        "source": "user",
        "occurred_at": "2026-09-15T11:48:30.000000Z"
      }
    },
    {
      "id": "982541",
      "type": "order-events",
      "attributes": {
        "event": "delivery_state_changed",
        "from": "in_transit",
        "to": "out_for_delivery",
        "failure": null,
        "source": "system",
        "occurred_at": "2026-09-15T11:48:30.000000Z"
      }
    },
    {
      "id": "983902",
      "type": "order-events",
      "attributes": {
        "event": "delivery_state_changed",
        "from": "out_for_delivery",
        "to": "delivered",
        "failure": null,
        "source": "api",
        "occurred_at": "2026-09-15T16:21:07.000000Z"
      }
    }
  ],
  "meta": {
    "tracking_number": "4N202609146790",
    "external_reference": "288698844",
    "delivery_state": "delivered",
    "scope": "internal"
  }
}
```

```json
{
  "id": "983110",
  "type": "order-events",
  "attributes": {
    "event": "delivery_attempt_failed",
    "from": null,
    "to": null,
    "failure": {
      "type": "Entrega fallida",
      "reason": "not_home",
      "notes": "Timbre malo, nadie contesta"
    },
    "source": "api",
    "occurred_at": "2026-09-15T14:05:44.000000Z"
  }
}
```

```json
{
  "success": false,
  "message": "Internal events are not enabled for this account.",
  "errors": null,
  "timestamp": "2026-09-16T09:00:00.000000Z"
}
```

---

<a id="list-orders"></a>

## List your orders

`GET /orders`

Get a list of your orders, newest first. Like the orders table in your panel, but in your code.

### Query Parameters

- `page` (integer): Page number for pagination. Default: `1`
- `per_page` (integer): Number of results per page. Default: `15`, Max: `100`

Each item carries the same package counters as [Get order](#get-order) (`picked_up_packages_count`, `pending_pickup_packages_count`, `delivered_packages_count`, `failed_packages_count`, `returned_packages_count`, `pending_packages_count`, `delivery_progress`) but not the `packages` list, `timeline` or `internal_state`.

```bash
curl "https://api.4nortes.app/api/v1/orders?page=1&per_page=10" \
  -H "Authorization: Bearer {token}"
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders?page=1&per_page=10',
  {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  }
)
const { data } = await response.json()
// data.items, data.meta
```

```json
{
  "success": true,
  "message": "Orders retrieved successfully",
  "data": {
    "items": [
      {
        "id": 12345,
        "tracking_number": "4N000000012345",
        "external_reference": "ORDER-001",
        "cost_center": null,
        "purchase_order": null,
        "bill_of_lading": null,
        "destination": {
          "address_line": "Calle Secundaria 456",
          "locality_id": 2,
          "locality_name": "Providencia",
          "reference": "Near the plaza"
        },
        "recipient": {
          "name": "Jane Doe",
          "phone": "+56987654321",
          "email": "jane@example.com"
        },
        "delivery_state": "pending",
        "packages_count": 1,
        "picked_up_packages_count": null,
        "pending_pickup_packages_count": null,
        "delivered_packages_count": 0,
        "failed_packages_count": 0,
        "returned_packages_count": 0,
        "pending_packages_count": 1,
        "delivery_progress": "0/1",
        "weight": 2.5,
        "valuation": 0,
        "created_at": "2025-02-03T10:30:00.000000Z"
      }
    ],
    "meta": {
      "current_page": 1,
      "from": 1,
      "to": 10,
      "last_page": 5,
      "per_page": 10,
      "total": 47
    }
  },
  "timestamp": "2025-02-04T08:00:00.000000Z"
}
```

---

<a id="update-order"></a>

## Update an order

`PATCH /orders/{tracking_number}`

Edit an existing order. All fields are optional — send only the ones you want to change. Orders in a final state (`delivered`, `closed_with_return`, `nulled`) cannot be updated.

> **Note:**
>
> This endpoint can update recipient details, the destination address, and order metadata. It cannot change `sender_*`, `packages_count`, or `service_id` — use [Update package count](#update-packages) for package counts.

### Path Parameters

- `tracking_number` (string): The order tracking number. **Required.**

### Request Body

All fields are optional. Only include the ones you want to change. `recipient_name`, `destination_address` and `destination_locality` cannot be set to `null` — send a value or omit the field. Every other field accepts `null` to clear it.

- `recipient_name` (string): Name of the recipient. Max 255 characters. Not nullable.
- `recipient_company` (string): Recipient company name. Max 255 characters.
- `recipient_phone` (string): Recipient phone number. Max 50 characters.
- `recipient_email` (string): Recipient email address. Must be a valid email. Max 255 characters.
- `destination_address` (string): Destination street address. Max 500 characters. Not nullable.
- `destination_locality` (string): Destination locality (comuna) name or numeric ID. Max 100 characters. Not nullable. Resolved like on create: unknown → `404`, outside your coverage → `422`.
- `destination_reference` (string): Reference or landmark. Max 500 characters.
- `destination_building_number` (string): Building or apartment number. Max 50 characters.
- `external_reference` (string): Your own order code. Max 255 characters. Must stay unique within your account — a value already used by another of your orders returns `422`.
- `cost_center` (string): Cost center for internal accounting. Max 255 characters.
- `purchase_order` (string): Purchase order number. Max 255 characters.
- `bill_of_lading` (string): Bill of lading reference. Max 255 characters.
- `valuation` (integer): Declared value of the package. Must be >= 0.
- `weight` (number): Package weight in kilograms. Must be >= 0.

### Errors

- `404` (envelope) — `Order not found.`, or `Locality not found: {value}`.
- `422` (envelope) — `Order cannot be updated. Current state: {state}` for final states; `Merchant {name} has no coverage for locality: {locality}`.
- `422` (validation) — malformed tracking number, duplicate `external_reference`, invalid field values.

The response body is the same order detail as [Get order](#get-order).

```bash
curl -X PATCH https://api.4nortes.app/api/v1/orders/4N000000012345 \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient_phone": "+56911112222",
    "destination_address": "Nueva Avenida 789",
    "destination_reference": "Building B, gate 3"
  }'
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345',
  {
    method: 'PATCH',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      recipient_phone: '+56911112222',
      destination_address: 'Nueva Avenida 789',
      destination_reference: 'Building B, gate 3',
    }),
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Order updated successfully",
  "data": {
    "id": 12345,
    "tracking_number": "4N000000012345",
    "external_reference": "ORDER-001",
    "cost_center": null,
    "purchase_order": null,
    "bill_of_lading": null,
    "delivery_state": "pending",
    "internal_state": null,
    "sender": {
      "name": "John Store",
      "phone": "+56912345678",
      "email": "store@example.com"
    },
    "recipient": {
      "name": "Jane Doe",
      "phone": "+56911112222",
      "email": "jane@example.com"
    },
    "destination": {
      "address_line": "Nueva Avenida 789",
      "locality_id": 2,
      "locality_name": "Providencia",
      "reference": "Building B, gate 3"
    },
    "packages_count": 1,
    "picked_up_packages_count": null,
    "pending_pickup_packages_count": null,
    "delivered_packages_count": 0,
    "failed_packages_count": 0,
    "returned_packages_count": 0,
    "pending_packages_count": 1,
    "delivery_progress": "0/1",
    "packages": [
      {
        "tracking_number": "4N000000012345-305",
        "delivery_state": "pending",
        "picked_up_at": null,
        "delivered_at": null,
        "returned_at": null
      }
    ],
    "weight": 2.5,
    "valuation": 0,
    "timeline": [
      {
        "state": "pending",
        "timestamp": "2025-02-03T10:30:00.000000Z"
      }
    ],
    "estimated_delivery_date": "2025-02-05T00:00:00.000000Z",
    "delivery_date": null,
    "created_at": "2025-02-03T10:30:00.000000Z"
  },
  "timestamp": "2025-02-03T11:15:00.000000Z"
}
```

```json
{
  "success": false,
  "message": "Order cannot be updated. Current state: delivered",
  "errors": null,
  "timestamp": "2025-02-06T09:00:00.000000Z"
}
```

---

<a id="cancel-order"></a>

## Cancel an order

`POST /orders/{tracking_number}/cancel`

Cancel an order that hasn't reached a final state. Same as the cancel button in your panel.

### Path Parameters

- `tracking_number` (string): The order tracking number to cancel. **Required.**

> **Note:**
>
> Orders can be cancelled while in any non-final state. Once an order reaches `delivered`, `closed_with_return` or `nulled`, it cannot be cancelled and the request returns `422` with `Order cannot be cancelled. Current state: {state}`.

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/4N000000012345/cancel \
  -H "Authorization: Bearer {token}"
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345/cancel',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Order cancelled successfully",
  "data": {
    "tracking_number": "4N000000012345",
    "delivery_state": "nulled"
  },
  "timestamp": "2025-02-03T11:00:00.000000Z"
}
```

```json
{
  "success": false,
  "message": "Order cannot be cancelled. Current state: delivered",
  "errors": null,
  "timestamp": "2025-02-06T09:00:00.000000Z"
}
```

---

<a id="get-labels"></a>

## Get labels

`GET /orders/{tracking_number}/labels`

Get the shipping label PDF for an order (one label per package).

### Path Parameters

- `tracking_number` (string): The order tracking number. **Required.**

### Response Fields

- `url` (string): URL to download the label PDF.
- `packages_count` (integer): Number of packages in the order (one label per package).
- `generated_at` (string): Timestamp when the label was generated.

### Errors

- `404` (envelope) — `Order not found.`
- `422` (validation) — malformed tracking number.
- `500` (envelope) — `Failed to generate labels.` Retry later.

```bash
curl "https://api.4nortes.app/api/v1/orders/4N000000012345/labels" \
  -H "Authorization: Bearer {token}"
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345/labels',
  {
    headers: {
      'Authorization': `Bearer ${token}`,
    },
  }
)
const { data } = await response.json()
// data.url
```

```json
{
  "success": true,
  "message": "Operation successful",
  "data": {
    "url": "https://storage.example.com/labels/4N000000012345.pdf",
    "packages_count": 1,
    "generated_at": "2025-02-03T10:35:00.000000Z"
  },
  "timestamp": "2025-02-03T10:35:00.000000Z"
}
```

```json
{
  "success": false,
  "message": "Order not found.",
  "errors": null,
  "timestamp": "2025-02-03T10:35:00.000000Z"
}
```

---

<a id="batch-labels"></a>

## Batch labels

`POST /orders/labels/batch`

Generate one PDF with the labels of several orders at once.

### Request Body

- `tracking_numbers` (array): Array of 1 to 100 tracking numbers. **Required.** Numbers that don't match any of your orders are skipped and listed in `missing_tracking_numbers`.

### Errors

- `404` (envelope) — `No orders found for the provided tracking numbers.` when none of them matches.
- `422` (validation) — empty array or more than 100 entries.
- `500` (envelope) — `Failed to generate labels.` Retry later.

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/labels/batch \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "tracking_numbers": ["4N000000012345", "4N000000012346"]
  }'
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/labels/batch',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tracking_numbers: ['4N000000012345', '4N000000012346'],
    }),
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Operation successful",
  "data": {
    "url": "https://storage.example.com/labels/batch-abc123.pdf",
    "orders_count": 2,
    "packages_count": 3,
    "missing_tracking_numbers": [],
    "generated_at": "2025-02-03T10:35:00.000000Z"
  },
  "timestamp": "2025-02-03T10:35:00.000000Z"
}
```

---

<a id="update-packages"></a>

## Update package count

`POST /orders/{tracking_number}/packages/set`

Set the number of packages for an order. Packages are added or removed to reach the new total, and the order weight is split evenly across them.

### Path Parameters

- `tracking_number` (string): The order tracking number. **Required.**

### Request Body

- `packages_count` (integer): New total number of packages, 1 to 100. **Required.**

### Errors

- `404` (envelope) — `Order not found.`
- `422` (envelope) — `Order cannot be modified. Current state: {state}` for orders in a final state (`delivered`, `closed_with_return`, `nulled`).
- `422` (validation) — malformed tracking number, or `packages_count` outside 1–100.

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/4N000000012345/packages/set \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{
    "packages_count": 3
  }'
```

```js
const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345/packages/set',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ packages_count: 3 }),
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Successfully added 2 package(s)",
  "data": {
    "tracking_number": "4N000000012345",
    "previous_count": 1,
    "packages_count": 3,
    "change": 2
  },
  "timestamp": "2025-02-03T11:00:00.000000Z"
}
```

```json
{
  "success": false,
  "message": "Order cannot be modified. Current state: delivered",
  "errors": null,
  "timestamp": "2025-02-06T09:00:00.000000Z"
}
```

---

<a id="fulfill-order"></a>

## Record a delivery

`POST /orders/{tracking_number}/fulfill`

Record the outcome of a delivery attempt yourself — delivered, failed, or per package — with proof of delivery. Use it when your own team hands the parcel over and you want the order closed in 4 Nortes with the same evidence a driver would capture. The request body is `multipart/form-data` because it carries photos.

The outcome is applied to the order's most recent active delivery shipment (one in `pending`, `dispatched`, `in_transit` or `partially_delivered`). On a successful delivery the order moves to `delivered` and `delivery_date` is set; on a failure it moves to `failed`; with mixed per-package outcomes it becomes `partially_delivered`.

### Path Parameters

- `tracking_number` (string): The order tracking number. **Required.**

### Request Body (multipart)

- `success` (boolean): `1` = delivered, `0` = failed attempt. **Required unless `packages` is sent.** In multipart bodies send `1` or `0` — the strings `"true"` / `"false"` are rejected with `422`.
- `person_name` (string): Who received the parcel (or who you dealt with on a failed attempt). Max 255 characters. **Required.**
- `person_rut` (string): Recipient's RUT. Max 255 characters.
- `person_role` (string): Relationship of the person: `recipient`, `merchant_staff`, `operator` or `other`. Any other value returns `422`.
- `latitude` (number): Delivery location latitude, between -90 and 90.
- `longitude` (number): Delivery location longitude, between -180 and 180.
- `notes` (string): Free-text notes. Max 1000 characters.
- `photos[]` (file\[]): 1 to 10 image files, each up to 10 MB. **Required.**
- `failed_reason` (string): Why the attempt failed. Max 1000 characters. **Required when `success` is `0`.** Stored on the proof notes as `Motivo del fallo: …`, after your `notes` if you sent any.
- `packages[]` (array): Optional per-package outcomes instead of `success`. Each entry has `tracking_number` (a package tracking number of this order, e.g. `4N000000012345-305`), `delivered` (`1` or `0`) and, for failed packages, an optional `failure_reason`: `not_home`, `refused`, `wrong_address`, `inaccessible`, `business_closed`, `pending_stock_break`, `no_cargo_ready`, `no_cargo_to_ship`, `customer_unavailable` or `other`. Tracking numbers must be distinct.

### Response

`data` has three blocks: `order` (state, dates, package counters, `delivery_attempts_count`, `is_on_time`, `is_late`), `shipment` (`state`, `failed_attempts_count`) and `proof` (what was recorded). `delivery_state_label` and `state_label` are human-readable Spanish labels — use the raw `delivery_state` / `state` values for logic. `message` is `Order delivered successfully`, `Order partially delivered` or `Failed delivery attempt recorded`.

### Errors

- `404` (envelope) — `Order not found.`
- `422` (envelope, `errors: null`) — `Order has already been delivered.` when `success` is `1` and the order is already `delivered`.
- `422` (envelope, `errors: null`) — `No active shipment found for this order.` when there is nothing left to deliver against.
- `422` (envelope, `errors.packages`) — a package tracking number that doesn't belong to the order, was already delivered, already returned, or is reserved for return to sender.
- `422` (validation) — missing `success`/`person_name`/`photos`, a non-image or oversized photo, missing `failed_reason` on a failed attempt, an unknown `person_role`, or a malformed tracking number.

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/4N000000012345/fulfill \
  -H "Authorization: Bearer {token}" \
  -F "success=1" \
  -F "person_name=Jane Doe" \
  -F "person_rut=12.345.678-9" \
  -F "person_role=recipient" \
  -F "latitude=-33.4489" \
  -F "longitude=-70.6693" \
  -F "notes=Left with the recipient at the door" \
  -F "photos[]=@/path/to/photo.jpg"
```

```js
const form = new FormData()
form.append('success', '1')
form.append('person_name', 'Jane Doe')
form.append('person_role', 'recipient')
form.append('latitude', '-33.4489')
form.append('longitude', '-70.6693')
form.append('photos[]', photoFile) // a File or Blob

const response = await fetch(
  'https://api.4nortes.app/api/v1/orders/4N000000012345/fulfill',
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
    },
    body: form,
  }
)
const { data } = await response.json()
```

```json
{
  "success": true,
  "message": "Order delivered successfully",
  "data": {
    "order": {
      "id": 12345,
      "tracking_number": "4N000000012345",
      "delivery_state": "delivered",
      "delivery_state_label": "Entregado",
      "delivery_date": "2025-02-05T16:21:07.000000Z",
      "estimated_delivery_date": "2025-02-05T00:00:00.000000Z",
      "packages_count": 1,
      "delivered_packages_count": 1,
      "delivery_attempts_count": 1,
      "delivery_progress": "1/1",
      "is_on_time": false,
      "is_late": true
    },
    "shipment": {
      "id": 98765,
      "state": "delivered",
      "state_label": "Entregado",
      "failed_attempts_count": 0
    },
    "proof": {
      "id": 55501,
      "type": "destination",
      "person_name": "Jane Doe",
      "latitude": -33.4489,
      "longitude": -70.6693,
      "notes": "Left with the recipient at the door",
      "created_at": "2025-02-05T16:21:07.000000Z"
    }
  },
  "timestamp": "2025-02-05T16:21:07.000000Z"
}
```

```bash
curl -X POST https://api.4nortes.app/api/v1/orders/4N000000012345/fulfill \
  -H "Authorization: Bearer {token}" \
  -F "person_name=Jane Doe" \
  -F "packages[0][tracking_number]=4N000000012345-305" \
  -F "packages[0][delivered]=1" \
  -F "packages[1][tracking_number]=4N000000012345-418" \
  -F "packages[1][delivered]=0" \
  -F "packages[1][failure_reason]=refused" \
  -F "photos[]=@/path/to/photo.jpg"
```

```json
{
  "success": false,
  "message": "The following tracking numbers do not belong to this order: 4N000000099999-001",
  "errors": {
    "packages": [
      "The following tracking numbers do not belong to this order: 4N000000099999-001"
    ]
  },
  "timestamp": "2025-02-05T16:21:07.000000Z"
}
```
