Shipping Module — Vendor surface
Vendor-facing HTTP surface for shipping configuration (flat customer-charge rate, free-above threshold, enabled providers) and per-sub-order tracking timeline. The vendor charge…
Vendor-facing HTTP surface for shipping configuration (flat customer-charge rate, free-above threshold, enabled providers) and per-sub-order tracking timeline. The vendor charge layer is provider-agnostic and lives in this module; the provider integrations themselves are separate plugin modules (e.g. shipping-clickpost, shipping-self-handled). The two-layer model is intentional — customer pays a per-vendor flat rate (cart-side, plugin-free); vendor assigns a provider at the pending→fulfilled transition (post-order, plugin-driven).
Source:
api-modules/shipping/src/controllers/vendor-shipping.controller.ts,api-modules/shipping/src/controllers/vendor-shipping-tracking.controller.ts.
Conventions
Authentication
All endpoints require a Better-Auth bearer session with an active vendor.
Authorization: Bearer <session-token>The active vendor is resolved via resolveActiveVendorId(session). Sessions missing an active vendor are rejected with 403 Forbidden. There is no platform RBAC permission — vendor users are not platform staff.
Tenant scoping
Every read and write scopes to the active vendor's id. The tracking endpoint joins against order_vendor.vendor_id so cross-vendor order_vendor.ids return 404 rather than 403 (no row leak).
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Money
Charge amounts (flatRateSubunit, freeAboveSubunit) are integer subunits (paise / cents / eurocents).
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (no active vendor) |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR |
Domain types
ShippingProviderSummary
type ShippingProviderSummary = {
id: string; // e.g. "clickpost", "self-handled"
methods: string[]; // provider-declared method ids, e.g. ["express", "standard"]
};ShippingConfigResponse
type ShippingConfigResponse = {
enabledProviders: string[]; // provider ids this vendor has switched on
flatRateSubunit: number; // customer-charge rate, subunits
freeAboveSubunit: number | null; // null = no free-above threshold
};ShippingNormalizedStatus
type ShippingNormalizedStatus =
| "pending"
| "dispatched"
| "in_transit"
| "out_for_delivery"
| "delivered"
| "failed"
| "returned";ShippingEventResponse
type ShippingEventResponse = {
id: string;
providerId: string;
externalEventId: string | null; // provider's event/webhook id (de-dup)
statusCode: string; // provider's raw status string
normalizedStatus: ShippingNormalizedStatus;
occurredAt: string | null; // ISO — courier scan time, null if not sent
location: string | null;
description: string | null;
payload: Record<string, unknown>; // provider-specific raw body
receivedAt: string; // ISO
};A checkpoint entered by hand (see the POST below) carries payload.source === "manual" and statusCode equal to its normalizedStatus — that flag is what makes it deletable and what the panels badge as Added manually. Courier-fed rows never carry it.
Shipping providers
Base path: /vendor/shipping/providers.
GET /vendor/shipping/providers — Providers this vendor may use
Three-way intersection: registered providers (modules wired in ShippingModule.forRoot()) ∩ admin allow-list (platform-level admin.shipping.enabled_providers) ∩ vendor's enabled list (this vendor's admin.shipping.enabled_providers). Use it to populate the bulk-fulfill provider picker.
The list is small (one row per registered provider). The response is wrapped in the paginated envelope for consistency but always returns the full set in a single page.
Response 200 — paginated envelope of ShippingProviderSummary.
{
"data": [
{ "id": "clickpost", "methods": ["express", "surface"] },
{ "id": "self-handled", "methods": ["standard"] }
],
"metadata": { "total": 2, "items": 2, "perPage": 2, "currentPage": 1, "lastPage": 1 }
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | No active vendor on session |
Shipping config
Base path: /vendor/shipping/config. Reads/writes the three shipping settings under vendor.admin.shipping.* (see settings.md). Audit rows are written per changed key by VendorSettingsService.
GET /vendor/shipping/config — Current shipping config
Response 200 — ShippingConfigResponse.
{
"data": {
"enabledProviders": ["clickpost", "self-handled"],
"flatRateSubunit": 4900,
"freeAboveSubunit": 99900
}
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | No active vendor on session |
PATCH /vendor/shipping/config — Update shipping config
Partial update — fields not in the body are left untouched. The body is .strict() so unknown keys are rejected at the zod layer.
Body
{
"enabledProviders": ["clickpost", "self-handled"],
"flatRateSubunit": 4900,
"freeAboveSubunit": 99900 // nullable — send null to drop the free-above threshold
}| Field | Type | Constraints |
|---|---|---|
enabledProviders | string[]? | At least 1 entry when present; each entry must be a known provider id (and pass the admin allow-list at order time) |
flatRateSubunit | int? | >= 0 |
freeAboveSubunit | int | null? | >= 0; null to disable free-above |
Response 200 — updated ShippingConfigResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (unknown key, negative amount, empty enabledProviders[]) |
| 403 | FORBIDDEN | No active vendor on session |
Tracking timeline
Base path: /vendor/shipping/orders/:id/tracking. :id is order_vendor.id. Reads and writes both scope to the active vendor; cross-vendor ids return 404.
GET /vendor/shipping/orders/:id/tracking — Sub-order tracking events
Returns the shipping event timeline for one sub-order, newest first. Provider-agnostic — any provider that lands rows on shipping_event shows up here.
For a self-handled delivery there is no courier feed: fulfillment seeds a single dispatched checkpoint and everything after it is entered through the POST below. Provider-integrated shipments (ClickPost) keep receiving courier scans; manual rows are still allowed alongside, for corrections.
Path params
| Name | Type | Notes |
|---|---|---|
id | string (UUID) | order_vendor.id; must belong to active vendor |
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 50 | 1..200 |
Response 200 — paginated envelope of ShippingEventResponse.
{
"data": [
{
"id": "01J9...",
"providerId": "clickpost",
"externalEventId": "evt_abc123",
"statusCode": "IT",
"normalizedStatus": "in_transit",
"occurredAt": "2026-05-13T08:15:00.000Z",
"location": "DEL_GeetaColony",
"description": "Shipment in transit",
"payload": { /* provider-specific */ },
"receivedAt": "2026-05-12T08:01:11.000Z"
}
],
"metadata": { "total": 4, "limit": 50, "offset": 0, "hasMore": false }
}Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order does not exist or is not owned by the active vendor |
POST /vendor/shipping/orders/:id/tracking — Add a tracking update
Appends an operator-written checkpoint. This is the whole timeline for a self-handled delivery — in_transit, out_for_delivery and delivered only exist because someone entered them here.
Two statuses also move the sub-order, so the timeline and the order never disagree:
| Status | Side effect |
|---|---|
delivered | Sub-order flips to delivered (same path as POST /vendor/orders/:id/delivered, including the COD/earnings handling) |
returned | Raises an RTO on the sub-order |
Both are skipped when the sub-order already moved on — the checkpoint still records.
Path params
| Name | Type | Notes |
|---|---|---|
id | string (UUID) | order_vendor.id; must belong to active vendor |
Body
{
"status": "out_for_delivery", // required
"occurredAt": "2026-08-11T09:30:00.000Z", // optional, defaults to now
"location": "Pune hub", // optional, <= 200 chars
"description": "Out with the rider", // optional, <= 500 chars
"shipmentId": "01J9..." // optional, defaults to the latest dispatch
}| Field | Type | Notes |
|---|---|---|
status | ShippingNormalizedStatus | Required |
occurredAt | string? (ISO) | May not be in the future (one minute of clock skew tolerated) |
location | string? | Shown on the customer tracking page |
description | string? | Shown on the customer tracking page; defaults to a label for the status |
shipmentId | string? | Back-fill an earlier dispatch after an RTO re-ship; defaults to the latest |
Response 201 — the created ShippingEventResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Unknown status, over-long text |
| 404 | NOT_FOUND | Sub-order (or shipmentId) not found for the active vendor |
| 409 | CONFLICT | Sub-order is still pending or is cancelled; or occurredAt is forward-dated |
DELETE /vendor/shipping/orders/:id/tracking/:eventId — Remove a manual update
Corrects a mis-entered checkpoint. Only rows with payload.source === "manual" can be removed — a courier-fed row is the provider's record and would simply be re-delivered on the next webhook retry.
Removing a row re-derives order_vendor.metadata.tracking_status from whatever checkpoints remain. It does not un-deliver a sub-order that a delivered checkpoint already flipped — reverse that from the order surface.
Response 200
{ "data": { "id": "01J9...", "removed": true } }Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order or event not found for the active vendor |
| 409 | CONFLICT | Event was fed by a provider, not added by hand |
Related modules
settings— config is persisted undervendor.admin.shipping.*; thevendor-settingscontroller can read/write the same data with the generic settings API. Seesettings.md.shipping-clickpost— provider plugin for ClickPost; its own config block lives atvendor.admin.shipping.clickpost.*and has a dedicated controller. Seeshipping-clickpost.md.shipping-self-handled— built-in fallback provider; no per-vendor config.order—POST /vendor/orders/:id/fulfilledvalidatesproviderIdagainst the list returned by/vendor/shipping/providersbefore dispatchingcreateShipment(). Seeorder.md.
Shipping ClickPost Module — Vendor surface
Vendor-facing HTTP surface for per-vendor ClickPost integration — credentials, pickup address, courier map, parcel defaults, webhook secret, and the courier allow-list. ClickPost is one shipping provider…
Tax (Flat Provider) Module — Vendor surface
Vendor self-service for the flat tax provider — the set of inclusive tax rows (type + rate) that apply uniformly to every line on the vendor's orders. Tax computation is…