Skip to content

Parcel Tracking

Once a parcel label is bought, Badger follows the parcel to the door: the carrier's scans are kept on the label, the customer sees a timeline on their order page, staff see the latest scan in the admin, the fulfilment portal and the POS app, and when every parcel on an order has arrived the order moves to DELIVERED. Problems (a failed delivery, an exception, a parcel going back) show in the ops inbox and can email the customer.

Tracking comes in two ways: webhooks from the provider as things happen (ShipStation and Sendcloud), and polling by the worker as the fallback. It works on every plan that has labels; there is nothing to switch on beyond labels themselves, but setting up the webhook makes updates arrive in minutes instead of hours.

Statuses

Every provider's codes map onto one vocabulary (TrackingStatus):

Status Meaning Final Problem
LABEL_CREATED Label bought; the carrier hasn't scanned the parcel yet
IN_TRANSIT The carrier has it and it's moving
OUT_FOR_DELIVERY On the van today
AVAILABLE_FOR_PICKUP Waiting at a locker, post office or shop for the customer
DELIVERED Delivered, or collected from the pickup point Yes
DELIVERY_ATTEMPTED The carrier tried and couldn't deliver Yes
EXCEPTION Held, damaged, address problem, customs Yes
RETURNED_TO_SENDER On its way back to the shop Yes Yes
CANCELLED The label was cancelled with the carrier Yes
UNKNOWN A code Badger doesn't map yet: kept, but changes nothing

Rules. A parcel only moves forward, except for problems: a problem can follow any status that isn't final, and anything can follow a problem (the carrier sorted it out). A final status never changes. Every report is idempotent (events are matched by the provider's event id, or by code and time) and out-of-order safe: the status is worked out from all the events in time order, so an old webhook arriving late can't move a parcel back. Each event keeps the provider's raw code and wording beside the mapped status, so a mapping can be improved without migrating data. Unmapped codes are counted (Micrometer badger.shipping.tracking.unmapped, tagged by provider) and logged, code only.

The label's own status follows: a parcel the carrier is moving makes the label In transit (and frees it to be replaced, as dispatching does), delivered makes it Delivered, and a problem makes it Exception. A cancelled label is never revived.

Per provider

Provider Webhooks Polling Mapping
ShipStation (ShipEngine API) Yes: event track GET /v1/labels/{id}/track status_code AC, IT → in transit; DE → delivered; EX → exception; AT → delivery attempted; NY → label created; SP (delivered to the collection location) → available for pickup; UN → unknown. Detail codes OUT_FOR_DELIVERY, AVAILABLE_FOR_PICKUP, DELIVERY_ATTEMPTED, RETURNED_TO_SENDER in status_detail_code or carrier_detail_code take precedence
Sendcloud Yes: parcel_status_changed GET /parcels/{id} By status message first: Ready to send, Announced → label created; En route to sorting center, At sorting centre, Sorted, At customs, Parcel en route, Shipment picked up by driver, Delivery delayed → in transit; Driver en route → out for delivery; Awaiting customer pickup → available for pickup; Delivered, Shipment collected by customer → delivered; Delivery attempt failed → delivery attempted; Exception, Unable to deliver, Refused by recipient, Address invalid, Error collecting → exception; Returned to sender; Cancelled. Then by status id
Royal Mail Click and Drop No No Click and Drop's API has no tracking (Royal Mail's separate Tracking API needs its own registration), so its labels stay LABEL_CREATED; the customer still gets the Royal Mail tracking link
Sandbox No Simulated Nothing happens until the parcel is dispatched; then collected after an hour, sorted at six hours, out for delivery at 18 and delivered at 24. Deterministic, for demos and tests

Verified against the providers' public docs (October 2026): ShipStation's status_code values, its event fields and its webhook signature scheme; Sendcloud's status names and its webhook signature. Not verified: ShipStation's detail code values (the docs show the fields but list no values) and Sendcloud's numeric status ids (no longer in its docs; the message is matched first). Each provider class's javadoc says the same.

Setting up webhooks

The address is Settings › Shipping › Tracking updates › Show webhook address: https://<shop domain>/api/public/v1/shipping/webhooks/<provider>/<site key>. The site key is a random per-shop secret (shipping-webhook-key, made the first time the address is shown, never copied by site transfer); changing it stops updates until the new address is given to the provider.

  • ShipStation. Press Register with ShipStation (it calls POST /v1/environment/webhooks with event track on your API key; nothing is registered without that click), or add the address in ShipStation's API settings as a webhook for Any tracking event.
  • Sendcloud. In the Sendcloud integration's settings, paste the address as the webhook URL and turn on webhook feedback. If the integration shows a separate webhook signature key, put it in sendcloud-webhook-signature-key; otherwise the secret key is used.

