Supercommerce API Docs
Full Module Docs

Shipping Module

Two-layer shipping model. Customer-charge layer: every vendor has a flat per-order shipping rate (with optional free-above threshold) that the cart applies as a single line at…

Two-layer shipping model. Customer-charge layer: every vendor has a flat per-order shipping rate (with optional free-above threshold) that the cart applies as a single line at checkout — no provider involved. Provider layer: at the pending → fulfilled sub-order transition, the vendor assigns a registered shipping provider (e.g. clickpost, self-handled) which dispatches createShipment() and starts recording tracking events.

Sources:

  • api-modules/shipping — base module, registry, config, tracking, audit events. Registered via ShippingModule.forRoot({ providers: [...] }) in apps/api/src/app.module.ts.
  • api-modules/shipping-clickpost — concrete provider (ClickPost.in). Registers into the base module's SHIPPING_PROVIDERS token.
  • api-modules/shipping-self-handled — fallback "no courier" provider; its timeline is written by hand from the admin/vendor panels.

The order module knows nothing about ClickPost specifically — it dispatches through ShippingRegistryService keyed by providerId. New couriers register a new ShippingProvider class against the registry.


Conventions

Authentication

Endpoint groupAuthPermission
GET /vendor/shipping/providers, GET /vendor/shipping/config, PATCH /vendor/shipping/configrequired (vendor session)active vendor in session
GET /vendor/shipping/clickpost/config, PATCH /vendor/shipping/clickpost/configrequired (vendor session)active vendor in session
GET /vendor/shipping/orders/:id/trackingrequired (vendor session)active vendor in session
POST /vendor/shipping/orders/:id/tracking, DELETE /vendor/shipping/orders/:id/tracking/:eventIdrequired (vendor session)active vendor in session
GET /store/shipping/orders/:id/trackingrequired (customer)
GET /store/tracking/orders/:orderId, GET /store/tracking/sub-orders/:orderVendorIdrequired (customer)
GET /tracknone — public, tracking/guest token or order number + AWB/phone
GET /admin/orders/:orderId/tracking, GET /admin/orders/:orderId/tracking-linkrequiredorder: view
GET /admin/shipping/orders/:id/trackingrequiredorder: view
POST /admin/shipping/orders/:id/tracking, DELETE /admin/shipping/orders/:id/tracking/:eventIdrequiredorder: update
GET /admin/vendors/:vendorId/shipping/configrequiredplatformVendorSetting: read
PATCH /admin/vendors/:vendorId/shipping/configrequiredplatformVendorSetting: update
POST /webhooks/shipping/clickpost/:vendorIdnone — shared secret replayed by ClickPost in any header (X-Api-Key, Authorization, or a custom one)

:id on the tracking endpoints is order_vendor.id (a sub-order). Vendor-scoped reads enforce order_vendor.vendor_id = activeVendor; customer-scoped reads enforce order.customer_id = caller via join. Cross-scope ids return 404 Not Found (no row leak).

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED (webhook credential missing or wrong)
403FORBIDDEN (no active vendor, or missing platform permission)
404NOT_FOUND (cross-vendor sub-order, unknown vendor webhook target)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency

Customer-charge fields (flatRateSubunit, freeAboveSubunit) are integer subunits (paise / cents / eurocents — single currency per tenant). The cart applies flatRateSubunit per vendor unless the vendor's per-order subtotal meets or exceeds freeAboveSubunit (null = no free-above threshold).

Two-layer model — quick reference

ConcernLayerWhere
What does the customer pay for shipping?Customer-chargevendor/shipping/config (flatRateSubunit, freeAboveSubunit). Applied at the cart layer (cart.md), no provider involved
Which provider ships this sub-order?ProviderSelected at POST /vendor/orders/:id/fulfilled from this vendor's enabled providers. See order.md
How do tracking updates land?ProviderProvider-specific webhook (e.g. POST /webhooks/shipping/clickpost/:vendorId), normalized + persisted as shipping_event rows
…and when there is no courier?ProviderA provider that doesn't set tracksExternally gets a seeded dispatched checkpoint at fulfillment; every checkpoint after it is entered by hand from the admin or vendor panel (POST …/shipping/orders/:id/tracking)

Domain types

ShippingConfigResponse

The customer-charge config + the vendor's enabled provider list.

