Skip to main content
QCSQuick Courier ServiceQCS — Quick Courier Service, home

Developers

Webhooks

Subscribe to parcel events, verify the signature, make your handler idempotent, and replay a failed delivery.

Merchant developers3 min readPrintable handbook

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

  1. 01Expose an https endpointA public https URL that answers within 10 seconds. Private, loopback and cloud metadata addresses are refused by the outbound guard.
  2. 02Subscribe to the events you needPick named events, or order.status_changed to receive every status change on one endpoint.
  3. 03Store the signing secretIt is shown once, on creation or rotation. Keep it in your server configuration, never in client code.
  4. 04Answer 2xx fastAcknowledge first, process afterwards. A slow handler looks like a failure and earns a retry.

Events

Subscribable events
EventMeans
order.bookedParcel booked
order.pickup_requestedParcel forwarded for collection
order.pickup_assignedPickup rider assigned
order.picked_upParcel collected from you
order.pickup_failedParcel not handed over
order.at_warehouseParcel received at the warehouse
order.out_for_deliveryParcel out for delivery
order.deliveredParcel delivered
order.on_holdDelivery attempt held
order.refusedDelivery refused
order.returningParcel returning to the warehouse
order.cancelledParcel cancelled
order.returned_to_shipperParcel returned to you
order.lostParcel declared lost
order.damagedParcel declared damaged
order.status_changedEvery status change

Request headers

Headers on every delivery
HeaderCarries
x-qcs-eventThe event name, so you can route without parsing the body.
x-qcs-delivery-idThe delivery id. Use it to make your handler idempotent.
x-qcs-environmentSANDBOX or LIVE.
x-qcs-signatureThe timestamp and the HMAC-SHA256 digest, in the form t=<unix seconds>,v1=<64 hex characters>.

Payload

Example delivery body
{
  "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.

Node
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
<?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]);
}
Python
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

  1. 01Verify firstAn unverified request is not a QCS request. Reject it with 401 before reading the body.
  2. 02Deduplicate on the delivery idStore x-qcs-delivery-id and ignore a repeat. A retry after your 500 will arrive with the same id.
  3. 03Treat events as unorderedNetwork retries mean a later event can arrive first. Compare the status against what you already hold rather than assuming sequence.
  4. 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.

Next