Track shipment status

Updated May 31, 20263 min read

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_attempt or returned.
  • 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 event field, no data wrapper.
  • Status lives at post_shipping_info.status, not at the top. A common mistake is checking shipment.status. The Shipment object nests transit status under post_shipping_info.
  • Order across events isn't guaranteed. A delivered event can arrive before out_for_delivery if the carrier batches updates. Use the timestamps inside post_shipping_info (carrier_status_date, key_milestones.*) as your ordering key rather than arrival time.