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:
- same schema as the API: the payload carries the shipment in the same
shape as
GET /v2/shipments, so an integration has one data model; - signed: every request carries an HMAC-SHA256 of the body, verifiable with a secret only you and we know;
- configurable: you choose when to receive (status changes only, the first time in a status, every courier event) and for which statuses;
- reliable: success = any
2xx, retries with backoff for about 22 hours, failed deliveries kept and replayable.
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.
- same envelope as
shipment.updated, with a fixed sample shipment (not one of yours);consigneeis there only if the endpoint hasinclude: ["consignee"]; - the
idlooks likeevt_test_…and changes on every test; - one attempt only: not retried and not listed among deliveries. A failed
test does not count towards alerts and switch-off; a successful one
(
2xx) brings the endpoint back to healthy, resetting the failure count.
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
tis the send time, in Unix seconds;v1is the lowercase hex HMAC-SHA256 of the string"<t>.<raw body>", keyed with the endpoint secret as is,whsec_prefix included;- during a secret rotation two
v1values arrive, one per valid secret: the request is authentic if at least one matches.
To verify:
- 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;
- extract
tand everyv1from the header; - reject the request if
tdiffers from your clock by more than 5 minutes (protection against replaying an intercepted request); - compute the HMAC-SHA256 of
t + "." + bodywith your secret and compare it with eachv1in constant time (hash_equals,crypto.timingSafeEqual,hmac.compare_digest), never with==; - invalid signature: answer
401or403and 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:
- generate the new secret from the Control Panel;
- 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) |
httpsonly, with the server certificate verified: an expired, self-signed or wrong-host certificate is a connection error;- 10 second timeout for the whole request, connection included;
- no redirects: a
3xxis not followed and counts as a final failure. Configure the final URL; - the host is resolved on every send and must resolve to public addresses only. If DNS does not answer, we retry; if it resolves to a non-public address, the delivery fails without retry.
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 |
|---|---|---|
| 1 | 1 minute | 1 min |
| 2 | 5 minutes | 6 min |
| 3 | 15 minutes | 21 min |
| 4 | 1 hour | 1 h 21 min |
| 5 | 3 hours | 4 h 21 min |
| 6 | 6 hours | 10 h 21 min |
| 7 | 12 hours | 22 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:
- once 24 hours have passed since the first failure
with no successful delivery, we send a first alert to
alertEmail; - once 3 days have passed, the endpoint becomes
disabled, with a second alert; - the thresholds depend on the time elapsed since the first failure, not on the number of attempts or on how many events arrive in the meantime;
- the count resets on the first
2xx, whether from a delivery or from a test event sent from the Control Panel: once your receiver is fixed, a successful test brings the endpoint back to healthy.
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):
- deliveries still waiting for a retry move to the failed ones at once, and remain available for replay;
- no events are generated while it is off: nothing piles up while it is down.
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
- build the v2 receiver: signature check, deduplication on
id, discard rule onstatusDate, fast2xxanswer; - test it with the test vector;
- put it in production next to the v1 receiver, on a different URL;
- create the v2 endpoint from the Control Panel and store the secret: from here v1 stops for the channel;
- send a test event and check the outcome;
- 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:
- status representation in the v2 API (integer or name);
- date offsets in the v2 API;
- outbound IPs of webhooks v2.
Contact: tech@qapla.it