Developers
Webhooks
Subscribe to parcel events, verify the signature, make your handler idempotent, and replay a failed delivery.
A webhook tells your system a parcel moved, so you do not have to poll. Create endpoints at /m/integrations/webhooks or over the API. Payloads carry version 2026-01-01.
Setting one up
- 01Expose an https endpointA public https URL that answers within 10 seconds. Private, loopback and cloud metadata addresses are refused by the outbound guard.
- 02Subscribe to the events you needPick named events, or order.status_changed to receive every status change on one endpoint.
- 03Store the signing secretIt is shown once, on creation or rotation. Keep it in your server configuration, never in client code.
- 04Answer 2xx fastAcknowledge first, process afterwards. A slow handler looks like a failure and earns a retry.
Events
Request headers
Payload
{
"id": "whd_01hz...",
"version": "2026-01-01",
"event": "order.delivered",
"environment": "LIVE",
"createdAtIso": "2026-01-05T11:04:19.000Z",
"merchantCode": "QCS-M-00042",
"data": {
"order": {
"orderId": "ord_01hz...",
"trackingNumber": "QCS-260105-000123",
"merchantOrderRef": "SHOP-1042",
"status": "DELIVERED",
"previousStatus": "OUT_FOR_DELIVERY",
"statusLabel": "Delivered",
"serviceType": "STANDARD",
"cityName": "Lahore",
"zoneName": "Lahore City",
"area": "Johar Town",
"pieceCount": 1,
"chargeableWeightGrams": 1500,
"codAmountPaisa": 350000,
"totalChargePaisa": 26000,
"merchantNetPayablePaisa": 324000,
"attemptCount": 1,
"isReturnPending": false,
"reasonCode": null,
"reasonText": null,
"occurredAtIso": "2026-01-05T11:04:18.000Z",
"deliveredAtIso": "2026-01-05T11:04:18.000Z",
"receivedByName": "Hira Nadeem"
}
}
}Verifying the signature
The digest is HMAC-SHA256 over the timestamp, a full stop, and the exact raw request body. Capture the raw body before any JSON parsing, because re-serialising changes the bytes. Reject a timestamp more than 300 seconds away from your clock, and compare digests in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function qcsVerify(rawBody, headerValue, secret) {
const match = /^t=(\d{10,13}),v1=([0-9a-f]{64})$/.exec(headerValue ?? '')
if (match === null) {
return false
}
const timestamp = Number(match[1])
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
return false
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex')
const left = Buffer.from(expected, 'utf8')
const right = Buffer.from(match[2], 'utf8')
return left.length === right.length && timingSafeEqual(left, right)
}<?php
function qcs_verify(string $rawBody, ?string $headerValue, string $secret): bool
{
if ($headerValue === null || preg_match('/^t=(\d{10,13}),v1=([0-9a-f]{64})$/', $headerValue, $m) !== 1) {
return false;
}
$timestamp = (int) $m[1];
if (abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $m[2]);
}import hmac
import re
import time
from hashlib import sha256
QCS_SIGNATURE = re.compile(r"^t=(\d{10,13}),v1=([0-9a-f]{64})$")
def qcs_verify(raw_body: bytes, header_value: str, secret: str) -> bool:
match = QCS_SIGNATURE.match(header_value or "")
if match is None:
return False
timestamp = int(match.group(1))
if abs(int(time.time()) - timestamp) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, sha256).hexdigest()
return hmac.compare_digest(expected, match.group(2))Retries
- A delivery that does not answer 2xx is retried up to 6 times with a widening gap, from seconds to a day.
- Every attempt is recorded with the HTTP status and the first part of your response body, visible at /m/integrations/logs.
- An endpoint that keeps failing is disabled after 20 consecutive failures, so a dead URL does not consume the queue forever. Re-enable it once it is fixed.
- A failed delivery can be replayed from the portal or the API with a fresh signature.
Writing a handler that cannot double-process
- 01Verify firstAn unverified request is not a QCS request. Reject it with 401 before reading the body.
- 02Deduplicate on the delivery idStore x-qcs-delivery-id and ignore a repeat. A retry after your 500 will arrive with the same id.
- 03Treat events as unorderedNetwork retries mean a later event can arrive first. Compare the status against what you already hold rather than assuming sequence.
- 04Acknowledge, then workReturn 2xx immediately and do the slow part in your own queue.
Testing
Use a sandbox key and a sandbox endpoint while you build. The webhook tester at /m/integrations/webhooks sends a signed sample delivery to your URL so you can prove your verification works before a real parcel moves. If you need help, send the delivery id to cs@qcs.com.pk.