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.
| Permission | Grants |
|---|---|
shippingLabel:view | List batches, read one batch's detail, download the PDF. |
shippingLabel:bulk | Start 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 rawapplication/pdfbytes withContent-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 backgroundRendering 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):
| Field | Source |
|---|---|
PREPAID / COD | order.payment_method — cod is the only COD signal. A COD label also prints the sub-order total to collect. |
| Delivery address | The order's shipping-address snapshot (name, address, phone, city | state, pincode). |
| Order number | order.order_number, boxed. |
| Order date | order.placed_at, rendered in Asia/Kolkata. |
| Courier name / AWB | The latest shipment row, falling back to order_vendor's cached awb_number / tracking_code / shipping_provider_id. |
| Package dimension | The shipping-label settings group (parcel_length_cm / parcel_breadth_cm / parcel_height_cm). Omitted unless all three are set. |
| Shipment total weight | Sum of order_line.weight_at_order × quantity, falling back per line to fallback_weight_grams. |
| Barcode | Code 128 of the AWB, or of the order number when the courier returned no AWB. |
| Sold By / GSTIN | The shipping vendor's vendor_profile (business name, address, tax_id), falling back to order_vendor.vendor_name_at_order. |
| SKU / Qty table | The 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 idsExpansion 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_numberexists (or the sub-order is alreadyfulfilled), and it is not yetdelivered,cancelledorreturned— 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 stillpending:fulfilledfollows the courier's pickup scan, and that pickup needs this label. - Sub-orders are ordered by the parent's
placedAt, then the sub-order'screatedAtand 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
orderIdthat 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.
Shipping ClickPost Module — Admin surface
Admin-facing HTTP surface for the platform (central warehouse) ClickPost account — credentials, pickup address, courier map, parcel defaults, webhook secret, the live active-courier lookup, and automatic courier assignment. This is the account used when an admin fulfills a sub-order with credentialSource=platform.
Shipping Module — Admin
HTTP surface for the platform-admin override of any vendor's shipping config (enabled providers list + customer-charge flat-rate + free-shipping threshold), plus the ops-side write access to any sub-order's tracking timeline.