type ShippingConfigResponse = {
  enabledProviders: string[];      // provider ids; subset of the platform-allowed list
  flatRateSubunit: number;          // >= 0, in subunits
  freeAboveSubunit: number | null;  // >= 0, null = no free-above threshold
};

ShippingProviderSummary

type ShippingProviderSummary = {
  id: string;          // e.g. "clickpost", "self-handled"
  methods: string[];   // e.g. ["express", "standard"] or ["self"] for self-handled
};

shipment

One row per physical dispatch of a sub-order, so a re-ship after an RTO keeps its own AWB, courier and timeline instead of overwriting the first attempt. sequence orders the attempts (1 = first dispatch), and shipping_event.shipment_id ties each courier checkpoint to the dispatch it belongs to.

order_vendor.awb_number / tracking_code / shipping_provider_id / shipping_method remain as a denormalized cache of the latest dispatch — every existing reader keeps working — but the shipment rows are the record. Events written before this table existed carry a null shipment_id and are attributed to the first dispatch.

ShippingEventResponse

type ShippingNormalizedStatus =
  | "pending"           // manifested / awaiting pickup, and the unmapped fallback
  | "dispatched"        // courier physically has the parcel
  | "in_transit"
  | "out_for_delivery"
  | "delivered"
  | "failed"
  | "returned";

type ShippingEventResponse = {
  id: string;
  providerId: string;              // e.g. "clickpost"
  externalEventId: string | null;  // provider-side event id (used for idempotency)
  statusCode: string;              // raw provider code (e.g. "OFD")
  normalizedStatus: ShippingNormalizedStatus;
  occurredAt: string | null;       // ISOcourier scan time, null if not sent
  location: string | null;         // courier scan location
  description: string | null;      // courier remark for this checkpoint
  payload: Record<string, unknown>; // full raw webhook payload
  receivedAt: string;              // ISOwhen the webhook reached us
};

ClickPostConfigResponse

type ClickPostConfigResponse = {
  apiKey: string;
  username: string;
  webhookSecret: string;
  pickupPincode: string;
  enabledCouriers: string[];
  // Fully-qualified URL the vendor should configure in ClickPost's webhook
  // settings. Computed from PUBLIC_API_BASE_URL + the per-vendor route.
  // Null when PUBLIC_API_BASE_URL isn't set on the API process — fall back
  // to manual instructions.
  webhookUrl: string | null;
};

Vendor

GET /vendor/shipping/providers — Providers I may use

Three-way intersection: registered ∩ admin allow-list ∩ vendor's enabled list. Use this to populate the bulk-fulfill provider picker. Paginated for consistency, but always returns the full set in one page (one row per provider).

Response 200 — paginated envelope of ShippingProviderSummary[].


GET /vendor/shipping/config — Customer-charge + enabled providers

Response 200

{
  "data": {
    "enabledProviders": ["clickpost", "self-handled"],
    "flatRateSubunit": 4900,
    "freeAboveSubunit": 99900
  },
  "message": "Success",
  "statusCode": 200
}

PATCH /vendor/shipping/config — Update shipping config

Partial update — fields not in the body are left untouched. Audit row written per changed key by VendorSettingsService.

Body

{
  "enabledProviders": ["clickpost"],
  "flatRateSubunit": 4900,
  "freeAboveSubunit": 99900
}
FieldTypeConstraints
enabledProvidersstring[]?At least one entry; each must be a registered provider id permitted by the admin allow-list
flatRateSubunitint?>= 0
freeAboveSubunitint | null?>= 0. null = no free-above threshold

Response 200 — updated ShippingConfigResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod; unknown providerId; provider not in admin allow-list
403FORBIDDENNo active vendor on session

GET /vendor/shipping/orders/:id/tracking — Sub-order timeline

Paginated, newest first. Provider-agnostic — every row that's been written to shipping_event for this sub-order, regardless of which provider produced it. Useful when a sub-order changes hands between providers mid-flight (rare but supported).

Path params

NameTypeNotes
idstringorder_vendor.id. Cross-vendor ids → 404

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint501..200

Response 200 — paginated envelope of ShippingEventResponse[].


POST /vendor/shipping/orders/:id/tracking — Add a checkpoint by hand

The write path for a provider with no courier feed. delivered also flips the sub-order to delivered; returned raises an RTO — both skipped when the sub-order already moved on.

Body: status (required ShippingNormalizedStatus), occurredAt (ISO, defaults to now, may not be forward-dated), location, description, shipmentId (defaults to the latest dispatch).

