← Mati Logistics

API Reference

Track ocean containers, bills of lading, and bookings across major carriers through one REST API. Submit a reference, and receive normalized milestone events by polling or by webhook.

Base URL https://api.matilogistics.com

Getting access

API access is available with a subscription plan. A 7-day free trial is available. Pay-as-you-go credits include the full web app but not the API. Once subscribed, issue a key from the API keys page in the app. You never lose access to shipments you’re already tracking.

Authentication

Every request requires a bearer token in the Authorization header. Keys are issued per organization and are scoped to it, so that a key can only ever read and write your own shipments.

curl https://api.matilogistics.com/v2/tracking_requests \
  -H "Authorization: Bearer tnt_your_key_here"
Keys are issued from the API keys page in the app, available on any subscription plan. Your key is shown exactly once when issued and is not recoverable afterwards; if you lose it, revoke it and issue a new one.

Core concepts

Understanding the primary resources and their lifecycle is essential for integrating with the API.

ObjectWhat it is
tracking_requestThe initial request to track a reference number. Created immediately with a status of pending, then resolved asynchronously against the carrier.
shipmentThe verified carrier tracking data (e.g., vessel, voyage, ETA, milestone events). Attached as the tracked_object on the tracking_request once resolved.
containerAn individual container unit associated with a shipment. A single bill of lading (shipment) may encompass multiple containers.

Asynchronous resolution workflow

Carrier lookups are processed asynchronously and typically complete within seconds to a few minutes, depending on carrier response times.

New references. Creating a tracking request for a new reference returns 201 Created with status: "pending" and no tracked_object payload.

Existing references. If the reference is already tracked, the endpoint returns status: "succeeded" immediately, populated with the tracked_object.

Integration guidance. Evaluate the response status synchronously. If status is succeeded, process the tracked_object payload immediately. Otherwise, rely on the tracking_request.succeeded webhook event rather than polling the endpoint.

Quickstart

1. Submit a reference

POST/v2/tracking_requests
curl -X POST https://api.matilogistics.com/v2/tracking_requests \
  -H "Authorization: Bearer tnt_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "request_type": "container",
    "request_number": "MRKU3643730"
  }'

request_type defaults to auto and may be omitted. Under auto, the reference is classified server-side: container by ISO 6346 shape and check digit, bill of lading when letter-prefixed, booking for bare digits. A reference whose shape is invalid, such as one with an incorrect check digit, is rejected with 422 naming the reason before any carrier is queried. An explicit container, bol or booking value is treated as authoritative and passed through unchanged.

Supply carrier where known, as either a SCAC ( "MAEU") or a carrier name ("Maersk"), to make routing deterministic. When omitted, the carrier is detected from the reference; see Finding the carrier for the detection sequence and the response when detection is inconclusive. Set continue_tracking: false to perform a one-off lookup rather than establish a monitored shipment.

201 Created

{
  "id": "e7da5c9f-d56d-44ae-b820-1434d48120d6",
  "request_type": "container",
  "request_number": "MRKU3643730",
  "scac": "MAEU",
  "status": "pending",
  "error_code": null,
  "error_message": null,
  "shipment_id": null,
  "created_at": "2026-07-29T16:42:37.316057+00:00"
}

2. Read the result

GET/v2/tracking_requests/{id}

Once resolved, status becomes succeeded and the shipment is embedded as tracked_object, trimmed here for length:

200 OK

{
  "id": "e7da5c9f-d56d-44ae-b820-1434d48120d6",
  "request_type": "container",
  "request_number": "MRKU3643730",
  "scac": "MAEU",
  "status": "succeeded",
  "shipment_id": "c5872f9d-03fb-4aa8-9b56-c513d9d4b6c4",
  "tracked_object": {
    "id": "c5872f9d-03fb-4aa8-9b56-c513d9d4b6c4",
    "carrier": "maersk",
    "tags": ["VESSEL_DEPARTED", "VESSEL_ARRIVED"],
    "original_eta": "2026-06-05T14:00:00Z",
    "original_etd": "2026-05-20T08:00:00Z",
    "summary": {
      "vessel": "EVER GIVEN",
      "voyage": "123E",
      "eta": "2026-06-05T14:00:00Z",
      "last_free_day": null,
      "location": "Los Angeles, CA"
    },
    "tracking_enabled": true,
    "stale": false,
    "containers": [
      {
        "id": "088a50e4-3841-4d2f-a21c-06f30d6c3103",
        "container_no": "MRKU3643730",
        "last_free_day": null,
        "availability": null,
        "hold_flag": false,
        "last_event_at": "2026-06-05T14:00:00"
      }
    ],
    "events": [
      {
        "containerNo": "MRKU3643730",
        "carrier": "maersk",
        "sourceLabel": "Maersk",
        "milestoneCode": "VESSEL_DEPARTURE",
        "milestoneDescription": "Vessel departed",
        "location": "Shanghai, CN",
        "eventTime": "2026-05-20T08:00:00Z",
        "demurrageFlag": false,
        "holdFlag": false
      },
      {
        "containerNo": "MRKU3643730",
        "carrier": "maersk",
        "sourceLabel": "Maersk",
        "milestoneCode": "VESSEL_ARRIVAL",
        "milestoneDescription": "Vessel arrived",
        "location": "Los Angeles, CA",
        "eventTime": "2026-06-05T14:00:00Z",
        "eta": "2026-06-05T14:00:00Z",
        "demurrageFlag": false,
        "holdFlag": false
      }
    ]
  }
}

milestoneCode is normalized across all carriers: a given code carries the same meaning regardless of source. milestoneDescription retains the carrier’s original text. Wording that cannot be mapped is preserved rather than discarded. See Milestones for the complete vocabulary.

Route

A shipment also carries route: the locations named by the carrier, in the order stated. This includes intermediate ports a container passes through, which are not otherwise apparent from the origin and destination alone. route reflects the carrier’s own statement; see Transshipments for the derived view, including dwell time at each hub.

"route": [
  {
    "sequence": 0,
    "role": "POL",
    "location": "Chittagong",
    "location_code": null,
    "country": null,
    "terminal": null,
    "arrival_time": null,
    "arrival_is_actual": null,
    "departure_time": "2026-05-14T07:15:42Z",
    "departure_is_actual": true,
    "vessel": null,
    "voyage": null,
    "mode": null
  },
  {
    "sequence": 1,
    "role": "POD",
    "location": "Xiaochan Beach",
    "arrival_time": "2026-06-02T11:20:00Z",
    "arrival_is_actual": true,
    "vessel": "EM SPETSES"
  },
  {
    "sequence": 2,
    "role": "POD",
    "location": "New York",
    "arrival_time": "2026-07-20T05:37:28Z",
    "arrival_is_actual": false,
    "vessel": "OOCL SUNFLOWER"
  }
]
role is a free-text label, not an enumeration. It contains the carrier’s own designation for the location, verbatim. Values observed across carriers include "POL", "pol", "receipt", "Place of Receipt" and "route-location.transit.sea". These are not normalized: consolidating them into a single vocabulary would discard distinctions the carriers themselves draw, as a place of receipt is not equivalent to a port of loading. Clients should treat the value as opaque.

arrival_is_actual and departure_is_actual reflect the carrier’s own actual-versus-estimated designation. A value of null indicates the carrier did not specify, which is distinct from an estimate and should not be rendered as one. Fields the carrier does not provide are returned as null, and route is an empty array for carriers that publish no routing data. Route data is never synthesized.

Reading times correctly

Most carriers publish a local wall-clock time, such as 16-Jul-2026 06:00, with no UTC offset stated. Such a value cannot be converted to an absolute instant without inventing information, so it is returned exactly as the carrier published it, accompanied by a flag identifying it as local.

FieldWhereMeaning
eventTimeIsLocaleventtrue eventTime is the carrier’s own wall clock at that place. The trailing Z is a storage convention, not a claim about UTC.
eventTimeZoneeventThe carrier’s own zone label, verbatim ( "PDT", "ICT"). Usually null, as most carriers do not state one.
times_are_localsummaryApplies to eta, etd, ata, atd and last_free_day.
times_are_localroute pointApplies to that point’s arrival_time and departure_time.

Do not convert local times

Interpreting 2026-07-16T06:00:00Z as UTC and rendering it in US Pacific time produces “Jul 15, 11:00 PM”, which is incorrect by both hour and calendar day and contradicts the carrier’s own published time. Render the value as supplied, in most libraries by formatting in UTC, and label it with eventTimeZone where present.

