IT
Webhooks v2
rel. 0.1.0

Introduction

Draft — webhooks v2 are not live yet. This page describes the contract we are about to open; it may change before release. The TODO boxes mark the points still to be decided. For today's webhooks see the v1 documentation.

Purpose

A webhook is a POST that Qapla' makes to a URL of yours when something happens to a shipment: its status changes, the courier records a new event. Instead of polling the API to find out whether anything changed, you receive the fact as soon as we know it.

Webhooks v2 are the generation that goes with the v2 API:

Configuration

A webhook is configured from the Control Panel, in Channel settings → Updates → “Webhook v2”. The same page is where you send tests, rotate the secret, browse and replay deliveries, and reactivate a switched-off endpoint. The endpoint belongs to the channel: with several channels, configure it on each of them, even with the same URL. Each channel can have up to 5 endpoints (disabled ones count too), for example the ERP receiving only status changes and customer care receiving every event.

There are no public management routes today: creating, editing, testing, rotating the secret and replaying are done from the Control Panel. Your side of the contract is receiving, which is what this page describes.

Field Meaning
url Where we send. https:// only, at most 2048 characters, no credentials in the URL (https://user:pass@… is rejected). The host must resolve to public addresses only: private, loopback, link-local and similar addresses are rejected both when saving and on every send, after DNS resolution.
events The subscribed event types. Today only shipment.updated (Event types).
trigger status_change (default), first_occurrence or every_event (Triggers).
statuses The statuses to receive. Empty = all (Status filter).
include Optional payload sections. Empty by default; today it only accepts consignee (Optional sections).
alertEmail Required. Who to warn when the endpoint stops responding (Alerts and switch-off). Use the address of whoever built the integration, not the accounting one.
status active or disabled. Disabled by hand, or automatically after 3 days of failures; reactivated from the Control Panel.

On creation the Control Panel shows the signing secret, shaped as whsec_ followed by 64 hex characters. It is shown only once: copy it into your receiver's configuration right away. If you lose it, generate a new one (Secret rotation).

Configuring a v2 endpoint switches off v1 for the channel. From then on the channel no longer receives v1 webhooks, so nothing arrives twice. Your v2 receiver must be ready before you create the endpoint. Switching the v2 endpoint off does not bring v1 back, deleting it does: see Migrating from v1.

Events

Event types

type When
shipment.updated A tracking event of one of the channel's shipments became a delivery according to the endpoint's trigger and status filter.
webhook.test On demand only, from the test button in the Control Panel (Test event). Not subscribable.

More types (labels, returns) will join the same catalogue. The type comes both in the body (type) and in the X-Qapla-Event-Type header.

Triggers

The trigger decides when a shipment event becomes a delivery to your endpoint. A Qapla' status is a status + detail pair (for instance EXCEPTION with detail "held in stock"): the first two triggers work on that pair.

trigger Fires when Use case
status_change
default
the Qapla' status or detail of the shipment changes. Three "in transit" events at three different hubs produce one delivery "send me actual status changes only". This is how v1 behaves
first_occurrence the shipment enters a status + detail pair for the first time. Going back to a pair already seen sends nothing "tell me when it is in transit, but once"
every_event every status change and every new courier event, even when the status does not change (new place or date) "I want the full history"

A first_occurrence example. A shipment is held because the recipient was away, then held for a damaged parcel, then held again because the recipient was away. The first two fire (two different pairs, same EXCEPTION status); the third does not, that pair was already delivered.

The trigger in use comes in the payload as data.trigger.

Status filter

statuses is a list of Qapla' status names. Empty = all statuses. It applies after the trigger and on the status only, never on the detail: ["EXCEPTION"] takes every exception, whatever its detail. If you need specific details only, filter them on your side with data.event.statusDetailId.

Allowed values: WAITING_TO_COMPUTE, PENDING, INFO_RECEIVED, IN_TRANSIT, OUT_FOR_DELIVERY, FAILED_ATTEMPT, EXCEPTION, DELAY, PICKUP_POINT, DEPARTED, PROCESSING, RETURNED, DELIVERED. An unknown name is a configuration error.

"Deliveries only" is status_change + statuses: ["DELIVERED"].

Optional sections

include adds payload sections that are absent by default. Today it accepts a single value:

consignee Adds data.shipment.consignee (recipient name, address, email, phone). It is absent by default: order reference and tracking number are enough to match the event to your order, and you already know the recipient. Turn it on only if you really need it (personal data minimisation, GDPR art. 5.1.c).

