# Introduction > Connect your system to 4N NextDay and automate your shipping. Sync orders, print labels, and track deliveries without manual work. Source: https://api-doc.4nortes.app Automate what you already do in your panel. Instead of manually entering orders, printing labels, and checking tracking, let your system do it automatically. [Quickstart](https://api-doc.4nortes.app/quickstart.md) [API Reference](https://api-doc.4nortes.app/api/orders.md) ## Getting started Ask your 4N NextDay administrator for an API token (tokens are issued per merchant account from the admin panel — there is no login step), then connect your system. Once connected, your orders sync automatically, labels print on demand, and tracking updates flow to your customers in real-time. [Set up authentication](https://api-doc.4nortes.app/authentication.md) --- # Quickstart > Connect your first order to 4N NextDay. Learn how to sync orders from your system automatically. Source: https://api-doc.4nortes.app/quickstart Get your system connected in minutes. This guide shows you how to sync an order with 4N NextDay - the same as clicking "Create Order" in your panel, but automated. > **Note:** > > You'll need an API token for your merchant account. Tokens are issued by your 4N NextDay administrator from the admin panel — there is no login step. See the [Authentication](https://api-doc.4nortes.app/authentication.md) guide for details. ## Base URL All API requests should be made to the following base URL: ```bash https://api.4nortes.app/api/v1 ``` ## Authentication The 4N NextDay API uses bearer tokens for authentication. Your token is issued by your 4N NextDay administrator and may carry an expiry date — once it passes, requests return `401` until you get a new one. Include the token in the `Authorization` header of all requests: ```bash Authorization: Bearer {token} ``` ## Sync your first order This is the same as filling out the order form in your panel. Instead of typing the details manually, your system sends them automatically: ```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', 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, }), }) const data = await response.json() console.log(data) ``` ```python import requests response = requests.post( 'https://api.4nortes.app/api/v1/orders/create', headers={ 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', }, json={ '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, } ) print(response.json()) ``` ```php $client = new \GuzzleHttp\Client(); $response = $client->post('https://api.4nortes.app/api/v1/orders/create', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'json' => [ '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, ], ]); echo $response->getBody(); ``` A few things about the payload: - `destination_locality` accepts the locality (comuna) name or its numeric id. The id must be sent as a string (`"2"`, not `2`). See [Localities](https://api-doc.4nortes.app/localities.md). - `service_id` must be one of **your** services — get the ids from `GET /services`. Omit it to use your account's default active service. Replace the `1` in the example with a real id. - `external_reference` is your own order code. It must be unique within your account. You'll get back `201 Created` with the order details and the tracking number - just like when you create an order in your panel: ```json { "success": true, "message": "Order created successfully", "data": { "id": 12345, "tracking_number": "4N202502030001", "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" } ``` `cost_center`, `purchase_order` and `bill_of_lading` echo the optional request fields of the same name and are `null` when you don't send them; `valuation` defaults to `0`. > **Note:** > > Not sure whether you deliver to a locality? Call [`POST /rates/quote`](https://api-doc.4nortes.app/api/references.md#quote-rates) first. It runs the same locality and coverage checks as order creation and returns the price, so a successful quote means the locality will be accepted. ## What's next? Your first order is synced. Here's what else you can automate: - [Understand order states](https://api-doc.4nortes.app/orders.md) - See how orders flow through the system - [Sync orders automatically](https://api-doc.4nortes.app/api/orders.md) - Full orders reference - [Check coverage and price](https://api-doc.4nortes.app/api/references.md#quote-rates) - Quote before you create - [Track the order](https://api-doc.4nortes.app/api/tracking.md) - Timeline, proofs and per-package progress - [Get notified of updates](https://api-doc.4nortes.app/webhooks.md) - Receive tracking updates automatically - [Handle errors](https://api-doc.4nortes.app/errors.md) - What to do when something goes wrong ## Build it with your AI agent Using Claude Code, Cursor, Copilot or ChatGPT? These docs are published in formats agents read directly: - [`/llms-full.txt`](https://api-doc.4nortes.app/llms-full.txt) — the whole documentation in one Markdown file. - [`/llms.txt`](https://api-doc.4nortes.app/llms.txt) — an index of every page, following the [llms.txt](https://llmstxt.org) convention. - Any page as Markdown: add `.md` to its URL (for example [`/api/orders.md`](https://api-doc.4nortes.app/api/orders.md)), or use **Copy page** at the top of each page. Paste this prompt into your agent, fill in the last line, and let it scaffold the integration: ```text You are integrating our system with the 4N NextDay shipping API (4 Nortes, Chile). 1. Read the full documentation first: https://api-doc.4nortes.app/llms-full.txt Every page is also available as Markdown, e.g. https://api-doc.4nortes.app/api/orders.md 2. Base URL: https://api.4nortes.app/api/v1 Authenticate every request with "Authorization: Bearer $NEXTDAY_API_TOKEN". Read the token from an environment variable; never hard-code or log it. 3. Build: - A typed client for the endpoints we need. Start with POST /rates/quote, POST /orders/create, GET /orders/{tracking_number} and GET /tracking/{tracking_number}. - Error handling that branches on the HTTP status, not on the response body: 401, 404, 422 (field errors), 429 (honour Retry-After). - If we receive webhooks: an endpoint that verifies X-4Nortes-Signature (HMAC-SHA256 of the raw request body) before parsing, answers 2xx quickly, and deduplicates on (event, data.tracking_number, timestamp). 4. Use only fields and values documented there. Ignore unknown fields and unknown event names so new ones never break us. 5. Ask me before assuming anything the docs don't state. Our stack: ``` --- # Authentication > Authenticate with the 4N NextDay API using the bearer token issued for your merchant account. Source: https://api-doc.4nortes.app/authentication The 4N NextDay API uses bearer tokens. Your token is issued for your merchant account by 4N NextDay — there is no login endpoint for integrations. ## Get a token API tokens are created by the 4N NextDay team in the admin panel, under your merchant account. Ask your account contact for one. Keep in mind: - The full token is shown **once**, when it is created. If you lose it, a new one has to be issued. - A token may carry an **expiry date**. Once it passes, every request returns `401` until you get a new token. - A token can be **revoked** at any time from the panel, or by you with [`POST /auth/logout`](#logout). - One account can hold several tokens — use separate ones for staging and production. > **Note:** > > Don't try to exchange panel credentials for a token. The API has no login flow for merchant integrations; the token you receive is the only credential you need. ## Use the token in requests Include the token in the `Authorization` header of every API request: ```bash Authorization: Bearer 1|abc123def456... ``` ```bash curl https://api.4nortes.app/api/v1/orders \ -H "Authorization: Bearer 1|abc123def456..." \ -H "Accept: application/json" ``` ```js const response = await fetch('https://api.4nortes.app/api/v1/orders', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }) ``` ```python response = requests.get( 'https://api.4nortes.app/api/v1/orders', headers={ 'Authorization': f'Bearer {token}', 'Accept': 'application/json', } ) ``` ```php $response = $client->get('https://api.4nortes.app/api/v1/orders', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', ], ]); ``` ## Headers to include | Header | Value | Description | | --------------- | ------------------ | ---------------------------------------------------- | | `Authorization` | `Bearer {token}` | The token issued for your merchant account | | `Content-Type` | `application/json` | Required when you send a JSON body (`POST`, `PATCH`) | | `Accept` | `application/json` | Recommended. Errors are returned as JSON either way | ## If authentication fails There are two different `401` bodies, depending on the cause. Branch on the status code, not on the body. **Missing, invalid, expired or revoked token.** The request is rejected before it reaches the API logic, so the body is a bare `message` — no `success`, no `timestamp`. Check that: - the `Authorization` header is present and reads `Bearer {token}` (with the space) - the token hasn't passed its expiry date - the token wasn't revoked from the panel or via `/auth/logout` **Valid token, wrong kind.** Merchant-only endpoints (`/orders/*`, `/services`) accept merchant API tokens only. A valid token of another kind gets the standard error envelope with `Authentication required.`. This should not happen with the token you were issued; if it does, ask your administrator to verify it was created under your merchant account. ```json { "message": "Unauthenticated." } ``` ```json { "success": false, "message": "Authentication required.", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` See [Error Handling](https://api-doc.4nortes.app/errors.md) for the full picture of response shapes. ## Revoke the current token `POST /auth/logout` Revokes the token used to make the request. Use it when rotating credentials or decommissioning an integration. No request body. The response is the standard success envelope with `data: null`. > **Warning:** > > Revocation is permanent. The next request with that token returns `401 Unauthenticated.`, and you need a new token from your 4N NextDay administrator to continue. ```bash curl -X POST https://api.4nortes.app/api/v1/auth/logout \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/auth/logout', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) ``` ```json { "success": true, "message": "Logged out successfully", "data": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ## Keep your token safe 1. **Use environment variables**: Don't hardcode the token in source control 2. **Rotate when needed**: Ask for a new token, switch your integration over, then revoke the old one 3. **Separate tokens**: Use different tokens for testing vs production 4. **Watch for `401`**: Alert on it — it means the token expired or was revoked, and nothing will work until it's replaced ```bash # Set your token as an environment variable export NEXTDAY_TOKEN="1|abc123def456..." # Use it in your requests curl https://api.4nortes.app/api/v1/orders \ -H "Authorization: Bearer $NEXTDAY_TOKEN" ``` --- # SDKs & Libraries > Code examples and helpers to connect your system to 4N NextDay faster. Source: https://api-doc.4nortes.app/sdks We don't have official SDKs yet, but connecting is simple with standard HTTP libraries. Here's how to get started in your language. ## Making requests Use these libraries to call the API: ### JavaScript / Node.js ```js // Using native fetch (Node.js 18+) const response = await fetch('https://api.4nortes.app/api/v1/orders', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }) // Using axios import axios from 'axios' const client = axios.create({ baseURL: 'https://api.4nortes.app/api/v1', headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }) const response = await client.get('/orders') ``` ### Python ```python import requests # Using requests library headers = { 'Authorization': f'Bearer {token}', 'Accept': 'application/json', } response = requests.get( 'https://api.4nortes.app/api/v1/orders', headers=headers ) data = response.json() ``` ### PHP ```php // Using Guzzle use GuzzleHttp\Client; $client = new Client([ 'base_uri' => 'https://api.4nortes.app/api/v1/', 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json', ], ]); $response = $client->get('orders'); $data = json_decode($response->getBody(), true); ``` ### Ruby ```ruby require 'net/http' require 'json' uri = URI('https://api.4nortes.app/api/v1/orders') request = Net::HTTP::Get.new(uri) request['Authorization'] = "Bearer #{token}" request['Accept'] = 'application/json' response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end data = JSON.parse(response.body) ``` ## Build a simple wrapper Here's a JavaScript helper class you can copy into your project. It branches on the HTTP status code rather than on a `success` flag, because authentication, validation and rate-limit errors don't carry one (see [Error Handling](https://api-doc.4nortes.app/errors.md)): ```js class NextDayAPI { constructor(token) { this.token = token this.baseUrl = 'https://api.4nortes.app/api/v1' } async request(method, endpoint, data = null) { const response = await fetch(`${this.baseUrl}${endpoint}`, { method, headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: data ? JSON.stringify(data) : undefined, }) const body = await response.json().catch(() => ({})) if (!response.ok) { const error = new Error(body.message ?? `HTTP ${response.status}`) error.status = response.status // Only validation errors (422) carry `errors`; keys are the failing fields error.fields = body.errors ? Object.keys(body.errors) : [] error.retryAfter = Number(response.headers.get('Retry-After')) || null throw error } return body.data } // Orders createOrder(orderData) { return this.request('POST', '/orders/create', orderData) } getOrder(trackingNumber) { return this.request('GET', `/orders/${trackingNumber}`) } listOrders(params = {}) { const query = new URLSearchParams(params).toString() return this.request('GET', `/orders${query ? `?${query}` : ''}`) } cancelOrder(trackingNumber) { return this.request('POST', `/orders/${trackingNumber}/cancel`) } // Tracking track(trackingNumber) { return this.request('GET', `/tracking/${trackingNumber}`) } // Coverage and price check quote(destinationLocality, weight, packagesCount = 1) { return this.request('POST', '/rates/quote', { destination_locality: destinationLocality, weight, packages_count: packagesCount, }) } // Reference data getRegions() { return this.request('GET', '/regions') } getLocalities() { return this.request('GET', '/localities') } getServices() { return this.request('GET', '/services') } } // Usage - use the token issued for your merchant account const api = new NextDayAPI(process.env.NEXTDAY_TOKEN) const order = await api.createOrder({ sender_name: 'My Store', sender_phone: '+56912345678', recipient_name: 'Jane Doe', recipient_phone: '+56987654321', destination_address: 'Calle Principal 123', destination_locality: 'Providencia', weight: 1.2, }) console.log(`Created order: ${order.tracking_number}`) ``` > **Note:** > > The example above omits `service_id`, so the order is created with your account's default active service. If your account has no active service, the call fails with `422` and the message `There are no active services available for this merchant.` — pass a `service_id` from `GET /services` to be explicit. See [Services](https://api-doc.4nortes.app/services.md). ## Community libraries Built something for 4N NextDay? Let us know and we'll list it here. > **Note:** > > Check that any third-party library is up to date before using it. ## Full API docs For all the details: - [Orders API](https://api-doc.4nortes.app/api/orders.md) - Sync orders and download labels - [Tracking API](https://api-doc.4nortes.app/api/tracking.md) - Fetch tracking for customers - [References API](https://api-doc.4nortes.app/api/references.md) - Get regions, comunas, services and rate quotes --- # Orders > Understand how orders work in 4N NextDay. Same statuses you see in your panel, now synced with your system. Source: https://api-doc.4nortes.app/orders When you sync an order from your system, it works just like creating one in your panel. You'll see the same information: sender, recipient, destination, and tracking status. ## What's in an order Every order has the same fields you fill out in your panel: ### Identifiers - **`id`**: Internal order ID - **`tracking_number`**: Public tracking number (e.g., `4N000000012345`) - **`external_reference`**: Your own order code, unique within your account. Orders created with one can also be looked up by it in the [event history](#event-history) - **`cost_center`**: Cost center for internal accounting - **`purchase_order`**: Purchase order number - **`bill_of_lading`**: Bill of lading reference ### Sender Information - **`sender_name`**: Name of the sender or business - **`sender_phone`**: Contact phone number - **`sender_email`**: Contact email address - **`sender_company`**: Company name (defaults to your account name) ### Recipient Information - **`recipient_name`**: Name of the recipient - **`recipient_phone`**: Contact phone number - **`recipient_email`**: Contact email address - **`recipient_company`**: Company name ### Destination - **`destination_address`**: Street address - **`destination_locality`**: Locality (comuna) name or numeric ID - **`destination_reference`**: Reference or landmark - **`destination_building_number`**: Building or apartment number ### Package Details - **`weight`**: Package weight in kilograms - **`packages_count`**: Number of packages - **`picked_up_packages_count`**: Packages already scanned at pickup (`null` until per-package scanning starts) - **`pending_pickup_packages_count`**: Packages not yet scanned at pickup (`null` until per-package scanning starts) - **`delivered_packages_count`**: Packages delivered so far (for per-package deliveries) - **`failed_packages_count`**: Packages whose last delivery attempt failed - **`returned_packages_count`**: Packages returned to the sender - **`pending_packages_count`**: Packages neither delivered nor returned - **`delivery_progress`**: Packages delivered vs. total, as a fraction string (e.g. `"2/3"`) - **`packages`**: One entry per package with its own `tracking_number` (e.g. `4N000000012345-305`), `delivery_state` (`pending`, `delivered`, `failed`, `returned`) and `picked_up_at` / `delivered_at` / `returned_at` timestamps. Only on the order detail - **`valuation`**: Declared value (integer) ### Delivery Information - **`delivery_state`**: Current state of the delivery — the field to build your logic on - **`internal_state`**: The courier-side status name as shown in the 4 Nortes panel (free text, Spanish, may be `null`). Managed by our operations team and subject to change — display it if you like, never branch on it - **`service_id`**: Type of delivery service - **`estimated_delivery_date`**: Expected delivery date - **`delivery_date`**: Actual delivery date (when delivered) ## Delivery states These are the same statuses you see in your panel. Your orders move through them as they're picked up and delivered: - `pending` - `picked_up` - `in_transit` - `out_for_delivery` - `delivered` - `partially_delivered` - `failed` - `closed_with_return` - `nulled` ### State Descriptions | State | Final | Description | | --------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | | `pending` | no | Order has been created and is awaiting pickup | | `picked_up` | no | Package has been collected from the sender | | `in_transit` | no | Package is being transported to the destination area | | `out_for_delivery` | no | Package is on the delivery vehicle for final delivery | | `delivered` | yes | Package has been successfully delivered to the recipient | | `partially_delivered` | no | Some packages were delivered, others failed | | `failed` | no | Delivery attempt failed (recipient unavailable, wrong address, etc.) | | `closed_with_return` | yes | Order closed with at least one package returned to the sender after a failed delivery (any remaining packages were delivered) | | `nulled` | yes | Order has been cancelled | Final states (`delivered`, `closed_with_return`, `nulled`) can't be updated or cancelled through the API. ## How orders flow Orders move through these states - same as what you see in your panel: ``` pending → picked_up → in_transit → out_for_delivery → delivered ↓ failed → (retry) → delivered ↓ closed_with_return ``` ### Normal flow 1. **pending**: You sync the order from your system 2. **picked\_up**: Courier collects the package from you 3. **in\_transit**: Package is on its way 4. **out\_for\_delivery**: Package is out with the driver 5. **delivered**: Customer receives the package ### When things don't go as planned - **Failed delivery**: If the driver can't deliver, the order moves to `failed`. It can be retried. - **Return to sender**: If failed packages are sent back to you instead of retried, the order ends in `closed_with_return` once every package is either delivered or returned. This is a final state. - **Cancellation**: You can cancel orders in any non-final state. They move to `nulled`. > **Note:** > > Want to know when statuses change? Set up [webhooks](https://api-doc.4nortes.app/webhooks.md) to get notified automatically - no need to check your panel. ## Event history `delivery_state` tells you where an order is now. When you need to know how it got there — when it was created, every state change, each failed attempt, and (if enabled for your account) the warehouse receptions and route assignments in between — fetch its step-by-step history with [`GET /orders/{reference}/events`](https://api-doc.4nortes.app/api/orders.md#order-events). You can look an order up by our tracking number or by your own `external_reference`, and events come back oldest first. ## Proof of delivery When an order is delivered, you get the same proof you see in your panel: - **Recipient name**: Who signed for the package - **Recipient RUT**: Their Chilean ID (if provided) - **Recipient role**: Their relationship to the order - **Location**: GPS coordinates of where it was delivered - **Photos**: Pictures taken at delivery You can fetch this through the [Tracking API](https://api-doc.4nortes.app/api/tracking.md) to show customers in your app. --- # Tracking > Get the same tracking info you see in your panel. Show customers where their package is, right in your app. Source: https://api-doc.4nortes.app/tracking Get the same tracking information you see in your panel. Fetch it to show customers in your app or to update your order management system. ## Tracking Numbers Every order is assigned a unique tracking number when created: ``` 4N YYYYMMDD NNNN ``` - `4N` — prefix for regular orders. `SH` is used for orders that start from a blank label scanned by a driver in the field ("shell" orders) and keep that prefix for life. - `YYYYMMDD` — the creation date. - `NNNN` — a 4-digit code, randomly assigned. Don't infer volume or sequence from it. For example, `4N202512310001` is an order created on 2025-12-31. Tracking numbers are always 14 characters: the two-letter prefix plus 12 digits. The tracking endpoint rejects any other shape with a `422`. Each package inside an order has its own tracking number, built from the order's: `4N202512310001-001`, `4N202512310001-002`, and so on. ## Delivery states The `delivery_state` field is the order's current position in the flow. These are the possible values: - `pending` - `picked_up` - `in_transit` - `out_for_delivery` - `partially_delivered` - `delivered` - `closed_with_return` - `failed` - `nulled` | State | Meaning | | --------------------- | ----------------------------------------------------------- | | `pending` | Created, waiting for pickup | | `picked_up` | Collected from the sender | | `in_transit` | Moving through the network | | `out_for_delivery` | On a vehicle heading to the recipient | | `partially_delivered` | Some packages were delivered, others not yet | | `delivered` | All packages delivered — final | | `closed_with_return` | Closed; undelivered packages returned to the sender — final | | `failed` | The last delivery attempt failed; a new attempt may follow | | `nulled` | Cancelled — final | See [Orders](https://api-doc.4nortes.app/orders.md) for how an order moves between these states. ## Timeline events You get the same timeline you see when clicking an order in your panel — one entry per state change, oldest first. The first entry is always `pending` at the order's creation time. | Field | Type | Description | | -------------- | -------- | -------------------------------------------------------------- | | `state` | string | The delivery state reached at this point | | `timestamp` | datetime | When it happened | | `type` | string | Only on failure entries: always `failure` | | `failure_info` | object | Only on failure entries: `type`, `reason`, `notes` (see below) | A failed delivery attempt produces **two** entries at (almost) the same time: a `failed` entry carrying `type: "failure"` and `failure_info` with the details, and the resulting `failed` state change. Use the one with `failure_info` to tell your customer what happened. ### Failure info | Field | Type | Description | | -------- | ------------ | ----------------------------------------------------------------------------------------------------- | | `type` | string\|null | A human-readable label for what failed (delivery or pickup), in the platform's language. Display only | | `reason` | string | The failure reason. Normally one of the codes below; branch on this field | | `notes` | string\|null | Free-text notes from the driver | Reason codes: `not_home`, `refused`, `wrong_address`, `inaccessible`, `business_closed`, `pending_stock_break`, `no_cargo_ready`, `no_cargo_to_ship`, `customer_unavailable`, `other`. ### Example Timeline ```json { "timeline": [ { "state": "pending", "timestamp": "2025-02-03T10:00:00.000000Z" }, { "state": "picked_up", "timestamp": "2025-02-03T14:30:00.000000Z" }, { "state": "in_transit", "timestamp": "2025-02-03T18:00:00.000000Z" }, { "state": "out_for_delivery", "timestamp": "2025-02-04T08:00:00.000000Z" }, { "state": "failed", "timestamp": "2025-02-04T11:05:00.000000Z", "type": "failure", "failure_info": { "type": "Entrega fallida", "reason": "not_home", "notes": "Nobody answered the door" } }, { "state": "failed", "timestamp": "2025-02-04T11:05:01.000000Z" }, { "state": "out_for_delivery", "timestamp": "2025-02-05T08:30:00.000000Z" }, { "state": "delivered", "timestamp": "2025-02-05T11:30:00.000000Z" } ] } ``` ## Package progress Orders with several packages can be delivered in parts. The tracking response gives you both the counters and the per-package detail: | Counter | Meaning | | ------------------------------- | ------------------------------------- | | `packages_count` | Total packages in the order | | `picked_up_packages_count` | Packages collected from the sender | | `pending_pickup_packages_count` | Packages not yet collected | | `delivered_packages_count` | Packages handed to the recipient | | `failed_packages_count` | Packages whose last attempt failed | | `returned_packages_count` | Packages returned to the sender | | `pending_packages_count` | Packages with no delivery outcome yet | Each entry in `packages[]` has: | Field | Type | Description | | ----------------- | -------------- | ---------------------------------------------- | | `tracking_number` | string | Package tracking number (`{order}-NNN`) | | `delivery_state` | string | `pending`, `delivered`, `failed` or `returned` | | `picked_up_at` | datetime\|null | When it was collected | | `delivered_at` | datetime\|null | When it was delivered | | `returned_at` | datetime\|null | When it was returned to the sender | ## Delivery attempts `delivery_attempts[]` lists every visit to the recipient, in order, with its outcome and evidence — the same cards you see in the panel. Each attempt has: | Field | Type | Description | | ---------------- | ------------ | ------------------------------------------------------------------------------------------------ | | `attempt` | integer | 1 for the first visit, 2 for the second, … | | `outcome` | string | `success` or `failed` | | `recipient_name` | string\|null | Who received the package (successful attempts) | | `recipient_rut` | string\|null | Their Chilean ID, if captured | | `recipient_role` | string\|null | `merchant_staff`, `recipient`, `operator` or `other` | | `location` | object\|null | `latitude` / `longitude` where the attempt was recorded | | `notes` | string\|null | Driver notes | | `photos` | array | `url` and `preview_url` pairs, signed links valid for **2 hours** | | `packages` | array | Per-package outcome: `tracking_number`, `delivered` (boolean), `failure_reason` (code or `null`) | | `created_at` | datetime | When the attempt was recorded | ## Proofs Three evidence blocks share the same core shape — `location`, `notes`, `photos[]` (`url` + `preview_url`) and `created_at` — and differ in when they're present: | Block | Present when | Extra fields | | ---------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | | `delivery_proof` | `delivery_state` is `delivered` or `partially_delivered` and the driver recorded a proof | `recipient_name`, `recipient_rut`, `recipient_role` | | `pickup_proof` | A photographed pickup proof exists (from pickup onwards). `null` before that | — | | `failure_proof` | `delivery_state` is `failed` and the failed attempt has photos. `null` otherwise | — (it documents the scene, not a signee) | `recipient_role` is one of `merchant_staff`, `recipient`, `operator` or `other`. ### Location Object ```json { "location": { "latitude": -33.4489, "longitude": -70.6693 } } ``` ### Photos Array ```json { "photos": [ { "url": "https://storage.example.com/proofs/abc123.jpg?signature=...", "preview_url": "https://storage.example.com/proofs/abc123-preview.jpg?signature=..." } ] } ``` Photo links in the proof blocks are signed and valid for **60 minutes**; the ones inside `delivery_attempts[]` last 2 hours. Fetch the tracking again to get fresh links — don't store them. > **Note:** > > Delivery photos are kept for 90 days. Download any you need for your records before then. ## Full event history The tracking timeline shows delivery states only. For the complete step-by-step history of an order — every change, who made it and when — use [`GET /orders/{reference}/events`](https://api-doc.4nortes.app/api/orders.md#order-events). It accepts either the tracking number or your own `external_reference`, so you can look an order up without storing our number first. ## Get updates automatically Instead of checking your panel or calling the API repeatedly, set up [webhooks](https://api-doc.4nortes.app/webhooks.md) to get notified when orders move. ### When to use what | Approach | Best for | | ---------------- | ---------------------------------------------------------- | | **Webhooks** | Updating your system automatically when status changes | | **Tracking API** | Showing customers their order status on demand | | **Order events** | Audits, support tooling, reconciling with your own records | See the [Webhooks documentation](https://api-doc.4nortes.app/webhooks.md) for setup. --- # Localities & Regions > Validate delivery coverage before customers check out. Show them if their comuna is covered. Source: https://api-doc.4nortes.app/localities Check if a customer's location is covered before they check out. Use the same region and comuna data you see in your panel to validate addresses. ## How it works Chile has 16 regions, each with multiple comunas. When syncing an order, you identify the destination comuna in the `destination_locality` field - the same locations shown in your panel dropdowns. ``` Region (e.g., "Metropolitana de Santiago") └── Locality (e.g., "Santiago") └── Locality (e.g., "Providencia") └── Locality (e.g., "Las Condes") ... ``` ## Regions Regions are the top-level geographic divisions. Each region has: | Field | Type | Description | | ------------------ | ------- | -------------------------------- | | `id` | integer | Unique region identifier | | `name` | string | Region name | | `slug` | string | URL-friendly identifier | | `localities_count` | integer | Number of comunas in this region | ### Example Regions | Name | Slug | | ------------------ | ------------------------- | | Arica y Parinacota | arica-y-parinacota | | Tarapacá | tarapaca | | Antofagasta | antofagasta | | Atacama | atacama | | Coquimbo | coquimbo | | Valparaíso | valparaiso | | Metropolitana | metropolitana-de-santiago | | O'Higgins | ohiggins | | Maule | maule | | Ñuble | nuble | | Biobío | biobio | | La Araucanía | la-araucania | | Los Ríos | los-rios | | Los Lagos | los-lagos | | Aysén | aysen | | Magallanes | magallanes | ## Comunas Comunas (localities) are the delivery areas within each region. Each has: | Field | Type | Description | | ----------- | ------- | ---------------------------------------------------------------- | | `id` | integer | Unique locality identifier | | `name` | string | Locality name | | `slug` | string | URL-friendly identifier | | `type` | string | `urban`, `rural` or `both` | | `region_id` | integer | The region it belongs to | | `aliases` | array | Alternative spellings that are also matched when you send a name | ### Name or id — both work `destination_locality` on `POST /orders/create` (and on `POST /rates/quote`) accepts either form: - **The name** (or an alias). Matching ignores case and accents, so `"Nunoa"` resolves to `Ñuñoa`. If nothing matches you get a `404` with `Locality not found: …`. - **The numeric id**, sent **as a string**: `"2"`, not `2`. Ids come from `GET /localities` or from a previous quote's `locality_id`. Prefer this once you have mapped your addresses — it can't be misread. ```json { "destination_address": "Calle Principal 123", "destination_locality": "Providencia" } ``` ```json { "destination_address": "Calle Principal 123", "destination_locality": "2" } ``` > **Note:** > > Fetch the [full list of comunas](https://api-doc.4nortes.app/api/references.md#list-localities) once and cache it. This data rarely changes. ## Check coverage before you create Your account delivers to a configured set of comunas. An order to a comuna outside that set is rejected with a `422` (`Merchant … has no coverage for locality: …`) - after the customer has already checked out, if you don't validate earlier. Call [`POST /rates/quote`](https://api-doc.4nortes.app/api/references.md#quote-rates) with the destination and weight while the customer is still in your checkout. It runs the same locality resolution and coverage check as order creation and returns the price and transit time, so you can show the shipping cost and block uncovered addresses up front. --- # Services > The delivery services configured for your account, and how to pick one when you create an order. Source: https://api-doc.4nortes.app/services A service is a delivery product configured for your account - the same options you pick from when creating an order in your panel. Each order belongs to exactly one service. ## Your services Services are set up per merchant by 4N NextDay, so the list, the codes and the names are specific to your account. Each service has: | Field | Type | Description | | ------ | ------- | ----------------------------------------------------------------- | | `id` | integer | Unique service identifier - this is what you send as `service_id` | | `code` | string | Short code chosen for your account (for example `ECOM-STGO`) | | `name` | string | Display name, as shown in your panel | | `type` | string | `b2b`, `b2c` or `field` (see below) | ### Service types | Type | Meaning | | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `b2b` | Deliveries to businesses - stores, warehouses, offices | | `b2c` | Deliveries to consumers - home delivery | | `field` | Cargo created by drivers on the road from blank labels. Auto-provisioned; **not for integration orders** and never chosen automatically | > **Note:** > > Only **active** services are returned by the API. If a service you used to send disappears from the list, it was deactivated - orders with that `service_id` are rejected until you switch to another one. ## Choose a service When syncing an order, include the `service_id` - just like selecting a service in your panel: ```json { "destination_address": "Calle Secundaria 456", "destination_locality": "Providencia", "weight": 2.5, "service_id": 27 } ``` What happens with `service_id`: | You send | Result | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The id of one of your active services | The order is created with that service | | Nothing | Your account's default active service is used (never a `field` one). `422` with `There are no active services available for this merchant.` if you have none | | The id of a service that isn't yours or is inactive | `422` with `The specified service is not available for this merchant.` | | An id that doesn't exist at all | `422` validation error on `service_id`: `The selected service does not exist.` | ### Fetch the list of services Get your active services programmatically. This endpoint requires a merchant token: ```bash curl https://api.4nortes.app/api/v1/services \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch('https://api.4nortes.app/api/v1/services', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }) const { data: services } = await response.json() ``` ### Response ```json { "success": true, "message": "Services retrieved successfully", "data": [ { "id": 31, "code": "B2B-RM", "name": "Distribución B2B Región Metropolitana", "type": "b2b" }, { "id": 27, "code": "ECOM-STGO", "name": "E-commerce Santiago", "type": "b2c" } ], "timestamp": "2025-02-03T10:00:00.000000Z" } ``` > **Note:** > > Need a new service, or a service for another destination area? Contact your 4N NextDay account representative - services, coverage and pricing are configured on our side. --- # 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.`. --- ## 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" } ``` --- ## 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."] } } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- ## 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" } ``` --- # Tracking API > Fetch tracking info for your customers. Same data you see in your panel, in your app. Source: https://api-doc.4nortes.app/api/tracking Show your customers where their package is. Fetch the same tracking timeline, per-package progress and proofs you see in your panel. --- ## Track by tracking number `GET /tracking/{tracking_number}` Get the full tracking record for an order using its 4N NextDay tracking number. Merchant tokens can only see their own orders; anything else is reported as not found. ### Path Parameters - `tracking_number` (string): The order tracking number: `4N` or `SH` followed by 12 digits (e.g. `4N202502030001`). **Required.** ### Response Fields - `tracking_number` (string): The order tracking number. - `delivery_state` (string): Current state: `pending`, `picked_up`, `in_transit`, `out_for_delivery`, `partially_delivered`, `delivered`, `closed_with_return`, `failed` or `nulled`. - `estimated_delivery_date` (string|null): Expected delivery date (ISO 8601). - `delivery_date` (string|null): Actual delivery date, once delivered. - `packages_count` (integer): Total number of packages in the order. - `picked_up_packages_count` (integer): Packages collected from the sender. - `pending_pickup_packages_count` (integer): Packages not yet collected. - `delivered_packages_count` (integer): Packages handed to the recipient. - `failed_packages_count` (integer): Packages whose last delivery attempt failed. - `returned_packages_count` (integer): Packages returned to the sender. - `pending_packages_count` (integer): Packages with no delivery outcome yet. - `timeline` (array): State changes in chronological order, starting with `pending` at creation. Each entry has `state` and `timestamp`; failure entries add `type: "failure"` and `failure_info`. - `delivery_proof` (object|null): Proof of delivery. Present when `delivery_state` is `delivered` or `partially_delivered` and a proof was recorded; `null` otherwise. - `pickup_proof` (object|null): Proof captured when the order was collected from the sender. Same shape as `failure_proof` (no recipient fields). `null` until a photographed pickup proof exists. - `failure_proof` (object|null): Proof captured at the failed delivery attempt — `location`, `notes`, `photos`, `created_at`. **No recipient fields** (it documents the scene, not a signee). Present only when `delivery_state` is `failed` and photos were captured; `null` otherwise. - `delivery_attempts` (array): One record per visit to the recipient, oldest first. See below. - `packages` (array): Per-package progress. See below. ### Timeline entry - `state` (string): The delivery state reached at this point. - `timestamp` (string): When it happened (ISO 8601). - `type` (string): Only on failure entries. Always `failure`. - `failure_info` (object): Only on failure entries. `type` (display label, platform language), `reason` (normally one of `not_home`, `refused`, `wrong_address`, `inaccessible`, `business_closed`, `pending_stock_break`, `no_cargo_ready`, `no_cargo_to_ship`, `customer_unavailable`, `other`) and `notes` (driver notes or `null`). ### Proof blocks `delivery_proof`, `pickup_proof` and `failure_proof` share these fields. Photo links are signed and valid for **60 minutes**. - `recipient_name` (string|null): `delivery_proof` only. Who received the package. - `recipient_rut` (string|null): `delivery_proof` only. Their Chilean ID, if captured. - `recipient_role` (string|null): `delivery_proof` only. `merchant_staff`, `recipient`, `operator` or `other`. - `location` (object|null): `latitude` and `longitude` where the proof was recorded. - `notes` (string|null): Driver notes. - `photos` (array): Objects with `url` (full size) and `preview_url` (thumbnail). - `created_at` (string): When the proof was recorded. ### Delivery attempt Photo links inside attempts are signed and valid for **2 hours**. - `attempt` (integer): 1-based attempt number. - `outcome` (string): `success` or `failed`. - `recipient_name` (string|null): Who received the package, on successful attempts. - `recipient_rut` (string|null): Their Chilean ID, if captured. - `recipient_role` (string|null): `merchant_staff`, `recipient`, `operator` or `other`. - `location` (object|null): `latitude` and `longitude`. - `notes` (string|null): Driver notes. - `photos` (array): Objects with `url` and `preview_url`. - `packages` (array): Per-package outcome: `tracking_number`, `delivered` (boolean) and `failure_reason` (a reason code, or `null` when delivered). - `created_at` (string): When the attempt was recorded. ### Package - `tracking_number` (string): Package tracking number: the order's number plus a 3-digit suffix (`4N202502030001-001`). - `delivery_state` (string): `pending`, `delivered`, `failed` or `returned`. - `picked_up_at` (string|null): When it was collected from the sender. - `delivered_at` (string|null): When it was delivered. - `returned_at` (string|null): When it was returned to the sender. ```bash curl https://api.4nortes.app/api/v1/tracking/4N202502030001 \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/tracking/4N202502030001', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) const data = await response.json() ``` ```json { "success": true, "message": "Operation successful", "data": { "tracking_number": "4N202502030001", "delivery_state": "delivered", "estimated_delivery_date": "2025-02-04T00:00:00.000000Z", "delivery_date": "2025-02-05T11:30:00.000000Z", "packages_count": 2, "picked_up_packages_count": 2, "pending_pickup_packages_count": 0, "delivered_packages_count": 2, "failed_packages_count": 0, "returned_packages_count": 0, "pending_packages_count": 0, "timeline": [ { "state": "pending", "timestamp": "2025-02-03T10:00:00.000000Z" }, { "state": "picked_up", "timestamp": "2025-02-03T14:30:00.000000Z" }, { "state": "in_transit", "timestamp": "2025-02-03T18:00:00.000000Z" }, { "state": "out_for_delivery", "timestamp": "2025-02-04T08:00:00.000000Z" }, { "state": "failed", "timestamp": "2025-02-04T11:05:00.000000Z", "type": "failure", "failure_info": { "type": "Entrega fallida", "reason": "not_home", "notes": "Nobody answered the door" } }, { "state": "failed", "timestamp": "2025-02-04T11:05:01.000000Z" }, { "state": "out_for_delivery", "timestamp": "2025-02-05T08:30:00.000000Z" }, { "state": "delivered", "timestamp": "2025-02-05T11: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/proofs/d41d8c.jpg?signature=...", "preview_url": "https://storage.example.com/proofs/d41d8c-preview.jpg?signature=..." } ], "created_at": "2025-02-05T11:30:00.000000Z" }, "pickup_proof": { "location": { "latitude": -33.4372, "longitude": -70.6506 }, "notes": null, "photos": [ { "url": "https://storage.example.com/proofs/7f3a9b.jpg?signature=...", "preview_url": "https://storage.example.com/proofs/7f3a9b-preview.jpg?signature=..." } ], "created_at": "2025-02-03T14:30:00.000000Z" }, "failure_proof": null, "delivery_attempts": [ { "attempt": 1, "outcome": "failed", "recipient_name": null, "recipient_rut": null, "recipient_role": null, "location": { "latitude": -33.4291, "longitude": -70.6090 }, "notes": "Nobody answered the door", "photos": [ { "url": "https://storage.example.com/proofs/0c1e2f.jpg?signature=...", "preview_url": "https://storage.example.com/proofs/0c1e2f-preview.jpg?signature=..." } ], "packages": [ { "tracking_number": "4N202502030001-001", "delivered": false, "failure_reason": "not_home" }, { "tracking_number": "4N202502030001-002", "delivered": false, "failure_reason": "not_home" } ], "created_at": "2025-02-04T11:05:00.000000Z" }, { "attempt": 2, "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/proofs/d41d8c.jpg?signature=...", "preview_url": "https://storage.example.com/proofs/d41d8c-preview.jpg?signature=..." } ], "packages": [ { "tracking_number": "4N202502030001-001", "delivered": true, "failure_reason": null }, { "tracking_number": "4N202502030001-002", "delivered": true, "failure_reason": null } ], "created_at": "2025-02-05T11:30:00.000000Z" } ], "packages": [ { "tracking_number": "4N202502030001-001", "delivery_state": "delivered", "picked_up_at": "2025-02-03T14:30:00.000000Z", "delivered_at": "2025-02-05T11:30:00.000000Z", "returned_at": null }, { "tracking_number": "4N202502030001-002", "delivery_state": "delivered", "picked_up_at": "2025-02-03T14:30:00.000000Z", "delivered_at": "2025-02-05T11:30:00.000000Z", "returned_at": null } ] }, "timestamp": "2025-02-05T12:00:00.000000Z" } ``` --- ## Errors - `404 Not Found`: No order with that tracking number — or it belongs to another account. Standard error envelope. - `422 Unprocessable Entity`: The path value doesn't match `4N`/`SH` + 12 digits. Raw validation body (no envelope). See [Error Handling](https://api-doc.4nortes.app/errors.md) for the two response shapes. ```json { "success": false, "message": "Tracking number not found.", "errors": null, "timestamp": "2025-02-05T12:00:00.000000Z" } ``` ```json { "message": "The tracking number format is invalid.", "errors": { "tracking_number": ["The tracking number format is invalid."] } } ``` --- # References API > Get regions, comunas and your services, and quote a shipment before you create it. Source: https://api-doc.4nortes.app/api/references Fetch the same regions, comunas and services you see in your panel, and check coverage and price before creating an order. Use this to build address selectors and shipping options in your checkout. > **Note:** > > Regions and localities rarely change. Fetch them once and cache them for a day. Services and quotes are account-specific — see [Caching](#caching). --- ## Get regions `GET /regions` Get all Chilean regions for your address selector. Returns the full list, sorted by name — no pagination. ### Response Fields - `id` (integer): Unique region identifier. - `name` (string): Region name. - `slug` (string): URL-friendly region identifier. - `localities_count` (integer): Number of localities in this region. ```bash curl https://api.4nortes.app/api/v1/regions \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/regions', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) const data = await response.json() ``` ```json { "success": true, "message": "Regions retrieved successfully", "data": [ { "id": 1, "name": "Arica y Parinacota", "slug": "arica-y-parinacota", "localities_count": 4 }, { "id": 7, "name": "Metropolitana de Santiago", "slug": "metropolitana-de-santiago", "localities_count": 52 }, { "id": 2, "name": "Tarapacá", "slug": "tarapaca", "localities_count": 7 }, { "id": 6, "name": "Valparaíso", "slug": "valparaiso", "localities_count": 38 } ], "timestamp": "2025-02-03T10:00:00.000000Z" } ``` --- ## Get comunas `GET /localities` Get all comunas (localities), sorted by name. Returns the full list — no pagination. ### Response Fields - `id` (integer): Unique locality identifier. You can send it (as a string) in `destination_locality` when creating orders. - `name` (string): Locality name (comuna). Also accepted in `destination_locality`. - `slug` (string): URL-friendly locality identifier. - `type` (string): `urban`, `rural` or `both`. - `region_id` (integer): ID of the region this locality belongs to. - `aliases` (array): Alternative spellings for this locality. They are also matched when you send a name. ```bash curl https://api.4nortes.app/api/v1/localities \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/localities', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) const data = await response.json() ``` ```json { "success": true, "message": "Localities retrieved successfully", "data": [ { "id": 3, "name": "Las Condes", "slug": "las-condes", "type": "urban", "region_id": 7, "aliases": [] }, { "id": 2, "name": "Providencia", "slug": "providencia", "type": "urban", "region_id": 7, "aliases": [] }, { "id": 1, "name": "Santiago", "slug": "santiago", "type": "urban", "region_id": 7, "aliases": ["Santiago Centro"] } ], "timestamp": "2025-02-03T10:00:00.000000Z" } ``` --- ## Get services `GET /services` Get the delivery services configured for **your** account. Only active services are returned, sorted by name, with no pagination. Codes and names are set up per account by 4N NextDay, so they differ from one merchant to another. Merchant token only: any other token gets a `401` with `Authentication required.`. ### Response Fields - `id` (integer): Unique service identifier. Use this as `service_id` when creating orders. - `code` (string): Short code, specific to your account. - `name` (string): Service name, as shown in your panel. - `type` (string): `b2b` (deliveries to businesses), `b2c` (deliveries to consumers) or `field` (cargo created by drivers on the road — never used for integration orders). ```bash curl https://api.4nortes.app/api/v1/services \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/services', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) const data = await response.json() ``` ```json { "success": true, "message": "Services retrieved successfully", "data": [ { "id": 31, "code": "B2B-RM", "name": "Distribución B2B Región Metropolitana", "type": "b2b" }, { "id": 27, "code": "ECOM-STGO", "name": "E-commerce Santiago", "type": "b2c" } ], "timestamp": "2025-02-03T10:00:00.000000Z" } ``` --- ## Quote a rate `POST /rates/quote` Check coverage and price **before** creating an order. The quote resolves the locality and applies your account's coverage rules exactly like `POST /orders/create` does, so a `200` here means the locality will be accepted at creation time. Use it in your checkout to show the shipping cost and to stop uncovered addresses early. ### Request Body - `destination_locality` (string): The destination locality (comuna): its name, one of its aliases, or its numeric id **as a string** (`"2"`). Name matching ignores case and accents. Max 100 characters. **Required.** - `weight` (number): Total weight in kilograms. Between `0.1` and `100`. **Required.** - `packages_count` (integer): Number of packages. Between `1` and `50`. Default `1`. ### Response Fields - `locality_id` (integer): The locality that matched. Store it and send it back as `destination_locality` to avoid any ambiguity. - `locality_name` (string): Its canonical name. - `region_id` (integer): Its region. - `price` (integer): Total price, in whole pesos. - `currency` (string): Always `CLP`. - `transit_days` (integer): Estimated transit time to that locality, in days. At least `1`. - `breakdown` (object): `base_price` and `weight_surcharge` (both integers, in pesos). They add up to `price`. - `delivery_message` (string|null): A note about deliveries to that locality for today's weekday (for example a cut-off or frequency notice), when one is configured. `null` otherwise. Show it to the customer as is. ### Errors - `404 Not Found`: `Locality not found: {input}` — nothing matched the name or id; order creation would fail the same way. Or `No tariff configured for locality: {name}` — the locality exists and may well be covered, but has no price yet. This one only affects the quote: `POST /orders/create` doesn't check tariffs, so don't block checkout on it; contact your account representative to get the price configured. - `422 Unprocessable Entity`: Envelope with `Merchant {name} has no coverage for locality: {name}` when your account doesn't deliver there. Or a raw validation body when `destination_locality`, `weight` or `packages_count` is missing or out of range. ```bash curl -X POST https://api.4nortes.app/api/v1/rates/quote \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "destination_locality": "Providencia", "weight": 3.0, "packages_count": 1 }' ``` ```js const response = await fetch( 'https://api.4nortes.app/api/v1/rates/quote', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ destination_locality: 'Providencia', weight: 3.0, packages_count: 1, }), } ) const data = await response.json() ``` ```json { "success": true, "message": "Operation successful", "data": { "locality_id": 2, "locality_name": "Providencia", "region_id": 7, "price": 5500, "currency": "CLP", "transit_days": 1, "breakdown": { "base_price": 4000, "weight_surcharge": 1500 }, "delivery_message": null }, "timestamp": "2025-02-03T10:00:00.000000Z" } ``` ```json { "success": false, "message": "Merchant Acme Store has no coverage for locality: Putre", "errors": null, "timestamp": "2025-02-03T10:00:00.000000Z" } ``` ```json { "success": false, "message": "Locality not found: Providencai", "errors": null, "timestamp": "2025-02-03T10:00:00.000000Z" } ``` --- ## Cache this data | Data | Cache Duration | Notes | | ---------- | ----------------------- | ----------------------------------------------------------------- | | Regions | 1 day | Refresh on application startup | | Localities | 1 day | Daily background job | | Services | 1 hour | Account-specific; refresh when a `service_id` is rejected | | Quotes | Don't cache across days | Tariffs and `delivery_message` can change; quote at checkout time | ### Example: cache comunas in the browser ```js // Cache localities in memory or localStorage const CACHE_KEY = 'nextday_localities' const CACHE_DURATION = 24 * 60 * 60 * 1000 // 24 hours async function getLocalities() { const cached = localStorage.getItem(CACHE_KEY) if (cached) { const { data, timestamp } = JSON.parse(cached) if (Date.now() - timestamp < CACHE_DURATION) { return data } } const response = await fetch('https://api.4nortes.app/api/v1/localities', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, }) if (!response.ok) { throw new Error(`Failed to load localities: HTTP ${response.status}`) } const { data } = await response.json() localStorage.setItem(CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() })) return data } ``` --- # 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. ## 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. ## 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": {"..."} } } ``` --- ## 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`. --- ## 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. ### 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. #### `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. #### `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). --- ### `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 } } } ``` ### `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" } } } ``` ### `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" } } } ``` ### `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" } } } ``` ### `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" } } } ``` ### `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 } } ``` --- ## 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. --- ## 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 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. --- ## 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. ### 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) }) ``` --- # Error Handling > What to do when something goes wrong. Status codes, the response shapes you will actually see, and how to handle them. Source: https://api-doc.4nortes.app/errors Here's what can go wrong, what the response looks like in each case, and how to handle it in your code. > **Warning:** > > Branch on the **HTTP status code** and, for validation errors, on the **field keys** inside `errors`. Never branch on a `success` flag (not every error has one) or on message text (it can change, and validation messages may come back in Spanish). ## HTTP status codes - `200 OK`: The request succeeded. - `201 Created`: A new order was created (`POST /orders/create`). - `401 Unauthorized`: Your token is missing, invalid, expired or revoked — or it is valid but isn't a merchant API token and you called a merchant-only endpoint. See [Authentication](https://api-doc.4nortes.app/authentication.md). - `403 Forbidden`: Only returned by `GET /orders/{reference}/events` when you ask for internal events and that option isn't enabled for your account. - `404 Not Found`: The order, tracking number, locality or tariff doesn't exist — or the order doesn't belong to your account. - `422 Unprocessable Entity`: Either the request failed validation (missing fields, wrong types, out-of-range values) or a business rule blocked it (locality not covered, order can't be cancelled, no active service). - `429 Too Many Requests`: Rate limit exceeded: 100 requests per minute per account. Wait for `Retry-After`. - `500 Internal Server Error`: Something went wrong on our end. Retry with backoff. The API never returns `400 Bad Request`; malformed input is a `422`. --- ## Response shapes There are two shapes, depending on where the error is produced. ### Application errors (envelope) Errors raised by the API's own logic — not found, business rules, locality not covered, merchant-only endpoint called with another kind of token — use the same envelope as successful responses: - `success` (boolean): Always `false`. - `message` (string): Human-readable description, in English. - `errors` (null): Always `null` for these errors. - `timestamp` (string): ISO 8601 UTC timestamp of the response. ```json { "success": false, "message": "Merchant Acme Store has no coverage for locality: Putre", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ### Framework errors (no envelope) Authentication, request validation, rate limiting and unexpected server errors are answered **before** the API logic runs. They carry a `message` and, for validation only, an `errors` map — but **no `success` and no `timestamp`**. - `message` (string): For validation: the first error, plus `(and N more errors)` when there are several. Validation messages may be in Spanish — don't parse them. - `errors` (object): Validation only. Keys are the fields that failed; values are arrays of messages. Branch on the keys. ```json { "message": "The sender name is required. (and 2 more errors)", "errors": { "sender_name": ["The sender name is required."], "recipient_name": ["The recipient name is required."], "weight": ["The weight must be a number."] } } ``` ```json { "message": "Unauthenticated." } ``` --- ## Common problems ### Bad token **401 Unauthorized** Missing `Authorization` header, wrong format, or a token that is invalid, expired or revoked. The body is a bare `message`. If the token is valid but was not issued to a merchant account, merchant-only endpoints (`/orders/*`, `/services`) answer with an *envelope* `401` and `Authentication required.` instead. **Fix it:** - Send `Authorization: Bearer {token}` (with the space) - Ask your 4N NextDay administrator for a new token if yours expired or was revoked ```json { "message": "Unauthenticated." } ``` ```json { "success": false, "message": "Authentication required.", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ### Invalid data **422 Unprocessable Entity (validation)** Something's wrong with the data you sent: a required field is missing, a type is wrong or a value is out of range. Nothing was processed. **Fix it:** - Read the keys of `errors` to find the fields to fix - Compare against the request body tables in the [Orders API](https://api-doc.4nortes.app/api/orders.md) - Treat message text as display-only ```json { "message": "The weight must be a number. (and 1 more error)", "errors": { "weight": ["The weight must be a number."], "service_id": ["The selected service does not exist."] } } ``` ### Business rules **422 Unprocessable Entity (envelope)** The payload is valid, but the action isn't allowed right now. `errors` is `null`. **Common cases:** - Your account doesn't deliver to that locality — check with [`POST /rates/quote`](https://api-doc.4nortes.app/api/references.md#quote-rates) first - The order is already in a final state (`delivered`, `closed_with_return` or `nulled`) and can't be cancelled or updated - Your account has no active service, or `service_id` isn't one of your services ```json { "success": false, "message": "There are no active services available for this merchant.", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ### Not found **404 Not Found** The resource doesn't exist, or it isn't yours. Orders from other accounts are reported as not found, never as forbidden. **Fix it:** - Double-check the tracking number (`4N` or `SH` + 12 digits) - Make sure the order belongs to your account - For quotes: the locality name didn't match anything, or it has no tariff configured yet ```json { "success": false, "message": "Order not found.", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ```json { "success": false, "message": "Tracking number not found.", "errors": null, "timestamp": "2025-02-03T10:30:00.000000Z" } ``` ### Too many requests **429 Too Many Requests** You're calling the API too fast. The limit is **100 requests per minute per account**. **Fix it:** - Wait the number of seconds in the `Retry-After` header, then retry - Watch `X-RateLimit-Remaining` on every response to pace yourself - Use [webhooks](https://api-doc.4nortes.app/webhooks.md) instead of polling, and cache [reference data](https://api-doc.4nortes.app/api/references.md#caching) ```json { "message": "Too Many Attempts." } ``` ```http Retry-After: 42 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1738578642 ``` --- ## Handle errors properly ### 1. Branch on the status code One request helper that turns every failure into a typed error, whatever the body shape: ```js class ApiError extends Error { constructor(status, body, response) { super(body.message ?? `HTTP ${status}`) this.status = status // Only validation errors (422) carry `errors`; its keys are the failing fields this.fields = body.errors ? Object.keys(body.errors) : [] this.retryAfter = Number(response.headers.get('Retry-After')) || null } } async function callApi(path, options = {}) { const response = await fetch(`https://api.4nortes.app/api/v1${path}`, { ...options, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', ...options.headers, }, }) const body = await response.json().catch(() => ({})) if (!response.ok) { throw new ApiError(response.status, body, response) } return body.data } // Usage try { const order = await callApi('/orders/create', { method: 'POST', body: JSON.stringify(orderData), }) } catch (error) { if (error.status === 422 && error.fields.length > 0) { // Validation: highlight error.fields in your form } else if (error.status === 422) { // Business rule: show error.message to the operator } else if (error.status === 401) { // Token problem: alert — nothing will work until it's replaced } else { throw error } } ``` ### 2. Retry only when it makes sense Retry `429` (after `Retry-After`) and `5xx` (with exponential backoff). Never retry `401`, `404` or `422` — the same request will fail the same way. ```js async function withRetry(fn, maxRetries = 3) { for (let attempt = 1; ; attempt++) { try { return await fn() } catch (error) { if (attempt >= maxRetries) throw error if (error.status === 429) { await sleep((error.retryAfter ?? 60) * 1000) continue } if (error.status >= 500) { await sleep(2 ** attempt * 1000) continue } throw error } } } ``` ### 3. Log what you need to debug ```js console.error('4N NextDay API error', { status: error.status, path, message: error.message, fields: error.fields, retryAfter: error.retryAfter, at: new Date().toISOString(), }) ``` --- # Pagination > Learn how to work with the paginated order list when querying the 4N NextDay API. Source: https://api-doc.4nortes.app/pagination One endpoint in the 4N NextDay API is paginated: the order list. This guide covers how to navigate it. ## What paginates | Endpoint | Paginated? | | ----------------- | ---------------------------------------------------------------------------- | | `GET /orders` | **Yes** — `page` and `per_page` query parameters, `items` + `meta` in `data` | | `GET /localities` | No — `data` is the full array | | `GET /regions` | No — `data` is the full array | | `GET /services` | No — `data` is the full array | Everything below applies to `GET /orders` only. ## Pagination Parameters - `page` (integer): The page number to retrieve. Default: `1` - `per_page` (integer): Number of items per page. Default: `15`, Min: `1`, Max: `100`. Values outside this range return a `422` validation error on `per_page`. ## Paginated Response Structure The response puts an `items` array inside `data`, along with a `meta` object containing pagination information: - `current_page` (integer): The current page number. - `from` (integer|null): The index of the first item on this page (`null` when the page is empty). - `to` (integer|null): The index of the last item on this page (`null` when the page is empty). - `last_page` (integer): The total number of pages. - `per_page` (integer): Items per page. - `total` (integer): Total number of items across all pages. Orders are sorted newest first. ```json { "success": true, "message": "Orders retrieved successfully", "data": { "items": [ { "id": 12346, "tracking_number": "4N202502030002" // ... }, { "id": 12345, "tracking_number": "4N202502030001" // ... } ], "meta": { "current_page": 2, "from": 16, "to": 30, "last_page": 5, "per_page": 15, "total": 73 } }, "timestamp": "2025-02-03T10:00:00.000000Z" } ``` ## Example: Fetching Orders ```bash # First page (default) curl "https://api.4nortes.app/api/v1/orders" \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" # Specific page with custom page size curl "https://api.4nortes.app/api/v1/orders?page=2&per_page=25" \ -H "Authorization: Bearer {token}" \ -H "Accept: application/json" ``` ```js // Fetch first page const response = await fetch( 'https://api.4nortes.app/api/v1/orders', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) // Fetch specific page const page2 = await fetch( 'https://api.4nortes.app/api/v1/orders?page=2&per_page=25', { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) ``` ```python import requests # Fetch first page response = requests.get( 'https://api.4nortes.app/api/v1/orders', headers={'Authorization': f'Bearer {token}'} ) # Fetch specific page response = requests.get( 'https://api.4nortes.app/api/v1/orders', headers={'Authorization': f'Bearer {token}'}, params={'page': 2, 'per_page': 25} ) ``` ## Iterating Through All Pages Here's how to iterate through all pages of results: ```js async function getAllOrders() { const allOrders = [] let page = 1 let hasMore = true while (hasMore) { const response = await fetch( `https://api.4nortes.app/api/v1/orders?page=${page}&per_page=100`, { headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json', }, } ) if (!response.ok) { throw new Error(`Failed to load page ${page}: HTTP ${response.status}`) } const result = await response.json() allOrders.push(...result.data.items) hasMore = page < result.data.meta.last_page page++ } return allOrders } ``` ```python def get_all_orders(): all_orders = [] page = 1 while True: response = requests.get( 'https://api.4nortes.app/api/v1/orders', headers={'Authorization': f'Bearer {token}'}, params={'page': page, 'per_page': 100} ) response.raise_for_status() result = response.json() all_orders.extend(result['data']['items']) if page >= result['data']['meta']['last_page']: break page += 1 return all_orders ``` ## Best Practices 1. **Use appropriate page sizes**: Request only what you need. Smaller page sizes mean faster responses. 2. **Don't poll the list for status changes**: Use [webhooks](https://api-doc.4nortes.app/webhooks.md) to be told when an order moves, and the [Tracking API](https://api-doc.4nortes.app/api/tracking.md) for a single order. 3. **Handle empty results**: Always check if `items` is empty before processing. `from` and `to` are `null` on an empty page. 4. **Respect rate limits**: The API allows 100 requests per minute per account. A full export at `per_page=100` costs one request per 100 orders — add a short delay between pages for very large accounts. ```js // Add delay between pagination requests async function fetchWithDelay(page) { const response = await fetch( `https://api.4nortes.app/api/v1/orders?page=${page}&per_page=100`, { headers: { 'Authorization': `Bearer ${token}` } } ) await new Promise(resolve => setTimeout(resolve, 100)) // 100ms delay return response.json() } ```