EN
Courier Events API 1.0
rel. 1.0.0

Introduzione

Bozza — l'invio degli eventi non è ancora aperto. Questa specifica è pubblicata per essere valutata e commentata dai corrieri. POST /v1/events non accetta ancora traffico: risponde 404 finché non lo attiviamo per il vostro codice corriere.

POST /v1/events/validate invece è attivo, in produzione. Dall'attivazione in poi — è il passaggio che vi assegna URL, token e segreto di firma — potete validare i vostri payload contro lo schema senza aspettare altro. Prima di pianificare il resto del lavoro, concordare i tempi con tech@qapla.it.

Scopo

Un contratto unico, indipendente dal corriere, per inviare a Qapla' gli eventi di tracking delle spedizioni.

Se ci avete chiesto un URL webhook a cui inviare gli eventi di tracking, questa è la specifica di quel webhook: cosa mandare, in che forma, e come rispondiamo. L'URL non è pubblico e non è unico per tutti — ne assegniamo uno a voi insieme alle credenziali, al primo passo dell'attivazione.

Qapla' traccia le spedizioni di migliaia di merchant europei. Storicamente lo stato lo andiamo a leggere noi, interrogando l'API di ogni corriere: autenticazioni diverse, limiti di polling diversi, payload diversi, e un ritardo fra l'evento reale e la notifica che arriva al destinatario.

Questa specifica ribalta il verso: il corriere invia, noi ascoltiamo. Un endpoint, una forma JSON, una politica di retry. Toglie carico di polling dall'infrastruttura del corriere e porta l'evento al destinatario finale in secondi invece che in decine di minuti.

La regola di progetto dietro ogni scelta che segue: il corriere non deve cambiare il modo in cui pensa i propri dati. Invia i propri codici di stato, con le proprie parole, come flusso di fatti in sola aggiunta. La normalizzazione è compito nostro, non suo.

Endpoint

Assegniamo un URL in fase di attivazione. Gli URL non sono elencati qui: sono per corriere, e comunicarli fuori dal percorso di attivazione non avrebbe senso.

Oggi l'ambiente è uno solo. Non c'è una sandbox separata, e per sviluppare non serve: /v1/events/validate non salva nulla, quindi si può chiamare quanto si vuole senza produrre alcun effetto.

Quello che è stabile è la forma dei path sotto la base assegnata — https://{endpoint-host} qui sotto sta per quella:

Path Scopo
POSThttps://{endpoint-host}/v1/events invio degli eventi
POSThttps://{endpoint-host}/v1/events/validate validazione di un payload, senza salvare nulla. Attivo: usarlo liberamente in sviluppo e nella propria CI
GEThttps://{endpoint-host}/v1/ping verifica delle credenziali. Risponde 200 con il codice corriere assegnato. Nessun effetto collaterale

Tenere l'URL configurabile, non compilato nel codice. La nostra piattaforma di ingestione è serverless (Google Cloud, europe-west1) e l'URL può cambiare per ragioni operative nostre: migrazione di regione, isolamento del traffico di un singolo corriere, ridimensionamento. Diamo preavviso e una finestra di sovrapposizione durante la quale sia il vecchio sia il nuovo URL rispondono, ma serve che dalla vostra parte sia un parametro di configurazione, non una costante da rilasciare.

Per le vostre regole di uscita: la destinazione è nello spazio di indirizzi di Google Cloud, regione europe-west1, sempre HTTPS sulla porta 443. Non esponiamo un indirizzo IP fisso su cui fare allowlist. Se la vostra policy richiede un hostname o un IP stabile, segnalatelo in fase di attivazione: è gestibile, ma va previsto prima.

Content-Type: application/json, UTF-8. Content-Encoding: gzip è supportato e raccomandato per i batch.

Autenticazione

Obbligatoria — bearer token
POST /v1/events HTTP/1.1
Host: {endpoint-host}
Authorization: Bearer <token>
Content-Type: application/json

Qapla' emette un token per corriere e per ambiente. Il token identifica il mittente; non scade a scadenza fissa e si può ruotare su richiesta, con una finestra di sovrapposizione durante la quale sono accettati sia il vecchio sia il nuovo.

