Supercommerce API Docs
Webhooks

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.status 102 or 202, 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

PropertyValue
SchemeHMAC-SHA256 over the raw request body, hex-encoded
Headerx-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
ComparisonConstant-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

NameTypeNotes
vendorIdstringTarget vendor id — must match the AWB's vendor scope

Headers

NameRequiredNotes
x-clickpost-signatureyesHex 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 fieldHowRequired
AWBawb ?? awb_number ?? waybill (via awbFromPayload)yes — 400 if absent
Status codeFirst non-empty of additional_info, status_code, status (via statusCodeFromPayload)yes — 400 if absent
External event idevent_id (nullable)no — drives idempotency when present

Status codes are normalized via ClickPostStatusMapperService.toNormalized (uppercased and looked up in CLICKPOST_STATUS_MAP):

ClickPost codesNormalized status
OM, OPpending
OS, OT, INTin_transit
OO, OFDout_for_delivery
DEL, ODdelivered
OR, RTO, RTDreturned
OND, OUD, OCfailed
anything unmappedpending (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.record appends a shipping_event row 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.metadata is 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_UPDATED is 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 statusSub-order stateAction
deliveredfulfilledOrderService.markVendorDelivered({ actorType: "webhook", source: "clickpost-webhook" })
returnedfulfilled or pendingOrderService.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_id is present and already recorded, record() returns { inserted: false }. The response carries duplicate: true, no metadata refresh, no event emit, no state-machine flip.
  • When event_id is absent (some courier integrations omit it), every delivery is treated as fresh — there is no fallback dedup key. Carriers without event_id should not be retried for at-least-once semantics; otherwise duplicate state-machine flips are guarded by OrderService's own conflict-on-already-terminal logic.
  • Terminal state-machine transitions (markVendorDelivered, markRTOFromShipping) catch ConflictException and log + continue, so even if a duplicate makes it past event-level dedup the order state stays consistent.

Failure modes

StatusCodeWhen
400BAD_REQUESTreq.rawBody missing; body is not valid JSON; body fails the lenient clickpostWebhookPayloadSchema; payload missing AWB; payload missing status code
401UNAUTHORIZEDHMAC signature header missing, malformed, or mismatched against the vendor's webhook_secret
404NOT_FOUNDNo 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.


On this page