There is still one schema: the consignee field is either there or not, its shape does not change. include is a list rather than a yes/no so that it can take more sections without breaking the contract.

Test event

From the Control Panel you can send a webhook.test event to the endpoint right away. It is signed with your real secret, with the same headers as a real delivery: it is how you check the signature before going live. The Control Panel shows the HTTP status you returned, the duration and any error.

Your receiver must answer 2xx to webhook.test as well, and must not treat it as a real update.

    

Payload

Structure

One event per request, no batches. Content-Type: application/json, UTF-8.

Field Meaning
id(string) Event identity, evt_…. Unchanged across retries and replays: it is your deduplication key (Ordering and duplicates). Also sent as the X-Qapla-Event-Id header.
type(string) shipment.updated or webhook.test.
createdAt(string) When the event was created on our side, ISO 8601 with offset (2026-10-03T09:41:07+02:00).
apiVersion(string) "2". An incompatible change of the resource will be a new apiVersion, for the API and the webhook together.
data.trigger(string) The trigger of the endpoint that produced the delivery.
data.shipment(object) The shipment in its current state: the v2 API ShipmentSummary resource, the same as each item of GET /v2/shipments. The full schema is in Swagger UI (Schemas section → ShipmentSummary) and in openapi.json (#/components/schemas/ShipmentSummary). No consignee unless include: ["consignee"]. For parcels, order lines and the full history: GET /v2/shipments/{id} with data.shipment.id.
data.event(object) The fact that produced the delivery, shaped as an item of history[] (TrackingHistoryEvent): date, courierStatus (the courier's text), place, statusCode (Qapla' status name), plus statusDetailId (detail id, 0 = none). status and statusDetail (descriptions) are null today.

The payload is built at send time. data.event is the original fact, data.shipment is the shipment as it is now. An "in transit" retried two hours later may arrive with event.statusCode: IN_TRANSIT and shipment.status already "delivered". For the same reason a retry or a replay is not byte-identical to the previous send: the id is what stays the same.

New fields may appear in the resource (and therefore in the webhook) without an apiVersion change: your receiver must ignore fields it does not know.

Status representation (open in the v2 API). Today data.shipment.status is the status integer (4), while data.event.statusCode uses its name (OUT_FOR_DELIVERY), as history[].statusCode does. data.event.status and data.event.statusDetail are null. To be decided in the v2 API before this contract is frozen: the webhook inherits the choice.

Date offsets (open in the v2 API). createdAt carries an offset (+02:00, Rome time), but the shipment dates (statusDate, statusUpdatedAt) and data.event.date are YYYY-MM-DD HH:MM:SS without offset. The database runs on a fixed time_zone: how to expose the offset is to be decided in the API, and therefore here.

Ordering and duplicates

Arrival order is not guaranteed. Deliveries are independent and may travel in parallel: an old event being retried does not hold back newer ones. This is deliberate: serial delivery would keep a "delivered" waiting for hours behind a temporary error on an earlier event.

Discard rule: ignore an event if its data.shipment.statusDate is older than the one you already stored for that shipment. That way a late event never overwrites a more recent status.

The same event may arrive more than once: a retry after a timeout in which you had already processed the request, a replay. Use id (or X-Qapla-Event-Id) as the idempotency key: if you have already processed it, answer 2xx and do nothing else. A failed delivery can be replayed for 30 days (Replay): keep the ids at least that long.

When several endpoints of the same channel subscribe to the same event, each gets its own delivery with the same id.

Answer before doing the heavy work: store the event, answer 2xx, process it afterwards. You have 10 seconds overall, and an answer past the timeout counts as a failure even if you processed it.

Security

Signature

Every request carries the header:

X-Qapla-Signature: t=1791015120,v1=961fde4b87a60528ed28100be4badc6dcf1d64ce84b82f2a3fe9b077f45c0d2a

To verify:

  1. read the raw body, the bytes as they arrived, before any JSON parsing. Re-encoding the JSON changes whitespace and escapes and the signature no longer matches;
  2. extract t and every v1 from the header;
  3. reject the request if t differs from your clock by more than 5 minutes (protection against replaying an intercepted request);
  4. compute the HMAC-SHA256 of t + "." + body with your secret and compare it with each v1 in constant time (hash_equals, crypto.timingSafeEqual, hmac.compare_digest), never with ==;
  5. invalid signature: answer 401 or 403 and drop it. It will not be retried (Retry).

It is the same scheme as the Courier Events API, which uses the signature in the opposite direction.

Verifying the signature

Complete implementations, no external dependencies. All three accept the request if at least one v1 matches, so they keep working during a rotation.

<?php
/**
 * True when at least one v1 in X-Qapla-Signature matches and t is within the tolerance.
 * $rawBody: the request body exactly as received, before any json_decode.
 */
function qaplaSignatureIsValid(string $rawBody, string $header, string $secret, int $tolerance = 300, ?int $now = null): 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) || abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Usage
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_QAPLA_SIGNATURE'] ?? '';
if (!qaplaSignatureIsValid($rawBody, $header, getenv('QAPLA_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody);
const crypto = require('crypto');

// rawBody: Buffer with the request body exactly as received (express.raw, not express.json).
function qaplaSignatureIsValid(rawBody, header, secret, toleranceSec = 300, now = Math.floor(Date.now() / 1000)) {
  let timestamp = null;
  const signatures = [];
  for (const part of String(header || '').split(',')) {
    const i = part.indexOf('=');
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value);
  }

  if (!/^\d+$/.test(timestamp || '') || Math.abs(now - Number(timestamp)) > toleranceSec) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((signature) => {
    const received = Buffer.from(signature, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

// Usage with Express: the body must stay a Buffer
app.post('/qapla/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!qaplaSignatureIsValid(req.body, req.get('X-Qapla-Signature'), process.env.QAPLA_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(204);
});
import hashlib
import hmac
import time


def qapla_signature_is_valid(raw_body: bytes, header: str, secret: str, tolerance: int = 300, now: float | None = None) -> bool:
    """raw_body: the request body exactly as received (e.g. Flask request.get_data())."""
    timestamp, signatures = None, []
    for part in (header or "").split(","):
        key, sep, value = part.strip().partition("=")
        if not sep:
            continue
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    if timestamp is None or not timestamp.isdigit():
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)

# Usage with Flask
@app.post("/qapla/webhook")
def qapla_webhook():
    if not qapla_signature_is_valid(request.get_data(), request.headers.get("X-Qapla-Signature", ""), os.environ["QAPLA_WEBHOOK_SECRET"]):
        abort(401)
    event = request.get_json()
    return "", 204
Test vector

To check your implementation without waiting for a real send:

body 90-signature-test-body.json (883 bytes, one line, no trailing newline: use the file as is)
secret whsec_3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f
previous secret whsec_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1
header t=1791015120,v1=961fde4b87a60528ed28100be4badc6dcf1d64ce84b82f2a3fe9b077f45c0d2a,v1=6e5964a9d6a80fd60aca19bdbb62ddd531a4490e3ad7b37ba1bc9a2bcf4b3499

Verification must succeed with either secret and fail with any other. t is in the past: for the test, turn off the tolerance check or pass as "now" an instant within 5 minutes of 1791015120 (the samples take a now parameter).

Secret rotation

From the Control Panel you generate a new secret, shown only once. The old one stays valid for 24 hours: in that window every request carries two v1 values, first the new secret's, then the old one's. Afterwards, only the new one.

To rotate without losing deliveries:

  1. generate the new secret from the Control Panel;
  2. within 24 hours put it in your receiver's configuration in place of the old one. Requests keep passing with either configuration, because they carry both signatures.

Rotate immediately if the secret ended up where it should not (logs, repositories, tickets).

Outbound IPs

The authentication mechanism is the signature, not the source address: always verify X-Qapla-Signature, even if you filter by IP.

If you still need to configure a firewall, the addresses our calls come from are published here, as plain text, one per line: https://cdn.qapla.it/sys/219a40582012cf87b88a782bdfe3a569. The list can change: if you use it, re-read it periodically rather than copying it once.

Check that webhooks v2 leave from the addresses in the list (or add theirs) before the first send.

Delivery

Request

POST{your url}
Header Value
Content-Type application/json
User-Agent Qapla-Webhooks/2
X-Qapla-Event-Id same as id in the body (evt_…)
X-Qapla-Event-Type same as type in the body
X-Qapla-Delivery-Attempt attempt number, from 1. Starts again from 1 after a replay
X-Qapla-Signature t=…,v1=… (Signature)

Response

Success = any 2xx. The response body is ignored: there is no need to answer {"result":"OK"} as in v1, an empty 204 is fine.

Outcome What we do
2xx delivered
timeout, connection or TLS error, DNS not answering we retry
408, 429, 5xx we retry. You may send Retry-After (Retry)
3xx, other 4xx (400, 401, 404, 410, …) we do not retry: the delivery fails at once and remains available for replay

A 4xx says "this request will never be acceptable". If the problem is temporary on your side (deploy in progress, database down), answer 503, not 400: otherwise the event does not come back on its own.

Retry

A retriable outcome puts the delivery back in the queue with a growing delay, with ±20% jitter so that everything does not restart at the same instant. At most 8 attempts, spread over about 22 hours:

After attempt Wait (nominal) Next attempt, from the first
11 minute1 min
25 minutes6 min
315 minutes21 min
41 hour1 h 21 min
53 hours4 h 21 min
66 hours10 h 21 min
712 hours22 h 21 min (eighth and last attempt)
8—the delivery moves to the failed ones (DLQ)

Retry-After (seconds or HTTP date) can only lengthen the wait, up to 12 hours: if it says less than the scheduled backoff, the backoff applies. It does not add attempts.

Alerts and switch-off

An endpoint that fails for days is not retried forever:

Non-2xx outcomes and connection errors count as failures. A failed test event does not.

When the endpoint is switched off (automatically or by hand):

It is reactivated from the Control Panel (Channel settings → Updates → “Webhook v2”). It restarts clean, with no flood of old events. Catch up on the gap with GET /v2/shipments?updatedAfter=…, starting from the last update you received.

Replay

Failed deliveries (retries exhausted, rejected with a 4xx, or cut short by the endpoint being switched off) can be sent again from the Control Panel, one by one or by time range, for 30 days from the creation of the delivery. For each delivery the Control Panel shows status, attempts, last HTTP status and error.

A replay puts the delivery back in the queue as new: same id, X-Qapla-Delivery-Attempt starting again from 1, up to 8 attempts again, and the payload rebuilt with the shipment as it is at the new send.

The deliveries list and replay are in Channel settings → Updates → “Webhook v2”.

Reference

Migrating from v1

v2 does not replace v1 automatically: it is opt-in, by configuring a v2 endpoint on the channel, because it requires changes to your code. v1 (documentation) stays as it is, with no new features: filters and triggers exist only in v2. There is no end date for v1 yet; it will be announced 12 months in advance.

v1 v2
Authenticity the channel apiKey in clear in the body no credentials in the body; HMAC signature in X-Qapla-Signature
Success the response body must be {"result":"OK"} any 2xx, the body is ignored
Payload v1 format envelope with the v2 API ShipmentSummary + the event
Consignee data only with fullData enabled (opt-in): name, address, email only with include: ["consignee"] (Optional sections)
When on every status change chosen trigger + statuses filter
Transport http or https https only, verified certificate, no redirects, 10 s timeout
Errors few attempts, no replay 8 attempts over ~22 h, failed deliveries replayable for 30 days, alert and switch-off

If you receive on http:// today, you need a valid TLS certificate on your endpoint before moving to v2.

A channel with a v2 endpoint stops receiving v1. No double sends, but no period in which you receive both either: the v2 receiver must be in production before you create the endpoint.

Suggested procedure
  1. build the v2 receiver: signature check, deduplication on id, discard rule on statusDate, fast 2xx answer;
  2. test it with the test vector;
  3. put it in production next to the v1 receiver, on a different URL;
  4. create the v2 endpoint from the Control Panel and store the secret: from here v1 stops for the channel;
  5. send a test event and check the outcome;
  6. catch up on any update missed during the switch with GET /v2/shipments?updatedAfter=….

Switched off and deleted are not the same thing for v1.

  • A switched-off v2 endpoint (by hand or after 3 days of failures) keeps the channel out of v1: the channel receives neither v2 nor v1 until you reactivate it;
  • a deleted v2 endpoint does not: if no other v2 endpoint is left on the channel, the channel goes back to receiving v1, provided the v1 webhook is still configured.

To pause v2 without receiving v1 again, switch the endpoint off; to go back to v1, delete it.

Examples

File Content
01-shipment-updated.json shipment.updated with trigger status_change, no consignee
02-webhook-test.json the test event, with the fixed sample shipment
90-signature-test-body.json raw body of the signature test vector

Open points

The TODO boxes on this page must be closed before publishing:

Contact: tech@qapla.it