Guide to ClickPost Webhooks: Features and Best Practices
ClickPost webhooks keep your systems up to date with every shipment you track through it. ClickPost collects scans from your carrier partners, normalises them into one set of status codes, and posts each update to your endpoint, so you can notify customers, update order status and flag delivery problems without polling carrier APIs yourself.
This guide covers the two kinds of ClickPost tracking webhook, what each request contains, how to authenticate it with the Simple Token, how ClickPost retries failed deliveries, and the practices that keep a ClickPost receiver accurate.
What are ClickPost webhooks?
ClickPost gets status updates from carriers, by carrier webhooks where they exist and by polling where they don't. Whenever a new scan comes in, ClickPost sends a JSON POST to the URL you configured. There are two webhooks, configured and retriggered independently:
- All Status fires "for every scan update, not only when the status changes". If a carrier reports several scans with the same status, you get several calls.
- Selected Status fires only for the checkpoints you opt into in the dashboard, and adds
notification_event_idto theadditionalobject. If ClickPost receives several carrier notifications at the same time, only the latest is sent.
ClickPost doesn't sign webhook requests. Instead, each webhook carries a credential you configure, and your endpoint checks it. The options are a Simple Token, an x-api-key header, a webhook-key header, Basic Auth, or, for enterprise accounts, a Dynamic Token fetched from your own auth endpoint.
ClickPost webhook features
| Feature | Details |
|---|---|
| Configuration | Dashboard: Settings > Notifications > Webhooks, with All Status and Selected Status tabs |
| Method | POST with a JSON body |
| Trigger | Every new carrier scan (All Status), or chosen checkpoints only (Selected Status) |
| Status field | clickpost_status_code (integer), at the root and in additional.latest_status |
| Authentication | Simple Token (Authorization: Token <token>), X-API-Key, Webhook-Key, Basic Auth or Dynamic Token. No signature |
| Success | HTTP 200, 201, 202 or 204 |
| Timeout | 5 seconds |
| Retries | Up to 6, at 30, 60, 90, 120, 150 and 180 minutes after the first failure |
| Ordering | Not guaranteed |
| Deduplication key | waybill plus scan_id |
| Manual resend | Retrigger from the dashboard, which sends the shipment's latest status |
| Source IPs | Not published |
Status codes and payloads
ClickPost's status vocabulary is numeric. Route on clickpost_status_code rather than on clickpost_: ClickPost says the text fields are "subject to change". The common tracking codes are:
| Code | Description |
|---|---|
1 | OrderPlaced |
2, 3, 4, 25 | PickupPending, PickupFailed, PickedUp, OutForPickup |
5 | InTransit |
6 | OutForDelivery |
8 | Delivered |
9 | FailedDelivery |
10 | Cancelled |
11 to 15, 21 | Return to origin (RTO): requested, marked, out for delivery, delivered, failed, in transit |
16, 17, 18, 20 | Lost, Damaged, ShipmentDelayed, ShipmentHeld |
Quick-commerce shipments add codes from 40 upwards, such as 42 RiderAllocated and 48 PartialDelivered, and returns and exchanges have their own codes from 101. The full tables are on ClickPost's tracking status codes page. Selected Status webhooks also carry additional.notification_, which is a separate numbering from clickpost_status_code.
Every body is a flat scan record plus an additional object. Here is ClickPost's All Status example, trimmed:
{
"waybill": "SF49245NER",
"cp_id": 9,
"status": "OFD",
"remark": "Shipment is Out for Delivery",
"location": "DEL_GeetaColony",
"timestamp": "2019-05-06T10:04:20Z",
"clickpost_status_code": 6,
"clickpost_status_description": "OutForDelivery",
"additional": {
"latest_status": {
"clickpost_status_code": 6,
"clickpost_status_description": "OutForDelivery",
"clickpost_status_bucket": 4,
"clickpost_status_bucket_description": "Out for delivery",
"status": "OFD",
"timestamp": "2019-05-06T10:04:20Z",
"location": "DEL_GeetaColony",
"remark": "Shipment is Out for Delivery",
"reference_number": "WERA-9616974"
},
"is_rvp": false,
"courier_partner_edd": "2020-03-25",
"order_id": "ABC123"
}
}
The root fields describe this scan. additional.latest_status describes the shipment as it stands after ClickPost's de-duplication, and ClickPost recommends it for customer-facing notifications. The full field list also includes account_code, scan_id, coordinates, rider details, NDR codes for failed deliveries and NPR codes for failed pickups. Note that timestamp is in the courier's timezone, not necessarily UTC.
ClickPost also warns against strict JSON schema validation: "new fields [are] added without prior notice".
Setting up ClickPost webhooks
- In the ClickPost dashboard, go to Settings, then Notifications and Webhooks.
- Choose the All Status tab, or Selected Status and pick the checkpoints you want.
- Enter your HTTPS endpoint URL.
- Choose Simple Token authentication and enter a long random token, for example the output of
openssl rand -hex 32. - Save. Configure the other tab separately if you need both.
With Hookdeck. Choose Simple Token authentication on the ClickPost webhook and paste the token. Create a Hookdeck source with the ClickPost type and paste only the token into the Token field. ClickPost sends it as Authorization: Token <token>, and Hookdeck handles the Token prefix. Use the source URL as the webhook URL in ClickPost. The source accepts POST only, which is the method ClickPost uses. Hookdeck's ClickPost source checks the Simple Token only, not the X-API-Key, Webhook-Key, Basic Auth or Dynamic Token options.
Securing ClickPost webhooks
Read the Authorization header, check that the scheme is Token (not Bearer), and compare the rest with your token in constant time. Do this before you parse the body.
const crypto = require('crypto');
const express = require('express');
const TOKEN = process.env.CLICKPOST_WEBHOOK_TOKEN; // the bare token, without "Token "
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyClickPost(authorization) {
if (!TOKEN || typeof authorization !== 'string') return false;
if (authorization.slice(0, 6).toLowerCase() !== 'token ') return false; // scheme is case-insensitive
return safeEqual(authorization.slice(6), TOKEN);
}
const app = express();
app.post('/webhooks/clickpost', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyClickPost(req.get('authorization'))) return res.sendStatus(401);
let scan;
try {
scan = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Deduplicate on scan.waybill + scan.scan_id, queue the scan, then respond within 5 seconds.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
TOKEN = os.environ.get("CLICKPOST_WEBHOOK_TOKEN", "")
def verify_clickpost(authorization) -> bool:
if not TOKEN or not authorization:
return False # fail closed when unconfigured or missing
if authorization[:6].lower() != "token ":
return False
# Compare bytes: compare_digest raises TypeError on a non-ASCII str.
return hmac.compare_digest(authorization[6:].encode("utf-8"), TOKEN.encode("utf-8"))
A static token authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS, a long random token, and replace it in ClickPost and in your receiver together if it might have leaked.
Make ClickPost webhooks production-ready. Hookdeck Event Gateway checks the Simple Token on every scan, answers ClickPost straight away, and queues each update durably until your service has processed it.
ClickPost webhook limitations and pain points
A 5-second timeout and long gaps between retries
The Problem: A brief slowdown on your side delays a tracking update by half an hour or more.
Why It Happens: ClickPost waits 5 seconds for a response. A timeout or an unsuccessful status schedules a retry 30 minutes later, then at 60, 90, 120, 150 and 180 minutes after the first failure, and that is the last attempt. A customer-facing "out for delivery" message can arrive after the parcel has been delivered.
Workarounds:
- Respond as soon as the token checks out, and do the work in the background.
- After an outage, use the dashboard's retrigger, which sends each shipment's latest status.
How Hookdeck Can Help: Hookdeck answers ClickPost straight away and stores the update, so slow processing on your side never counts as a timeout. Hookdeck then delivers to your service with retries you control, and you can replay any stored scan.
Is 204 a success?
The Problem: Your endpoint returns 204 No Content and you see the same scan again.
Why It Happens: ClickPost's success table lists 200, 201, 202 or 204, but its retry rule says a retry is scheduled "if your endpoint does not return HTTP 200, 201, or 202".
Workarounds:
- Return
200to avoid the question. - Deduplicate anyway, because retries resend the same payload.
How Hookdeck Can Help: Hookdeck answers ClickPost with a 200 and lets your service respond however it likes.
Out of order and repeated scans
The Problem: A shipment shows "in transit" after it was delivered, or a customer gets the same message twice.
Why It Happens: Carriers report late, so "scans may arrive out of chronological order". All Status fires on every scan, including several with the same status, and retries repeat a payload while its latest_status may have moved on.
Workarounds:
- Deduplicate on
waybillplusscan_id. ClickPost saysscan_idis globally unique and increases monotonically. - Compare
timestamp(orscan_id) before you overwrite a shipment's state, and drive customer messages fromadditional.latest_status.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on waybill and scan_id before they reach your service.
A single shared token
The Problem: A wrong or rotated token turns every scan into a failed delivery.
Why It Happens: The Simple Token is a static credential set once per webhook tab, and nothing about it is tied to the request body.
Workarounds:
- Rotate by accepting the old and new token in your receiver until ClickPost has the new one.
- Use HTTPS, and keep the token out of logs.
How Hookdeck Can Help: Hookdeck's ClickPost source compares the token you paste. Hookdeck answers ClickPost with a 200 either way, then records a request without the right token as failed verification, so it never reaches your destination, and you can see exactly which Authorization value arrived.
Best practices
Route on codes, not text
Switch on clickpost_status_code. Treat clickpost_, the raw status and remark as display text that can change.
Use the right status for the job
Use the root clickpost_status_code for scan history, and additional.latest_ for anything a customer sees, as ClickPost recommends.
Parse permissively
Ignore unknown fields and expect null in nullable ones. ClickPost adds fields over time, and a strict schema will start rejecting valid scans.
Make the endpoint idempotent
ClickPost asks for this explicitly. Store waybill and scan_id before you act, and skip pairs you've seen. See our guide to webhook idempotency.
Acknowledge fast
Five seconds is a short window. Check the token, store the scan, return 200, and process afterwards. See why to process webhooks asynchronously.
Conclusion
ClickPost posts a JSON scan record for every carrier update (All Status) or for the checkpoints you choose (Selected Status), authenticated by a static credential rather than a signature. Use the Simple Token and check Authorization: Token <token>, route on clickpost_status_code, deduplicate on waybill and scan_id, and answer within 5 seconds, because a missed response costs a 30-minute wait.
Hookdeck Event Gateway sits in front of your receiver, checks the token, answers ClickPost at once, and keeps every scan until your service has processed it.
Get started with Hookdeck for free and handle ClickPost webhooks reliably in minutes.