EN
Webhook v2
rel. 0.1.0

Introduzione

Bozza — i webhook v2 non sono ancora attivi. Questa pagina descrive il contratto che stiamo per aprire; può cambiare prima del rilascio. I riquadri TODO segnano i punti ancora da decidere. Per i webhook attuali vale la documentazione v1.

Scopo

Un webhook è una chiamata POST che Qapla' fa verso un URL vostro quando succede qualcosa a una spedizione: cambia stato, il corriere registra un nuovo evento. Invece di interrogare l'API per sapere se qualcosa è cambiato, ricevete il fatto appena lo conosciamo.

I webhook v2 sono la generazione che accompagna l'API v2:

Configurazione

Un webhook si configura dal Control Panel, in Impostazioni canale → Aggiornamenti → «Webhook v2». Dalla stessa pagina si mandano i test, si ruota il secret, si consultano e si rimandano le consegne e si riattiva un endpoint spento. L'endpoint appartiene al canale: chi ha più canali lo configura su ognuno, anche con lo stesso URL. Ogni canale può avere fino a 5 endpoint (contano anche quelli spenti), per esempio l'ERP che riceve solo i cambi di stato e il customer care che riceve ogni evento.

Oggi non esistono rotte pubbliche di gestione degli endpoint: creazione, modifica, test, rotazione del secret e replay si fanno dal Control Panel. Il vostro lato del contratto è ricevere: è quello che questa pagina descrive.

Campo Significato
url Dove inviamo. Solo https://, al massimo 2048 caratteri, senza credenziali nell'URL (https://user:pass@… è rifiutato). L'host deve risolvere solo a indirizzi pubblici: indirizzi privati, loopback, link-local e simili sono rifiutati sia al salvataggio sia a ogni invio, dopo la risoluzione DNS.
events I tipi di evento sottoscritti. Oggi solo shipment.updated (Tipi di evento).
trigger status_change (default), first_occurrence o every_event (Trigger).
statuses Gli stati per cui ricevere. Vuoto = tutti (Filtro per stato).
include Sezioni opzionali del payload. Vuoto di default; oggi accetta solo consignee (Sezioni opzionali).
alertEmail Obbligatorio. L'indirizzo da avvisare se l'endpoint smette di rispondere (Avvisi e spegnimento). Mettete quello di chi ha fatto l'integrazione, non quello dell'amministrazione.
status active o disabled. Si spegne a mano o da solo dopo 3 giorni di errori; si riattiva dal Control Panel.

Alla creazione il Control Panel mostra il secret di firma, nella forma whsec_ seguito da 64 caratteri esadecimali. Viene mostrato una volta sola: copiatelo subito nella configurazione del vostro ricevitore. Se lo perdete, si genera un secret nuovo (Rotazione del secret).

Configurare un endpoint v2 spegne la v1 del canale. Da quel momento il canale non riceve più i webhook v1, per non ricevere tutto due volte. Il vostro ricevitore v2 deve essere pronto prima di creare l'endpoint. Spegnere l'endpoint v2 non riaccende la v1, cancellarlo sì: vedi Migrazione dalla v1.

Eventi

Tipi di evento

type Quando
shipment.updated Un evento di tracking di una spedizione del canale è diventato una consegna secondo il trigger e il filtro per stato dell'endpoint.
webhook.test Solo su richiesta, dal pulsante di test del Control Panel (Evento di test). Non si sottoscrive.

Altri tipi (etichette, resi) si aggiungeranno nello stesso catalogo. Il tipo arriva sia nel corpo (type) sia nell'header X-Qapla-Event-Type.

Trigger

Il trigger decide quando un evento della spedizione diventa una consegna verso il vostro endpoint. Lo stato Qapla' è una coppia stato + dettaglio (per esempio EXCEPTION con dettaglio «giacenza»): è su questa coppia che ragionano i primi due trigger.

