Skip to main content

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:

KeyTD form section
general_informationA: countries, offices, the three parties, totals
means_of_transportB: the vehicles
guaranteeC: reference, amount, currency, date
commoditiesD: one entry per goods item
declarantE: TIN, declaration text, fees, date
departure_formalitiesF: the departure stamp
authorizationG: exporting country, transit time
envelopeEverything 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

QueryReturns
?version=3Version 3, from its own stored bytes, with its own hash as the ETag
?as_of=2026-08-11T09:30:00ZThe 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
includeAdds under dataNotes
corridorcorridorWhere the goods have been and where they are declared to be going
versionsversionsThe version history
deliveriesdeliveriesWho has been told about each version. Only the filing country and the Secretariat may read it.
guaranteeguaranteeAsked live of the departure country's guarantee platform
trackingtrackingAsked live of the cargo tracking platform
  • The document's own keys never move. The includes sit beside them.
  • A misspelt include is 400 VAL-ETD-059, listing the valid names. It is never a 200 with a section missing.
  • guarantee and tracking call 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 ETag is 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_at is when ITS received the stamp, by its own clock. It is the only stamp time ITS holds.
  • current_holder is null while the goods are between administrations. That is a real state, not missing data.

The version history

FieldMeaning
version_noCounts from 1
change_typeISSUANCE, AMENDMENT, ENDORSEMENT, STATE_CHANGE or ATTACHMENT
change_categoriesFor an amendment, the section letters it touched, sorted, such as ABD. null otherwise.
acting_countryThe country the act was performed for
acting_client_idWhose credential performed it. Differs from acting_country when a REC acts for a member.
content_hashThat 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_statusMeaningWhat to do
VERIFIEDA platform answered that the reference is goodProceed
INVALIDA platform answered that it is not: unknown, expired, exhausted or revokedStop the consignment
DECLARED_UNVERIFIEDNobody to ask: no platform registered for the departure country, or no reference on the declarationTreat the declared guarantee as an unchecked claim
VERIFICATION_UNAVAILABLEA platform could be asked and did not usefully answerNothing 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_statusMeaning
TRACKEDA tracking platform answered and its source is live. Only this status carries a position.
NOT_TRACKEDNo stamp carries an ELECTRONIC seal. Not a failure: a metallic seal has no telemetry.
TRACKING_UNAVAILABLEThere 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_kindMeaning
OFFICERThe office of destination stamped the discharge. Evidence the goods arrived.
SYSTEM_DESTINATION_NOT_ONBOARDEDITS closed the transit because the destination is not on ITS and the authorised transit time expired. Not evidence of arrival.
AbsentNot discharged, or discharged before this field existed. On a DISCHARGED declaration, read absence as OFFICER.
warning

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.

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"
ParameterMeaning
page-number1-based. Absent means 1. A page past the end is an empty page, not an error.
page-sizeDefaults to 50, capped at 200. A larger value is clamped, not refused.
order-bycreated_at or serial_no, optionally with :asc or :desc. Absent means newest first.
filterExact and typed. Repeat it; every expression must hold.
queryFree text. One loose search across many fields.
tzAn 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:

FieldOperatorsValue
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.

warning
Percent-encode > 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_TRANSIT searches for those characters.
  • An empty query= is no search at all.
  • Accents are not folded yet: Cote does not find Cô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

EndpointReturns
GET /etd/statisticsLive counts for the countries you may see, such as active_transits, held_by_me and by_state
GET /etd/scopecaller_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:

  1. Take the document out of data: general_information, means_of_transport, guarantee, commodities, declarant, departure_formalities, authorization and envelope. Drop everything else, including includes and discharge_kind.
  2. Delete envelope.attestation.
  3. Canonicalise the result with RFC 8785 (JCS).
  4. Verify attestation.signature over those bytes with the key named by attestation.key_id, from GET /.well-known/jwks.json.

The key set includes retired keys, because a version is verified against the key in force when it was signed.