API and webhooks
Verifying webhook signatures
Check that a webhook really came from TrackParcel, with code for Node.js, PHP and Python. Events, headers, retries and replays.
Updated
A webhook is a POST we send to your server when something happens to a shipment, so you don't have to keep asking the API. Every webhook is signed with a secret only you and TrackParcel know. Check the signature before trusting a request: anyone can send a POST to your address, but only TrackParcel can sign it.
Setting up an endpoint
- Owners and Admins add endpoints under Developers → Webhooks, or through the API with a key that has
webhooks:manage. Starter has 1 endpoint, Growth 3 and Pro 10. - The address must use HTTPS, on port 443 or 8443. Choose the events to send and add a description if you like.
- The secret, starting
whsec_, is shown once, when the endpoint is created. Seeing it again later needs your authenticator code. - “Send test” sends a
webhook.testevent so you can check your code; the status your server answered and how long it took show at once. Tests are never retried and don't count as failures. - Customers' contact fields are off unless you turn them on: see Customer contact fields below.
Events
| Event | Sent when |
|---|---|
shipment.created | A shipment is added, by any route |
shipment.status_changed | Its status changes |
shipment.out_for_delivery | It goes out for delivery |
shipment.delivered | It is delivered (data.provisional is true until the next check confirms it) |
shipment.confirmed | The next check confirms a provisional delivery or return (data.provisional is false) |
shipment.failed_attempt | A new failed delivery attempt |
shipment.exception | An exception starts (data.exception.type says which) |
shipment.event_added | Every new courier update (Growth and above) |
shipment.archived, shipment.unarchived | It is archived or restored |
webhook.test | You press Send test, or ask for one through the API |
{
"id": "evt_01JA3…",
"type": "shipment.status_changed",
"created_at": "2026-10-12T10:42:13+05:30",
"org_id": "c0a8…",
"data": {
"shipment": { "…": "the shipment, as in the API" },
"previous_status": "in_transit",
"status": "out_for_delivery",
"event": {"at": "2026-10-12T08:02:00+05:30", "location": "PUNE", "activity": "Out For Delivery"},
"sequence": 7,
"provisional": false
}
}Provisional statuses
When a courier first reports a parcel delivered or returned, we check it again before we stop tracking it. Until then the webhook carries data.provisional: true. When the next check agrees, you get shipment.confirmed with data.provisional: false. If it disagrees, you get shipment.status_changed with the corrected status instead, and no shipment.confirmed. Every other webhook has data.provisional: false.
Customer contact fields
- Off by default:
data.shipment.customerthen holds the destination city only. Tracking numbers, order ids and statuses are always sent. - An Owner or Admin turns them on for one endpoint at a time under Developers → Webhooks, with two-step sign-in (a code from your authenticator app). The API can't turn them on or off.
- With them on,
customeralso has the name, phone, email, address, state and pincode you gave for the shipment. - The workspace's Owners get a notice in the app when contact fields are turned on, when an endpoint is added and when its URL changes, whoever made the change: a teammate, an API key or TrackParcel support. Support can turn contact fields off, never on.
Headers
| Header | Value |
|---|---|
TrackParcel-Event-Id | The event's id, the same every time it is sent. Use it to ignore repeats. |
TrackParcel-Delivery-Id | This attempt's id. |
TrackParcel-Timestamp | When it was sent, in Unix seconds. |
TrackParcel-Signature | t=1760245933,v1=5257a869e7ec… |
User-Agent | TrackParcel-Webhooks/1.0 |
How to check the signature
- Read the body as raw bytes, before any JSON parsing: re-encoding JSON changes the bytes and breaks the signature.
- Split
TrackParcel-Signatureon commas.tis the timestamp; eachv1is a signature. - Work out HMAC-SHA256 of
t, a full stop, and the raw body, using your whole secret (includingwhsec_) as the key, and write it in hex. - Accept the request if your result equals any of the v1 values, comparing in constant time.
- Reject it if the timestamp is more than 300 seconds away from your clock, so an old request can't be replayed.
Node.js
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.TRACKPARCEL_WEBHOOK_SECRET; // the whole secret, whsec_…
const TOLERANCE_SECONDS = 300;
export function verifyTrackParcelSignature(rawBody, header, secret = SECRET) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") timestamp = value;
if (key === "v1" && value) signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody) // the exact bytes received, before any JSON parsing
.digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
app.post("/webhooks/trackparcel", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyTrackParcelSignature(req.body, req.get("TrackParcel-Signature"))) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Skip events you have seen (event.id), queue the work, and answer quickly.
res.sendStatus(200);
});PHP
<?php
// The whole secret as shown, whsec_…
$secret = getenv('TRACKPARCEL_WEBHOOK_SECRET');
$payload = file_get_contents('php://input'); // the exact bytes received
$header = $_SERVER['HTTP_TRACKPARCEL_SIGNATURE'] ?? '';
function trackparcel_verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$timestamp = $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1];
}
}
if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
if (!trackparcel_verify($payload, $header, $secret)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// Skip events you have seen ($event['id']), queue the work, and answer quickly.
http_response_code(200);Python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["TRACKPARCEL_WEBHOOK_SECRET"] # the whole secret, whsec_...
TOLERANCE_SECONDS = 300
def verify_trackparcel_signature(payload: bytes, header: str, secret: str = SECRET) -> bool:
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1" and value:
signatures.append(value)
if not timestamp or not timestamp.isdigit() or not signatures:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
@app.post("/webhooks/trackparcel")
def trackparcel_webhook():
payload = request.get_data() # the exact bytes received
if not verify_trackparcel_signature(payload, request.headers.get("TrackParcel-Signature", "")):
abort(400)
event = request.get_json()
# Skip events you have seen (event["id"]), queue the work, and answer quickly.
return "", 200Delivery and retries
- Answer with any 2xx status within 10 seconds. Do the slow work after answering.
- If we don't get one, we try again: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, 8 attempts over about 45 hours in all.
- A
410 Goneanswer switches the endpoint off straight away. After 100 failures in a row, or 72 hours in which every delivery failed, it is switched off and the Owner and Admins get a notice in the app. When you switch it back on, you can replay the failed deliveries from the last 72 hours. - Webhooks can arrive out of order. Use
data.sequence, which rises with each status change of a shipment, andcreated_atto put them back in order. - The delivery log shows every attempt: the time, event, attempt number, the status you returned, how long it took, any error and the first 1 KB of your reply. Any delivery can be sent again from there.
To add and read shipments, start with the API quickstart.