trigger Parte quando Caso d'uso
status_change
default
cambia lo stato o il dettaglio Qapla' della spedizione. Tre eventi «in transito» in tre hub diversi producono una sola consegna «mandami solo gli effettivi cambi di stato». È il comportamento della v1
first_occurrence la spedizione entra per la prima volta in una coppia stato + dettaglio. Se torna in una coppia già vista, non parte niente «dimmi quando è in transito, ma una volta»
every_event ogni cambio di stato e ogni nuovo evento del corriere, anche a stato invariato (luogo o data nuovi) «voglio la history completa»

Esempio di first_occurrence. Una spedizione va in giacenza per destinatario assente, poi in giacenza per collo danneggiato, poi di nuovo per destinatario assente. Le prime due partono (sono due coppie diverse, stesso stato EXCEPTION); la terza no, quella coppia è già stata consegnata.

Il trigger in uso arriva nel payload come data.trigger.

Filtro per stato

statuses è una lista di nomi di stato Qapla'. Vuota = tutti gli stati. Si applica dopo il trigger e solo sullo stato, mai sul dettaglio: ["EXCEPTION"] prende tutte le eccezioni, qualunque sia il dettaglio. Se vi servono solo alcuni dettagli, filtrateli dal vostro lato con data.event.statusDetailId.

Valori ammessi: WAITING_TO_COMPUTE, PENDING, INFO_RECEIVED, IN_TRANSIT, OUT_FOR_DELIVERY, FAILED_ATTEMPT, EXCEPTION, DELAY, PICKUP_POINT, DEPARTED, PROCESSING, RETURNED, DELIVERED. Un nome sconosciuto è un errore di configurazione.

«Solo le consegne» è status_change + statuses: ["DELIVERED"].

Sezioni opzionali

include aggiunge al payload sezioni che di default non ci sono. Oggi accetta un solo valore:

consignee Aggiunge data.shipment.consignee (nome, indirizzo, email, telefono del destinatario). Di default non c'è: per agganciare l'evento al vostro ordine bastano riferimento ordine e tracking number, e il destinatario lo conoscete già. Attivatelo solo se vi serve davvero (minimizzazione dei dati personali, GDPR art. 5.1.c).

Lo schema resta uno: il campo consignee c'è o non c'è, non cambia forma. include è una lista e non un sì/no perché potrà accogliere altre sezioni senza rompere il contratto.

Evento di test

Dal Control Panel si può mandare subito un evento webhook.test all'endpoint. È firmato col vostro secret vero, con gli stessi header di una consegna reale: serve a verificare la firma prima di andare live. Il Control Panel mostra il codice HTTP che avete risposto, la durata e l'eventuale errore.

Il ricevitore deve rispondere 2xx anche a webhook.test, e non deve trattarlo come un aggiornamento reale.

    

Payload

Struttura

Un evento per richiesta, niente batch. Content-Type: application/json, UTF-8.

