Skip to content
IMPROVEDAuto-detect is smarter - the right courier, first timeTry it →

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.test event 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

Webhook events
EventSent when
shipment.createdA shipment is added, by any route
shipment.status_changedIts status changes
shipment.out_for_deliveryIt goes out for delivery
shipment.deliveredIt is delivered (data.provisional is true until the next check confirms it)
shipment.confirmedThe next check confirms a provisional delivery or return (data.provisional is false)
shipment.failed_attemptA new failed delivery attempt
shipment.exceptionAn exception starts (data.exception.type says which)
shipment.event_addedEvery new courier update (Growth and above)
shipment.archived, shipment.unarchivedIt is archived or restored
webhook.testYou press Send test, or ask for one through the API
Example payload
{
  "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.customer then 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, customer also 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

Webhook request headers
HeaderValue
TrackParcel-Event-IdThe event's id, the same every time it is sent. Use it to ignore repeats.
TrackParcel-Delivery-IdThis attempt's id.
TrackParcel-TimestampWhen it was sent, in Unix seconds.
TrackParcel-Signaturet=1760245933,v1=5257a869e7ec…
User-AgentTrackParcel-Webhooks/1.0

How to check the signature

  1. Read the body as raw bytes, before any JSON parsing: re-encoding JSON changes the bytes and breaks the signature.
  2. Split TrackParcel-Signature on commas. t is the timestamp; each v1 is a signature.
  3. Work out HMAC-SHA256 of t, a full stop, and the raw body, using your whole secret (including whsec_) as the key, and write it in hex.
  4. Accept the request if your result equals any of the v1 values, comparing in constant time.
  5. Reject it if the timestamp is more than 300 seconds away from your clock, so an old request can't be replayed.

Node.js

Node.js (Express)
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
<?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

Python (Flask)
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 "", 200

Delivery 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 Gone answer 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, and created_at to 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.

Contact support.

Still stuck? Talk to us

In the app, open Help → Contact support to write to us, and follow your conversations under My conversations. On the Free plan, email us at support [at] trackparcel.in.

We answer Monday to Saturday, 10:00–19:00 IST, excluding national holidays. Include your workspace's name and the tracking numbers involved, in full, and we can look straight away.

Support channels and first-reply targets by plan
PlanHow to reach usFirst reply
FreeHelp centre, and email3 business days, best effort
StarterEmail and the in-app form2 business days
GrowthEmail and in-app1 business day
ProEmail and in-appSame business day
EnterpriseA named contact and an onboarding call4 business hours