Opzionale — firma del payload

Per non affidarsi al solo bearer token, aggiungere:

X-Qapla-Signature: t=1787654400,v1=5257a869e7ecebeda32affa62cdca3fa793333cd8b3f7a0d3d9ed3c1f0b1b1ab

v1 è l'HMAC-SHA256 in esadecimale di "<t>.<corpo grezzo della richiesta>", con chiave il segreto condiviso che emettiamo insieme al token. Rifiutiamo una firma il cui t si discosti di più di 5 minuti dal nostro orologio. Se l'header c'è lo verifichiamo, se non c'è accettiamo la richiesta sul solo bearer token. Su richiesta la firma può diventare obbligatoria per il singolo codice corriere.

Con Content-Encoding: gzip si firma il corpo compresso. Il corpo grezzo sono i byte come viaggiano sul filo: grezzo vuol dire prima di qualsiasi decodifica, non prima di qualsiasi codifica. Prima si comprime, poi si calcola l'HMAC su quello che si sta per trasmettere.

Firmando il corpo trasmesso l'autenticazione avviene prima della decompressione. L'altra lettura ci obbligherebbe a decomprimere un payload non ancora autenticato per poterlo autenticare, cioè a far girare il decompressore sull'input di chiunque conosca la URL. È anche quello che fanno Stripe e GitHub.

Opzionale — allowlist di IP

Comunicandoci i propri range di uscita, accetteremo traffico solo da quelli.

Non richiesti: OAuth2 client-credentials e mTLS. Entrambi sono disponibili se la vostra policy di sicurezza li impone — è sufficiente segnalarlo e li attiviamo per il vostro codice corriere — ma nessuno dei due è una precondizione, e non riteniamo che aggiungano molto a un invio unidirezionale server-to-server su TLS 1.2+.

Eventi

POST/v1/events

Un messaggio è una busta che trasporta una lista piatta di eventi. Un evento è un fatto, su un collo, in un istante. Non esistono uno "stato corrente" e uno "storico" separati: l'evento più recente è lo stato corrente.

Si può inviare la storia intera, solo ciò che è cambiato dall'ultima chiamata, o eventi fuori ordine: sono tutti e tre validi, perché ogni evento porta con sé la propria identità e il proprio istante.

POSThttps://{endpoint-host}/v1/events

La chiamata completa, senza niente di implicito:

curl -X POST https://{endpoint-host}/v1/events \
  -H "Authorization: Bearer $QAPLA_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @event.json

POST/v1/events/validate

Identico a /v1/events per autenticazione e payload, ma non salva nulla: valida il messaggio contro lo schema e restituisce gli stessi results[], con outcome ACCEPTED per gli eventi che sarebbero stati accettati.

POSThttps://{endpoint-host}/v1/events/validate

È il punto da cui conviene partire: è attivo già ora, prima che l'invio degli eventi sia aperto, non richiede che le spedizioni esistano già da noi, e si può tenere nella propria pipeline di CI come test di non-regressione del payload.

GET/v1/ping

GEThttps://{endpoint-host}/v1/ping
{
  "courier": "ACME_EXPRESS",
  "environment": "production",
  "schemaVersions": ["1.0"]
}

Payload

Struttura della busta

Campo Descrizione
schemaVersion*(string) versione dello schema, "1.0". Vedi Versioning
messageId*(string, max 128) univoco per chiamata HTTP. Nel retry della stessa chiamata va reinviato lo stesso valore. Suggeriamo un UUID, ma va bene qualunque identificativo stabile: non imponiamo un formato. Serve alla tracciabilità: non deduplichiamo sul messageId, ma sull'eventId. Rimettere un evento in un messaggio nuovo, con un messageId nuovo, è ammesso purché l'eventId resti lo stesso
sentAt*(string, date-time) quando il messaggio è stato composto
courier*(string) il codice corriere che Qapla' assegna in fase di attivazione. Deve corrispondere al token
events*(array) da 1 a 500 elementi

* Campo obbligatorio

Campi sconosciuti: tollerati sulla busta, rifiutati sull'evento. Un campo vostro accanto a courier o sentAt viene ignorato — un batch di 500 eventi non si perde per un campo che non ci aspettavamo. Dentro l'evento vale l'opposto: un campo sconosciuto rende INVALID quell'evento, ed è voluto, perché attributes è il posto per tutto ciò che è vostro e non ha un campo proprio.

Campi dell'evento — obbligatori

Campo Descrizione
eventId*(string, max 128) Identità stabile e permanente dell'evento nei sistemi del corriere. È la chiave di idempotenza: se lo vediamo due volte, scartiamo la seconda copia. Deve essere lo stesso valore ogni volta che l'evento viene inviato, retry e reinvii della storia completa compresi. L'id di riga del proprio registro eventi è la scelta ideale
trackingNumber(string, max 64) il numero che conosce il destinatario. Obbligatorio questo oppure externalId — vedi Riferimento alternativo. Se cambia, vedi Cambio tracking number
occurredAt*(string, date-time) quando l'evento è avvenuto nel mondo reale, non quando è stato inviato. ISO 8601 con offset esplicito, nell'ora locale del luogo in cui l'evento è avvenuto: 2026-08-24T10:28:00+02:00. Mai il fuso del proprio datacenter, mai un timestamp senza fuso. Z è accettato ma perde la lettura locale — ed è la lettura locale quella che viene mostrata al destinatario. Il JSON Schema lo impone con un'espressione regolare, non solo con format: un timestamp senza fuso fallisce la validazione nella vostra pipeline, che è dove correggerlo costa poco
status.code*(string, max 64) il proprio codice nativo, alla lettera. Da non tradurre in un vocabolario normalizzato — vedi Codici di stato
status.description*(string, max 500) il proprio testo leggibile, nella lingua del paese dell'evento se disponibile
Campi dell'evento — opzionali

Tutti realmente opzionali: inviare ciò che si ha.

Campo Descrizione
externalId(string, max 128) identificativo alternativo del collo, tipicamente il riferimento ordine del merchant. Obbligatorio se manca trackingNumber
senderAccount(string, max 64) il codice conto, contratto o cliente sotto cui la spedizione è stata prenotata. Obbligatorio quando si invia externalId senza trackingNumber — vedi Riferimento alternativo. Gradito sempre
location.name(string) nome della filiale, città o hub
location.countryCode(string, 2) ISO 3166-1 alpha-2
location.postalCode(string)  
parcel.trackingNumber(string) numero del singolo collo in una spedizione multi-collo
parcel.index / parcel.total(integer) collo 2 di 3
reason.code / reason.description(string) perché si è verificata l'eccezione o il tentativo fallito. È il più utile fra i campi opzionali: è ciò che permette al merchant di agire prima che il destinatario si lamenti
delivery.estimatedDeliveryDate(string, date) giorno di consegna previsto, nel fuso del luogo di consegna. Vedi Consegna prevista
delivery.estimatedDeliveryWindow.from / .to(string, date-time) finestra ristretta, quando è nota. Vedi Consegna prevista
delivery.deliveredTo(string) RECIPIENT, NEIGHBOUR, MAILBOX, PICKUP_POINT, SAFE_PLACE, OTHER
delivery.signedBy(string) nome sulla prova di consegna
delivery.podUrl(string, URI) link alla prova di consegna
delivery.attemptNumber(integer) numero del tentativo, da 1
pickupPoint.id / .name / .address / .postalCode / .countryCode(string) dove il collo è in attesa, quando l'evento lo deposita in un locker o in un punto di ritiro
pickupPoint.availableUntil(string, date-time)  
trackingNumberChange.newTrackingNumber / .reason(string) vedi Cambio tracking number
recipientActionUrl(string, URI) la propria pagina dove il destinatario può riprogrammare, dirottare o autorizzare il deposito. La esponiamo al destinatario
isReturn(boolean) true se il collo sta tornando al merchant. Default false
attributes(object) mappa piatta di valori stringa per qualunque dato specifico del corriere che non abbia un campo dedicato. Da usare al posto di chiedere una modifica dello schema. Massimo 20 chiavi