How webhooks are checked. The endpoint is public (no JWT; the shop is resolved from the Host, as for the other /api/public/** endpoints) and spec-first (receiveShippingWebhook in the API reference). In order: the site key must match (constant-time; wrong key or provider → 404); the body is read up to 256 KB (413 beyond); then the provider's signature on the raw bytes:

  • ShipStation: RSA-SHA256 over x-shipengine-timestamp + "." + body, checked against the key named by x-shipengine-rsa-sha256-key-id in ShipEngine's JWKS (https://api.shipengine.com/jwks, fetched with no redirects and a capped body, cached for an hour, fetched early at most every five minutes for an unknown key). A timestamp more than five minutes off is refused.
  • Sendcloud: hex HMAC-SHA256 of the body, compared in constant time. Sendcloud signs no timestamp header, so a parcel_status_changed whose body timestamp is more than a day old is refused (its retries stop well within that); replays inside the day change nothing because reports are idempotent.

A bad signature is 401. A signed webhook about something Badger doesn't track (another event, a parcel from elsewhere) is 200, so the provider doesn't retry it. The work is done while the provider waits: a cached signature check, an indexed lookup, one targeted write and at most an email put on the queue. A failure answers 5xx and the provider retries. Bodies and headers are never logged.

Polling

The worker's TrackingPollJob runs every ten minutes (badger.shipping.tracking-poll-schedule). For each active site it asks the provider about at most 50 parcels whose next check is due: every two hours for the first three days after dispatch (or buying), every six hours after that, never for a final status or a label over 30 days old. Each parcel is claimed before it's polled, so several workers don't ask twice; a failed poll is tried again an hour later. Polling starts two hours after a label is bought (labels bought before tracking existed start when they're dispatched). Refresh tracking on the admin order page, or manageShipments refreshTracking, asks at once.

What customers see

The order page (orderSummaryExtension, in the Nova, Brock and Bootstrap themes) shows each parcel with its status, the time it last changed, the carrier and service, the tracking number and the Track your parcel link, and a timeline of status, date and town, newest first. The carrier's own wording isn't shown (it can name whoever signed), and Badger never stores streets, postcodes or signers. Return labels show the same, as Your return: …. Orders dispatched with typed-in tracking (no label) keep the plain tracking panel.

Emails (template orderTracking, HTML and text, one per kind, each sent once per parcel):

Setting Default Sent when
tracking-email-out-for-delivery off The parcel is out for delivery
tracking-email-delivered off The parcel is delivered
tracking-email-problem on A delivery attempt failed, an exception, or the parcel is going back

They go through the worker like the other order emails (TrackingEmailSender), carrying the carrier, tracking number and link. Return labels never email.

What staff see

  • Admin order page: each label's tracking status (red for a problem), its latest scan (time, town, the carrier's wording), when Badger last checked, and Refresh tracking.
  • Fulfilment portal: sent labels show their tracking status on the dispatch screen.
  • POS app: a ship task's label carries trackingStatus and lastEvent (status, time, place, wording).
  • MCP: shipments gives each label's trackingStatus, lastTrackedAt and newest three scans (status, time, place; no carrier wording). opsInbox counts deliveryProblems: live outbound parcels in a problem status, with how long the oldest has waited; and lateDeliveries: orders past their delivery promise and not yet delivered, with how many hours the longest has been late.
  • Late deliveries: an order with a delivery promise that isn't DELIVERED by the end of its last promised day (in the sending location's time zone) shows Late: promised by Tue 13 Oct on the admin order page's parcel labels and delivery promise cards. Tracking is what usually moves the order to DELIVERED, so a parcel the carrier hasn't reported delivered counts as late.
  • Order state: when every live outbound label on a DISPATCHED order is delivered, the order becomes DELIVERED, recorded on its state history like any change (OrderService.markDelivered). An order still packing, or with lines that went without a label, isn't moved by a delivered label alone.

Data

On each shipments row: trackingStatus, trackingStatusAt, trackingEvents (the newest 50: time, status, raw code and wording, town, country, provider event id), lastTrackedAt, deliveredAt, nextTrackAt (the next poll; absent when polling has stopped) and trackingNotified (the emails already sent). TrackingServiceImpl holds the rules; ShippingLabelService owns the storage and the providers, and saves tracking with a targeted update so a webhook can't overwrite a void or dispatch made at the same moment. Logs carry shipment, order and site ids and statuses only.

Limitations

  • Not tried against live accounts yet: the unverified mappings above, ShipStation's webhook registration response, and both providers' webhook payloads beyond their documented examples.
  • Click and Drop labels aren't tracked (no tracking in its API).
  • Returns aren't moved on by tracking: staff still mark a return received (choosing whether to restock); the return label's tracking is shown.
  • Webhooks are received on the shop's own domain; a shop that changes domain must give its provider the new address.