Introduction
Draft — event ingestion is not open yet. This specification is
published for couriers to review and comment on. POST /v1/events is not
accepting traffic yet: it answers 404 until we activate it for your courier
code.
POST /v1/events/validate, on the other hand, is live in
production. From onboarding onwards — that is the step that
issues your URL, token and signing secret — you can validate your payloads
against the schema without waiting for anything else. Before you plan the rest of the
work, agree the timing with
tech@qapla.it.
Purpose
A single, courier-agnostic contract for pushing parcel tracking events to Qapla'.
If you asked us for a webhook URL to send tracking events to, this is the specification of that webhook: what to send, in what shape, and how we answer. The URL is neither public nor shared — we assign one to you together with your credentials, at the first step of onboarding.
Qapla' tracks shipments for thousands of European merchants. Historically we pull status from each courier's own API — different auth, different polling limits, different payloads, and a delay between the real-world event and the notification the consumer receives.
This specification is the opposite: you push, we listen. One endpoint, one JSON shape, one retry policy. It removes polling load from your infrastructure and it gets the event to the end consumer in seconds instead of tens of minutes.
The design rule behind every choice below: the courier should not have to change how it thinks about its own data. You send your own status codes, in your own wording, as an append-only stream of facts. Normalisation is our job, not yours.
Endpoints
We issue one URL at onboarding. The URLs are not listed here: they are per courier, and handing them out outside the onboarding path would serve no purpose.
There is a single environment today. There is no separate sandbox,
and you do not need one to develop against: /v1/events/validate
stores nothing, so you can call it as much as you like with no
effect.
What is stable is the shape of the paths under the base you are given —
https://{endpoint-host} below stands for it:
| Path | Purpose |
|---|---|
| POSThttps://{endpoint-host}/v1/events | send events |
| POSThttps://{endpoint-host}/v1/events/validate | validate a payload, nothing is stored. Live — use it freely during development and in your CI |
| GEThttps://{endpoint-host}/v1/ping | credential check. Returns 200 with your courier code. No side effects |
Keep the URL configurable, not compiled into your code. Our ingestion platform is serverless (Google Cloud, europe-west1) and the URL may change for operational reasons on our side: a region migration, isolating one courier's traffic, resizing. We will give you notice and an overlap window during which both the old and the new URL answer, but it needs to be a configuration parameter on your side rather than a constant you have to release.
For your egress rules: the destination is within Google Cloud address space, region europe-west1, always HTTPS on port 443. We do not expose a fixed IP address to allowlist. If your policy requires a stable hostname or IP, raise it at onboarding: it can be arranged, but it has to be planned ahead.
Content-Type: application/json, UTF-8. Content-Encoding: gzip is
supported and recommended for batches.
Authentication
Required — bearer token
POST /v1/events HTTP/1.1
Host: {endpoint-host}
Authorization: Bearer <token>
Content-Type: application/json
Qapla' issues one token per courier per environment. The token identifies you; it does not expire on a schedule and it can be rotated on request, with an overlap window during which both the old and the new token are accepted.
Optional — payload signature
If you prefer not to rely on a bearer token alone, add:
X-Qapla-Signature: t=1787654400,v1=5257a869e7ecebeda32affa62cdca3fa793333cd8b3f7a0d3d9ed3c1f0b1b1ab
v1 is the hex-encoded HMAC-SHA256 of "<t>.<raw request
body>", keyed with a shared secret we issue alongside the token. We reject a
signature whose t is more than 5 minutes away from our clock. If the header is
present we verify it; if it is absent we accept the request on the bearer token alone.
Signature enforcement can be switched to mandatory for your courier code on request.
With Content-Encoding: gzip, sign the compressed body.
The raw body is the bytes as they travel on the wire: raw means before any
decoding, not before any encoding. Compress first, then compute the HMAC over
what you are about to send.
Signing the transmitted body means the request is authenticated before it is decompressed. The other reading would force us to decompress a payload that is not authenticated yet in order to authenticate it — that is, to run the decompressor on input from anyone who knows the URL. It is also what Stripe and GitHub do.
Optional — source IP allowlist
Send us your egress ranges and we will only accept your traffic from them.
Not required: OAuth2 client-credentials and mTLS. Both are available if your security policy mandates them — tell us and we will provision them for your courier code — but neither is a precondition, and we do not think either one buys much for a one-way, server-to-server push over TLS 1.2+.
Events
POST/v1/events
A message is an envelope carrying a flat list of events. An event is one fact about one parcel at one point in time. There is no separate "current status" and "history": the most recent event is the current status.
Send the whole history, send only what changed since your last call, send events out of order — all three are valid, because every event carries its own identity and timestamp.
POSThttps://{endpoint-host}/v1/events
The whole call, with nothing omitted:
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
Identical to /v1/events in auth and payload, but stores
nothing: it validates the message against the schema and returns the same
results[], with outcome ACCEPTED for the events that
would have been accepted.
POSThttps://{endpoint-host}/v1/events/validate
This is the place to start: it is live already, before event ingestion opens, it does not require that we already know the parcels, and it can live in your CI pipeline as a non-regression test of your payload.
GET/v1/ping
GEThttps://{endpoint-host}/v1/ping
{
"courier": "ACME_EXPRESS",
"environment": "production",
"schemaVersions": ["1.0"]
}
Payload
Envelope
| Field | Notes |
|---|---|
| schemaVersion*(string) | schema version, "1.0". See Versioning |
| messageId*(string, max 128) | unique per HTTP call. On a retry of the same call, resend the
same value. A UUID is what we suggest, but any stable identifier of
yours is accepted — we do not require a specific format for it. It is there
for traceability: we do not deduplicate on messageId,
we deduplicate on eventId. Putting an event into a new message,
with a new messageId, is fine as long as its eventId
stays the same |
| sentAt*(string, date-time) | when you built the message |
| courier*(string) | the courier code Qapla' assigns you at onboarding. Must match the token |
| events*(array) | 1 to 500 items |
* Required field
Unknown fields: tolerated on the envelope, rejected on the event. If
you add a field of your own next to courier or sentAt we ignore
it — a batch of 500 events is never lost over a field we did not expect. Inside an event
it is the opposite: an unknown field makes that event INVALID, on
purpose, because attributes is the place for anything of yours that has no
field of its own.
Event fields — required
| Field | Notes |
|---|---|
| eventId*(string, max 128) | Stable, permanent identity of this event in your systems. This is the idempotency key: if we see it twice we discard the second copy. It must be the same value every time you send this event, including across retries and full-history resends. A row id from your event log is ideal |
| trackingNumber(string, max 64) | the number the consumer knows. Either this or
externalId is required — see Alternative
reference. If it changes, see Tracking number change |
| occurredAt*(string, date-time) | when the event happened in the real world — not when you
sent it. ISO 8601 with an explicit offset, in the local time of the place
where the event happened: 2026-08-24T10:28:00+02:00. Never
your own datacentre's timezone, and never a naive timestamp. Z is
accepted, but it loses the local reading — and the local reading is what the
consumer is shown. The JSON Schema asserts this with a
regular expression, not only with format: a naive timestamp fails
validation in your own pipeline, which is where that mistake is cheap to fix |
| status.code*(string, max 64) | your own native code, verbatim. Do not translate it into a normalised vocabulary — see Status codes |
| status.description*(string, max 500) | your own human-readable text, in the language of the event's country if you have it |
Event fields — optional
Every one of these is genuinely optional; send what you have.
| Field | Notes |
|---|---|
| externalId(string, max 128) | alternative identifier for the parcel, typically the merchant's order reference. Required if trackingNumber is absent |
| senderAccount(string, max 64) | the account, contract or customer code the shipment was booked under. Required when externalId is sent without trackingNumber — see Alternative reference. Always welcome otherwise |
| location.name(string) | facility, city, or hub name |
| location.countryCode(string, 2) | ISO 3166-1 alpha-2 |
| location.postalCode(string) | |
| parcel.trackingNumber(string) | per-parcel number on a multi-parcel consignment |
| parcel.index / parcel.total(integer) | 2 of 3 |
| reason.code / reason.description(string) | why an exception or failed delivery attempt happened. The single most valuable optional field: it is what lets a merchant act before the consumer complains |
| delivery.estimatedDeliveryDate(string, date) | expected delivery day, in the local time of the delivery place. See Expected delivery |
| delivery.estimatedDeliveryWindow.from / .to(string, date-time) | narrow window, when known. See Expected delivery |
| delivery.deliveredTo(string) | RECIPIENT, NEIGHBOUR, MAILBOX, PICKUP_POINT, SAFE_PLACE, OTHER |
| delivery.signedBy(string) | name on the POD |
| delivery.podUrl(string, URI) | link to the proof of delivery |
| delivery.attemptNumber(integer) | 1-based |
| pickupPoint.id / .name / .address / .postalCode / .countryCode(string) | where the parcel is waiting, when the event puts it in a locker or shop |
| pickupPoint.availableUntil(string, date-time) | |
| trackingNumberChange.newTrackingNumber / .reason(string) | see Tracking number change |
| recipientActionUrl(string, URI) | your own page where the consumer can reschedule, redirect, or authorise a safe-place drop. We surface it to the consumer |
| isReturn(boolean) | true if this parcel is travelling back to the merchant. Default
false |
| attributes(object) | free-form flat map of string values for anything courier-specific that has no field above. Use this instead of asking for a schema change. Max 20 keys |
Tracking number change
Some parcels get a provisional identifier at label creation and their definitive tracking
number only later; others are reassigned mid-journey. Both cases are the same event: send it
under the number the consumer currently knows, and put the new one in
trackingNumberChange.
From the next event onwards, use the new number as trackingNumber.
Alternative reference
Some courier event feeds are keyed off the merchant's order reference rather than the
tracking number. Send that value as externalId, together with
senderAccount — the account, contract or customer code the shipment was booked
under.
At least one of trackingNumber and externalId must be present.
Send both whenever you have both: it is the most reliable input we can get,
because it lets us match the parcel two ways and detect a mismatch.
Without trackingNumber, senderAccount is
required — and it is not paperwork. An order reference is generated by
your customer, not by you: two merchants shipping with you can both call an
order ORD-1, and nothing you or we can do makes that value unique. The
account code is what identifies which of them we are talking about. Without it we cannot
tell the two apart, and a delivery event on the wrong parcel is an email to the wrong
person — so we would rather answer UNKNOWN_TRACKING than guess. Whenever
more than one shipment matches, that is exactly what we do.
Expected delivery
Send the expected delivery inside delivery, attached to an event like any
other piece of information: estimatedDeliveryDate for the day,
estimatedDeliveryWindow for the time window where you have one. Both are
optional and may appear together.
There is no endpoint to update it. An estimate changes — the parcel
misses a leg, the day's round slips — and the way to revise it is to send a new
event carrying the updated value. The current estimate is the one on the most recent
event that carries it: an event without delivery does not clear the
previous estimate, it leaves it standing.
One practical consequence: if the estimate changes and there is no status progress to
report, send an event anyway — with your current status code, a new eventId and
the updated delivery. It is the only way we can see the change, and it is the
case the consumer cares about most.
The expected delivery reaches the consumer: it goes into the transactional notifications and onto the tracking page. An optimistic estimate that is never walked back is the most common complaint our merchants get — no estimate beats a stale one.
Status codes: send yours, not ours
We deliberately do not publish an enum for you to map onto.
Every courier's status vocabulary is finer-grained than any shared enum, and forcing a
mapping at the source destroys exactly the detail that makes an event useful — "delivery
attempted, recipient absent" and "delivery attempted, address not found" both collapse to
EXCEPTION, and the merchant loses the ability to react differently. Worse, that
mapping then lives in your code, where we cannot fix it when it turns out to be wrong.
So: send status.code exactly as it appears in your own systems. Qapla'
maintains the mapping from your codes to our normalised statuses, and we own the consequences
of getting it wrong.
What we need from you once, at onboarding: your status code catalogue — code, description, and (if you have the notion) whether the code is terminal. CSV, JSON, or a page in your API docs is fine.
Codes that show up in traffic without being in the catalogue are accepted, stored, and flagged to us for mapping; they simply produce no consumer-facing notification until we map them.
Responses
Per-event outcome — 200 OK
We answer per event. A batch is never rejected as a whole because one of its events was not usable.
| Field | Notes |
|---|---|
| messageId(string) | echo of the messageId you sent |
| receivedAt(string, date-time) | when we received the message |
| accepted / duplicate / unknownTracking / invalid(integer) | one counter per outcome, so the four always add up to the number
of events you sent. There is deliberately no single rejected counter:
only invalid is a problem you can act on |
| results(array) | one entry per event, in the order you sent them: eventId,
outcome, and errors[] when the outcome is
INVALID |
If you alert on one number, alert on invalid. Not on
unknownTracking: if you send us your whole stream, that counter is most of
your traffic on a perfectly healthy day, and paging someone on it means paging them every
minute from day one.
| outcome | Meaning | Retry? |
|---|---|---|
| ACCEPTED | stored and being processed | no |
| DUPLICATE | we already have this eventId. Not an error |
no |
| UNKNOWN_TRACKING | we do not track this parcel — it belongs to a merchant who is not our customer. Expected and harmless; if you push your whole event stream, most of it will come back like this | no |
| INVALID | the event violates the schema. Fix and resend with the same
eventId |
no |
Why always 200, and never 201 for a first
write? A batch is normally mixed — some events new, some already seen, some for
parcels we do not track — and a single status line cannot express that.
ACCEPTED versus DUPLICATE in results[] carries the
same information per event, and more precisely. Treat the HTTP status as "was the message
processed", and results[] as "what happened to each event".
What we do with an UNKNOWN_TRACKING event. It reaches no
merchant and no consumer, and we do not keep it as tracking data for a parcel that is not
ours to track.
We do, however, set it aside for 7 days, and
that is not an implementation detail: many merchants declare their shipments to us at the
end of the day or the next morning, so your first events — acceptance, pickup, first
scan — routinely arrive before the shipment exists on our side. Once it does,
the events we set aside are applied together with the first event that finds it, in
occurredAt order, as if we had matched them straight away. You do not need to resend them:
UNKNOWN_TRACKING stays "do not retry".
Of a set-aside event we keep only what it takes to rebuild the tracking —
identifiers, status, occurredAt, location.name —
and we drop the fields that are about a person right away:
delivery.signedBy, delivery.podUrl, the pickup point address,
recipientActionUrl. After 7 days nothing is left.
If you can filter your stream down to the merchants you know use Qapla', that is welcome
— but it is not something we ask you to build.
Message-level failures
| Code | Meaning | Retry? |
|---|---|---|
| 400 | body is not JSON, or the envelope is invalid | no |
| 401 | missing or unknown bearer token | no |
| 403 | bad signature, source IP not allowed, or courier does not match the token | no |
| 404 | /v1/events is not accepting traffic for your courier code yet (see Onboarding) | no |
| 413 | payload too large (see Limits) | no — split it |
| 415 | wrong Content-Type | no |
| 429 | rate limited. Honour Retry-After | yes |
| 5xx | our problem | yes |
Every 4xx body is an
RFC 7807 problem
document.
The 404 is deliberate, and not a 501 or a 503:
those are 5xx, and the retry policy published here tells
you to retry on 5xx — a courier that started before activation would end
up in a retry loop. A 404 stops it, full stop.
Retry policy
Retry on 429, on 5xx, on connection failures, and on timeouts.
Exponential backoff with jitter, at least 5 attempts spread over ~15
minutes (e.g. 10s → 30s → 2m → 5m → 10m).
Three attempts inside a few seconds — a common default — is not enough: our own
deploys and failovers can make the endpoint unavailable for longer than that, and an
event dropped after 7 seconds is an event the consumer never sees. Please keep
undeliverable messages in a dead-letter queue and replay them: we recognise a replayed
eventId as a duplicate for at least 6 months, so a
replay within that window is free. Beyond it, tell us before you replay.
Read timeout: 10 seconds. We answer well inside 1 second in normal operation. A
timeout is not a failure to deliver — we may already have stored the events.
Retrying the same messageId and the same eventIds is the correct and
safe response. If you retry per event, you may also put a failed event into a later batch:
what counts is the eventId, not the messageId.
Reference
Versioning
schemaVersion is a top-level field, major.minor.
- Minor bump: new optional fields, new
outcomevalues. Backward compatible; we deploy it and your integration keeps working untouched. Ignore fields you do not know. - Major bump: anything that could break a sender. Announced with at least 90 days' notice, and the previous major stays accepted for at least 12 months after the announcement.
We never remove or repurpose a field inside a major version.
Limits
| Events per message | 500 |
|---|---|
| Uncompressed body | 5 MiB |
| Rate limit | 20 requests/second sustained, burst 100 — raised on request, tell us your expected volume |
| Read timeout | 10 s |
X-RateLimit-Limit and X-RateLimit-Remaining are returned on
every response.
Onboarding
- You send us your expected daily event volume and peak rate, and
your logo in vector format (SVG, transparent background, no margins):
we use it in tracking emails and on the public tracking page. We assign you
your endpoint URL, a token, a signing secret, and your
couriercode. - You send us your status code catalogue.
- You develop against
POST /v1/events/validate, at whatever pace suits you. The JSON Schema and the example payloads are the normative artefacts — a validator run against them is the fastest way to know you are done. - We activate
/v1/eventsfor your courier code and compare the first day of real traffic against the tracking we already collect for the same parcels, which catches mapping and timezone mistakes before they reach a consumer. - You are live. You can keep your existing pull integration running in
parallel for as long as you like; duplicate events are free, thanks to
eventId.
Schema and examples
| File | Contents |
|---|---|
| courier-events.postman_collection.json | Postman collection: /v1/ping, /v1/events/validate
and /v1/events, plus every example ready to run. Generated from the
examples below |
| courier-events-v1.schema.json | JSON Schema 2020-12 of the envelope. Normative artefact |
| 01-minimal.json | the smallest valid message |
| 02-full-history-batch.json | a shipment's whole history in one message |
| 03-failed-attempt-and-pickup-point.json | failed attempt with reason, then dropped at a pickup point |
| 04-tracking-number-change.json | definitive tracking number assigned |
| 05-return-shipment.json | return leg towards the merchant |
| 06-external-id-only.json | events keyed off the order reference |
| 90-response-200.json | response with per-event outcome |
| 91-response-403.json | RFC 7807 problem document |
The collection ships with empty variables, on purpose. In the
collection's Variables tab, fill in endpoint_host — the host of
your endpoint, without https://, the one we assign you at
onboarding — and token. Then start with
GET /v1/ping: a wrong URL or a wrong token surfaces there, instead of
inside a payload where it would look like a schema problem.
Once they are filled in, do not re-export the file: the export would
carry your token. And the optional X-Qapla-Signature is not part of the
collection, because it is an HMAC-SHA256 that has to be recomputed per request and a
static collection cannot do that; the requests authenticate with the bearer token alone,
which is what we accept when the header is absent.
Questions this specification does not answer
Raised here deliberately, because they are worth deciding together rather than assuming:
- Do you want an acknowledgement channel? We can expose a signed callback telling you which events produced a consumer notification. Nobody has asked yet.
- Do you want us to push back? Some couriers want the merchant's re-delivery instruction to arrive as an API call rather than as a consumer clicking a link. Out of scope for v1.
- Consignment-level vs parcel-level identity. v1 keys everything off the tracking number the consumer knows. If your model is consignment-first with parcels underneath, tell us and we will look at it before v1 freezes.
Contact: tech@qapla.it