Cambio tracking number

Alcuni colli ricevono un identificativo provvisorio alla creazione dell'etichetta e il tracking number definitivo solo più tardi; altri vengono riassegnati durante il viaggio. I due casi sono lo stesso evento: inviarlo sotto il numero che il destinatario conosce adesso, e mettere il nuovo in trackingNumberChange.


        

Dall'evento successivo, usare il nuovo numero come trackingNumber.

Riferimento alternativo

Alcuni flussi di eventi non sono indicizzati sul tracking number ma sul riferimento ordine del merchant. In quel caso, inviare quel valore come externalId insieme a senderAccount — il codice conto, contratto o cliente sotto cui la spedizione è stata prenotata.


        

Almeno uno fra trackingNumber ed externalId deve essere presente. Inviarli entrambi quando si hanno entrambi: è l'input più affidabile che possiamo ricevere, perché ci permette di agganciare il collo in due modi e di accorgerci di un disallineamento.

Senza trackingNumber, senderAccount è obbligatorio — e non è burocrazia. Il riferimento ordine lo genera il vostro cliente, non voi: due merchant che spediscono con voi possono chiamare entrambi un ordine ORD-1, e non c'è niente che voi o noi possiamo fare per rendere unico quel valore. Il codice conto è ciò che dice di quale dei due stiamo parlando. Senza, non possiamo distinguerli, e un evento di consegna sul collo sbagliato è un'email alla persona sbagliata — quindi preferiamo rispondere UNKNOWN_TRACKING piuttosto che tirare a indovinare. Ogni volta che più di una spedizione corrisponde, è esattamente quello che facciamo.

Consegna prevista

La consegna prevista si invia dentro delivery, agganciata a un evento come ogni altra informazione: estimatedDeliveryDate per il giorno, estimatedDeliveryWindow per la finestra oraria quando esiste. Entrambi sono opzionali e possono comparire insieme.

Non esiste un endpoint per aggiornarla. Una stima cambia — il collo perde una tratta, il giro del giorno slitta — e il modo per rivederla è emettere un evento nuovo che porti il valore aggiornato. Vale come stima corrente quella dell'evento più recente che la contiene: un evento senza delivery non cancella la stima precedente, la lascia in piedi.

Ne segue una conseguenza pratica: se la stima cambia e non c'è nessun avanzamento di stato da comunicare, va comunque inviato un evento — con il proprio codice di stato corrente, eventId nuovo e delivery aggiornato. È il solo modo che abbiamo di vedere il cambiamento, ed è il caso in cui il destinatario ci tiene di più.

La consegna prevista arriva al destinatario: finisce nelle notifiche transazionali e nella pagina di tracking. Una stima ottimistica non ritrattata è il reclamo più comune che i nostri merchant ricevono — meglio nessuna stima che una stima ferma.

Codici di stato: i vostri, non i nostri

Deliberatamente non pubblichiamo un elenco di valori su cui mappare.

Il vocabolario di stati di ogni corriere è più fine di qualunque elenco condiviso, e imporre una mappatura alla sorgente distrugge esattamente il dettaglio che rende utile l'evento: "tentativo di consegna, destinatario assente" e "tentativo di consegna, indirizzo non trovato" collassano entrambi in EXCEPTION, e il merchant perde la possibilità di reagire in modo diverso. Peggio: quella mappatura finirebbe nel vostro codice, dove non possiamo correggerla quando si rivela sbagliata.

Quindi: inviare status.code esattamente come appare nei propri sistemi. Qapla' mantiene la mappatura dai vostri codici ai propri stati normalizzati, e si assume le conseguenze di sbagliarla.

Cosa ci serve da voi, una volta sola, in fase di attivazione: il catalogo dei codici di stato — codice, descrizione e, se la nozione esiste, se il codice è terminale. CSV, JSON o una pagina della vostra documentazione vanno benissimo.

I codici che compaiono nel traffico senza essere in catalogo vengono accettati, memorizzati e segnalati a noi per la mappatura: semplicemente non producono una notifica al destinatario finché non li mappiamo.

Risposte

Esito per evento — 200 OK

