Webhook — ClickPost (Shipping)
Public HTTP surface that ClickPost's servers call to deliver shipment tracking events. The route is vendor-scoped (/:vendorId) so each vendor configures their own ClickPost…
Public HTTP surface that ClickPost's servers call to deliver shipment tracking events. There are two routes: a vendor-scoped one (/:vendorId) so each vendor configures their own ClickPost dashboard with a unique URL keyed by the per-vendor webhook_secret, and a platform one (/platform) for bags shipped from the central warehouse under the platform ClickPost account. Both share one handler: it authenticates the shared secret, resolves the target sub-order from the AWB (falling back to the echoed order_id), records the event idempotently on (provider_id, external_event_id), projects the latest normalized status onto order_vendor.metadata.tracking_status, and emits SHIPPING_TRACKING_UPDATED. Terminal delivered / returned events opportunistically drive the sub-order state machine via SHIPMENT_FULFILLMENT_PORT.
Resolving the sub-order
The AWB is tried first — order_vendor.awb_number, scoped to the vendor on the vendor route, global on the platform route, and every bag carrying that waybill is resolved, not just one. When no row carries it, the handler falls back to the order_id ClickPost echoes back from the create-order call, matched against order.order_number through buildOrderNumberFilter. Both forms resolve:
order_id in the payload | Matched as |
|---|---|
ORD-2026-00000123 | exact order number |
4028593 (bare legacy display id) | padded serial suffix — %-04028593 |
The bare form matters during the migration window: a dispatch booked on the legacy portal has no AWB on the sub-order until the delta migration re-reads the legacy row, and ClickPost echoes that portal's display id rather than our order number. Migrated numbers keep the legacy display id as their serial (ORD-<year>-<display_id>), so the two line up.
The fallback resolves only when it lands on exactly one sub-order. A bare serial matches the tail of the order number, so it can in principle hit two years' orders; and a multi-vendor order has one sub-order per bag, with nothing in the payload to say which one shipped. In either case the event is dropped with a 404 and a warning is logged — flipping an unrelated customer's order to delivered is the worse failure. In practice the serial namespace is shared: the migration bumps order_number_seq past the highest legacy display id.
When the fallback resolves a sub-order that has no AWB on record, the waybill is written to order_vendor.awb_number (and tracking_code, if empty) so subsequent events take the fast path and the tracking view has something to render. The write is guarded on the column still being null, so a concurrent booking wins.
Async order acceptance (ClickPost v3): When ClickPost responds to a create-order call with
meta.status102or202, the order is accepted but no AWB is assigned yet. ClickPost delivers the AWB assignment as a subsequent tracking webhook event to this endpoint. Until that event arrives the sub-order's AWB field is empty; once received the AWB and label URL are populated automatically.
Source:
api-modules/shipping-clickpost/src/controllers/clickpost-webhook.controller.ts
Authentication
ClickPost does not sign the webhook body. The operator saves a credential on the ClickPost dashboard (Webhook URLs → Auth Configuration) and ClickPost replays it on every push. All four credential modes are supported, because the secret is matched against every inbound header — the "Custom Webhook Hyphen Key" mode lets the operator name the header, so pinning one name would break it.
| Dashboard mode | What ClickPost sends | Accepted |
|---|---|---|
| X Api Key Auth | X-Api-Key: <key> | yes |
| Simple Token Auth | Authorization: <key> (bare, or Bearer/Token) | yes |
| Basic Auth Token | Authorization: Basic <base64> — decoded whole, or the password half of user:pass | yes |
| Custom Webhook Hyphen Key | operator-named header carrying the key | yes |
| None | no credential | no — rejected with 401 |
An HMAC-SHA256 hex digest of the raw body in x-clickpost-signature is also accepted, for a signing proxy in front of the API. That header is excluded from the token scan, so the raw secret sent there is not treated as a credential.
| Property | Value |
|---|---|
Secret source (/:vendorId) | Per-vendor settings — vendor.shipping.clickpost.webhook_secret (read via VendorSettingsService.getGroup(vendorId, 'admin', 'shipping')). Not an environment variable |
Secret source (/platform) | Platform settings — admin.shipping.clickpost.webhook_secret |
| Comparison | Constant-time (crypto.timingSafeEqual); a blank credential fails closed |
Failure modes deliberately leak nothing about vendor existence: a missing secret returns the same 404 NOT_FOUND as a missing AWB, so an attacker can't probe for valid vendor ids from response codes. External error messages stay generic; detail goes to the server log.
The webhook URL to paste into ClickPost is derived from the PUBLIC_API_BASE_URL env var by ClickPostConfigService and surfaced in the config UI of the matching panel — getView() for the vendor panel, getPlatformView() for Admin → Plugins → ClickPost.
Endpoints
POST /webhooks/shipping/clickpost/:vendorId — Receive a ClickPost tracking event
Path params
| Name | Type | Notes |
|---|---|---|
vendorId | string | Target vendor id — must match the AWB's vendor scope |
Headers — one credential carrying this vendor's webhook_secret, in any of the forms listed under Authentication.
Body — Lenient parse (clickpostWebhookPayloadSchema); ClickPost payloads vary by courier integration, so only the fields we depend on are validated and the full body is preserved on shipping_event.payload. Unknown fields pass through.
{
// AWB / waybill — whichever the courier integration sets.
// String or number; coerced to string.
"waybill": "SF49245NER",
"order_id": "ORD-2026-00000001", // echoed back from create-order
// ClickPost's own courier-independent status. This is what we map.
"clickpost_status_code": 6,
"clickpost_status_description": "OutForDelivery",
// The courier's own code — stored for the audit trail, never mapped.
"status": "OFD",
"remark": "Shipment is Out for Delivery",
"location": "DEL_GeetaColony",
"timestamp": "2019-05-06T10:04:20Z",
"cp_id": 9,
"additional": {
// The same checkpoint, duplicated. Read as a fallback when the
// top level omits a field.
"latest_status": {
"clickpost_status_code": 6,
"clickpost_status_bucket": 4,
"location": "DEL_GeetaColony",
"remark": "Shipment is Out for Delivery",
"timestamp": "2019-05-06T10:04:20Z"
},
"courier_partner_edd": "2026-08-14", // promised delivery date
"notification_event_id": 5
}
// ...everything else passes through onto shipping_event.payload
}| Extracted field | How | Required |
|---|---|---|
| AWB | awb ?? awb_number ?? waybill | yes — 400 if absent |
| Order reference | order_id, else additional.order_id — only read when the AWB matches no sub-order | no |
| Status code | clickpost_status_code, else additional.latest_status.clickpost_status_code | yes, unless a courier status string is present — 400 if neither |
| Raw status | First non-empty of status, status_code, additional_info, the nested status, then the ClickPost description — stored on shipping_event.status_code | no |
| Occurred at | timestamp, else the nested one, read as wall clock in admin.shipping.clickpost.webhook_timezone → shipping_event.occurred_at | no — falls back to received_at |
| Location | location, else the nested one | no |
| Description | remark, else the nested one, else clickpost_status_description | no |
| EDD | additional.courier_partner_edd → order_vendor.metadata.tracking_edd | no |
| External event id | event_id when present, else synthesized as `<awb>|<code>|<timestamp>` | no — null only when the payload has no timestamp |
Checkpoint timestamps are local wall clock, not UTC
ClickPost stamps timestamp with the courier account's local wall clock and suffixes it Z. Taken at face value that pushes every checkpoint forward by the account's UTC offset — 5h30m for an Indian account, enough to show the next day's date on a scan after 18:30 local.
The handler therefore drops the trailing offset and re-reads the wall clock in admin.shipping.clickpost.webhook_timezone (default Asia/Kolkata), so 2026-08-19T18:55:04Z is stored as the instant 2026-08-19T13:25:04Z. The setting is platform-level and applies to the per-vendor webhooks too — the convention is ClickPost's, not per-seller. Set it to UTC for an account that genuinely reports UTC; an unknown zone logs a warning and falls back to the default.
The synthesized external_event_id keys off the raw timestamp string, so the idempotency key is unaffected by the setting.
Status codes are normalized via ClickPostStatusMapperService.toNormalized, which maps ClickPost's own numeric codes — not the courier's status string, which differs per carrier:
clickpost_status_code | Normalized status |
|---|---|
0–3, 25, 28 (OrderPlaced, PickupPending, PickupFailed, OutForPickup, AwbRegistered) | pending |
4 (PickedUp) | dispatched |
5, 18, 20, 1004–1006 (InTransit, Delayed, Held, hub scans) | in_transit |
6 (OutForDelivery) | out_for_delivery |
8, 48 (Delivered, PartialDelivered) | delivered |
7, 9, 10, 16, 17, 19, 23 (NotServiceable, FailedDelivery, Cancelled, Lost, Damaged, ContactCustomerCare, Expired) | failed |
11–15, 21, 26, 27, 50, 52 (the RTO family) | returned |
| anything unmapped | clickpost_status_bucket if mappable, else pending (logged so ops can extend the map) |
Response 200
{
"data": {
"accepted": true,
"eventId": "01J9...", // shipping_event row id
"normalizedStatus": "delivered",
"duplicate": false // true when (provider_id, external_event_id) already exists
},
"message": "Success",
"statusCode": 200
}POST /webhooks/shipping/clickpost/platform — Receive a platform-warehouse tracking event
Register this URL on the platform ClickPost account (the one configured under Admin → Plugins → ClickPost). Identical body, headers, normalization, response shape, and side effects as the vendor route, with two differences:
- The credential is checked against
admin.shipping.clickpost.webhook_secret. - The sub-order is resolved globally rather than scoped to a vendor — admin-fulfilled bags may belong to any vendor. Both the AWB lookup and the
order_idfallback drop the vendor predicate.
The recorded shipping_event and any resulting state-machine flip use source: "clickpost-platform-webhook" instead of "clickpost-webhook", so the audit trail distinguishes the two accounts.
Side effects
For every accepted event:
ShippingEventService.recordappends ashipping_eventrow with provider, external event id, raw status code, normalized status, the courier'soccurred_at/location/description, and the full payload preserved as jsonb.- On a fresh event (not a duplicate),
order_vendor.metadatais jsonb-merged with{ tracking_status, tracking_status_at }— plustracking_eddwhen the payload carried one — so the order detail view doesn't need a separate join.tracking_status_atis the courier's scan time, falling back to receipt time. - On a fresh event,
SHIPPING_TRACKING_UPDATEDis emitted with{ orderId, orderVendorId, vendorId, providerId, statusCode, normalizedStatus, eventId, occurredAt }. Duplicates emit nothing. - The shipment row's status (and
delivered_at) is updated from the checkpoint, then the sub-order flip below runs, and only then is theshipping_eventrow stampedmetadata.processed_at.
State-machine flips (run for any checkpoint not yet stamped processed_at — see Idempotency):
| Normalized status | Sub-order state | Action |
|---|---|---|
delivered | fulfilled | SHIPMENT_FULFILLMENT_PORT.markDelivered({ actorType: "webhook", source: "clickpost-webhook" }) → OrderDeliveryService.markVendorDelivered |
returned | fulfilled or pending | SHIPMENT_FULFILLMENT_PORT.markReturned({ awbNumber, source: "clickpost-webhook" }) → OrderDeliveryService.markRTOFromShipping — flips sub-order to returned and creates the matching order_return row |
The flip goes through the neutral SHIPMENT_FULFILLMENT_PORT bound by @sc/order, so an install without OrderModule still records checkpoints (the webhook logs that no port is bound and returns 200).
Both order-side calls swallow ConflictException — if the sub-order has already moved to a terminal state (already delivered, cancelled, etc.) the webhook treats it as a successful no-op and the response is still 200.
Idempotency / retry handling
Safe to receive duplicates. Idempotency is enforced explicitly on (provider_id, external_event_id) via ShippingEventRepository.append:
- ClickPost sends no
event_idof its own, so the handler synthesizes one from`<awb>|<clickpost_status_code>|<timestamp>`. The same checkpoint redelivered produces the same key; a later scan produces a different one. When already recorded,record()returns{ inserted: false }and the response carriesduplicate: true— no secondshipping_eventrow, no event emit. - The dedup index does not gate the side effects. The
shipping_eventrow doubles as an outbox: the shipment update and the sub-order flip run for any checkpoint whosemetadata.processed_atis unset, and the stamp is written only after both succeed. A handler that dies (or an order-side call that throws) between the insert and the flip is therefore repaired by ClickPost's next redelivery — or by an admin replay — instead of being acked as a duplicate and dropped forever. A redelivery of an already-stamped checkpoint changes nothing. - A payload with no timestamp yields no stable key (
external_event_idnull); those deliveries are all treated as fresh. Duplicate state-machine flips are then guarded byOrderDeliveryService's own conflict-on-already-terminal logic. - Terminal state-machine transitions (
markVendorDelivered,markRTOFromShipping) swallowConflictExceptionand log + continue, so re-running a flip — whether from a duplicate or from a repaired delivery — leaves the order state consistent.
When the push never arrives — polling
A webhook registered late, an outage, or a dispatch booked on the legacy portal all leave a parcel moving with nothing on our timeline. For those, the tracking view pulls ClickPost's own record instead of waiting: GET https://api.clickpost.in/api/v2/track-order/ (ClickPost docs). Note the host — polling is on api., every other ClickPost endpoint we call is on www.. Override with CLICKPOST_TRACKING_API_BASE.
Trigger. A shipment card with zero recorded checkpoints, read through any tracking surface (/track, the store and vendor tracking endpoints, admin order tracking). A bag that is cancelled, or that was never booked (no AWB, no dispatch row, still pending), is never polled — the courier has never heard of it.
Lookup key. The waybill is the precise key but ClickPost also requires the courier's cp_id, which is recovered from the shipment assignment or from order_vendor.metadata.courier_partner_id. A dispatch booked outside this platform gave us neither, so the fallback is order_ids — the order_id we send at create-order, plus the bare legacy display id for a migrated order — which needs no cp_id at all. Both forms take up to 5 comma-separated keys per call.
| Situation | Call |
|---|---|
AWB on record and cp_id recoverable | ?waybill=<awb>&cp_id=<cp_id> |
| Anything else, or the waybill lookup came back empty | ?order_ids=ORD-2026-04028593,4028593 |
What happens to the result. Every scan is recorded through the same ShippingEventService.record path a webhook takes — same shipping_event rows, same projection onto order_vendor.metadata.tracking_status, same SHIPPING_TRACKING_UPDATED emit — and then the latest status advances the sub-order exactly as the missed webhook would have: a post-handover scan fills in a missed pickup, then delivered / returned flip the bag. Polling is a catch-up mechanism, not a second source of truth.
Idempotency. Polled checkpoints key on ClickPost's own checkpoint_id (cp-<id>), which is stable across re-polls, so polling the same journey twice appends nothing. A scan with no checkpoint_id falls back to the webhook's synthesized `<awb>|<status_code>|<timestamp>` key. The two sources use different key shapes when ClickPost sends a checkpoint_id, so a webhook that starts arriving after a poll can re-record a scan the poll already had — the poll only runs on an empty timeline, which bounds this to shipments whose push feed was broken at the time.
Bounds. One call per sub-order per 5 minutes, claimed in Redis before the request so a burst of page refreshes makes one request and an upstream outage isn't hammered. result: null (ClickPost's answer for an unknown waybill) is a miss, not a failure. A response matching two parcels is dropped rather than guessed at. Any error is logged and swallowed — a courier that is down must not take the tracking page with it.
Failure modes
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | req.rawBody missing; body is not valid JSON; body fails the lenient clickpostWebhookPayloadSchema; payload missing AWB; payload carrying neither a ClickPost status code nor a courier status string |
| 401 | UNAUTHORIZED | No inbound header carried the vendor's webhook_secret (and no valid HMAC signature) |
| 404 | NOT_FOUND | No webhook_secret configured for the target (vendor or platform) or no order_vendor row matches the AWB and the order_id fallback found no order — or more than one. All cases return the same error to avoid leaking which one failed |
Unmapped ClickPost status codes do not raise — they fall through to pending and are logged. Order-side ConflictExceptions (sub-order already in terminal state) are caught and ignored; other order-side errors propagate as 500.
Related
- /Users/ashik/Codes/superlabs/supercommerce/docs/shipping.md — full shipping module doc: two-layer model, vendor config, tracking endpoints, ClickPost provider settings (
api_key,username,pickup_pincode,enabled_couriers,webhook_secret). - /Users/ashik/Codes/superlabs/supercommerce/docs/order.md —
OrderDeliveryService.markVendorDeliveredandmarkRTOFromShippingsemantics;order_vendor.fulfillmentStatustransitions;order_returnrow creation on RTO.
Webhook — Razorpay (Payments)
Public HTTP surface that Razorpay's servers call to deliver payment lifecycle events for orders placed through the platform's Razorpay provider. Verifies HMAC-SHA256 against the…
Storage — Presigned Upload
Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence…