Skip to content

Guides

Webhooks

Register an HTTPS endpoint, verify the HMAC signature, and let a Navo24 product push events to you instead of polling. One model across ocean, air and the rest of the family.

Polling is fine for a first integration, but it wastes calls and adds lag. Register an endpoint and the product posts a signed JSON body to it when an event fires.

Webhooks work the same way across the Navo24 family. The registration routes, the delivery shape, the HMAC signature scheme and the retry ladder are identical from one product to the next. Only two things change per product: the name of the signature header, and the catalogue of events you can subscribe to. Every product in the family speaks this API today: TrackingMCP (ocean), AirCargoMCP (air), SchedulesMCP (sailings), FreightRatesMCP (rates) and LoadingMCP (load plans).

Read this page top to bottom and you can build a receiver, verify a signature and handle every event a product sends, without asking us anything.

Which products emit webhooks

Each product exposes the same six routes at /v1/webhooks on its own host, and signs deliveries with its own header. Pick your product to see the two things that differ, its base host and its signature header, plus its response envelope and event catalogue. Everything else on this page is identical for every product.

Base host
api.trackingmcp.com
Signature header
X-TrackingMCP-Signature
Response envelope
{ "ok": true, "data": … }

All five products are live on webhooks today. Throughout this guide, wherever a header or a host reads X-<Product>-Signature or api.<product>.com, use the values for your product from the panel above.

Before you start

Every call on this page is authenticated with your tmcp_ key as a bearer token, exactly as in authentication. One key works across the family, so the same credential registers an ocean endpoint and an air endpoint.

Success and failure use the product’s standard envelope, shown in the table above and described in errors and rate limits. The webhook route fields are the same either way; only the wrapper differs.

Registering and testing an endpoint are writes that create delivery load, so on a metered product they require that product’s entitlement. Listing, inspecting, updating and deleting an endpoint need only a valid key, so a downgraded account can still inspect and turn off its endpoints.

Every route on this page has a reference entry under its product, with the full parameter and response tables and copy-paste samples in curl, JavaScript and Python.

Two practical notes before you write code. Your endpoint must be reachable over https://, because we reject anything else at registration time. And a product’s OpenAPI spec may not yet list the webhook routes, so a client generated from it today may not include them. Check the spec if you rely on generated clients, and call these routes directly meanwhile.

Managing endpoints

Six routes create and maintain a webhook endpoint. Each one has a reference page carrying its full parameter and response tables, so this guide never restates them and the two cannot drift apart. The reference links below point at AirCargoMCP; the equivalent pages for every other product live under that product’s reference: TrackingMCP, SchedulesMCP, FreightRatesMCP and LoadingMCP.

MethodPathWhat it does
POST/v1/webhooksRegister an endpoint. Returns the signing secret once.
GET/v1/webhooksList your endpoints with their health.
GET/v1/webhooks/{id}One endpoint plus its 20 most recent delivery attempts.
PATCH/v1/webhooks/{id}Change the URL, subscription, label or enabled state.
DELETE/v1/webhooks/{id}Remove the endpoint and its history.
POST/v1/webhooks/{id}/testQueue a ping delivery to prove your wiring.

Registration is the call worth walking through, because two of its behaviours will cost you an afternoon if you meet them by surprise.

curl -X POST https://api.aircargomcp.com/v1/webhooks \
  -H "Authorization: Bearer tmcp_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/aircargomcp",
    "event_types": ["awb_arrived", "awb_delivered"],
    "description": "Consignee milestones, ops channel"
  }'

The response carries the endpoint plus its secret, wrapped in the product’s envelope. On AirCargoMCP that is a bare { "data": … }; on TrackingMCP it is { "ok": true, "data": … }. The fields inside are identical:

{
  "data": {
    "id": "3f1c9a4e-7b52-4f0e-9a41-2c9d5e6b8a10",
    "url": "https://hooks.example.com/aircargomcp",
    "event_types": ["awb_arrived", "awb_delivered"],
    "description": "Consignee milestones, ops channel",
    "active": true,
    "created_at": "2026-08-11T09:12:04.881Z",
    "secret": "whsec_3f9a1c7e5b204d8619ac0f73e26b8d45a1c9e30f7b264d58"
  }
}

The first surprise is that secret appears here and nowhere else, ever. It is the key you verify every delivery with, and no other call returns it. Store it before you close the connection. If you lose it, your only route back is to delete the endpoint and register a new one.

The second is that an unrecognised event type is dropped without an error. Subscribe to a name the product does not emit and the call still answers success with "event_types": [], which is not an empty subscription but the subscribe-to-everything setting. Always read event_types back from the response and compare it with what you sent. The same silent-drop rule applies on PATCH, along with a url that is not https://, which is ignored rather than rejected.

Two health fields on a listed endpoint tell you how it is doing. failure_count counts consecutive failed deliveries and resets to zero on any success. last_delivery_at moves only on a success, so an endpoint that has been live for a week with last_delivery_at still null is almost always a signature check that never matches. When something is missing, GET /v1/webhooks/{id} shows the last 20 attempts, what your server answered, and whether we intend to try again.

What a delivery looks like

Every delivery is an HTTP POST with a JSON body and these five headers. The header names carry the product prefix, so an air delivery signs with X-AirCargoMCP-Signature and an ocean delivery with X-TrackingMCP-Signature.

HeaderValue
Content-Typeapplication/json
User-Agent<Product>-Webhooks/1, for example AirCargoMCP-Webhooks/1.
X-<Product>-EventThe event type, for example awb_arrived.
X-<Product>-DeliveryThe delivery id, the same value as id in the body. Stable across retries.
X-<Product>-Signaturesha256= followed by the hex HMAC of the body.

The body is always the same four keys, whatever the event and whatever the product:

FieldTypeDescription
idstringThe delivery id. Stable across every retry of this delivery, so it is your idempotency key.
typestringThe event type. Repeats the X-<Product>-Event header.
occurred_atstringISO 8601 UTC of when the event was raised, not when this attempt was sent. It does not move on a retry.
dataobjectEverything specific to the event type. Its shape is documented per event below.

There is no wrapper object and no sent_at field on a delivery body. The product envelope described above wraps API responses, not delivered events.

Verifying the signature

Verify every delivery before you act on it. An unverified endpoint is a public URL that anyone can post events to.

The scheme is identical across products, and only the header name differs:

  1. We take the raw body bytes we are about to send, which is the JSON above with no trailing newline and no reformatting.
  2. We compute HMAC-SHA256(rawBody, secret) using your whsec_ secret as the key, and hex-encode it in lower case.
  3. We send that as X-<Product>-Signature, prefixed with the literal string sha256=.

So the header value is sha256=<64 hex characters>, and the string signed is the request body and nothing else. No timestamp, no method, no path, and no separator are folded in.

One caveat applies to every product, and it is worth stating plainly rather than leaving you to discover it. There is no timestamp header and no replay-protection window: a delivery is verified by its signature over the body alone. Anyone who captured a valid body and its matching signature could in principle post it to you again, and it would verify. Your defence is the delivery id. Verify the signature, then deduplicate on id, which is stable across every retry, so a replayed body lands as a duplicate you already handled rather than as a second event.