The same applies to date-only values such as last_free_day. "2026-07-20" parses as UTC midnight, so any conversion to a western time zone yields the preceding day. An incorrect last free day carries direct demurrage exposure.

The summary object carries no zone label. Its arrival values pertain to the discharge port and its departure values to the loading port, so a single shipment-level label would be incorrect for one or the other. Zone labels are provided on events and route points, each of which corresponds to a single location.

Bills covering several containers

A bill of lading frequently covers multiple containers, and those containers do not move as a unit: they may discharge on different dates, gate out to different hauliers, and be returned empty weeks apart. Tracking is therefore performed per container. Every event carries containerNo, and the shipment enumerates each unit in containers.

"containers": [
  { "container_no": "TGBU4019273", "last_free_day": "2026-07-24", "hold_flag": false },
  { "container_no": "TXGU5832207", "last_free_day": "2026-07-24", "hold_flag": false }
],
"events": [
  { "containerNo": "TGBU4019273", "milestoneCode": "GATE_OUT_DELIVERY", "eventTime": "..." },
  { "containerNo": "TXGU5832207", "milestoneCode": "GATE_OUT_DELIVERY", "eventTime": "..." }
]

Include containerNo in any de-duplication key

Two containers discharged within the same hour at the same terminal produce events that are identical except for the container number. A de-duplication key omitting containerNo will collapse them, causing the shipment to report fewer milestones than the carrier published.

Events with containerNo: null are bill-level: stated once for the shipment as a whole and not attributable to an individual container.

Milestones

Events carry a milestoneDescription (the carrier’s exact text) and a milestoneCode (the normalized value). For unmapped events the code is either empty or contains the carrier’s own text. Match against the vocabulary below rather than testing for an empty value.

Ocean moves: EMPTY_PICKUP, GATE_IN, LOAD, VESSEL_DEPARTURE, TRANSSHIPMENT_DISCHARGE, TRANSSHIPMENT_LOAD, VESSEL_ARRIVAL, VESSEL_BERTHING, DISCHARGE, AVAILABLE_FOR_PICKUP, GATE_OUT_DELIVERY, EMPTY_RETURN.
Inland: RAIL_LOAD, RAIL_DEPARTURE, RAIL_ARRIVAL, RAIL_UNLOAD, INLAND_TRANSIT, INLAND_RECEIVED.
Schedule: ETA_UPDATE, ETD_UPDATE, LFD_UPDATE.

Document, customs and release states: CUSTOMS_HOLD, CUSTOMS_RELEASE, CUSTOMS_FILED, HOLD_PLACED, HOLD_RELEASED, CARRIER_RELEASE, ISF_FILED, BOOKING_SUBMITTED, BOOKING_CONFIRMED, DOCS_SUBMITTED, BL_ISSUED, BL_SURRENDERED, IN_BOND_AUTHORIZED, NOTIFICATION_SENT, CFS_STRIPPED, CFS_STUFFED, SERVICES_COMPLETED.

tags on the shipment is the progress summary: the set of milestones reached to date, in journey order. EMPTY_PICKED_UP, GATE_IN, LOADED, VESSEL_DEPARTED, TRANSSHIPPED, VESSEL_ARRIVED, VESSEL_BERTHED, DISCHARGED, AVAILABLE_FOR_PICKUP, RAIL_DEPARTED, RAIL_ARRIVED, INLAND_IN_TRANSIT, INLAND_RECEIVED, DELIVERED, EMPTY_RETURNED. Document states produce no tag, as they are not journey progress.

Last free day

The last free day could appear in three places:

LocationWhat it is
containers[].last_free_dayThe last free day for each container. Containers on the same bill of lading may carry different last free days.
summary.last_free_dayShipment-level value, applicable to single-container shipments.
events[].lastFreeDayThe value as recorded at that event, providing an audit trail when a carrier revises it.

An event coded LFD_UPDATE indicates the carrier has published or revised the value. null indicates no value has been published; it does not indicate unlimited free time. Where a carrier states the value as a calendar date only, refer to Reading times correctly before formatting it.

Transshipments

Transshipments are listed in journey order in summary.transshipments. A direct service has an empty array.

