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"
| "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;
payload: Record<string, unknown>; // provider-specific raw body
receivedAt: string; // ISO
};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.
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.
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",
"payload": { /* provider-specific */ },
"receivedAt": "2026-05-12T08:01:11.000Z"
}
],
"metadata": { "total": 4, "items": 4, "perPage": 50, "currentPage": 1, "lastPage": 1 }
}Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order does not exist or is not owned by the active vendor |
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 pincode, 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…