IT
Courier Events API 1.0
rel. 1.0.0

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?
400body is not JSON, or the envelope is invalidno
401missing or unknown bearer tokenno
403bad signature, source IP not allowed, or courier does not match the tokenno
404/v1/events is not accepting traffic for your courier code yet (see Onboarding)no
413payload too large (see Limits)no — split it
415wrong Content-Typeno
429rate limited. Honour Retry-Afteryes
5xxour problemyes

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.

We never remove or repurpose a field inside a major version.

Limits

Events per message500
Uncompressed body5 MiB
Rate limit20 requests/second sustained, burst 100 — raised on request, tell us your expected volume
Read timeout10 s

X-RateLimit-Limit and X-RateLimit-Remaining are returned on every response.

Onboarding

  1. 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 courier code.
  2. You send us your status code catalogue.
  3. 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.
  4. We activate /v1/events for 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.
  5. 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:

Contact: tech@qapla.it