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

Event types

You'll get notified when:

  • Name
    order.received
    Description

    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.

  • Name
    order.status_changed
    Description

    Order status changed (picked up, in transit, delivered, etc.).

  • Name
    order.delivered
    Description

    All packages were delivered successfully. Also fires order.status_changed.

  • Name
    order.partially_delivered
    Description

    Some packages were delivered, others failed. Also fires order.status_changed.

  • Name
    order.delivery_failed
    Description

    Every package in the attempt failed. Also fires order.status_changed.

When each event fires

EventWhen it fires
order.receivedOnce when the order is created (no reception key), then once per warehouse that scans it in (with reception)
order.status_changedAny delivery state update
order.deliveredAll packages were delivered successfully
order.partially_deliveredAt least one package was delivered and at least one failed
order.delivery_failedEvery package in the attempt failed (a retry may follow)

order.delivered event

{
  "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:

PendingPicked UpIn TransitOut for DeliveryDeliveredPartially DeliveredFailedReturned to SenderCancelled
StateTerminalDescription
pendingnoOrder created, awaiting pickup
picked_upnoPackage collected from sender
in_transitnoIn transport to destination area
out_for_deliverynoOn delivery vehicle for final delivery
deliveredyesAll packages delivered successfully
partially_deliveredyesSome packages delivered, others failed
failedyesEvery package in the delivery attempt failed
closed_with_returnyesOrder closed after the undelivered packages were returned to the sender. Final — nothing follows it
nulledyesOrder cancelled

What you'll receive

Headers we send

Every webhook request includes:

HeaderDescription
Content-Typeapplication/json
X-4Nortes-EventThe event type (e.g., order.status_changed)
X-4Nortes-SignatureHMAC-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 for how to deduplicate.

Envelope

Every event uses the same top-level shape:

{
  "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.

  • Name
    tracking_number
    Type
    string
    Description

    The order tracking number.

  • Name
    external_reference
    Type
    string|null
    Description

    Your custom reference for the order.

  • Name
    cost_center
    Type
    string|null
    Description

    Cost center associated with the order.

  • Name
    purchase_order
    Type
    string|null
    Description

    Purchase order number.

  • Name
    bill_of_lading
    Type
    string|null
    Description

    Bill of lading reference.

  • Name
    delivery_state
    Type
    string
    Description

    Current delivery state. See Delivery states.

  • Name
    estimated_delivery_date
    Type
    string|null
    Description

    Expected delivery date (ISO 8601). null when no estimate was set.

  • Name
    delivery_date
    Type
    string|null
    Description

    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.

  • Name
    timeline
    Type
    array
    Description

    Chronological list of state changes. See shape below.

  • Name
    delivery_attempts
    Type
    array
    Description

    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:

[
  { "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), 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:

  • Name
    attempt
    Type
    integer
    Description

    1-based attempt number for this order.

  • Name
    outcome
    Type
    string
    Description

    success when at least one package in the attempt was delivered (partial attempts count as success — check packages[]), failed otherwise.

  • Name
    recipient_name
    Type
    string|null
    Description

    Who received the packages. null when the driver didn't capture it (e.g. on failed attempts).

  • Name
    recipient_rut
    Type
    string|null
    Description

    Recipient's Chilean RUT, when captured.

  • Name
    recipient_role
    Type
    string|null
    Description

    Relationship of the recipient: merchant_staff, recipient, operator or other.

  • Name
    location
    Type
    object|null
    Description

    { latitude, longitude } captured at proof time.

  • Name
    notes
    Type
    string|null
    Description

    Free-text notes from the driver.

  • Name
    photos
    Type
    array
    Description

    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.

  • Name
    packages
    Type
    array
    Description

    Per-package outcome for the attempt: { tracking_number, delivered, failure_reason }. failure_reason uses the machine-readable enum on failed packages, null on successful ones.

  • Name
    created_at
    Type
    string
    Description

    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:

  • Name
    event
    Type
    string
    Description

    Always order.received.

  • Name
    reception
    Type
    object
    Description

    Optional. Present only when the event was triggered by a warehouse reception. Absent on the registration event.

  • Name
    reception.received_at
    Type
    string
    Description

    When the package was scanned (ISO 8601).

  • Name
    reception.warehouse_id
    Type
    integer
    Description

    ID of the warehouse that received the package.

order.received payloads

{
  "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": []
  }
}

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:

  • Name
    event
    Type
    string
    Description

    Always order.status_changed.

  • Name
    previous_state
    Type
    string|null
    Description

    State the order was in before this update. null when the prior state is unknown.

  • Name
    delivery_proof
    Type
    object|null
    Description

    Populated once the order reaches delivered or partially_delivered. null for intermediate transitions and for failed, closed_with_return and nulled.

order.status_changed payload (delivered)

{
  "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:

  • Name
    event
    Type
    string
    Description

    Always order.delivered.

  • Name
    previous_state
    Type
    string|null
    Description

    Previous delivery state (e.g., out_for_delivery). null when unknown.

  • Name
    delivery_proof
    Type
    object|null
    Description

    Recipient info and signed photo URLs for the successful delivery. Same fields as one delivery_attempts[] entry (without attempt, outcome or packages). null only when the order was closed as delivered without a routed delivery (e.g. an operator confirmation).

order.delivered payload

{
  "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:

  • Name
    event
    Type
    string
    Description

    Always order.partially_delivered.

  • Name
    previous_state
    Type
    string|null
    Description

    Previous delivery state (e.g., out_for_delivery). null when unknown.

  • Name
    packages_count
    Type
    integer
    Description

    Total packages in the order.

  • Name
    delivered_packages_count
    Type
    integer
    Description

    Packages delivered across all attempts so far.

  • Name
    delivery_progress
    Type
    string
    Description

    Packages delivered vs. total, as a fraction string (e.g. "2/3").

  • Name
    packages
    Type
    array
    Description

    Per-package rollup: { tracking_number, delivery_state, failure_reason, delivered_at }. failure_reason uses the machine-readable enum; delivered_at is null for packages that haven't been delivered yet.

  • Name
    delivery_proof
    Type
    object|null
    Description

    Recipient info and photos for the latest successful sub-delivery. Same shape as on order.delivered.

order.partially_delivered payload

{
  "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:

  • Name
    event
    Type
    string
    Description

    Always order.delivery_failed.

  • Name
    previous_state
    Type
    string|null
    Description

    Previous delivery state (e.g., out_for_delivery). null when unknown.

  • Name
    failure_info
    Type
    object|null
    Description

    { type, reason, notes }. reason is the machine-readable failure code (e.g. not_home — see 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.

  • Name
    failure_proof
    Type
    object|null
    Description

    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).

order.delivery_failed payload

{
  "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 — 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.

  • Name
    event
    Type
    string
    Description

    Always test. The X-4Nortes-Event header is also test.

  • Name
    data.message
    Type
    string
    Description

    Fixed text: This is a test webhook from 4Nortes.

  • Name
    data.merchant_id
    Type
    integer
    Description

    Your merchant ID in our system.

test payload

{
  "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

ValueMeaning
not_homeNobody was at the address to receive the package
refusedRecipient refused delivery
wrong_addressAddress is incorrect or cannot be located
inaccessibleAddress exists but the driver couldn't reach it (locked gate, off-grid, etc.)
business_closedDestination is a business that was closed at delivery time
pending_stock_breakInternal stock issue prevented delivery
no_cargo_readyCargo wasn't ready at the stop (typically a pickup attempt)
no_cargo_to_shipNothing to ship at the stop (typically a pickup attempt)
customer_unavailableNobody was available to complete the handoff
otherAnything else (check notes on the attempt for context)

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:

Verifying webhook signatures

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')
})

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.


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):

AttemptDelay
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

Async processing example

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)
})

Was this page helpful?