Rispondiamo per evento. Un batch non viene mai rifiutato in blocco perché uno dei suoi eventi non era utilizzabile.


        
Campo Descrizione
messageId(string) eco del messageId inviato
receivedAt(string, date-time) quando abbiamo ricevuto il messaggio
accepted / duplicate / unknownTracking / invalid(integer) un contatore per outcome: i quattro sommano sempre al numero di eventi inviati. Non esiste volutamente un contatore rejected unico: solo invalid è un problema su cui si può agire
results(array) un elemento per evento, nell'ordine di invio: eventId, outcome, e errors[] quando l'esito è INVALID

Se si mette un allarme su un numero, mettetelo su invalid. Non su unknownTracking: se ci inviate tutto il flusso, quel contatore è la maggior parte del traffico anche in una giornata perfettamente sana, e allarmarsi su quello significa svegliare qualcuno ogni minuto dal primo giorno.

outcome Significato Ritentare?
ACCEPTED memorizzato e in elaborazione no
DUPLICATE questo eventId è già noto. Non è un errore no
UNKNOWN_TRACKING non tracciamo questo collo — appartiene a un merchant che non è nostro cliente. È previsto e innocuo: se inviate l'intero flusso di eventi, la maggior parte tornerà così no
INVALID l'evento viola lo schema. Correggerlo e reinviarlo con lo stesso eventId no

Perché sempre 200 e mai 201 per una prima scrittura? Un batch è normalmente misto — qualche evento nuovo, qualcuno già visto, qualcuno di colli che non tracciamo — e un solo status line non può esprimerlo. ACCEPTED contro DUPLICATE in results[] porta la stessa informazione per evento, e in modo più preciso. Lo status HTTP dice "il messaggio è stato elaborato"; results[] dice "che cosa è successo a ciascun evento".

Che fine fa un evento UNKNOWN_TRACKING. Non raggiunge nessun merchant e nessun destinatario, e non lo conserviamo come dato di tracking di un collo che non è nostro da tracciare.

Lo mettiamo però da parte per 7 giorni, e non è un dettaglio implementativo: molti merchant ci dichiarano le spedizioni a fine giornata o la mattina dopo, quindi i vostri primi eventi — accettazione, presa in carico, primo transito — arrivano regolarmente prima che la spedizione esista da noi. Non appena esiste, gli eventi messi da parte si applicano insieme al primo evento che la trova, in ordine di occurredAt, come se li avessimo abbinati subito. Non dovete rimandarli: UNKNOWN_TRACKING resta "non ritentare".

Da parte teniamo solo ciò che serve a ricostruire il tracking — identificativi, status, occurredAt, location.name — e scartiamo subito i campi che riguardano una persona: delivery.signedBy, delivery.podUrl, l'indirizzo del punto di ritiro, recipientActionUrl. Passati i 7 giorni non resta niente. Se potete filtrare il flusso sui merchant che sapete usare Qapla', è gradito — ma non è una cosa che vi chiediamo di costruire.

Errori a livello di messaggio

Codice Significato Ritentare?
400il corpo non è JSON, o la busta non è validano
401bearer token assente o sconosciutono
403firma non valida, IP di origine non ammesso, o courier che non corrisponde al tokenno
404/v1/events non accetta ancora traffico per il vostro codice corriere (vedi Attivazione)no
413payload troppo grande (vedi Limiti)no — dividerlo
415Content-Type erratono
429rate limit superato. Rispettare Retry-Aftersì
5xxproblema nostrosì

Il corpo di ogni 4xx è un problem document RFC 7807.

Il 404 è deliberato, e non un 501 o un 503: quelli sono 5xx, e la retry policy pubblicata qui dice di ritentare sui 5xx — un corriere che partisse prima dell'attivazione finirebbe in un loop di tentativi. Il 404 lo ferma, e basta.

    

Retry

Ritentare su 429, su 5xx, sui fallimenti di connessione e sui timeout. Backoff esponenziale con jitter, almeno 5 tentativi distribuiti su circa 15 minuti (per esempio 10s → 30s → 2m → 5m → 10m).

