Track shipment status
Keep your systems in sync as a shipment moves by subscribing to its
status webhooks and reacting as each event fires. This is the right
pattern for tracking shipment status at scale, much better than
polling GET /shipments/{id} on a timer.
Scenario
Once shipments are booked, your system needs to react to status changes:
- Mark the order as shipped in your OMS when Carriyo reports
shipped. - Send the delivery confirmation email when status hits
delivered. - Trigger a support ticket on
failed_delivery_attemptorreturned. - Pass status into your revenue recognition flow on
delivered.
Carriyo POSTs the full Shipment object to your endpoint on each
status change. You authenticate the call, look up the shipment
in your system by partner_shipment_reference, and act.
Prerequisites
- API credentials: see Getting started.
- A publicly reachable HTTPS endpoint on your side that accepts POST requests. Carriyo will call it with a JSON body.
- A way to authenticate inbound calls: either a fixed custom header (e.g. an API key on every request) or an OAuth2 client Carriyo can fetch a token from.
- A webhook subscription configured in the Dashboard, pointing
at your endpoint and subscribed to the shipment statuses this
scenario reacts to (
booked,shipped,out_for_delivery,delivered,failed_delivery_attempt,returned). See Configure a webhook for the setup, and Webhook configurations for the subscription model (entity types, triggers, conditions, auth).
Step 1, receive an event
When a shipment's status changes, Carriyo POSTs the full Shipment object (no envelope) to your URL, plus a set of metadata headers:
POST /carriyo-webhook
Content-Type: application/json
event-id: 9a7c2e4b-1d8f-4f3a-91e2-3c5d7e9f1024
trigger: delivered
webhook-id: ACCOUNT_w1234-5678-abcd
X-API-Key: your-shared-api-key
{
"shipment_id": "MP8VJK42ACNQ7",
"references": {
"partner_order_reference": "ORDER-8842",
"partner_shipment_reference": "ORDER-8842-1"
},
"merchant": "ACME",
"carrier_account": {
"carrier": "DHL",
"carrier_id": "c1053e4b-f2e7-4cee-917f-a390e73887a6",
"carrier_account_name": "DHL Express UK"
},
"post_shipping_info": {
"status": "delivered",
"tracking_no": "DHL-9123-7700-1234",
"carrier_status_date": "2026-05-08T14:28:14Z",
"key_milestones": {
"booked": "2026-05-07T09:15:00Z",
"shipped": "2026-05-07T16:42:00Z",
"out_for_delivery": "2026-05-08T08:30:00Z",
"delivered": "2026-05-08T14:28:14Z"
}
},
"...": "(every other Shipment field, same shape as GET /shipments/{id})"
}
Headers worth knowing:
event-id: deterministic id for this state change. Same value on every retry of the same event. Use it as your idempotency key.trigger: the status (or label/schedule trigger) that fired the call. Read it to filter without parsing the body.webhook-id: the id of the subscription that matched.last-retry: present (true) on the final automatic retry attempt.
The body shape for entity_type: Shipment and
entity_type: ReverseShipment subscriptions is the full
Shipment object. For entity_type: ReturnRequest it's the full
ReturnRequest object. For Order webhooks (registered on a
separate core endpoint) the body is an envelope with
oldImage / newImage. See
webhook configurations for
the details per entity type.
Step 2, authenticate the call
Validate the inbound request using whichever auth shape you configured on the subscription:
function authIsValid(req) {
// Example: shared API key in a custom header.
return req.headers["x-api-key"] === process.env.CARRIYO_SHARED_API_KEY;
}
For OAuth2 client-credentials, Carriyo fetches a token from
your token endpoint and includes Authorization: Bearer <token>
on each call. Your endpoint validates the token the same way it
would for any of your own services.
Step 3, react and acknowledge
Look up the shipment in your system by
references.partner_shipment_reference, update your state, and
respond 2xx:
app.post("/carriyo-webhook", async (req, res) => {
if (!authIsValid(req)) {
return res.status(401).send("unauthorized");
}
const trigger = req.headers.trigger;
const eventId = req.headers["event-id"];
const shipment = req.body; // full Shipment object
const postShip = shipment.post_shipping_info || {};
await orders.updateByExternalRef(shipment.references.partner_shipment_reference, {
shipment_status: postShip.status,
tracking_no: postShip.tracking_no,
delivered_at: postShip.key_milestones?.delivered,
});
if (trigger === "delivered") {
await notifications.queueDeliveredEmail(
shipment.references.partner_shipment_reference
);
}
if (trigger === "failed_delivery_attempt") {
await support.createTicket({
ref: shipment.references.partner_shipment_reference,
reason: "delivery failed",
event_id: eventId,
});
}
res.status(200).send();
});
Respond fast — Carriyo's delivery has a per-request timeout documented in Retry & idempotency. Push slow work to a queue rather than doing it inline in the webhook handler.
Step 4, handle retries and idempotency
Carriyo retries failed deliveries on a fixed schedule (see Retry & idempotency for the exact tier and timing). Your handler will see the same event more than once during a transient error, so make it idempotent.
The simplest pattern: key on event-id. Carriyo guarantees the
same event-id across every retry of the same state change.
async function handle(req) {
const eventId = req.headers["event-id"];
if (await processedEvents.exists(eventId)) {
return ok(); // already processed, no-op
}
await applyTheEvent(req.body);
await processedEvents.record(eventId);
return ok();
}
Failed events are visible in the Dashboard at Settings →
Integration Monitor. You can re-trigger them manually from
there or via
POST /webhooks/retrigger-events.
What flows where
shipment status changes (carrier or Carriyo workflow)
│
▼
Carriyo POSTs the full Shipment object to your URL
│ (headers: event-id, trigger, webhook-id, last-retry,
│ plus any static or OAuth auth header)
▼
your handler authenticates the call
│
▼
lookup by references.partner_shipment_reference
│
▼
update OMS / fire notifications / open tickets
│
▼
respond 2xx
Pitfalls
- 5xx triggers retries. Carriyo retries on non-2xx responses. If the issue is unrecoverable on your side (a reference Carriyo sent that you don't recognise, for example), log it and return 200 so the retry loop doesn't keep firing.
- The body shape is the entity itself, not an envelope. For
Shipment, ReverseShipment, and ReturnRequest webhooks, the
body is the full entity object — no
eventfield, nodatawrapper. - Status lives at
post_shipping_info.status, not at the top. A common mistake is checkingshipment.status. The Shipment object nests transit status underpost_shipping_info. - Order across events isn't guaranteed. A
deliveredevent can arrive beforeout_for_deliveryif the carrier batches updates. Use the timestamps insidepost_shipping_info(carrier_status_date,key_milestones.*) as your ordering key rather than arrival time.