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/webhookswith eventtrackon 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 byx-shipengine-rsa-sha256-key-idin 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_changedwhose bodytimestampis 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
labelcarriestrackingStatusandlastEvent(status, time, place, wording). - MCP:
shipmentsgives each label'strackingStatus,lastTrackedAtand newest threescans(status, time, place; no carrier wording).opsInboxcountsdeliveryProblems: live outbound parcels in a problem status, with how long the oldest has waited; andlateDeliveries: 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
DELIVEREDby 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 toDELIVERED, so a parcel the carrier hasn't reported delivered counts as late. - Order state: when every live outbound label on a
DISPATCHEDorder is delivered, the order becomesDELIVERED, 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.