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 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:
- 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
| 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) |
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 — see Handling duplicates for the deduplication key.
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 |
closed_with_return | yes | Order closed after the undelivered packages were returned to the sender. Final — nothing follows it |
nulled | yes | Order cancelled |
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.
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 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 theX-4Nortes-Eventheader.timestamp— when the payload was built (ISO 8601). Events emitted for the same transition (e.g.order.status_changedandorder.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).
nullwhen no estimate was set.
- Name
delivery_date- Type
- string|null
- Description
When the whole order was delivered (ISO 8601).
nulluntil the order reachesdelivered— it staysnullonpartially_delivered,failedandclosed_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.reasonfor 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
successwhen at least one package in the attempt was delivered (partial attempts count assuccess— checkpackages[]),failedotherwise.
- Name
recipient_name- Type
- string|null
- Description
Who received the packages.
nullwhen 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,operatororother.
- 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). 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_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 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.
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.
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.
nullwhen the prior state is unknown.
- Name
delivery_proof- Type
- object|null
- Description
Populated once the order reaches
deliveredorpartially_delivered.nullfor intermediate transitions and forfailed,closed_with_returnandnulled.
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.
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).
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).nullwhen 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 (withoutattempt,outcomeorpackages).nullonly 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).nullwhen 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_reasonuses the machine-readable enum;delivered_atisnullfor 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.
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.
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": 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).nullwhen unknown.
- Name
failure_info- Type
- object|null
- Description
{ type, reason, notes }.reasonis the machine-readable failure code (e.g.not_home— see Failure reasons).typeandnotesare human-readable Spanish text:typeis"Intento de entrega fallido","Intento de recogida fallido","manual"(an operator changed the status from the panel) ornull.nullas 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.nullwhen no photos were captured. Photo URLs are signed and expire 2 hours after the payload was built (seephotos).
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.
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.
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. TheX-4Nortes-Eventheader is alsotest.
- 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 requests are signed exactly like real events, so this is the easiest way to validate your signature check end to end.
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 oforder.delivery_failedand insidetimeline[].failure_info. This is the machine-readable code for the failure (e.g.not_home). The siblingtypeandnotesfields are localized Spanish strings meant for display — don't write business logic against those two.failure_reason— ondelivery_attempts[].packages[](every event) and onpackages[](onorder.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) |
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:
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.
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.
We verify your endpoint's TLS certificate. Self-signed or otherwise invalid certificates make the delivery fail and go through the retry cycle, 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
- Respond fast: Return 2xx within 10 seconds
- Process later: Save the data and handle it in the background
- Handle duplicates: Deduplicate on
(event, data.tracking_number, timestamp) - 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)
})