Shipping ClickPost Module — Admin surface
Admin-facing HTTP surface for the platform (central warehouse) ClickPost account — credentials, pickup address, courier map, parcel defaults, webhook secret, the live active-courier lookup, and automatic courier assignment. This is the account used when an admin fulfills a sub-order with credentialSource=platform.
Admin-facing HTTP surface for the platform (central warehouse) ClickPost account — credentials, pickup address, courier partner map, parcel defaults, webhook secret, and the live active-courier lookup. This is the account used when an admin fulfills a sub-order with credentialSource=platform; sellers shipping under their own ClickPost account configure the mirror surface in vendor/shipping-clickpost.md.
Source:
api-modules/shipping-clickpost/src/controllers/admin-clickpost-config.controller.ts.The endpoints are a typed wrapper over the
admin.shipping.clickpost.*settings group. They exist as a dedicated surface becauseclickpost.courier_mapis a record of objects — the generic settings editor can edit an existing value but cannot add keys to it.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission. Uses the adminSetting resource — the same resource that gates the underlying settings group, so no separate grant is needed to manage ClickPost.
| Endpoint | Permission |
|---|---|
GET /admin/shipping/clickpost/config | adminSetting: read |
GET /admin/shipping/clickpost/active-couriers | adminSetting: read |
PATCH /admin/shipping/clickpost/config | adminSetting: update |
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 500 | INTERNAL_SERVER_ERROR |
Domain types
Identical to the vendor surface — see vendor/shipping-clickpost.md for the full field-by-field notes on ClickPostCourierEntry, weights/dimensions units, and where to obtain cpId / accountCode.
ClickPostConfigResponse
type ClickPostConfigResponse = {
apiKey: string;
username: string;
webhookSecret: string;
pickupPincode: string; // Indian 6-digit
enabledCouriers: string[];
courierMap: Record<string, { cpId: number; accountCode: string }>;
pickupName: string;
pickupAddress: string;
pickupCity: string;
pickupState: string;
pickupPhone: string;
pickupEmail: string;
pickupTin: string; // TIN / GSTIN
defaultParcel: {
weight: number; // grams
length: number; // centimetres
breadth: number;
height: number;
} | null;
/** Computed from PUBLIC_API_BASE_URL + the platform webhook route.
* Null when PUBLIC_API_BASE_URL isn't set on the API process. */
webhookUrl: string | null;
};Unlike the vendor variant, webhookUrl resolves to the platform route (/webhooks/shipping/clickpost/platform), not a per-vendor one.
ActiveCourier
type ActiveCourier = {
code: string; // courier code derived from the name, e.g. "DELHIVERY"
courierName: string; // name as shown on the ClickPost dashboard
cpId: number;
accountCode: string;
};Platform ClickPost config
Base path: /admin/shipping/clickpost.
GET /admin/shipping/clickpost/config — Get config
Returns the platform warehouse ClickPost credentials, pickup address, courier map, parcel defaults, and the resolved platform webhook URL.
Response 200 — ClickPostConfigResponse.
{
"data": {
"apiKey": "cp_live_...",
"username": "acme-warehouse",
"webhookSecret": "whsec_...",
"pickupPincode": "560001",
"enabledCouriers": ["DELHIVERY", "BLUEDART"],
"courierMap": {
"DELHIVERY": { "cpId": 4, "accountCode": "DL_ACME_002" },
"BLUEDART": { "cpId": 12, "accountCode": "BD_ACME_001" }
},
"pickupName": "Acme Central Warehouse",
"pickupAddress": "123 Industrial Area",
"pickupCity": "Bengaluru",
"pickupState": "Karnataka",
"pickupPhone": "9876543210",
"pickupEmail": "dispatch@acme.com",
"pickupTin": "29ABCDE1234F1Z5",
"defaultParcel": { "weight": 500, "length": 20, "breadth": 15, "height": 10 },
"webhookUrl": "https://api.example.com/webhooks/shipping/clickpost/platform"
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Caller lacks adminSetting:read |
GET /admin/shipping/clickpost/active-couriers — Active couriers
Calls ClickPost's fetch-accounts API with the saved platform API key and returns every active account, each with its cpId and accountCode. The code is derived from the account's courier name (uppercased, separators → _), falling back to a canonical spelling when we already have one for that courier — so activating a new courier partner on the ClickPost dashboard makes it selectable here with no code change. The config UI uses this to offer only couriers that will actually work and to prefill the courier map.
Returns an empty array — never an error — when the API key isn't saved yet or ClickPost is unreachable, so an optional lookup never breaks the page.
Response 200 — ActiveCourier[].
{
"data": [
{
"code": "DELHIVERY",
"courierName": "Delhivery",
"cpId": 4,
"accountCode": "DL_ACME_002"
}
],
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Caller lacks adminSetting:read |
PATCH /admin/shipping/clickpost/config — Update config
Partial update — fields not in the body are left untouched. The body is .strict() so unknown keys are rejected at the zod layer. Each field maps to a setting under admin.shipping.clickpost.* and is written via SettingsService.setMany (audit row per changed key, one transaction).
Body — same shape as the vendor PATCH:
{
"apiKey": "cp_live_...",
"username": "acme-warehouse",
"webhookSecret": "whsec_...",
"pickupPincode": "560001",
"enabledCouriers": ["DELHIVERY", "BLUEDART"],
"courierMap": {
"DELHIVERY": { "cpId": 4, "accountCode": "DL_ACME_002" },
"BLUEDART": { "cpId": 12, "accountCode": "BD_ACME_001" }
},
"pickupName": "Acme Central Warehouse",
"pickupAddress": "123 Industrial Area",
"pickupCity": "Bengaluru",
"pickupState": "Karnataka",
"pickupPhone": "9876543210",
"pickupEmail": "dispatch@acme.com",
"pickupTin": "29ABCDE1234F1Z5",
"defaultParcel": { "weight": 500, "length": 20, "breadth": 15, "height": 10 }
}| Field | Type | Constraints | Setting key |
|---|---|---|---|
apiKey | string? | Trimmed; 1..500 chars | clickpost.api_key |
username | string? | Trimmed; 1..200 chars | clickpost.username |
webhookSecret | string? | Trimmed; 32..500 chars | clickpost.webhook_secret |
pickupPincode | string? | Trimmed; exactly 6 digits (/^\d{6}$/) | clickpost.pickup_pincode |
enabledCouriers | string[]? | Each entry non-empty | clickpost.enabled_couriers |
courierMap | Record<string, { cpId: number; accountCode: string }>? | cpId positive int; accountCode 1..120 chars | clickpost.courier_map |
pickupName | string? | Trimmed; 1..120 chars | clickpost.pickup_name |
pickupAddress | string? | Trimmed; 1..500 chars | clickpost.pickup_address |
pickupCity | string? | Trimmed; 1..120 chars | clickpost.pickup_city |
pickupState | string? | Trimmed; 1..120 chars | clickpost.pickup_state |
pickupPhone | string? | Trimmed; 6..20 chars | clickpost.pickup_phone |
pickupEmail | string? | Valid email, max 200 chars | clickpost.pickup_email |
pickupTin | string? | Trimmed; 1..40 chars | clickpost.pickup_tin |
defaultParcel | { weight, length, breadth, height }? | All four positive integers; weight in grams, rest in cm. Required in practice — see below | clickpost.default_parcel |
defaultParcelis not optional in practice.getCredentialsrejects a config without it, andbuildCreateOrderPayloadsendslength/breadth/heightfrom it on every shipment — ClickPost's v3 payload carries no per-item dimensions, so this is the only source. Onlyweightbehaves as a true fallback (item.weight ?? defaultParcel.weight).
enabledCouriersgates nothing on the server. It drives the config UI's courier picker, but no code path validates a shipment against it:resolveCourierandmethodsForPlatformboth key offcourierMap. A courier is usable when it has acourierMapentry, whether or not it appears inenabledCouriers.
Response 200 — updated ClickPostConfigResponse (re-read after the write).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod, or a value fails its registry schema |
| 403 | FORBIDDEN | Caller lacks adminSetting:update |
Fulfillment flow
When an admin marks a sub-order fulfilled via POST /admin/orders/vendors/:orderVendorId/fulfilled with providerId="clickpost" and credentialSource="platform", ClickPostShippingProvider.createShipment reads these settings instead of the vendor's. credentialSource="vendor" reads the seller's own vendor.admin.shipping.clickpost.* keys.
GET /admin/orders/vendors/:orderVendorId/fulfillment-options returns both accounts with their selectable couriers; the platform account's list is the keys of the courierMap configured here — so a courier with no mapping simply cannot be picked.
The remaining mechanics (v3 payload construction, async 102/202 handling, variant weight/dimension fallbacks) are identical to the vendor flow — see vendor/shipping-clickpost.md.
Automatic courier assignment
Off by default. When enabled, an order that is placed has a courier chosen for it straight away — but nothing is booked until an operator says so.
The two steps
| Step | Trigger | What happens | Cost |
|---|---|---|---|
| Assign | order.placed | ClickPost's recommendation API is asked which of the operator's active accounts service this pickup → drop lane. The best-ranked one is recorded against the sub-order. | None — no waybill is created |
| Confirm | An operator clicks Confirm shipment | The order is created on ClickPost with the assigned account and an AWB comes back. | A real, billable shipment |
The sub-order stays pending through both. It becomes fulfilled only when
ClickPost reports the courier collected the parcel (status code 4,
PickedUp), so the customer's "shipped" notification lines up with the parcel
actually moving.
Assignment runs for sub-orders processed on the platform (central warehouse) account. Sub-orders a seller ships under their own ClickPost setup keep the manual courier picker.
Settings
Both live on Admin → Settings → Shipping.
| Key | Type | Default | Meaning |
|---|---|---|---|
clickpost.auto_assign_enabled | boolean | false | Master switch. Off, nothing is queued and nothing changes. |
clickpost.auto_assign_account | enum | platform | Which account to book against. Only platform is supported. |
The recommendation call
POST https://www.clickpost.in/api/v1/recommendation_api/?key=<API_KEY>
Two things differ from every other ClickPost endpoint we call:
- It authenticates with
keyalone — passingusernameis wrong here. - The request body is a JSON array, not an object.
length, breadth, height and weight are documented as optional but are
always sent from clickpost.default_parcel: without them ClickPost omits
shipping_charge, which is the figure that makes an assignment reviewable.
An unserviceable lane comes back as HTTP 200 with meta.success: false and
"Pin code not serviceable" — a verdict, not an outage. It is recorded on the
assignment as a recommendation failure and not retried, because retrying
cannot change the answer. Timeouts and 5xx are retried with backoff.
Every attempt is on the order log
Assignment runs unattended, so each decision it makes — including each decision
to do nothing — writes one row to the order's event log, readable at
GET /admin/orders/:id/events and on the order's Events tab. Without them
"the courier was never picked" has no visible cause.
eventType | When |
|---|---|
shipment.auto_assign_queued | A job was enqueued for the order or one bag. |
shipment.auto_assign_succeeded | A carrier was picked. metadata carries cpId, accountCode, cpName, shippingChargeRupees and the runners-up. |
shipment.auto_assign_skipped | Deliberately did nothing. changes.reason says which: feature_disabled, vendor_account_selected, vendor_fulfilled, not_configured (with the missing setting keys), order_not_found, order_cancelled, sub_order_not_found, no_pending_bags, bag_already_dispatched, already_claimed. |
shipment.auto_assign_failed | An attempt failed. changes.reason is the message, metadata.stage is enqueue, recommendation or job, and metadata.request is the parcel we quoted. |
A ClickPost verdict also carries metadata.diagnostics — the provider's own
response body — so an unserviceable lane can be diagnosed without a replay.
Only the final job attempt logs stage: "job"; the retries in between are
already on the order as the stage that raised them.
Courier map is bypassed
Assignment books on the raw cp_id + account_code the recommendation
returns, so it can pick an account that was never added to
clickpost.courier_map. Those two identifiers are authoritative; the method
code stored alongside them is for display and is derived from the
account_code (not cp_name, which would collapse several accounts of one
carrier into a single name).
Because such a courier is absent from the picker's method list,
fulfillment-options adds it explicitly — otherwise the picker would drop the
selection and silently swap the carrier.
When a carrier declines
ClickPost ranks several carriers, and a booking that the top one rejects is
offered to the next — under the same reference_number, which is what
ClickPost asks for when retrying one shipment across carriers. The runners-up
are stored on the assignment at recommendation time, so the fallback needs no
second recommendation call.
At most three carriers are tried. The bound matters: a malformed payload —
a bad drop pincode, say — is refused by every carrier in turn, and without a
limit that reads as "nobody would take it" rather than as the payload problem
it is. Each refusal is recorded on the assignment's attempts, so the first
carrier's reason survives even when a later one succeeds.
Only a carrier declining triggers the fallback. A timeout, an unreachable upstream or an unpaid order would fail identically at the next carrier, so those stop immediately.
Packing: one parcel per bag, or per order
admin.shipping.shipment_grouping decides how a multi-vendor order is handed
to the courier.
| Value | Behaviour |
|---|---|
per_vendor (default) | One shipment per vendor bag. Right when sellers ship from their own warehouses. Unchanged from before this setting existed. |
per_order | One shipment for the whole order — one waybill, one label, one reference. Only coherent when every bag leaves from the same warehouse. |
In per_order mode the recommendation is asked once per order, using the
combined weight and the order's total, because that is the parcel that will
actually be booked. Booking then writes a single shipment against the order and
mirrors the waybill onto every bag it covers, so tracking, returns and the
existing admin surfaces keep resolving shipping the way they always have.
Each sub-order keeps its own fulfilment status, payouts and returns — vendor accounting still needs them. Only the shipment consolidates.
Consequences worth knowing:
- The courier receives the order's invoice value and, for COD, the full amount due at the door. The label declares the same, so the collected sum matches what is in the box.
invoice_numberis order-scoped, drawn from its own sequence. Per-vendor tax invoices are unaffected — a parcel carrying several suppliers' goods still has one tax invoice each.- One tracking callback advances every bag the waybill covers.
- Cancelling one bag voids the shared waybill and re-books the remainder, since both the contents and the COD total have changed.
- The reference carries
0in the vendor position —ORD-26-00000001-0-1— marking a parcel that belongs to no single vendor.
The manual Fulfill action always acts on a single bag, whichever mode is set: it is a per-bag control, and it stays the way out when a consolidated booking is rejected.
Outcomes
The assignment row carries one of:
| Status | Meaning |
|---|---|
assigned | A courier is chosen and the shipment is awaiting confirmation. |
booked | Confirmed — a shipment exists and an AWB is live. |
failed | Nothing serviceable, or the carrier rejected the booking. failure_stage says which step. |
skipped | ClickPost is not configured on this deployment. Nothing is wrong. |
Failures never block an order and never block fulfilment: the manual picker keeps working exactly as it did before.
Printing labels
A confirmed shipment has an AWB while its sub-order is still pending, which
is precisely when its label needs printing. Admin → Print Labels therefore
lists orders with a booked shipment rather than fulfilled ones. ClickPost's own
label URL is kept on the shipment as a per-shipment fallback for couriers that
reject third-party artwork.
Shipment references
reference_number is unique per shipment and capped at 20 characters. It is
ORD-<yy>-<serial>-<vendorIndex>-<dispatchSequence> — e.g.
ORD-26-00000001-1-1. The dispatch counter matters: a re-dispatch after an
RTO is a new shipment and needs a new reference, and ClickPost answers a
reused one with status 323 reported as success, which would silently
attach the earlier dispatch's waybill. The same reference is used for the
recommendation and the later order creation, as ClickPost advises.
Webhook receiver
Tracking events for platform-fulfilled bags arrive at POST /webhooks/shipping/clickpost/platform, HMAC-verified against admin.shipping.clickpost.webhook_secret and resolved by AWB globally (not vendor-scoped). See webhooks/shipping-clickpost.md.
A pending sub-order holding a live AWB is promoted to fulfilled on the first checkpoint that shows the courier has the parcel — 4 PickedUp normally, but any of in-transit, out-for-delivery or delivered will do it. That fallback matters: without it a missed or out-of-order pickup scan would strand the order in pending for good, with no delivered transition, no delivered mail, no rewards and no payout. Returns are deliberately excluded, since backfilling one would email the customer that an order shipped as it comes back.
Checkpoint times on those pushes are read as wall clock in admin.shipping.clickpost.webhook_timezone (default Asia/Kolkata) — ClickPost sends the courier's local time suffixed Z. The key is edited on Admin → Settings → Shipping rather than through the endpoints above, and covers the per-vendor webhooks too.
Rollout
Deploying automatic assignment or per-order grouping has a required order — check the database, migrate, deploy the worker, configure, then enable. See ClickPost automation — rollout.
Related modules
shipping— provider-agnostic config;clickpostmust appear inadmin.shipping.enabled_providers. Seeadmin/shipping.md.settings—admin.shipping.clickpost.*keys live in the admin settings registry under theshippinggroup; these endpoints are a typed wrapper. Seeadmin/settings.md.order— admin fulfillment dispatch and thecredentialSourcechoice. Seeadmin/order.md.admin-rbac— gates every endpoint viaadminSetting:*. Seeadmin/admin-rbac.md.
ClickPost automation — rollout
How to deploy automatic courier assignment and per-order shipment grouping: the pre-migration check, the deploy order, what to verify at each step, and how to turn either feature off again.
Shipping Labels — Admin
HTTP surface for an admin to batch-print courier shipping labels for many orders at once — one label per vendor on them — merged into a single downloadable PDF, with async status polling and retry of failed labels.