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 viaShippingModule.forRoot({ providers: [...] })inapps/api/src/app.module.ts.api-modules/shipping-clickpost— concrete provider (ClickPost.in). Registers into the base module'sSHIPPING_PROVIDERStoken.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
ShippingRegistryServicekeyed byproviderId. New couriers register a newShippingProviderclass against the registry.
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
GET /vendor/shipping/providers, GET /vendor/shipping/config, PATCH /vendor/shipping/config | required (vendor session) | active vendor in session |
GET /vendor/shipping/clickpost/config, PATCH /vendor/shipping/clickpost/config | required (vendor session) | active vendor in session |
GET /vendor/shipping/orders/:id/tracking | required (vendor session) | active vendor in session |
POST /vendor/shipping/orders/:id/tracking, DELETE /vendor/shipping/orders/:id/tracking/:eventId | required (vendor session) | active vendor in session |
GET /store/shipping/orders/:id/tracking | required (customer) | — |
GET /store/tracking/orders/:orderId, GET /store/tracking/sub-orders/:orderVendorId | required (customer) | — |
GET /track | none — public, tracking/guest token or order number + AWB/phone | — |
GET /admin/orders/:orderId/tracking, GET /admin/orders/:orderId/tracking-link | required | order: view |
GET /admin/shipping/orders/:id/tracking | required | order: view |
POST /admin/shipping/orders/:id/tracking, DELETE /admin/shipping/orders/:id/tracking/:eventId | required | order: update |
GET /admin/vendors/:vendorId/shipping/config | required | platformVendorSetting: read |
PATCH /admin/vendors/:vendorId/shipping/config | required | platformVendorSetting: update |
POST /webhooks/shipping/clickpost/:vendorId | none — 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
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED (webhook credential missing or wrong) |
| 403 | FORBIDDEN (no active vendor, or missing platform permission) |
| 404 | NOT_FOUND (cross-vendor sub-order, unknown vendor webhook target) |
| 500 | INTERNAL_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
| Concern | Layer | Where |
|---|---|---|
| What does the customer pay for shipping? | Customer-charge | vendor/shipping/config (flatRateSubunit, freeAboveSubunit). Applied at the cart layer (cart.md), no provider involved |
| Which provider ships this sub-order? | Provider | Selected at POST /vendor/orders/:id/fulfilled from this vendor's enabled providers. See order.md |
| How do tracking updates land? | Provider | Provider-specific webhook (e.g. POST /webhooks/shipping/clickpost/:vendorId), normalized + persisted as shipping_event rows |
| …and when there is no courier? | Provider | A 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; // ISO — courier 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; // ISO — when 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
}| Field | Type | Constraints |
|---|---|---|
enabledProviders | string[]? | At least one entry; each must be a registered provider id permitted by the admin allow-list |
flatRateSubunit | int? | >= 0 |
freeAboveSubunit | int | null? | >= 0. null = no free-above threshold |
Response 200 — updated ShippingConfigResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod; unknown providerId; provider not in admin allow-list |
| 403 | FORBIDDEN | No 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
| Name | Type | Notes |
|---|---|---|
id | string | order_vendor.id. Cross-vendor ids → 404 |
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..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
| Name | Type | Notes |
|---|---|---|
id | string | order_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 200 — ShippingConfigResponse 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 403 | FORBIDDEN | Caller 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"]
}| Field | Type | Constraints |
|---|---|---|
apiKey | string? | 1..500 chars, trimmed |
username | string? | 1..200 chars, trimmed |
webhookSecret | string? | 8..500 chars |
pickupPincode | string? | Indian 6-digit pincode (/^\d{6}$/) |
enabledCouriers | string[]? | 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
| Name | Type | Notes |
|---|---|---|
vendorId | string | Target vendor id |
Headers
| Name | Required | Notes |
|---|---|---|
x-clickpost-signature | yes | HMAC 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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Missing rawBody; invalid JSON; payload missing AWB or status code |
| 401 | UNAUTHORIZED | No inbound header carried the configured webhook_secret |
| 404 | NOT_FOUND | No 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_code | normalizedStatus |
|---|---|
0–3, 25, 28 | pending |
4 | dispatched |
5, 18, 20, 1004–1006 | in_transit |
6 | out_for_delivery |
8, 48 | delivered |
7, 9, 10, 16, 17, 19, 23 | failed |
11–15, 21, 26, 27, 50, 52 | returned |
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
| Event | Fired when | Payload |
|---|---|---|
shipping.shipment.created | Sub-order pending → fulfilled (provider assigned) | order id, order-vendor id, vendor id, provider id, method, tracking code, AWB, occurred at |
shipping.shipment.cancelled | Sub-order cancelled while it had a provider attached | order-vendor id, provider id, reason |
shipping.tracking.updated | A 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):
| Key | Required | Purpose |
|---|---|---|
shipping.clickpost.api_key | yes (for createShipment) | Vendor's ClickPost API key |
shipping.clickpost.username | yes | Vendor's ClickPost username |
shipping.clickpost.webhook_secret | yes (for webhook auth) | Shared HMAC secret. Must match the value configured in ClickPost's webhook settings |
shipping.clickpost.pickup_pincode | yes | Indian 6-digit pincode used as the shipment's pickup point |
shipping.clickpost.enabled_couriers | yes | Per-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 var | Default | Purpose |
|---|---|---|
PUBLIC_API_BASE_URL | — | Used to render webhookUrl in GET /vendor/shipping/clickpost/config. Unset → null, and vendor docs should print manual instructions |
Related modules
order— owns thepending → fulfilledandfulfilled → deliveredtransitions, dispatchesprovider.createShipment()/provider.cancelShipment(). Seeorder.md.cart— applies the customer-charge layer per vendor at checkout (flatRateSubunit,freeAboveSubunit). Seecart.md.settings— providesVendorSettingsService(vendor-scope) andPlatformVendorSettingsService(admin override). Seesettings.md.
Settings Module
HTTP surface for platform settings (admin and store scopes managed by platform staff) and per-vendor settings (admin scope for vendor back-office config, store scope for…
Tax Module
Two-part tax system. Base tax module defines the neutral TaxProvider port + registry and shared types (TaxConfigLine, TaxComponent). tax-flat provider is the concrete…