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; manual delivery confirmation only.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 |
GET /store/shipping/orders/:id/tracking | required (customer) | — |
GET /admin/vendors/:vendorId/shipping/config | required | platformVendorSetting: read |
PATCH /admin/vendors/:vendorId/shipping/config | required | platformVendorSetting: update |
POST /webhooks/shipping/clickpost/:vendorId | none — HMAC over raw body, header x-clickpost-signature | — |
: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 (invalid HMAC signature) |
| 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 |
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
};ShippingEventResponse
type ShippingNormalizedStatus =
| "pending"
| "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;
payload: Record<string, unknown>; // full raw webhook payload
receivedAt: string; // ISO
};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[].
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 /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[]? | Subset of platform-supported couriers (see below) |
Supported couriers (universe — per-vendor filtering happens against enabledCouriers):
DELHIVERY, DELHIVERY_AIR, BLUEDART, BLUEDART_SURFACE,
EKART, DTDC, INDIA_POST, XPRESSBEES, ECOM_EXPRESS, SHADOWFAXResponse 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 OrderService.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 (event id, AWB, status code, courier metadata). The handler extracts AWB + status code; the full body is stored as-is on the resulting shipping_event.payload.
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 | HMAC signature mismatch |
| 404 | NOT_FOUND | No vendor at :vendorId (no webhook secret configured); AWB doesn't resolve to a sub-order on this vendor |
Status mapping
ClickPost status codes → normalized statuses (unmapped codes flow through as pending and are logged):
| ClickPost codes | normalizedStatus |
|---|---|
OM, OP | pending |
OS, OT, INT | in_transit |
OO, OFD | out_for_delivery |
DEL, OD | delivered |
OR, RTO, RTD | returned |
OND, OUD, OC | failed |
Auto-deliver
When a webhook lands a delivered normalized status and the sub-order is currently fulfilled, the handler calls OrderService.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…