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 verifies HMAC, resolves the target sub-order from the AWB, 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 OrderService.
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
| Property | Value |
|---|---|
| Scheme | HMAC-SHA256 over the raw request body, hex-encoded |
| Header | x-clickpost-signature |
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) after lowercasing + trimming the provided value |
Verification happens against req.rawBody. 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
| Name | Required | Notes |
|---|---|---|
x-clickpost-signature | yes | Hex HMAC-SHA256 of the raw body, keyed by this vendor's webhook_secret |
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.
"awb": "DLV123456789",
"awb_number": "DLV123456789",
"waybill": "DLV123456789",
// Status code — first non-empty string of these wins.
"additional_info": "OD",
"status_code": "OD",
"status": "Delivered",
// Optional dedup + audit fields.
"event_id": "clickpost_evt_abc123",
"timestamp": "2026-05-13T10:00:00Z"
// ...everything else passes through onto shipping_event.payload
}| Extracted field | How | Required |
|---|---|---|
| AWB | awb ?? awb_number ?? waybill (via awbFromPayload) | yes — 400 if absent |
| Status code | First non-empty of additional_info, status_code, status (via statusCodeFromPayload) | yes — 400 if absent |
| External event id | event_id (nullable) | no — drives idempotency when present |
Status codes are normalized via ClickPostStatusMapperService.toNormalized (uppercased and looked up in CLICKPOST_STATUS_MAP):
| ClickPost codes | Normalized status |
|---|---|
OM, OP | pending |
OS, OT, INT | in_transit |
OO, OFD | out_for_delivery |
DEL, OD | delivered |
OR, RTO, RTD | returned |
OND, OUD, OC | failed |
| anything unmapped | 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 signature is verified against
admin.shipping.clickpost.webhook_secret. - The sub-order is resolved by AWB alone (globally, not scoped to a vendor) — admin-fulfilled bags may belong to any vendor.
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, 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 }so the order detail view doesn't need a separate join. - On a fresh event,
SHIPPING_TRACKING_UPDATEDis emitted with{ orderId, orderVendorId, vendorId, providerId, statusCode, normalizedStatus, eventId, occurredAt }. Duplicates emit nothing.
State-machine flips (only on the fresh insert; duplicates never re-drive the order):
| Normalized status | Sub-order state | Action |
|---|---|---|
delivered | fulfilled | OrderService.markVendorDelivered({ actorType: "webhook", source: "clickpost-webhook" }) |
returned | fulfilled or pending | OrderService.markRTOFromShipping({ awbNumber, source: "clickpost-webhook" }) — flips sub-order to returned and creates the matching order_return row |
Both order-side calls are wrapped in ConflictException catches — 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:
- When
event_idis present and already recorded,record()returns{ inserted: false }. The response carriesduplicate: true, no metadata refresh, no event emit, no state-machine flip. - When
event_idis absent (some courier integrations omit it), every delivery is treated as fresh — there is no fallback dedup key. Carriers withoutevent_idshould not be retried for at-least-once semantics; otherwise duplicate state-machine flips are guarded byOrderService's own conflict-on-already-terminal logic. - Terminal state-machine transitions (
markVendorDelivered,markRTOFromShipping) catchConflictExceptionand log + continue, so even if a duplicate makes it past event-level dedup the order state stays consistent.
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 missing status code |
| 401 | UNAUTHORIZED | HMAC signature header missing, malformed, or mismatched against the vendor's webhook_secret |
| 404 | NOT_FOUND | No webhook_secret configured for the target (vendor or platform) or no order_vendor row matches the AWB. The two 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 —
OrderService.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…