Tre tentativi in pochi secondi — default frequente — non bastano: i nostri deploy e i failover possono rendere l'endpoint indisponibile più a lungo, e un evento abbandonato dopo 7 secondi è un evento che il destinatario non vedrà mai. Conservare i messaggi non recapitabili in una dead-letter queue e rigiocarli: riconosciamo un eventId già visto come duplicato per almeno 6 mesi, quindi un replay dentro quella finestra è gratis. Oltre, avvisateci prima di rigiocare.

Timeout di lettura: 10 secondi. In condizioni normali rispondiamo ampiamente sotto il secondo. Un timeout non è una mancata consegna: gli eventi potrebbero essere già stati memorizzati. Ritentare con lo stesso messageId e gli stessi eventId è la risposta corretta e sicura. Chi gestisce il retry per singolo evento può anche reinserirlo in un batch successivo: conta l'eventId, non il messageId.

Riferimento

Versioning

schemaVersion è un campo di primo livello, major.minor.

Non rimuoviamo né riusiamo per altro un campo dentro la stessa major.

Limiti

Eventi per messaggio500
Corpo non compresso5 MiB
Rate limit20 richieste/secondo sostenute, burst 100 — innalzabile su richiesta, comunicandoci il volume previsto
Timeout di lettura10 s

X-RateLimit-Limit e X-RateLimit-Remaining sono restituiti su ogni risposta.

Attivazione

  1. Ci comunicate il volume giornaliero di eventi previsto e il picco, e ci inviate il vostro logo in formato vettoriale (SVG, sfondo trasparente, senza margini): lo usiamo nelle email di tracking e nella pagina di tracking pubblica. Vi assegniamo l'URL del vostro endpoint, un token, un segreto di firma e il vostro codice courier.
  2. Ci inviate il vostro catalogo dei codici di stato.
  3. Sviluppate contro POST /v1/events/validate, con i vostri tempi. Lo JSON Schema e gli esempi sono gli artefatti normativi: far girare un validatore contro di essi è il modo più rapido per sapere di avere finito.
  4. Attiviamo /v1/events per il vostro codice corriere e confrontiamo il primo giorno di traffico reale con il tracking che già raccogliamo sugli stessi colli: è il passaggio che intercetta errori di mappatura e di fuso orario prima che raggiungano un destinatario.
  5. Passaggio a regime. Potete tenere in parallelo la vostra integrazione pull attuale quanto volete: grazie a eventId gli eventi duplicati non costano nulla.

Schema ed esempi

File Contenuto
courier-events.postman_collection.json collection Postman: /v1/ping, /v1/events/validate e /v1/events, più ogni esempio pronto da eseguire. Generata dagli esempi qui sotto
courier-events-v1.schema.json JSON Schema 2020-12 della busta. Artefatto normativo
01-minimal.json il messaggio più piccolo valido
02-full-history-batch.json storia completa di una spedizione in un solo messaggio
03-failed-attempt-and-pickup-point.json tentativo fallito con reason, poi deposito in punto di ritiro
04-tracking-number-change.json assegnazione del tracking number definitivo
05-return-shipment.json reso verso il merchant
06-external-id-only.json eventi indicizzati sul riferimento ordine
90-response-200.json risposta con esito per evento
91-response-403.json problem document RFC 7807

La collection arriva con le variabili vuote, ed è voluto. Dalla scheda Variables della collection compilate endpoint_host — l'host del vostro endpoint, senza https://, quello che vi assegniamo all'attivazione — e token. Poi partite da GET /v1/ping: una URL sbagliata o un token sbagliato si scoprono lì, invece che dentro un payload dove sembrerebbero un problema di schema.

Una volta compilate, non riesportate il file: l'export conterrebbe il vostro token. E la firma opzionale X-Qapla-Signature non è nella collection, perché è un HMAC-SHA256 da ricalcolare a ogni richiesta e una collection statica non può farlo; le richieste si autenticano col solo bearer token, che è quello che accettiamo quando l'header non c'è.

Domande che questa specifica non risolve

Le lasciamo qui apposta, perché vale la pena deciderle insieme invece di darle per scontate:

Contatto: tech@qapla.it