Campo Significato
id(string) Identità dell'evento, evt_…. Resta uguale nei retry e nei replay: è la vostra chiave di deduplica (Ordine e duplicati). Arriva anche nell'header X-Qapla-Event-Id.
type(string) shipment.updated o webhook.test.
createdAt(string) Quando è nato l'evento da noi, ISO 8601 con offset (2026-10-03T09:41:07+02:00).
apiVersion(string) "2". Un cambio incompatibile della risorsa sarà una nuova apiVersion, per l'API e per il webhook insieme.
data.trigger(string) Il trigger dell'endpoint che ha generato la consegna.
data.shipment(object) La spedizione, nello stato corrente: è la risorsa ShipmentSummary dell'API v2, la stessa di ogni elemento di GET /v2/shipments. Lo schema completo è in Swagger UI (sezione Schemas → ShipmentSummary) e in openapi.json (#/components/schemas/ShipmentSummary). Senza consignee salvo include: ["consignee"]. Per colli, righe d'ordine e history completa: GET /v2/shipments/{id} con data.shipment.id.
data.event(object) Il fatto che ha generato la consegna, nella forma di un elemento di history[] (TrackingHistoryEvent): date, courierStatus (il testo del corriere), place, statusCode (nome dello stato Qapla'), più statusDetailId (id del dettaglio, 0 = nessuno). status e statusDetail (descrizioni) oggi sono null.

Il payload si costruisce al momento dell'invio. data.event è il fatto originale, data.shipment è la spedizione com'è adesso. Un «in transito» ritentato due ore dopo può arrivare con event.statusCode: IN_TRANSIT e shipment.status già «consegnata». Per lo stesso motivo un retry o un replay non è identico byte per byte all'invio precedente: è uguale l'id.

Nuovi campi possono comparire nella risorsa (e quindi nel webhook) senza cambio di apiVersion: il ricevitore deve ignorare i campi che non conosce.

Rappresentazione dello stato (aperto nell'API v2). Oggi data.shipment.status è l'intero dello stato (4), mentre data.event.statusCode ne usa il nome (OUT_FOR_DELIVERY), come in history[].statusCode. data.event.status e data.event.statusDetail sono null. Va deciso nell'API v2 prima di congelare questo contratto: il webhook eredita la scelta.

Offset delle date (aperto nell'API v2). createdAt ha l'offset (+02:00, ora di Roma), ma le date della spedizione (statusDate, statusUpdatedAt) e data.event.date sono YYYY-MM-DD HH:MM:SS senza offset. Il database lavora con un time_zone fisso: va deciso come esporre l'offset, nell'API e quindi qui.

Ordine e duplicati

L'ordine di arrivo non è garantito. Le consegne sono indipendenti e possono viaggiare in parallelo: un evento vecchio in retry non blocca quelli nuovi. È una scelta: una consegna in serie farebbe aspettare ore una «consegnata» dietro un errore temporaneo di un evento precedente.

Regola di scarto: ignorate un evento se il suo data.shipment.statusDate è più vecchio di quello che avete già salvato per quella spedizione. Così un evento arrivato in ritardo non sovrascrive uno stato più recente.

Lo stesso evento può arrivare più di una volta: un retry dopo un timeout in cui avevate già elaborato la richiesta, un replay. Usate id (o X-Qapla-Event-Id) come chiave di idempotenza: se l'avete già elaborato, rispondete 2xx e non fate altro. Una consegna fallita è rimandabile per 30 giorni (Replay): conservate gli id almeno per quel periodo.

Se più endpoint dello stesso canale sottoscrivono lo stesso evento, ognuno riceve la sua consegna con lo stesso id.

Rispondete prima di fare il lavoro pesante: salvate l'evento, rispondete 2xx, elaboratelo dopo. Avete 10 secondi in tutto, e una risposta oltre il timeout vale un fallimento anche se l'avete elaborata.

Sicurezza

Firma

Ogni richiesta porta l'header:

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

Per verificare:

  1. leggete il corpo grezzo, i byte come sono arrivati, prima di qualsiasi parsing JSON. Ricodificare il JSON cambia spazi e escape e la firma non torna più;
  2. estraete t e tutti i v1 dall'header;
  3. rifiutate la richiesta se t si discosta dal vostro orologio di più di 5 minuti (protezione dal replay di una richiesta intercettata);
  4. calcolate l'HMAC-SHA256 di t + "." + corpo col vostro secret e confrontatelo con ogni v1 a tempo costante (hash_equals, crypto.timingSafeEqual, hmac.compare_digest), mai con ==;
  5. firma non valida: rispondete 401 o 403 e scartate. Non verrà ritentata (Retry).

Lo schema è lo stesso delle Courier Events API, che usano la firma nella direzione opposta.

Verifica della firma

Implementazioni complete, senza dipendenze esterne. Tutte e tre accettano la richiesta se almeno un v1 corrisponde, quindi funzionano anche durante una rotazione.

<?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;
}

// Uso
$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);
  });
}

// Uso con Express: il corpo deve restare un 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)

# Uso con 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
Vettore di prova

Per controllare la vostra implementazione senza aspettare un invio reale:

