Supercommerce API Docs
Store API

Shipping Module — Storefront

HTTP surface for customer-side shipment tracking — the timeline of provider-emitted events for a sub-order the customer placed. Read-only.

HTTP surface for customer-side shipment tracking — the timeline of provider-emitted events for a sub-order the customer placed. Read-only.

Source: api-modules/shipping/src/controllers/store-shipping-tracking.controller.ts.

Provider configuration, vendor-side shipment management, and admin operations live in sibling docs. The shipping module follows a two-layer model: the customer pays a per-vendor flat rate at cart time (plugin-free), and the vendor assigns a concrete provider at the pending→fulfilled transition (plugin-driven). Customers observe the latter via this tracking endpoint.


Conventions

Authentication

EndpointAuth
GET /store/shipping/orders/:id/trackingrequired (customer session)
GET /store/tracking/orders/:orderIdrequired (customer session)
GET /store/tracking/sub-orders/:orderVendorIdrequired (customer session)
GET /tracknone — public

The session-scoped endpoint resolves scope through order.customer_id — sub-orders for other customers return 404 Not Found, never 403 (no row leak). The public one is guarded by requiring two matching factors instead of a session; see below.

Response envelope

{
  "data": <payload>,
  "metadata": { "total", "limit", "offset", "hasMore" },
  "message": "Success",
  "statusCode": 200
}

Error envelope

statusCodeerrorCode examples
400VALIDATION_ERROR
401UNAUTHORIZED
404NOT_FOUND
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Domain types

ShippingNormalizedStatus

The mapper normalizes every provider's event code into a small, stable set:

type ShippingNormalizedStatus =
  | "pending"
  | "dispatched"
  | "in_transit"
  | "out_for_delivery"
  | "delivered"
  | "failed"
  | "returned";

The raw statusCode from the provider is preserved alongside the normalized value so the UI can render provider-specific copy. delivered is the trigger that lets the order module auto-stamp order_vendor.delivered_at (and, for COD, the parent payment_status -> paid).

ShippingEventResponse

type ShippingEventResponse = {
  id: string;
  providerId: string;                       // e.g. "clickpost", "self-handled"
  externalEventId: string | null;           // provider's id for the event, when present
  statusCode: string;                       // raw provider code
  normalizedStatus: ShippingNormalizedStatus;
  payload: Record<string, unknown>;         // raw provider event payload (for debugging/forensics)
  receivedAt: string;                       // ISOwhen the system received the event
};

The payload field is the raw provider payload as received — useful for support to introspect courier-reported timestamps, locations, and reasons. The storefront UI typically renders the timeline off normalizedStatus + receivedAt and uses payload only for an expandable "raw event" view.


Endpoints

GET /store/shipping/orders/:id/tracking — Tracking timeline for one of my sub-orders

Newest event first. Paginated.

Path params

NameNotes
idorder_vendor.id — the sub-order id (not the parent order id)

Query

NameTypeDefaultNotes
pageint1>= 1
limitint501..200

Response 200 — paginated ShippingEventResponse[].

{
  "data": [
    {
      "id": "01J9...",
      "providerId": "clickpost",
      "externalEventId": "evt_abc123",
      "statusCode": "OFD",
      "normalizedStatus": "out_for_delivery",
      "occurredAt": "2026-05-13T08:15:00.000Z",
      "location": "DEL_GeetaColony",
      "description": "Shipment is Out for Delivery",
      "payload": {
        "city": "Bengaluru",
        "remarks": "Out for delivery",
        "occurredAt": "2026-05-13T08:15:00Z"
      },
      "receivedAt": "2026-05-13T08:16:42.122Z"
    }
  ],
  "metadata": { "total": 4, "limit": 50, "offset": 0, "hasMore": false }
}

occurredAt is the courier's own scan time and is what a timeline should order and label by; receivedAt is when the webhook reached us. occurredAt is null for providers that send no scan time.

Errors

StatusCodeWhen
400VALIDATION_ERRORpage / limit out of range
404NOT_FOUNDSub-order does not exist or belongs to another customer

GET /track — Public tracking view

