Supercommerce API Docs
Admin API

Shipping Labels — Admin

HTTP surface for an admin to batch-print courier shipping labels for many orders at once — one label per vendor on them — merged into a single downloadable PDF, with async status polling and retry of failed labels.

HTTP surface behind Print Label: an admin selects whole orders and requests one merged PDF of courier labels. Each selected order is expanded into the sub-orders with a booked shipment — an order_vendor.awb_number, not yet delivered, cancelled or returned — and a fulfilled sub-order always qualifies, and a BullMQ worker renders a label for each, then concatenates them into a single file. A batch finishes ready as soon as at least one label succeeded; it only finishes failed when every one did. Failed labels can be retried without re-selecting the batch.

Source: api-modules/shipping-label/src/controllers/admin-shipping-labels.controller.ts.

Batches are platform-scoped (shipping_label_job.vendor_id IS NULL) and visible to every admin who can read labels, so ops can pick up a colleague's batch.

A label is a projection, not an artefact: unlike an invoice it carries no reserved serial and no per-label PDF is cached. Every render — including a retry, and including the labels that already succeeded — reads the shipment as it stands right now, so a corrected AWB shows up on the next print rather than replaying a stale file.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching permission.

PermissionGrants
shippingLabel:viewList batches, read one batch's detail, download the PDF.
shippingLabel:bulkStart a batch and retry its failed labels.

Response shape

  • Create / retry / status endpoints return the standard JSON envelope: { data, message, statusCode }.
  • List returns the standard paginated envelope: { data, message, statusCode, metadata: { total, limit, offset, hasMore } }.
  • Download (GET …/:id/download) streams raw application/pdf bytes with Content-Disposition: attachment — no JSON envelope.

Generation flow

POST /admin/shipping-labels  { orderIds: [...] }


  each order expanded into its DISPATCHED sub-orders
  (pending / delivered / cancelled / returned ones are skipped)


  shipping_label_job created (status: pending) — one item per sub-order


  background worker renders each label, then merges them into one file

       ├─ at least one label succeeded  ──►  status: "ready"   (fileName/fileSize set)
       └─ every label failed            ──►  status: "failed"

GET /admin/shipping-labels             — poll the batch list (or a single id) for status
GET /admin/shipping-labels/:id/download   — download the merged PDF once ready
POST /admin/shipping-labels/:id/retry     — re-run only the failed labels in the background

Rendering happens in the worker role (APP_ROLE=worker), never in the API process. If no worker is consuming the shipping-label queue, a batch stays pending indefinitely — the admin UI flags a batch that has been queued for over five minutes rather than polling forever.


What the label carries

One A4 page per label, cut into two blocks.

The shipment block (handed to the courier):

FieldSource
PREPAID / CODorder.payment_methodcod is the only COD signal. A COD label also prints the sub-order total to collect.
Delivery addressThe order's shipping-address snapshot (name, address, phone, city | state, pincode).
Order numberorder.order_number, boxed.
Order dateorder.placed_at, rendered in Asia/Kolkata.
Courier name / AWBThe latest shipment row, falling back to order_vendor's cached awb_number / tracking_code / shipping_provider_id.
Package dimensionThe shipping-label settings group (parcel_length_cm / parcel_breadth_cm / parcel_height_cm). Omitted unless all three are set.
Shipment total weightSum of order_line.weight_at_order × quantity, falling back per line to fallback_weight_grams.
BarcodeCode 128 of the AWB, or of the order number when the courier returned no AWB.
Sold By / GSTINThe shipping vendor's vendor_profile (business name, address, tax_id), falling back to order_vendor.vendor_name_at_order.
SKU / Qty tableThe sub-order's order_line rows. order_line.sku is blank on every migrated line (the migrator never filled it), so a blank snapshot falls back to the live product_variant.sku.

The handover slip (kept by the packer): the courier name, tracking id, a Code 128 barcode of the order number, and the return address — the vendor's profile address, falling back to the return_address_name / return_address_lines settings.

Barcodes are drawn as SVG rects by a dependency-free Code 128 encoder (api-modules/shipping-label/src/util/code128.ts); all-digit values ride subset C, everything else subset B.


