Reading and searching
Every read needs the etd:read permission. This guide covers the single read, the list, and what each can tell you.
Who may read a declaration
A country may read a declaration it filed, will receive, has stamped, or has been named next for. A REC reads what any of its members may read. The Secretariat reads everything.
A declaration you may not read answers 404, exactly like a serial that was never issued. It never answers 403, because a 403 would confirm the declaration exists. Do not build anything that tells "forbidden" and "missing" apart: ITS will not tell you.
The list applies the same rule. Declarations outside your scope are simply absent, and the count does not include them.
Read one declaration
curl --fail-with-body --silent --show-error \
--cert "$ITS_CLIENT_CERT" --key "$ITS_CLIENT_KEY" \
--header "Authorization: Bearer $ITS_TOKEN" \
"$ITS_GATEWAY_URL/etd/KEMBATD26081138"
The body is the stored, signed content of the current version. data holds the lettered sections of the TD form and the server-assigned envelope:
| Key | TD form section |
|---|---|
general_information | A: countries, offices, the three parties, totals |
means_of_transport | B: the vehicles |
guarantee | C: reference, amount, currency, date |
commodities | D: one entry per goods item |
declarant | E: TIN, declaration text, fees, date |
departure_formalities | F: the departure stamp |
authorization | G: exporting country, transit time |
envelope | Everything ITS assigns: serial_no, version_no, state, verification_code, endorsements, attachments, attestation |
The response carries an ETag header holding the version's content hash. That is the If-Match value an amendment needs. HEAD /etd/{serial_no} returns the same ETag without a body, which is a one-request way to ask whether anything has changed.
Read an earlier version
| Query | Returns |
|---|---|
?version=3 | Version 3, from its own stored bytes, with its own hash as the ETag |
?as_of=2026-08-11T09:30:00Z | The version in force at that instant |
Use one or the other, not both. A historical version is exactly what that version said, so it still verifies against the signature it was given.
Widen the read with include
Everything a declaration can tell you comes from one URL. Ask for more with include, and it arrives in the same response:
GET /etd/{serial_no}?include=corridor,versions,deliveries,guarantee,tracking
include | Adds under data | Notes |
|---|---|---|
corridor | corridor | Where the goods have been and where they are declared to be going |
versions | versions | The version history |
deliveries | deliveries | Who has been told about each version. Only the filing country and the Secretariat may read it. |
guarantee | guarantee | Asked live of the departure country's guarantee platform |
tracking | tracking | Asked live of the cargo tracking platform |
- The document's own keys never move. The includes sit beside them.
- A misspelt include is
400VAL-ETD-059, listing the valid names. It is never a200with a section missing. guaranteeandtrackingcall other countries' platforms, so they can be slow and can answer that they do not know. They are fetched only when you name them.- The
ETagis still the version's content hash, so you can read with includes and amend straight afterwards.
The corridor
{
"data": {
"corridor": {
"serial_no": "KEMBATD26081138",
"departure": { "country_code": "KE", "country_name": "Kenya" },
"destination": { "country_code": "UG", "country_name": "Uganda" },
"current_state": "IN_TRANSIT",
"stops": [
{
"stamp_type": "DEPARTURE",
"country_code": "KE",
"station_country": "KE",
"station_code": "MBA",
"next_country_code": "UG",
"received_at": "2026-08-11T14:02:26.45+03:00"
}
],
"current_holder": null,
"declared_next_country": "UG"
}
}
}
The corridor is derived from the stamps, in the order ITS received them. There is no declared route.
received_atis when ITS received the stamp, by its own clock. It is the only stamp time ITS holds.current_holderisnullwhile the goods are between administrations. That is a real state, not missing data.
The version history
| Field | Meaning |
|---|---|
version_no | Counts from 1 |
change_type | ISSUANCE, AMENDMENT, ENDORSEMENT, STATE_CHANGE or ATTACHMENT |
change_categories | For an amendment, the section letters it touched, sorted, such as ABD. null otherwise. |
acting_country | The country the act was performed for |
acting_client_id | Whose credential performed it. Differs from acting_country when a REC acts for a member. |
content_hash | That version's hash |
Is the guarantee real?
?include=guarantee asks the departure country's guarantee platform every time you call. ITS stores no guarantee verdict.
guarantee_status | Meaning | What to do |
|---|---|---|
VERIFIED | A platform answered that the reference is good | Proceed |
INVALID | A platform answered that it is not: unknown, expired, exhausted or revoked | Stop the consignment |
DECLARED_UNVERIFIED | Nobody to ask: no platform registered for the departure country, or no reference on the declaration | Treat the declared guarantee as an unchecked claim |
VERIFICATION_UNAVAILABLE | A platform could be asked and did not usefully answer | Nothing has been said about the guarantee. Check by other means. |
The declared values (declared_amount, declared_currency) and the platform's values (verified_amount, verified_currency) are shown side by side. When they differ, that difference is the finding.
Where are the goods?
?include=tracking asks the cargo tracking platform of the country that fitted the current electronic seal. ITS stores no positions.
tracking_status | Meaning |
|---|---|
TRACKED | A tracking platform answered and its source is live. Only this status carries a position. |
NOT_TRACKED | No stamp carries an ELECTRONIC seal. Not a failure: a metallic seal has no telemetry. |
TRACKING_UNAVAILABLE | There is a seal and no usable answer |
Read tracking_status before position. ITS never shows a stale position. If you cache tracking answers, cache the status with them and stop drawing a marker when it is not TRACKED.
occurred_at is when the position was observed. retrieved_at is when ITS asked. Use the first to show how old a position is.
The seal's state is ACTIVE, DEACTIVATED, TAMPERED or UNKNOWN. The declaration's state comes from customs stamps only, so do not expect the tracking platform and the declaration to agree.
Who closed the transit
A DISCHARGED declaration carries discharge_kind at the top level of data, beside the document:
discharge_kind | Meaning |
|---|---|
OFFICER | The office of destination stamped the discharge. Evidence the goods arrived. |
SYSTEM_DESTINATION_NOT_ONBOARDED | ITS closed the transit because the destination is not on ITS and the authorised transit time expired. Not evidence of arrival. |
| Absent | Not discharged, or discharged before this field existed. On a DISCHARGED declaration, read absence as OFFICER. |
ITS does not release guarantees. The departure country acquits a bond in its own system. Never acquit one on a SYSTEM_DESTINATION_NOT_ONBOARDED closure.
List and search
curl --fail-with-body --silent --show-error \
--cert "$ITS_CLIENT_CERT" --key "$ITS_CLIENT_KEY" \
--header "Authorization: Bearer $ITS_TOKEN" \
"$ITS_GATEWAY_URL/etd?query=acme&filter=state::IN_TRANSIT&page-number=1&page-size=50"
| Parameter | Meaning |
|---|---|
page-number | 1-based. Absent means 1. A page past the end is an empty page, not an error. |
page-size | Defaults to 50, capped at 200. A larger value is clamped, not refused. |
order-by | created_at or serial_no, optionally with :asc or :desc. Absent means newest first. |
filter | Exact and typed. Repeat it; every expression must hold. |
query | Free text. One loose search across many fields. |
tz | An IANA zone id, such as Africa/Kampala, to render created_at in. Absent means UTC. |
data is the array of rows. pagination sits beside it:
{
"pagination": { "page_number": 1, "page_size": 50, "total_pages": 3, "total_records": 121 },
"header": { "response_code": 200, "response_message": "SUCCESS" },
"data": [
{
"serial_no": "KEMBATD26081138",
"departure": { "country_code": "KE", "station_code": "MBA" },
"destination": { "country_code": "UG", "station_code": "EBB" },
"current_state": "IN_TRANSIT",
"version_no": 3,
"content_hash": "938501f2...",
"signature_status": "UNSIGNED",
"stamp_count": 2,
"last_stamp_country": "KE",
"created_at": "2026-08-11T14:02:26.45+03:00"
}
]
}
total_records counts only the declarations you may see. Each row carries version_no and content_hash, so you can decide what to fetch without fetching it, and send the hash straight back as If-Match.
An unknown query parameter is ignored, not refused. A misspelt parameter gives you page one at the default size and no error.
The filter grammar
Write a filter as field<operator>value:
| Field | Operators | Value |
|---|---|---|
serial_no | :: @@ | A serial number |
state | :: @@ | REGISTERED, DEPARTED, IN_TRANSIT, ARRIVED, DISCHARGED, CANCELLED |
departure.country_code | :: @@ | A two-letter country code |
destination.country_code | :: @@ | A two-letter country code |
submitted_by | :: @@ | The code of the organisation that filed, such as UG or EAC |
discharge_kind | :: @@ | OFFICER or SYSTEM_DESTINATION_NOT_ONBOARDED |
created_at | >> >: << <: | An ISO 8601 instant |
pending_closure_at | :: >> >: << <: | An ISO 8601 date, such as 2026-08-31 |
:: means equals. @@ means one of, comma-separated, at most 100 values. >: and <: are inclusive bounds; >> and << are strict.
submitted_by is who filed, which differs from whose declaration it is when a REC files for a member. pending_closure_at is the date from which ITS may close a transit whose destination is not on ITS. Neither appears on a row: you filter by them.
> and <Send > as %3E and < as %3C. Sent literally, a range filter is refused with a 400 and an empty body, before ITS can say why.
?filter=state@@IN_TRANSIT,ARRIVED
?filter=departure.country_code::UG&filter=created_at%3E%3A2026-08-01T00:00:00Z
A filter ITS cannot read — an unknown field, an operator the field does not take, an unknown state, a bad date — is refused with 400 VAL-ETD-034, naming what was wrong. It is never silently dropped.
Free-text query
query matches one term as a case-insensitive substring across the serial number, both offices and their countries, the container number, the three party names and their TINs, both vehicles' plates and nationalities, and every commodity description.
- It has no grammar.
query=state::IN_TRANSITsearches for those characters. - An empty
query=is no search at all. - Accents are not folded yet:
Cotedoes not findCôte. - It never widens what you may see.
Resolve many serials at once
GET /etd?page-size=200&filter=serial_no@@KEMBATD26081138,UGEBBTD2608091
You get back the declarations that resolved and nothing about the ones that did not. Compare the serials you asked for with the ones you received.
Resume a long traversal
Order oldest first, remember the created_at of the last row you processed, and resume from it:
GET /etd?order-by=created_at:asc&page-size=200
GET /etd?order-by=created_at:asc&page-size=200&filter=created_at%3E%3A2026-08-11T14:02:26.45Z
Use >: (at or after), not >>. Two declarations can share a created_at, and a strict bound would drop one of them. Skip the serials you have already seen.
tz changes how an instant is written, never which instant it is. Keep the offset when you send a created_at back.
Time zones
tz renders the times around a declaration in your zone: corridor stamp times, delivery times, version times and list created_at. It is never inferred from your country. An unknown zone id is 400 VAL-ETD-034.
tz never changes the document itself. Times inside the signed content, such as envelope.issued_at, are always UTC with a Z. Convert them yourself if you need local time.
Live counts and your scope
| Endpoint | Returns |
|---|---|
GET /etd/statistics | Live counts for the countries you may see, such as active_transits, held_by_me and by_state |
GET /etd/scope | caller_type, country_code, rec_code, visible_countries, actable_countries and sees_everything |
Both carry a weak ETag and Cache-Control: private, max-age=15. Poll them with If-None-Match and take the 304. Counts for a "today" use a UTC day.
For the Secretariat, sees_everything is true and visible_countries is empty. Read the flag first.
Verify a signature
Every version can be signed with a detached JSON Web Signature over its canonical bytes. envelope.attestation carries signature_status, content_hash, signature, key_id and signed_at. signature_status is SIGNED or UNSIGNED. A deployment with no signing key produces UNSIGNED versions; that is a valid state, not a fault.
To verify a version yourself:
- Take the document out of
data:general_information,means_of_transport,guarantee,commodities,declarant,departure_formalities,authorizationandenvelope. Drop everything else, including includes anddischarge_kind. - Delete
envelope.attestation. - Canonicalise the result with RFC 8785 (JCS).
- Verify
attestation.signatureover those bytes with the key named byattestation.key_id, fromGET /.well-known/jwks.json.
The key set includes retired keys, because a version is verified against the key in force when it was signed.