The endpoint behind a "track my order" page. No session, so guest-checkout orders and links in email work. Provider-agnostic: a ClickPost shipment renders its courier scan timeline, a self-handled one renders the checkpoints admin or the vendor entered by hand.

One identifier is enough — the order number, the AWB, or a token. A customer reads either number off the same confirmation email or shipping SMS, so the page asks for one field and nothing else. A miss is a flat 404, so the endpoint cannot be used to confirm that an order or AWB exists, and the response carries no customer PII, no address, no money, and no raw courier payload.

Note the trade-off this accepts: order numbers are sequential, so anyone can walk them and read a shipment's vendor name, AWB, courier, status and scan locations. Nothing identifying the customer is exposed, but the sequence is enumerable — put a rate limit in front of this route at the edge if that matters for a deployment.

Query — at least one of token, orderNumber or awb.

NameTypeNotes
tokenstring?1..200 chars — an order's tracking_token (the shareable link) or a guest's lookup_token from the confirmation email. Resolves the whole order
orderNumberstring?1..64 chars — case-insensitive, and the ORD-<year>- prefix is optional. ORD-2026-04019794, 04019794 and 4019794 all resolve the same order; the serial is zero-padded to 8 before matching, so leading zeros never matter. Sufficient on its own
awbstring?1..100 chars — matched against awb_number or the hand-entered tracking_code. Sufficient on its own
phonestring?4..20 chars — optional narrowing only, never required; compared on the last 10 digits, so +91 98765 43210 and 9876543210 both match

With awb, the response holds the shipment that AWB belongs to; with token or orderNumber alone, it holds every shipment of the order — a multi-vendor order ships once per vendor. An ambiguous match — a tracking code a vendor hand-entered on two self-handled deliveries, or a bare serial that also hits a migrated order from an earlier year — collapses to the most recently placed order, never a mix of two.

Tokens resolve in two steps: first order.tracking_token (minted by TrackingLinkService, the link support and the account page share), then GUEST_ORDER_TOKEN_PORT — bound by @sc/guest-checkout — for the older guest email links. With the guest plugin unmounted only the second path disappears; order tracking tokens and the orderNumber / awb paths keep working.

Response 200TrackingViewResponse.

