Appearance
Webhooks
Webhooks send Navexa's order, label and tracking events to your own systems as they happen: an email or SMS flow (Klaviyo, Postscript), a helpdesk, an ERP. They work the same for every channel: Shopify, Medusa, TikTok Shop and orders created in Navexa.
Add an endpoint
- In Navexa, open Webhooks in the menu (owners and admins).
- Choose Add endpoint, enter your endpoint's URL and pick the events it should get. Turn on Include customer details only if the receiving system needs the customer's name, email, phone and shipping address (see Customer details).
- Copy the signing secret when it is shown. It is shown once. If you lose it, rotate it (endpoint menu → Rotate secret) and update your receiver.
- Use Send test event to check your endpoint. A test event (
webhook.test) goes to that endpoint whatever events it takes, is sent once without retries, and its result shows in the endpoint's delivery log within a few seconds.
The URL must be https:// and reach a public address. Private, loopback and cloud-metadata addresses are refused, both when you save the endpoint and on every send. An account can have up to 10 endpoints.
Events
| Event | Sent when |
|---|---|
order.created | Navexa receives a new order from any channel, or one is created in Navexa. Orders placed more than 7 days before Navexa first sees them (a store's history import) don't send it |
label.purchased | A shipping label is bought. A consolidated box sends one event per order in it |
label.voided | A label is voided in Navexa, or the carrier cancels it |
shipment.status_changed | The carrier's tracking status changes (pre_transit, in_transit, out_for_delivery, delivered, available_for_pickup, return_to_sender, failure, cancelled, ...) |
shipment.shipped | The carrier's first scan of the parcel, not the label purchase, so a label voided before pickup never sends it. A courier whose first scan is already out for delivery or delivered sends that event instead |
shipment.out_for_delivery | The status changed to out_for_delivery |
shipment.delivered | The status changed to delivered |
shipment.exception | The status changed to failure, error or return_to_sender |
A delivered parcel sends both shipment.status_changed and shipment.delivered; subscribe to whichever your flow needs. shipment.shipped, shipment.out_for_delivery, shipment.delivered and shipment.exception are the same milestones Navexa's own customer shipment emails use, so a flow of your own (Klaviyo, Postscript) can send them instead.
The request
Navexa sends a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Navexa-Webhooks/1.0 |
X-Navexa-Event-Id | The event id (evt_...), the same on every retry and redelivery |
X-Navexa-Event-Type | The event, e.g. shipment.delivered |
X-Navexa-Delivery-Id | This delivery's id (whd_...), as shown in the delivery log |
X-Navexa-Timestamp | Unix seconds when this request was signed |
X-Navexa-Nonce | A random value, new on every request |
X-Navexa-Signature | v1= and the hex HMAC-SHA256 signature (see below) |
The body is an envelope around the event's data. This example is for an endpoint with Include customer details on; without it, order has no customer or shipping_address:
json
{
"id": "evt_d44m2q8v3s7k9a0b1c2d",
"type": "shipment.delivered",
"created_at": "2026-11-03T18:04:11Z",
"account_id": "acc_...",
"data": {
"order": {
"id": "ord_...",
"external_id": "6123456789",
"order_number": "#1042",
"channel": "shopify",
"integration_id": "int_...",
"placed_at": "2026-11-01T15:20:00Z",
"customer": { "name": "Sam Lee", "email": "[email protected]", "phone": "+15125550100" },
"shipping_address": {
"address1": "100 Congress Ave",
"city": "Austin",
"province": "TX",
"zip": "78701",
"country": "US"
}
},
"label": {
"id": "lbl_...",
"tracking_number": "9400100000000000000000",
"tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400100000000000000000",
"carrier": "USPS",
"service": "GroundAdvantage",
"created_at": "2026-11-02T16:00:00Z"
},
"shipment": {
"status": "delivered",
"previous_status": "out_for_delivery",
"last_event": "Delivered, Front Door/Porch",
"event_at": "2026-11-03T18:02:00Z",
"estimated_delivery_date": "2026-11-03"
}
}
}What data holds:
| Event | data |
|---|---|
order.created | order |
label.purchased, label.voided | order, label |
shipment.* | order, label, shipment |
webhook.test | message, endpoint_id (test events only) |
channel is shopify, medusa, tiktok_shop or custom (an order created in Navexa). Fields with no value are left out. New fields may be added at any time, so ignore fields you don't know; existing fields are not renamed or removed.
Customer details
order.customer (name, email, phone) and order.shipping_address are the customer's personal data. They are sent only to an endpoint with Include customer details on, which is off by default; every other endpoint gets the order, label and shipment data without them. Turn it on or off from the endpoint menu → Edit.
- Turning it off applies at once, also to retries and redeliveries of events from before.
- Turning it on applies to events from then on. A redelivered older event is sent as it was recorded, without the customer details.
Verify the signature
Each endpoint has its own secret (whsec_...). The signature covers the timestamp, the nonce and the raw body, joined by dots:
signed_content = X-Navexa-Timestamp + "." + X-Navexa-Nonce + "." + raw request body
signature = hex(HMAC-SHA256(key = your secret, message = signed_content))The header is X-Navexa-Signature: v1=<signature>. To verify a request:
- Compute the signature over the raw body bytes, before any JSON parsing or re-serializing.
- Compare it with the header's value after
v1=, in constant time. - Reject a timestamp more than 5 minutes from your clock, so a captured request can't be replayed later. To be strict, also remember nonces for those 5 minutes and reject one you have seen.
Node.js (Express):
js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.NAVEXA_WEBHOOK_SECRET;
app.post('/webhooks/navexa', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Navexa-Timestamp');
const nonce = req.get('X-Navexa-Nonce');
const sig = (req.get('X-Navexa-Signature') || '').replace(/^v1=/, '');
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${nonce}.`)
.update(req.body) // the raw Buffer
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const valid =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Respond fast; do slow work after.
res.sendStatus(200);
handle(event);
});Python (Flask):
python
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["NAVEXA_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/navexa")
def navexa():
ts = request.headers.get("X-Navexa-Timestamp", "")
nonce = request.headers.get("X-Navexa-Nonce", "")
sig = request.headers.get("X-Navexa-Signature", "").removeprefix("v1=")
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, f"{ts}.{nonce}.".encode() + body, hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
return "", 200Responding, retries and turning off
- Answer with any 2xx status within 10 seconds. Anything else (a timeout, a 3xx, 4xx or 5xx) is a failed attempt. Redirects are not followed.
- A failed attempt is retried up to 7 times, after about 30 seconds, 2 minutes, 8 minutes, 32 minutes, 2 hours, then every 6 hours: about 15 hours in all. The delivery log shows each delivery's attempts, last response and next try.
- Delivery is at least once: the same event can arrive more than once (a retry after a response that timed out, a redelivery). Use the event id (
id, or theX-Navexa-Event-Idheader) to drop one you already handled. - Events are not guaranteed to arrive in order. Use
shipment.event_atand the event'screated_atto order them. - After 5 deliveries in a row use up every retry, Navexa turns the endpoint off and emails the account's owners and admins. Events that happen while it is off are not queued. Fix the endpoint, turn it back on from its menu, then Redeliver the failed events from its delivery log.
- Redeliver sends a finished delivery's event again with the same event id. The delivery log keeps 30 days. When a customer's data is erased (a Shopify customer redact, or a store's data deleted after it disconnects), the deliveries of that customer's or store's orders leave the log too and can no longer be redelivered.
Rotate the secret
Endpoint menu → Rotate secret shows a new secret once. The old secret stops working at once, including for retries already queued, so update your receiver right away.