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:
- Create an endpoint in your app to receive notifications
- Register your URL in your 4N NextDay panel and pick which events you want
- Verify signatures to make sure they're really from us
- Respond quickly with a 2xx status
Respond within 10 seconds. If you need to do something slow, save the payload and process it later.
Terminal events (order.delivered, order.partially_delivered, order.delivery_failed) are sent after the delivery proof is captured, so every terminal payload includes the recipient info and photos when they exist. 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:
- Name
order.received- Description
Your package was scanned at our warehouse.
- 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
| Event | When it fires |
|---|---|
order.received | Package arrives at our warehouse |
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) |
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.
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 — match on tracking_number plus timestamp.
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:
| 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 |
nulled | yes | Order cancelled |
previous_state uses the same values. It tells you what the order was before this update. order.received does not include previous_state — it's the first event for the order.
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 |
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 theX-4Nortes-Eventheader.timestamp— when the event was emitted (ISO 8601).data— event payload, described below.
Common fields in data
These order-level fields are included on every 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
- Description
Expected delivery date (ISO 8601).
- Name
delivery_date- Type
- string|null
- Description
Actual delivery date (ISO 8601).
nulluntil the order reachesdeliveredorpartially_delivered.
- Name
timeline- Type
- array
- Description
Chronological list of state changes. See shape below.
- Name
delivery_attempts- Type
- array
- Description
One entry per physical delivery attempt (success or failure), with recipient info, photos and per-package outcomes. Empty until the first attempt happens. 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": "No está en casa",
"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. The strings inside are localized and meant for display — see Failure reasons for machine-readable values.
delivery_attempts[] shape
Every physical delivery attempt — successful or not — becomes an entry here:
- Name
attempt- Type
- integer
- Description
1-based attempt number for this order.
- Name
outcome- Type
- string
- Description
successorfailed.
- Name
recipient_name- Type
- string|null
- Description
Who received the packages.
nullon 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 (e.g.,
recipient,family,security,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 (
urlandpreview_url). URLs expire after 2 hours — re-fetch the tracking endpoint if you need fresh URLs later.
- Name
packages- Type
- array
- Description
Per-package outcome for the attempt:
{ tracking_number, delivered, failure_reason }.failure_reasonuses the machine-readable enum on failed packages,nullon successful ones.
- Name
created_at- Type
- string
- Description
When the attempt was recorded (ISO 8601).
order.received
Sent when your package is first scanned at our warehouse.
Adds the following on top of the common fields:
- Name
event- Type
- string
- Description
Always
order.received.
- 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.
previous_state is not included on this event. delivery_attempts is [] at this point.
order.received payload
{
"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:
- Name
event- Type
- string
- Description
Always
order.status_changed.
- Name
previous_state- Type
- string
- Description
State the order was in before this update.
- Name
delivery_proof- Type
- object|null
- Description
Populated once the order reaches a terminal state (
deliveredorpartially_delivered).nullfor intermediate transitions and forfailed.
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.
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
- Description
Previous delivery state (e.g.,
out_for_delivery).
- Name
delivery_proof- Type
- object
- Description
Recipient info and signed photo URLs for the successful delivery. Same fields as one
delivery_attempts[]entry (withoutattempt,outcomeorpackages).
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
- Description
Previous delivery state (e.g.,
out_for_delivery).
- 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
- integer
- Description
Percentage of packages delivered (0–100).
- Name
packages- Type
- array
- Description
Per-package rollup:
{ tracking_number, delivery_state, failure_reason, delivered_at }.failure_reasonuses the machine-readable enum;delivered_atisnullfor packages that haven't been delivered yet.
- Name
delivery_proof- Type
- object
- Description
Recipient info and photos for the latest successful sub-delivery. Same shape as on
order.delivered.
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.
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": "2026-02-04T11:30:00.000000Z",
"packages_count": 3,
"delivered_packages_count": 2,
"delivery_progress": 67,
"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
- Description
Previous delivery state (e.g.,
out_for_delivery).
- Name
failure_info- Type
- object
- Description
Human-readable summary of the failure —
{ type, reason, notes }. All values are localized Spanish strings meant for display.
failure_info is built for UI (emails, dashboards, SMS). For programmatic logic use the failure_reason enum inside delivery_attempts[].packages[].
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.
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": "No está en casa",
"notes": null
}
}
],
"delivery_attempts": [
{
"attempt": 1,
"outcome": "failed",
"recipient_name": null,
"recipient_rut": null,
"recipient_role": null,
"location": { "latitude": -33.4289, "longitude": -70.6093 },
"notes": null,
"photos": [],
"packages": [
{ "tracking_number": "SH000000012345", "delivered": false, "failure_reason": "not_home" }
],
"created_at": "2026-02-04T14:00:00.000000Z"
}
],
"failure_info": {
"type": "Intento de entrega fallido",
"reason": "No está en casa",
"notes": null
}
}
}
Failure reasons
Webhooks surface failures in two places, with different audiences:
failure_info— appears at the top level oforder.delivery_failedand insidetimeline[].failure_info. Localized Spanish labels, great for emails, SMS or dashboards. Don't write business logic against these strings; wording may change between locales or releases.failure_reason— appears ondelivery_attempts[].packages[](every event) and onpackages[](onorder.partially_delivered). Stable machine-readable enum — use this for retries, conditional routing, reporting.
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 |
other | Anything else (check notes on the attempt for context) |
New reason codes may be added over time. Treat unknown values as other in your handlers 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. Check it like this:
Verifying webhook signatures
const crypto = require('crypto')
function verifyWebhook(payload, signature, secret) {
const hash = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(signature)
)
}
// 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.
If your server is down
Don't worry — we'll retry if you don't respond with a 2xx:
| 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.
Tips
- Respond fast: Return 2xx within 10 seconds
- Process later: Save the data and handle it in the background
- Handle duplicates: You might get the same webhook twice
- Keep logs: Save events for debugging
- 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)
})