To verify, strip the sha256= prefix, recompute the HMAC over the raw body, and compare in constant time. The code is product-neutral: pass whichever X-<Product>-Signature header the delivery carried.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: Buffer or string of the body exactly as received, BEFORE JSON.parse.
// header:  the X-<Product>-Signature value, e.g. "sha256=1f3b…".
// secret:  your whsec_ signing secret.
function verify(rawBody, header, secret) {
  if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
  const received = header.slice("sha256=".length);
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

In Python:

import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    if not header or not header.startswith("sha256="):
        return False
    received = header[len("sha256="):]
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

Two mistakes account for nearly every report of events not arriving, and both fail silently, on every delivery, with nothing in your logs to explain it.

The first is comparing against the whole header value. The header is not the bare digest, it is sha256= plus the digest, so a check that skips the prefix can never match.

The second is signing a re-serialised body. Most frameworks parse JSON for you, and JSON.stringify of the parsed object is not guaranteed to reproduce the bytes we signed. Capture the raw body first. In Express that means mounting express.raw({ type: "application/json" }) on the webhook route, ahead of any JSON body parser. In FastAPI, use await request.body() rather than the parsed model.

When the check fails, reject the delivery. Do not fall back to trusting it.

Responding to a delivery

Return any 2xx and we mark the delivery delivered. Anything else is a failure, including a 3xx redirect, which we do not follow.

We wait 10 seconds for your response. Past that we abandon the attempt and record it as a failure, even if your handler goes on to finish the work. Acknowledge first, then do the work asynchronously.

The body of your response is ignored. An empty 200 is the ideal answer.

Retries and delivery guarantees

A worker sweeps for due deliveries every 30 seconds, so a first attempt normally lands within half a minute of the event.

A failed delivery is retried up to 6 attempts in total. The wait after each failed attempt is fixed:

After attemptNext attempt in
110 seconds
21 minute
35 minutes
430 minutes
52 hours
66 hours

That is a delivery window of roughly nine hours. After the sixth failed attempt the delivery is marked dead and never retried. There is no way to replay a dead delivery, so treat the nine hours as your outage budget.

Separately from any single delivery, an endpoint that fails 20 consecutive deliveries is set to active: false automatically, so we stop posting to a dead URL. While it is disabled nothing is queued for it, and those events are not backfilled when you re-enable it. PATCH it to active: true to resume, which also clears the counter.

What this gets you, stated plainly:

Delivery is at least once. If your handler commits its work and your 2xx is then lost, or arrives after our 10 second timeout, we send the same delivery again. Deduplicate on id.

There is no ordering guarantee. Deliveries are attempted in due order, and any retry lands behind events that were raised later. Use occurred_at if sequence matters to you.

A delivery is queued only for endpoints that are active at the moment the event is raised.

Event catalogues

The mechanics above are shared. The list of events you can subscribe to is per product. Each product’s reference page is the authority for its catalogue; the two sections below describe what each product sends today.

Across every product, one event never appears in a subscription list:

ping is sent only when you call POST /v1/webhooks/{id}/test. It is never sent unprompted and you do not subscribe to it. Its data carries a single fixed message string, and nothing else. A ping travels the same retry and dead-letter path as a real event, so a failing ping burns attempts and increments failure_count.

{
  "id": "5e2a7c33-01b8-4f9d-a6c2-7d4e8b1f30aa",
  "type": "ping",
  "occurred_at": "2026-08-11T09:14:30.008Z",
  "data": { "message": "AirCargoMCP webhook test" }
}

AirCargoMCP events (air)

Air events track an air waybill across its lifecycle. They are produced by the AWB poller, which diffs the top-level shipment status between polls and fires on a change. Subscribe with any of the names below, or leave event_types empty to take them all.

EventFires when
awb_acceptedThe shipment was accepted from the shipper at origin.
awb_in_transitThe shipment is moving: uplifted, airborne, or at a transit station.
awb_arrivedThe shipment reached its destination station, not yet released.
awb_availableThe shipment is notified and ready for pickup at destination.
awb_deliveredThe shipment was delivered to the consignee. Terminal.

Each data object carries the air waybill and the milestone that fired. A delivered event looks like this:

{
  "id": "c07f5b81-9a2c-4d63-8e10-15b4f7d0e992",
  "type": "awb_delivered",
  "occurred_at": "2026-08-11T04:31:57.663Z",
  "data": {
    "awb": "020-12345675",
    "airline_iata": "LH",
    "status": "delivered",
    "origin": "FRA",
    "destination": "JFK"
  }
}

Read type for the milestone and re-read the AWB with track an air waybill when you need the full timeline. Because the poller keys on the top-level status, an event fires once per status change per shipment, not once per poll.

TrackingMCP events (ocean)

Ocean webhooks carry platform and sailing signals. The event_types field accepts exactly these names, in lower snake case:

eta_changed, demurrage_warning, demurrage_started, customs_hold, container_arrived, container_available, container_delivered, vessel_rollover, vessel_departed, container_misrouted, api_change_announced.

Of those, api_change_announced is the only one with a live producer today. The ten container lifecycle names are accepted by the subscription API and reserved for the events they describe, but nothing currently emits them, so a subscription to container_arrived will sit quietly and receive nothing. Do not plan a launch around one without asking us where it stands.

One more ocean event can reach your endpoint without appearing in that list:

schedule_disruption is raised by the sailing watchdog, but it is not an accepted value for event_types. Passing it drops it, which leaves you subscribed to nothing. The only way to receive it today is to leave event_types empty and take every event type.

The api_change_announced event

An API change has been announced, ahead of the date it takes effect. We send it on the day a changelog entry is announced, not on the day the change lands, so you get the whole notice window instead of a surprise. It goes to every subscribed endpoint on every account, because a platform change is not specific to one shipment. It fires once per changelog entry. Subscribe from CI or an on-call channel rather than from shipment tooling.

FieldTypeDescription
data.idstringStable id of the changelog entry, never reused. Deduplicate announcements on it.
data.typestringThe change class: breaking, added, fixed or deprecated. Not the event name.
data.axisstringWhat changed: shape (the response structure), behaviour (the values) or platform (auth, limits, versioning).
data.titlestringOne-line summary, written for a human.
data.detailstringThe full explanation, including what does not change.
data.announced_atstringISO date (YYYY-MM-DD) the change was announced. The day this event fires.
data.effective_atstringISO date the change takes effect.
data.surfacesstring[]The API surfaces affected.
data.endpointsstring[]Path patterns affected, so a CI check can match them against what you actually call.
data.carriersstring[]SCACs whose normalisation changed. Absent when the change is not carrier-specific.
data.parser_versionsstring[]Parser versions carrying the change. Absent when it is not a parser change.
data.api_versionstringThe published API version that introduces the change. Absent when the change is not versioned.
data.action_requiredbooleanTrue when you must change code or reconfigure before effective_at. The one field to branch on.
data.action_detailstringWhat to do about it. Absent when action_required is false.
data.notice_daysintegerDays between announced_at and effective_at. Zero means the change shipped with no notice, and we say so rather than hide it.
data.oldest_supported_versionstringThe oldest API version still served. An unpinned request resolves to this.
data.latest_versionstringThe newest published API version.
data.changelog_urlstringWhere to read the full machine-readable changelog.
data.versions_urlstringWhere to read the supported version list.
{
  "id": "a94b2e10-33d7-4c8a-b0f1-6d2e7c9a4b55",
  "type": "api_change_announced",
  "occurred_at": "2026-08-05T06:00:11.402Z",
  "data": {
    "id": "2027-02-01-searates-compat-unk-three-letter",
    "type": "breaking",
    "axis": "behaviour",
    "title": "The SeaRates-compat unclassified milestone becomes UNK",
    "detail": "The compat surface emits the four-letter UNKN today and the three-letter UNK from the effective date. The envelope keeps its seven top-level keys and no other milestone code changes.",
    "announced_at": "2026-08-05",
    "effective_at": "2027-02-01",
    "surfaces": ["compat-searates"],
    "endpoints": ["/compat/searates/*"],
    "carriers": ["SEARATES"],
    "parser_versions": ["SEARATES 3.11"],
    "api_version": "2027-02-01",
    "action_required": true,
    "action_detail": "If you store or alert on the literal string UNKN, pin API-Version: 2026-08-04 to defer the change, or accept both spellings before the effective date.",
    "notice_days": 180,
    "oldest_supported_version": "2026-08-04",
    "latest_version": "2027-02-01",
    "changelog_url": "https://api.trackingmcp.com/v1/changelog",
    "versions_url": "https://api.trackingmcp.com/v1/versions"
  }
}

Note that data.id and data.type shadow the envelope’s id and type with different meanings. The envelope’s are the delivery id and the event name; the ones inside data are the changelog entry id and the change class. Read them from the right level. The optional fields are absent rather than null, so test with in or hasattr, not a null check.

The schedule_disruption event

The sailing your container is riding has slipped badly or has not departed at all. We raise it from schedule reconciliation rather than from the carrier’s box tracking, so it can fire before the carrier’s own tracking reflects the problem. It fires once per distinct disruption per container, not once per sweep. Remember that it cannot be named in event_types: you receive it only on an endpoint subscribed to everything.

FieldTypeDescription
data.container_idstringOur internal id for the container.
data.identifierstringThe container number, falling back to the bill of lading number, falling back to the internal id. Always a string.
data.kindstringslip or no_show. Determines which of the fields below are present.
data.dedup_keystringStable per sailing and per disruption. A useful secondary idempotency key when one incident touches several of your containers.
data.headlinestringOne-line human summary, for example Sailing slipped ~3d vs schedule.
data.vessel_imostring or nullIMO number of the vessel. Null means the matched sailing carried no IMO, not that the vessel is unknown to us.
data.voyage_numberstring or nullCarrier voyage number. Null means the sailing record had none.
data.carrier_codestring or nullSCAC of the operating carrier. Null means the sailing record had none.
data.published_departurestring or nullISO 8601 UTC of the scheduled departure. Never null on a no_show. It can be null on a slip.
data.observed_departurestring or nullISO 8601 UTC of the actual departure. slip only. Null means the matched sailing recorded no departure time.
data.variance_hoursnumberHow many hours late the departure was against schedule. slip only, and at least 48.
data.overdue_hoursintegerWhole hours since the published departure passed with nothing observed. no_show only, and at least 48.
{
  "id": "c07f5b81-9a2c-4d63-8e10-15b4f7d0e992",
  "type": "schedule_disruption",
  "occurred_at": "2026-08-07T04:31:57.663Z",
  "data": {
    "container_id": "8c4d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
    "kind": "slip",
    "dedup_key": "slip:9481234:214W:2026-08-02",
    "headline": "Sailing slipped ~3d vs schedule",
    "identifier": "MEDU1234562",
    "vessel_imo": "9481234",
    "voyage_number": "214W",
    "carrier_code": "MSCU",
    "published_departure": "2026-08-02T18:00:00.000Z",
    "observed_departure": "2026-08-05T21:40:00.000Z",
    "variance_hours": 75.7
  }
}

Branch on kind before you read the timing fields. A slip has observed_departure and variance_hours and no overdue_hours; a no_show has overdue_hours and neither of the other two. This is a prediction about a sailing, not an observation of your box, so use it as a signal to re-read the container, not as a new arrival date.

SchedulesMCP events (sailings)

Schedule events cover the sailings you are watching on a lane. They are produced by the alert cron, which sweeps the schedule store and fires when a sailing changes in a way a booking desk needs to know about. Registering and testing an endpoint are writes, so they need the schedules entitlement; listing, inspecting, updating and deleting need only a valid key. Subscribe with any of the names below, or leave event_types empty to take them all.

EventFires when
blank_sailingA sailing you are watching has been cancelled, so the slot is no longer sold.
cutoff_approachingA documentation, VGM or gate cut-off on a watched sailing is coming due.
sailing_slippedA watched sailing’s published departure has moved later than it was.
reliability_dropA carrier’s observed on-time record on a watched lane has fallen materially.

Each data object carries the sailing the alert is about. A slipped-sailing event looks like this:

{
  "id": "b1d3f5a7-2c48-4e6a-9f01-3a7c9e2b4d68",
  "type": "sailing_slipped",
  "occurred_at": "2026-08-11T06:02:14.117Z",
  "data": {
    "origin": "CNSHA",
    "destination": "NLRTM",
    "carrier_code": "MSCU",
    "vessel_imo": "9839430",
    "voyage_number": "IU432A",
    "published_departure": "2026-08-14T18:00:00Z"
  }
}

Read type for what changed and re-read the lane with upcoming sailings when you need the full picture.

FreightRatesMCP events (rates)

Rate events track the spot rates you are watching. They are produced by the rate-index refresh, a pg_cron job that rebuilds the published indices, and fire when a rate you care about moves. Registering and testing an endpoint are writes, so they need the freightrates entitlement; the read and management routes need only a valid key. Subscribe with any of the names below, or leave event_types empty to take them all.

EventFires when
lane_rate_updatedThe latest rate for a lane you are watching has changed.
index_refreshedThe rate index has been rebuilt, so every published figure is now as of a new date.

Each data object carries the lane and the new figure. A lane-rate event looks like this:

{
  "id": "d4e6f8a0-5b7c-4d9e-a1f2-6c8b0d2e4f60",
  "type": "lane_rate_updated",
  "occurred_at": "2026-08-11T03:00:09.204Z",
  "data": {
    "pol": "CNSHA",
    "pod": "NLRTM",
    "container": "40HC",
    "rate_usd": 3120,
    "wow_pct": -4.2,
    "as_of": "2026-08-11"
  }
}

Read type for what changed and re-read the lane with latest lane rate when you need the full card.

LoadingMCP events (load plans)

Load-plan events track the plans on your account. They are produced inline, at the moment a project is created or updated, rather than by a background sweep, so they land as soon as the write commits. Registering and testing an endpoint are writes, so they need the loading entitlement; listing, inspecting, updating and deleting need only a valid key. Subscribe with either name below, or leave event_types empty to take them both.

EventFires when
plan_createdA new load plan was created.
plan_updatedAn existing load plan was recomputed or edited.

Each data object carries the plan the event is about. A plan-created event looks like this:

{
  "id": "e5f7a9b1-6c8d-4e0f-b2a3-7d9c1e3f5a71",
  "type": "plan_created",
  "occurred_at": "2026-08-11T10:41:52.885Z",
  "data": {
    "plan_id": "9c2f7b41-0a5e-4d18-8c63-2b7e9a1f4d05",
    "equipment_code": "40HC",
    "mode": "ocean",
    "utilisation_pct": 84
  }
}

Read type to tell a fresh plan from a recomputed one, and re-read the plan when you need the full solver output.

A complete receiver

Everything above, in one Express handler. Swap the header name and the secret for the product you are wiring, and give each event type its own case.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const secret = process.env.WEBHOOK_SECRET;      // the whsec_ from registration
const SIGNATURE_HEADER = "X-AirCargoMCP-Signature"; // or X-TrackingMCP-Signature
const seen = new Set(); // in production: a durable store keyed on delivery id

function verify(rawBody, header, secret) {
  if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
  const received = header.slice("sha256=".length);
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(received, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

app.post(
  "/webhooks",
  express.raw({ type: "application/json" }), // raw bytes, before any JSON parser
  (req, res) => {
    if (!verify(req.body, req.get(SIGNATURE_HEADER), secret)) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString("utf8"));
    if (seen.has(event.id)) return res.status(200).end(); // at least once
    seen.add(event.id);

    res.status(200).end(); // acknowledge inside 10 seconds

    switch (event.type) {
      case "ping":
        break;
      case "awb_arrived":
      case "awb_delivered":
        recordMilestone(event.data);
        break;
      default:
        logUnknown(event); // a new event type must never crash the handler
    }
  }
);

Verify against the raw bytes, acknowledge before you work, deduplicate on id, and ignore event types you do not recognise. Get those four right and the channel looks after itself, whichever product feeds it.