"transshipments": [
  {
    "port": "TANGER MED",
    "arrived_from": "VALENCIA",
    "departing_to": "NORFOLK, VA",
    "inbound_vessel": "RDO CONCORD",
    "outbound_vessel": "SEOUL EXPRESS",
    "eta": null,
    "ata": "2026-06-25T07:00:00.000Z",
    "etd": null,
    "atd": "2026-07-04T08:06:00.000Z",
    "atd_is_load": true,
    "dwell_hours": 217.1,
    "evidence": ["Vessel arrived - TANGER MED", "Loaded - TANGER MED - SEOUL EXPRESS 625W"]
  }
]

dwell_hours is the elapsed time at the intermediate port. It is computed from actual values only: where either side is an estimate, or the carrier published no departure, the value is null.

Inferred departures

atd_is_load: true indicates the departure value is derived from the onward load rather than a stated departure. Several carriers publish an actual arrival at the intermediate port but no actual departure, in which case the load onto the onward vessel is the only available signal.

These entries are derived from the carrier’s events rather than stated directly, so evidence carries the verbatim wording underlying each entry. An intermediate port that appears only in a carrier’s forward schedule, with no actual movements recorded, is represented in route rather than here.

Finding the carrier

Supplying the carrier is recommended wherever it is known. A bill of lading generally carries the issuing carrier’s SCAC as a prefix, but booking numbers rarely identify the carrier, and a container number identifies only the equipment owner, which for leased units is a leasing company rather than the operating line.

Where carrier is not supplied, detection proceeds in sequence: the SCAC prefix on a bill of lading or booking; the ISO 6346 owner prefix on a container; and, where that prefix belongs to a leasing company (Triton, SEACO, CAI, Textainer, Touax, Blue Sky, SeaCube), the lessor’s public lookup to identify the on-hire operator. If no stage resolves a carrier, the request completes with status: "failed" and error_code: "carrier_unknown". Resubmitting the same reference with carrier specified updates the existing request, retaining its id, rather than creating a duplicate.

To determine the carrier before submission, or to populate a carrier selector, query the detection endpoint directly:

GET/v2/carriers/detect
curl "https://api.matilogistics.com/v2/carriers/detect?reference=TCLU1234568" \
  -H "Authorization: Bearer tnt_your_key_here"

{
  "normalized": "TCLU1234568",
  "valid": true,
  "ref_type": "container",
  "carrier": null,
  "scac": null,
  "carrier_name": null,
  "method": null,
  "lessor": "triton",
  "lessor_name": "Triton",
  "on_hire": null,
  "message": "Leased container owned by Triton. Pass deep=true to ask the lessor who has it on hire (may take several seconds)."
}

The default call performs reference validation against the prefix maps. method identifies which stage resolved the carrier ( container_prefix, scac_prefix or lessor). Add deep=true to perform the lessor lookup for a leased container. This takes several seconds, counts against the write rate limit, and returns on_hire: true together with the operating carrier, or false where the container is idle in a depot and no active shipment exists. Florens does not disclose the operating carrier through its lookup, so the carrier must be supplied explicitly for Florens-owned equipment.

Endpoints

Tracking requests

POST/v2/tracking_requests
Submit a reference for tracking.
GET/v2/tracking_requests
List your tracking requests. Cursor-paginated.
GET/v2/tracking_requests/{id}
One request, with its resolved shipment.
POST/v2/tracking_requests/batch
Retrieve multiple records by id in a single call: {"ids": [...]}.
DELETE/v2/tracking_requests/{id}
Stop tracking. Other subscribers tracking the same container are unaffected.

Shipments & containers

GET/v2/shipments
List shipments.
GET/v2/shipments/{id}
One shipment with its full event history.
GET/v2/shipments/{id}/containers
Containers on a shipment.
GET/v2/containers/{id}
One container.
GET/v2/containers/{id}/events
Milestone events for one container.

Reference data

GET/v2/carriers
Carrier coverage and SCACs; the source of the table below.
GET/v2/carriers/detect
Detect the carrier for a reference before submission. See Finding the carrier.
GET/v2/terminals
Terminals available for direct query.

Pagination

List endpoints are cursor-paginated. Pass the previous response’s meta.next_cursor as ?cursor=; stop when has_more is false.

{
  "data": [  ],
  "meta": { "next_cursor": null, "has_more": false }
}

Webhooks

Register an endpoint to receive status changes by POST rather than polling. Each endpoint is issued its own signing secret, returned once at creation.