corpo 90-signature-test-body.json (883 byte, una riga, senza a capo finale: usate il file così com'è)
secret whsec_3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f
secret precedente whsec_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1
header t=1791015120,v1=961fde4b87a60528ed28100be4badc6dcf1d64ce84b82f2a3fe9b077f45c0d2a,v1=6e5964a9d6a80fd60aca19bdbb62ddd531a4490e3ad7b37ba1bc9a2bcf4b3499

Con uno qualsiasi dei due secret la verifica deve riuscire, con un altro secret no. t è vecchio: per la prova disattivate il controllo della tolleranza o passate come «adesso» un istante entro 5 minuti da 1791015120 (gli esempi accettano un parametro now).

Rotazione del secret

Dal Control Panel si genera un secret nuovo, mostrato una volta sola. Il vecchio resta valido per 24 ore: in quella finestra ogni richiesta porta due v1, prima quello del secret nuovo, poi quello del vecchio. Dopo, solo il nuovo.

Per ruotare senza perdere consegne:

  1. generate il secret nuovo dal Control Panel;
  2. entro 24 ore mettetelo nella configurazione del ricevitore al posto del vecchio. Le richieste continuano a passare in entrambe le configurazioni, perché portano tutte e due le firme.

Ruotate subito se il secret è finito dove non doveva (log, repository, ticket).

IP di uscita

Il meccanismo di autenticazione è la firma, non l'indirizzo di provenienza: verificate sempre X-Qapla-Signature, anche se filtrate per IP.

Per chi deve comunque configurare un firewall, gli indirizzi da cui partono le nostre chiamate sono pubblicati qui, in testo semplice, uno per riga: https://cdn.qapla.it/sys/219a40582012cf87b88a782bdfe3a569. La lista può cambiare: se la usate, rileggetela periodicamente invece di copiarla una volta.

Verificare che i webhook v2 escano dagli stessi indirizzi della lista (o aggiungere i loro) prima del primo invio.

Consegna

Richiesta

POST{il vostro url}
Header Valore
Content-Type application/json
User-Agent Qapla-Webhooks/2
X-Qapla-Event-Id uguale a id nel corpo (evt_…)
X-Qapla-Event-Type uguale a type nel corpo
X-Qapla-Delivery-Attempt numero del tentativo, da 1. Riparte da 1 dopo un replay
X-Qapla-Signature t=…,v1=… (Firma)

Risposta

Successo = qualunque 2xx. Il corpo della risposta viene ignorato: non serve rispondere {"result":"OK"} come nella v1, va benissimo un 204 vuoto.

Esito Cosa facciamo
2xx consegnata
timeout, errore di connessione o TLS, DNS senza risposta ritentiamo
408, 429, 5xx ritentiamo. Potete indicare Retry-After (Retry)
3xx, altri 4xx (400, 401, 404, 410, …) non ritentiamo: la consegna fallisce subito e resta disponibile per il replay

Un 4xx dice «questa richiesta non andrà mai bene». Se il problema è temporaneo dal vostro lato (deploy in corso, database giù), rispondete 503, non 400: altrimenti l'evento non torna da solo.

Retry

Un esito ritentabile rimette in coda la consegna con un ritardo crescente, con un jitter di ±20% per non far ripartire tutto nello stesso istante. Al massimo 8 tentativi, distribuiti su circa 22 ore:

Dopo il tentativo Attesa (nominale) Tentativo successivo, dal primo
11 minuto1 min
25 minuti6 min
315 minuti21 min
41 ora1 h 21 min
53 ore4 h 21 min
66 ore10 h 21 min
712 ore22 h 21 min (ottavo e ultimo tentativo)
8—la consegna passa fra le fallite (DLQ)

Retry-After (secondi o data HTTP) può solo allungare l'attesa, fino a un massimo di 12 ore: se indica meno del backoff previsto vale il backoff. Non aggiunge tentativi.

Avvisi e spegnimento

Un endpoint che fallisce per giorni non viene ritentato per sempre:

Contano come fallimenti gli esiti non 2xx e gli errori di connessione. Un evento di test fallito non conta.

Quando l'endpoint si spegne (da solo o a mano):

Si riattiva dal Control Panel (Impostazioni canale → Aggiornamenti → «Webhook v2»). Riparte pulito, senza un'ondata di eventi vecchi. Il buco si recupera con GET /v2/shipments?updatedAfter=…, partendo dall'ultimo aggiornamento che avete ricevuto.

Replay

Le consegne fallite (esauriti i retry, rifiutate con un 4xx, o interrotte dallo spegnimento dell'endpoint) si possono rimandare dal Control Panel, una per una o per intervallo di tempo, per 30 giorni dalla nascita della consegna. Il Control Panel mostra per ogni consegna stato, tentativi, ultimo codice HTTP ed errore.

Un replay rimette la consegna in coda come nuova: stesso id, X-Qapla-Delivery-Attempt che riparte da 1, di nuovo fino a 8 tentativi, e payload ricostruito con la spedizione com'è al momento del nuovo invio.

Lista delle consegne e replay sono in Impostazioni canale → Aggiornamenti → «Webhook v2».

Riferimento

Migrazione dalla v1

La v2 non sostituisce la v1 d'ufficio: si attiva per scelta, configurando un endpoint v2 sul canale, perché richiede modifiche al vostro codice. La v1 (documentazione) resta com'è, senza nuove funzioni: filtri e trigger esistono solo nella v2. Non c'è ancora una data di fine della v1; sarà annunciata con 12 mesi di preavviso.

v1 v2
Autenticità apiKey del canale in chiaro nel body nessuna credenziale nel body; firma HMAC X-Qapla-Signature
Successo il corpo della risposta deve essere {"result":"OK"} qualunque 2xx, il corpo è ignorato
Payload formato v1 envelope con ShipmentSummary dell'API v2 + l'evento
Dati del destinatario solo con fullData attivo (opt-in): name, address, email solo con include: ["consignee"] (Sezioni opzionali)
Quando a ogni cambio di stato trigger a scelta + filtro statuses
Trasporto http o https solo https, certificato verificato, nessun redirect, timeout 10 s
Errori pochi tentativi, nessun replay 8 tentativi in ~22 h, consegne fallite rimandabili per 30 giorni, avviso e spegnimento

Se oggi ricevete su http://, prima di passare alla v2 serve un certificato TLS valido sul vostro endpoint.

Un canale con un endpoint v2 smette di ricevere la v1. Niente doppio invio, ma anche niente periodo in cui ricevete entrambe: il ricevitore v2 deve essere in produzione prima di creare l'endpoint.

Procedura consigliata
  1. implementate il ricevitore v2: verifica della firma, deduplica su id, regola di scarto su statusDate, risposta 2xx veloce;
  2. provatelo col vettore di prova;
  3. mettetelo in produzione accanto al ricevitore v1, su un URL diverso;
  4. dal Control Panel create l'endpoint v2 e salvate il secret: da qui la v1 del canale si ferma;
  5. mandate un evento di test e controllate l'esito;
  6. recuperate eventuali aggiornamenti persi nel passaggio con GET /v2/shipments?updatedAfter=….

Spento e cancellato non sono la stessa cosa per la v1.

  • Un endpoint v2 spento (a mano o dopo 3 giorni di errori) tiene il canale fuori dalla v1: il canale non riceve né la v2 né la v1 finché non lo riattivate;
  • un endpoint v2 cancellato no: se sul canale non ne restano altri, il canale torna a ricevere la v1, sempre che il webhook v1 sia ancora configurato.

Per sospendere la v2 senza ricevere di nuovo la v1, spegnete l'endpoint; per tornare alla v1, cancellatelo.

Esempi

File Contenuto
01-shipment-updated.json shipment.updated con trigger status_change, senza consignee
02-webhook-test.json l'evento di test, con la spedizione d'esempio fissa
90-signature-test-body.json corpo grezzo del vettore di prova della firma

Punti aperti

Prima della pubblicazione vanno chiusi i riquadri TODO di questa pagina:

Contatto: tech@qapla.it