Response 201 — the created ShippingEventResponse, with payload.source === "manual".

Errors: 404 cross-vendor sub-order or unknown shipmentId; 409 sub-order still pending / already cancelled, or a forward-dated occurredAt.


DELETE /vendor/shipping/orders/:id/tracking/:eventId — Remove a manual checkpoint

Only rows carrying payload.source === "manual" — a courier-fed row is the provider's record and would be re-delivered on the next retry (409). Removing one re-derives order_vendor.metadata.tracking_status from what remains; it does not un-deliver a sub-order.


Storefront

GET /store/shipping/orders/:id/tracking — My sub-order timeline

Same shape and semantics as the vendor endpoint, but scope is resolved through the parent order's customer_id. Sub-orders for other customers return 404 Not Found.

Path params

NameTypeNotes
idstringorder_vendor.id. Cross-customer ids → 404

Admin

GET|POST /admin/shipping/orders/:id/tracking, DELETE …/:eventId — Ops-side timeline

Same three operations as the vendor routes, unscoped, at /admin/shipping/orders/:id/tracking. order:view to read, order:update to write or remove. Support uses these to drive a self-handled delivery when the seller cannot.


GET /admin/vendors/:vendorId/shipping/config — Read any vendor's config

Required permission: platformVendorSetting:read.

Response 200ShippingConfigResponse for the target vendor.


PATCH /admin/vendors/:vendorId/shipping/config — Override any vendor's config

Required permission: platformVendorSetting:update. Same body shape as the vendor self-service endpoint; bypasses any forAdmin write guard on the underlying vendor-settings store, so platform staff can set any registered key.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
403FORBIDDENCaller lacks platformVendorSetting:update

ClickPost provider (shipping-clickpost)

A concrete ShippingProvider registered against the base shipping registry. Provider id is "clickpost".

Vendor: GET /vendor/shipping/clickpost/config

Response 200

{
  "data": {
    "apiKey": "ck_live_xxx...",
    "username": "vendor-shop",
    "webhookSecret": "shared-secret-32-chars-...",
    "pickupPincode": "560001",
    "enabledCouriers": ["DELHIVERY", "BLUEDART"],
    "webhookUrl": "https://api.example.com/webhooks/shipping/clickpost/01J9..."
  },
  "message": "Success",
  "statusCode": 200
}

webhookUrl is null when PUBLIC_API_BASE_URL isn't set on the API process — surface manual instructions in that case.


Vendor: PATCH /vendor/shipping/clickpost/config

Partial update. Audit row per changed key.

Body

{
  "apiKey":         "ck_live_xxx...",
  "username":       "vendor-shop",
  "webhookSecret":  "at-least-8-chars",
  "pickupPincode":  "560001",
  "enabledCouriers": ["DELHIVERY", "BLUEDART"]
}
FieldTypeConstraints
apiKeystring?1..500 chars, trimmed
usernamestring?1..200 chars, trimmed
webhookSecretstring?8..500 chars
pickupPincodestring?Indian 6-digit pincode (/^\d{6}$/)
enabledCouriersstring[]?Courier codes from the account's active couriers (see below)

Courier codes come from the ClickPost account itself — GET .../clickpost/active-couriers lists every courier active on that dashboard, with the code to use here. There is no fixed platform allow-list: activating a courier partner at ClickPost is what makes it selectable. Codes are the courier name uppercased with separators as _ (DELHIVERY, BLUEDART_SURFACE, AMAZON_SHIPPING); what gates fulfillment is courierMap, which must hold a cpId + accountCode for the chosen courier.

Response 200 — updated ClickPostConfigResponse.


Webhook: POST /webhooks/shipping/clickpost/:vendorId

Public ClickPost webhook. HMAC-verified against the per-vendor webhookSecret. Resolves the target sub-order from the AWB (scoped to the vendor), records the event idempotently (by (provider_id, external_event_id)), and — when the normalized status is delivered and the sub-order is fulfilled — auto-flips the sub-order via OrderDeliveryService.markVendorDelivered.

Failure modes minimize information leakage: external messages stay generic; detail goes to the server log.

Path params

NameTypeNotes
vendorIdstringTarget vendor id

Headers

NameRequiredNotes
x-clickpost-signatureyesHMAC over the raw body