Endpoints

Start a batch

POST /admin/shipping-labels · shippingLabel:bulk

Request body:

{ "orderIds": ["…", "…"] } // 1–100 parent order ids

Expansion rules and limits:

  • When an order was shipped as a single parcel (shipment_grouping = per_order), it yields one label carrying every bag's items and the order's full COD amount. Printing one per bag would understate what the courier must collect.
  • Per order, only sub-orders with a booked shipment are labelled: an awb_number exists (or the sub-order is already fulfilled), and it is not yet delivered, cancelled or returned — those are past the handover the label exists for. A sub-order with nothing booked has no AWB to print. Note a shipment is commonly booked while the sub-order is still pending: fulfilled follows the courier's pickup scan, and that pickup needs this label.
  • Sub-orders are ordered by the parent's placedAt, then the sub-order's createdAt and id, and that order is persisted on each item (position) so the merged PDF's page order is reproducible across retries.
  • At most 100 orders per request, expanding to at most 200 labels — both bounds exist because the merge runs in memory. Exceeding either returns 400.
  • An orderId that does not exist fails the whole request with 400.
  • If none of the selected orders have a booked sub-order, the request fails with 400 rather than creating an empty batch.

Response (data field) — a batch row:

{
  "id": "…",
  "status": "pending" | "processing" | "ready" | "failed",
  "orderCount": 24,          // labels (sub-orders), not parent orders
  "successCount": 0,
  "failedCount": 0,
  "fileName": null,
  "fileSize": null,
  "retryCount": 0,
  "error": null,
  "createdAt": "…",
  "startedAt": null,
  "completedAt": null,
  "downloadUrl": null,        // set only when status = ready
  "requestedBy": { "id": "…", "name": "…" } // null once that user is deleted
}

List batches

GET /admin/shipping-labels · shippingLabel:view

Standard offset/limit pagination (querySchema) plus an optional status filter. Rows are the same shape as the create response, newest first, and span every admin's batches.


Batch status + per-label breakdown

GET /admin/shipping-labels/:id · shippingLabel:view

Same row shape as above, plus an items array — one entry per sub-order, in page order. orderId lets a client group the items back under the order the admin selected:

{
  "...": "batch row fields",
  "items": [
    {
      "orderVendorId": "…",
      "orderId": "…" | null,
      "orderNumber": "ORD-2026-00000123" | null,
      "vendorName": "…" | null,
      "status": "pending" | "success" | "failed",
      "error": "…" | null
    }
  ]
}

A sub-order that has moved out of fulfilled between selection and rendering fails with a readable reason rather than sinking the batch.


Retry failed labels

POST /admin/shipping-labels/:id/retry · shippingLabel:bulk

Allowed only when the batch is ready or failed and has at least one failed item — returns 409 otherwise (including while a batch is still processing). Resets every failed item back to pending and re-runs them in the background; the merged PDF is rebuilt from the full current set of successful labels, so a partially-fixed retry still produces one complete file.

Response: the updated batch row (status: "processing", retryCount incremented).


Download the merged PDF

GET /admin/shipping-labels/:id/download · shippingLabel:view

Streams the merged application/pdf once the batch is ready. Returns 404 otherwise (not yet ready, or the batch failed outright).

Filename: the batch's stored fileName (shipping-labels-{id}.pdf).


Finding orders to label

GET /admin/orders?labelable=true narrows the order list to orders holding at least one labellable sub-order — what the Print Label picker uses. It pairs the filter with excludeLabelPrinted=true, so an order drops out of the picker once every live sub-order has a rendered label; the picker's Show all orders toggle lifts that second filter for a reprint. See Orders.


Removing the plugin

ShippingLabelModule.forRoot() in apps/api/src/app.module.ts is the only mount point. Delete that line and the feature is gone: the routes disappear from OpenAPI, the worker stops registering, and nothing else in the system references it. The module depends on no shipping provider — the courier id, AWB and tracking code are plain order_vendor / shipment columns, so a deployment that fulfills manually still prints labels.

On this page