{
  "data": {
    "orderNumber": "ORD-2026-00000001",
    "stages": [
      "order_placed",
      "dispatched",
      "in_transit",
      "out_for_delivery",
      "delivered"
    ],
    "shipments": [
      {
        "subOrderId": "01J9...",
        "attempt": null,
        "awbNumber": "SF49245NER",
        "providerId": "clickpost",
        "courier": "DELHIVERY",
        "vendorName": "Acme Bakery",
        "status": "out_for_delivery",
        "state": "in_progress",
        "stage": "out_for_delivery",
        "estimatedDeliveryDate": "2026-08-14",
        "deliveredAt": null,
        "checkpoints": [
          {
            "status": "pending",
            "previousStatus": null,
            "stage": "order_placed",
            "description": "Order placed",
            "location": null,
            "occurredAt": "2026-08-08T09:00:00.000Z"
          },
          {
            "status": "dispatched",
            "previousStatus": "pending",
            "stage": "dispatched",
            "description": "Shipment picked up",
            "location": "BLR_Hub",
            "occurredAt": "2026-08-09T10:00:00.000Z"
          }
        ]
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

stages is the fixed progress bar, in order; each shipment's stage is how far it has filled, and subOrderId is the key a UI deep-links a single card with.

One entry per dispatch, not per sub-order. A sub-order that shipped, came back as an RTO and went out again has two shipment rows, so it contributes two entries here — each with its own AWB, courier and slice of the event log. attempt is the dispatch number (1, 2, …) in that case and null for the ordinary single-dispatch shipment. Earlier attempts keep the outcome they ended on; only the latest reflects the sub-order's current state. A sub-order with no shipment row yet (not fulfilled, or placed before the table existed) still contributes exactly one entry, derived from order_vendor.

state is what the page should render:

stateMeaningBar?
in_progressOn its wayyes — fill to stage
deliveredDeliveredyes — full bar
attentionDelivery attempt failed, parcel lost/damaged/expirednostage is null
returnedRTO — coming back to the sellernostage is null
cancelledSub-order cancellednostage is null

stage is null for every off-bar state, because a bar would either read as forward progress (a returning parcel) or be a lie (a cancelled one) — render a state card instead. Cancellation is decided by the order, so a cancelled sub-order stays cancelled whatever the courier last scanned.

Within in_progress the bar never regresses — a failed-delivery scan that later resumes holds at out_for_delivery while status reports the latest reality. status is delivered whenever the sub-order itself is delivered, regardless of the last courier scan. checkpoints are oldest first, capped at 200.

Every timeline opens on the order. A courier feed starts at whatever scan the provider happened to send first, which for a mid-journey integration is a single meaningless line. So each shipment's checkpoints are the order's own milestones merged with the courier's scans: pending at the order's placement, dispatched at fulfilledAt (or the dispatch's dispatchedAt), delivered at deliveredAt, plus every recorded scan, ordered by time. A milestone the courier already reported is not seeded — its wording wins. On a re-shipment only the first card opens on the order; a later attempt opens on its own dispatch.

previousStatus carries the status the parcel moved from (null on the first checkpoint), so a client can render the transition — "Dispatched → In Transit" — rather than a bare status. It is derived from the ordered timeline, not stored.

A timeline with nothing on it is pulled from the courier. A push feed is not guaranteed — a webhook registered late, an outage, or a dispatch booked outside this platform all leave a parcel moving with no checkpoints on record. When a shipment has none, the tracking view asks the provider directly (ClickPost's polling API; providers without a poll API have no fallback and are unchanged), records what comes back through the same path a webhook takes, and renders it. So the first view of such an order is what fills its timeline in, and the sub-order advances exactly as the missed webhook would have advanced it.

The pull is bounded: one request per sub-order per 5 minutes, claimed before the call so a refresh loop makes one request; a bag that was never booked or is cancelled is never polled; and a courier that is down is logged and skipped, leaving the order's own milestones to render. None of it changes the response shape.

For a self-handled shipment awbNumber is the vendor's hand-entered tracking code (or null). Its checkpoints are real rows, not a courier feed: fulfillment seeds a dispatched one, and admin or the vendor adds the rest by hand (POST …/shipping/orders/:id/tracking) — so in_transit and out_for_delivery show up here exactly as a courier's would. A sub-order not yet fulfilled reports pending / order_placed with the single "Order placed" checkpoint.

Errors

StatusCodeWhen
400VALIDATION_ERRORNone of token / orderNumber / awb supplied (phone alone is not an identifier)
404NOT_FOUNDNothing matches; unknown/expired token; guest-checkout plugin not mounted

GET /store/tracking/orders/:orderId — Tracking for my whole order

Session-scoped equivalent of /track, keyed by order.id, returning every vendor's shipment. Same TrackingViewResponse shape, so account pages and the public page render with one component. Scope resolves through order.customer_id; another customer's order returns 404, never 403.


GET /store/tracking/sub-orders/:orderVendorId — Tracking for one vendor's shipment

Same as above keyed by order_vendor.id, returning a single-element shipments array. This is what an order-history row's per-vendor "Track" link calls, and what ?shipment= deep-links to on the tracking page.


Returns { orderNumber, token, url }. The token is minted on first ask and then permanent, so orders placed before tracking links existed get one too. url is <storefront base>/track/<token>, where the base comes from Settings → Storefront URLs → store_url (falling back to the STOREFRONT_URL environment variable). It is null when neither is configured — build the link from the token client-side in that case.

Admin has the same endpoint at GET /admin/orders/:orderId/tracking-link (order:view), which is what the order-detail Copy tracking link / Open tracking actions call, plus GET /admin/orders/:orderId/tracking for the agent-facing view of the same timeline.

Errors (session endpoints)

StatusCodeWhen
401UNAUTHORIZEDNo customer session
404NOT_FOUNDUnknown id, or it belongs to another customer

  • order — the sub-order id (order_vendor.id) comes from OrderResponse.vendorBreakdowns[].id. The fulfillmentStatus lifecycle that this timeline annotates lives there. See order.md.
  • shipping-clickpost / shipping-self-handled — the concrete provider plugins emit the events this endpoint reads.

On this page