Body — ClickPost's tracking event payload (AWB, clickpost_status_code, scan time, location, remark, EDD). The handler extracts those; the full body is stored as-is on the resulting shipping_event.payload. See webhooks/shipping-clickpost.md for the field-by-field contract.

Response 200

{
  "data": {
    "accepted": true,
    "eventId": "01J9...",
    "normalizedStatus": "out_for_delivery",
    "duplicate": false
  },
  "message": "Success",
  "statusCode": 200
}

duplicate: true means the (provider_id, external_event_id) pair already existed — the event is not re-recorded and auto-deliver is not re-attempted.

Errors

StatusCodeWhen
400BAD_REQUESTMissing rawBody; invalid JSON; payload missing AWB or status code
401UNAUTHORIZEDNo inbound header carried the configured webhook_secret
404NOT_FOUNDNo vendor at :vendorId (no webhook secret configured); AWB doesn't resolve to a sub-order on this vendor

Polling — when the push feed never arrives

A shipment whose webhooks never reached us (a late-registered webhook, an outage, or a dispatch booked on the legacy portal) has an empty timeline. Reading any tracking surface for such a shipment pulls ClickPost's own record instead — GET https://api.clickpost.in/api/v2/track-order/, by waybill + cp_id when both are on record and by order_ids otherwise — and records what comes back through the same path a webhook takes, advancing the sub-order the same way. One call per sub-order per 5 minutes; a cancelled or never-booked bag is not polled; a courier that is down is logged and skipped. See the webhook doc for the full contract.

Status mapping

ClickPost's own numeric clickpost_status_code → normalized statuses. The courier's status string is stored for audit but never mapped, since it differs per carrier. Unmapped codes fall back to clickpost_status_bucket, then to pending (logged).

clickpost_status_codenormalizedStatus
03, 25, 28pending
4dispatched
5, 18, 20, 10041006in_transit
6out_for_delivery
8, 48delivered
7, 9, 10, 16, 17, 19, 23failed
1115, 21, 26, 27, 50, 52returned

Auto-deliver

When a webhook lands a delivered normalized status and the sub-order is currently fulfilled, the handler calls OrderDeliveryService.markVendorDelivered(actorType="webhook", source="clickpost-webhook"). If the sub-order has since transitioned (e.g. customer already received it via vendor-panel mark-delivered, or it was cancelled), the order-side ConflictException is swallowed — the webhook still acks 200 with accepted: true.


Domain events

EventFired whenPayload
shipping.shipment.createdSub-order pending → fulfilled (provider assigned)order id, order-vendor id, vendor id, provider id, method, tracking code, AWB, occurred at
shipping.shipment.cancelledSub-order cancelled while it had a provider attachedorder-vendor id, provider id, reason
shipping.tracking.updatedA shipping_event row is persisted (any status)order-vendor id, provider id, normalized status, raw status code

Consumed by the notifications module (push + email — "your order is out for delivery", etc.) and by analytics jobs.


Configuration

Base shipping module

No env vars — vendor-side knobs are entirely in vendor settings (vendor.shipping.*).

ClickPost provider

Lives under the vendor settings shipping group as clickpost.* keys (read via VendorSettingsService):

KeyRequiredPurpose
shipping.clickpost.api_keyyes (for createShipment)Vendor's ClickPost API key
shipping.clickpost.usernameyesVendor's ClickPost username
shipping.clickpost.webhook_secretyes (for webhook auth)Shared HMAC secret. Must match the value configured in ClickPost's webhook settings
shipping.clickpost.pickup_pincodeyesIndian 6-digit pincode used as the shipment's pickup point
shipping.clickpost.enabled_couriersyesPer-vendor allow-list of courier codes the vendor wants exposed

The vendor's enabled-providers list also has to contain "clickpost" (/vendor/shipping/config) for the provider to be selectable at the fulfill step.

Process env

Env varDefaultPurpose
PUBLIC_API_BASE_URLUsed to render webhookUrl in GET /vendor/shipping/clickpost/config. Unset → null, and vendor docs should print manual instructions

  • order — owns the pending → fulfilled and fulfilled → delivered transitions, dispatches provider.createShipment() / provider.cancelShipment(). See order.md.
  • cart — applies the customer-charge layer per vendor at checkout (flatRateSubunit, freeAboveSubunit). See cart.md.
  • settings — provides VendorSettingsService (vendor-scope) and PlatformVendorSettingsService (admin override). See settings.md.

On this page