POST/v2/webhooks
curl -X POST https://api.matilogistics.com/v2/webhooks \
  -H "Authorization: Bearer tnt_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/mati"}'

201 Created

{
  "id": "86eb5bb5-87bb-4263-a455-624f42916dd2",
  "url": "https://example.com/hooks/mati",
  "secret": "78572cbf8d27171c9a2c931083053ec5afc138fb7224c604",
  "active": true,
  "event_types": null
}

event_types: null subscribes to everything. Pass an array to narrow it. Supply a secret at creation where the receiving system is already configured with one, or rotate it subsequently via POST /v2/webhooks/{id}/rotate_secret.

Event types

tracking_request.succeededA reference resolved; the shipment is available.
tracking_request.failedThe carrier lookup failed.
tracking_request.not_foundThe carrier has no record of this reference.
shipment.updatedNew milestone data on a tracked shipment.
container.transport_eventA container-level movement.

Verifying a delivery

Every delivery carries X-TNT-Event and X-TNT-Signature, an HMAC-SHA256 of the raw request body, hex-encoded, keyed with your endpoint’s secret. Compute the signature over the raw bytes as received, before JSON parsing or re-serialization; otherwise it will not match.

import hmac, hashlib

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
Deliveries are retried up to 3 times on failure. Return 2xx promptly and process asynchronously; a slow handler is treated as a failure and retried. Handlers must be idempotent: a repeated event should be a no-op rather than a second shipment update.

Errors

Errors share a common envelope. request_id is present on every response, successful or otherwise. Include it when reporting an issue to identify the specific call.

401 Unauthorized

{
  "error": {
    "code": "unauthenticated",
    "message": "Missing bearer token",
    "request_id": "b40ca7a0c8b14aec8b7eafd7598b07b3"
  },
  "detail": "Missing bearer token"
}

Validation failures include a fields array identifying the invalid input:

422 Unprocessable

{
  "error": {
    "code": "unprocessable",
    "message": "Request body or parameters failed validation.",
    "request_id": "d662fa2499584bf091748a050d49b7c7",
    "fields": [
      {
        "type": "literal_error",
        "loc": ["body", "request_type"],
        "msg": "Input should be 'container', 'bol' or 'booking'",
        "input": "nope"
      }
    ]
  }
}
StatusMeaning
400Malformed request, or an unknown carrier hint.
401Missing, invalid, or revoked API key.
403The key is not permitted to act as the subscriber it named.
404No such object, or one outside the requesting organization.
422Well-formed but unprocessable: a validation failure, or a recognized carrier not yet supported.
429Rate limit exceeded.
A 422 on create states the reason a carrier is unavailable, distinguishing an unsupported carrier from an invalid reference: "Carrier ‘Crowley’ is reachable only behind a customer login and can’t be tracked publicly."

Rate limits

Limits are applied per organization on a fixed hourly window. Write operations are the constrained resource, as each may trigger a live carrier lookup; read operations are served from stored data.

ClassLimitCovers
Writes1,000 / hourCreating tracking requests, webhook changes
Reads10,000 / hourEvery GET

Exceeding a limit returns 429. These are default values and can be raised on request.

Carrier coverage

Coverage spans ocean carriers and NVOCCs. The list of the current coverage is listed below. You can also query this complete list dynamically using the GET /v2/carriers API endpoint. New coverage requests can be made in the app.

CarrierSCACType
Atlantic Container LineACLUOcean carrier
CMA CGMCMDUOcean carrier
COSCOCOSUOcean carrier
EvergreenEGLVOcean carrier
Expeditors InternationalEXDONVOCC
Hapag LloydHLCUOcean carrier
Hecny ShippingHYSLNVOCC
HEDE ShippingHDUJOcean carrier
HMMHDMUOcean carrier
Honour Lane LogisticsHNLTNVOCC
Kuehne + NagelBANQNVOCC
MaerskMAEUOcean carrier
MatsonMATSOcean carrier
MSCMSCUOcean carrier
ONEONEYOcean carrier
OOCLOOLUOcean carrier
Orient Express ContainerOERTNVOCC
Pacific InternationalPCIUOcean carrier
Shipco TransportSHPTNVOCC
SM LineSMLMOcean carrier
TurkonTRKUOcean carrier
Wan Hai LinesWHLCOcean carrier
Yang MingYMLUOcean carrier
ZimZIMUOcean carrier