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": … }- Event catalogue
- TrackingMCP reference →
- Delivery schema
- Download JSON Schema →
- Base host
api.aircargomcp.com- Signature header
X-AirCargoMCP-Signature- Response envelope
{ "data": … }- Event catalogue
- AirCargoMCP reference →
- Delivery schema
- No machine-readable webhook schema yet. The field tables on this page are the reference.
- Base host
api.schedulesmcp.com- Signature header
X-SchedulesMCP-Signature- Response envelope
{ "ok": true, "data": … }- Event catalogue
- SchedulesMCP reference →
- Delivery schema
- No machine-readable webhook schema yet. The field tables on this page are the reference.
- Base host
api.freightratesmcp.com- Signature header
X-FreightRatesMCP-Signature- Response envelope
{ "data": … }- Event catalogue
- FreightRatesMCP reference →
- Delivery schema
- No machine-readable webhook schema yet. The field tables on this page are the reference.
- Base host
api.loadingmcp.com- Signature header
X-LoadingMCP-Signature- Response envelope
{ "ok": true, "data": … }- Event catalogue
- LoadingMCP reference →
- Delivery schema
- No machine-readable webhook schema yet. The field tables on this page are the reference.
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.
One practical note before you write code. Your endpoint must be reachable over https://, because we reject anything else at registration time.
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. If you work from a client generated from a product’s OpenAPI spec, the webhook routes may not be in it yet; call them directly with the samples on those pages. The links below point at TrackingMCP (ocean); jump straight to the same page for your product: register an endpoint on AirCargoMCP, SchedulesMCP, LoadingMCP or FreightRatesMCP, and the other five routes sit beside it in that product’s sidebar.
| Method | Path | What it does |
|---|---|---|
POST | /v1/webhooks | Register an endpoint. Returns the signing secret once. |
GET | /v1/webhooks | List 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}/test | Queue 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.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | <Product>-Webhooks/1, for example AirCargoMCP-Webhooks/1. |
X-<Product>-Event | The event type, for example awb_arrived. |
X-<Product>-Delivery | The delivery id, the same value as id in the body. Stable across retries. |
X-<Product>-Signature | sha256= followed by the hex HMAC of the body. |
The body is always the same four keys, whatever the event and whatever the product:
| Field | Type | Description |
|---|---|---|
id | string | The delivery id. Stable across every retry of this delivery, so it is your idempotency key. |
type | string | The event type. Repeats the X-<Product>-Event header. |
occurred_at | string | ISO 8601 UTC of when the event was raised, not when this attempt was sent. It does not move on a retry. |
data | object | Everything specific to the event type. Its shape is documented per event below. |
Validates the body of every TrackingMCP delivery; type selects the shape of data. Test deliveries arrive as ping.
The same shapes are also in the full OpenAPI 3.1 spec. Other products do not publish a machine-readable webhook schema yet; their field tables on this page are the reference.
Dates are always UTC
Every timestamp in a delivery, meaning occurred_at and every date inside data, is a true UTC instant in ISO 8601, ending in Z. It never carries a local offset and never shifts on a retry, so you can compare and store deliveries without knowing where an event happened.
Where a delivery reports a real-world shipment event, such as the container lifecycle events, the event object also carries the wall-clock companions alongside the UTC instant. This is the same shape the tracking event feed uses, so you can show local time without re-deriving it:
| Field | Type | Description |
|---|---|---|
event_date_time | string | The authoritative UTC instant (Z). This is the field to sort, compare and store on. |
event_local_datetime | string | The same moment as local wall-clock at the place it happened, without an offset suffix. For display only; never compare across events on it. |
utc_offset_minutes | integer | Minutes to add to UTC to reach the local wall-clock. -300 for a -05:00 port. Absent when the source did not report a zone. |
event_timezone | string | IANA zone of the place, for example America/New_York. Absent when the source did not report a zone. |
Read event_date_time for logic and event_local_datetime for display. The two companions are absent rather than null when a source gives no zone, so test with in or hasattr, not a null check.
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:
- We take the raw body bytes we are about to send, which is the JSON above with no trailing newline and no reformatting.
- We compute
HMAC-SHA256(rawBody, secret)using yourwhsec_secret as the key, and hex-encode it in lower case. - We send that as
X-<Product>-Signature, prefixed with the literal stringsha256=.
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 attempt | Next attempt in |
|---|---|
| 1 | 10 seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 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.
| Event | Fires when |
|---|---|
awb_accepted | The shipment was accepted from the shipper at origin. |
awb_in_transit | The shipment is moving: uplifted, airborne, or at a transit station. |
awb_arrived | The shipment reached its destination station, not yet released. |
awb_available | The shipment is notified and ready for pickup at destination. |
awb_delivered | The 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 these names, in lower snake case:
| Event | Fires when |
|---|---|
vessel_departed | The box’s vessel set out on a sailing leg: the origin departure, or the onward leg after a transshipment. Never the anchoring and berthing noise a vessel produces mid-voyage. |
eta_changed | The carrier moved the estimated arrival by 24 hours or more against the previously stored value, in either direction. At most once per box per day; smaller wobbles are deliberately kept off your endpoint. |
container_arrived | The box is arriving: the estimated arrival at its destination port dropped under 48 hours. |
customs_hold | The box went on a customs hold at the destination. |
container_available | The box is discharged and ready for pickup at the terminal. |
container_delivered | The laden box was handed to the consignee. Terminal. |
container_misrouted | A laden box was discharged at a port that is neither its destination nor a transshipment on its route. |
demurrage_warning | The box is within three days of its last free day. Requires demurrage alerting to be armed for your organisation. |
demurrage_started | The free time ran out and the demurrage clock started. Requires demurrage alerting to be armed. Arming acts forward only: boxes whose last free day passed before you armed stay visible in the API but never fire, so switching it on cannot flood you with history. |
schedule_disruption | The sailing the box is riding slipped badly or did not depart, raised from schedule reconciliation, often before the carrier’s own tracking reflects it. |
shipment_updated | Fresh carrier data changed the shipment in a way the status events above do not name: one or more of status, ETA, vessel, discharge or carrier moved. The delivery lists what changed; re-read the shipment for the full state. Described below. |
api_change_announced | Platform lifecycle, not shipment lifecycle: an API change was announced ahead of its effective date. Described below. |
Status events fire on a genuine transition only: never on the first resolution of a box that had already moved before you added it, and never on a re-poll that rewrites the same status.
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.
| Field | Type | Description |
|---|---|---|
data.id | string | Stable id of the changelog entry, never reused. Deduplicate announcements on it. |
data.type | string | The change class: breaking, added, fixed or deprecated. Not the event name. |
data.axis | string | What changed: shape (the response structure), behaviour (the values) or platform (auth, limits, versioning). |
data.title | string | One-line summary, written for a human. |
data.detail | string | The full explanation, including what does not change. |
data.announced_at | string | ISO date (YYYY-MM-DD) the change was announced. The day this event fires. |
data.effective_at | string | ISO date the change takes effect. |
data.surfaces | string[] | The API surfaces affected. |
data.endpoints | string[] | Path patterns affected, so a CI check can match them against what you actually call. |
data.carriers | string[] | SCACs whose normalisation changed. Absent when the change is not carrier-specific. |
data.parser_versions | string[] | Parser versions carrying the change. Absent when it is not a parser change. |
data.api_version | string | The published API version that introduces the change. Absent when the change is not versioned. |
data.action_required | boolean | True when you must change code or reconfigure before effective_at. The one field to branch on. |
data.action_detail | string | What to do about it. Absent when action_required is false. |
data.notice_days | integer | Days 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_version | string | The oldest API version still served. An unpinned request resolves to this. |
data.latest_version | string | The newest published API version. |
data.changelog_url | string | Where to read the full machine-readable changelog. |
data.versions_url | string | Where 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-compat-unk-three-letter",
"type": "breaking",
"axis": "behaviour",
"title": "The 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": ["AGGREGATED"],
"parser_versions": ["AGGREGATED 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 container lifecycle events
container_arrived, container_available, container_delivered, customs_hold, vessel_departed, demurrage_started and demurrage_warning share one data shape:
| Field | Type | Description |
|---|---|---|
data.container_id | string | Our internal id for the container. Not a shipping reference; match on identifier instead. |
data.identifier | string | The shipping reference you track by: the container number, falling back to the bill of lading number, falling back to the booking number. |
data.identifier_type | string or null | What data.identifier is: container_id, bill_of_lading or booking. Null when unknown. Added 2026-08-19; absent on older deliveries. |
data.previous_status | string | The status the box held before this transition. |
data.new_status | string | The status that fired the event. |
data.eta | string or null | The current estimated arrival, ISO 8601 UTC. |
data.demurrage_days_overdue | number or null | Days past the last free day. Populated on the demurrage events, null elsewhere. |
data.demurrage_accrued_usd | number or null | Accrued demurrage in USD. Populated on the demurrage events, null elsewhere. |
eta_changed carries container_id, identifier and identifier_type as above, plus old_eta, new_eta (both ISO 8601 UTC) and delta_hours, a signed number: positive means the arrival moved later, negative means it moved earlier. Both directions are news, and both fire.
container_misrouted carries container_id, identifier and identifier_type as above, plus discharge_port (the UN/LOCODE where the laden box was actually discharged) and expected_port (the destination it should be heading to, or null when the shipment has no destination on record).
The shipment_updated event
A poll brought back carrier data that differs from what we held. The status events above cover the named transitions; this one fires for everything else that moved, so a receiver that only needs status transitions can leave it unsubscribed. Its data names the shipment and the fields that changed, nothing more. Fetch the shipment resource for the canonical state.
| Field | Type | Description |
|---|---|---|
data.container_id | string | Our internal id for the container. |
data.identifier | string or null | The shipping reference you track by: the container number, falling back to the bill of lading number, falling back to the booking number. |
data.identifier_type | string or null | What data.identifier is: container_id, bill_of_lading or booking. Null when unknown. |
data.changed_fields | string[] | Which of status, eta, vessel, discharge and carrier changed on this poll. |
This event is in the delivery JSON Schema but not yet in the OpenAPI spec.
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.
| Field | Type | Description |
|---|---|---|
data.container_id | string | Our internal id for the container. |
data.identifier | string | The shipping reference you track by: the container number, falling back to the bill of lading number, falling back to the booking number. |
data.identifier_type | string or null | What data.identifier is: container_id, bill_of_lading or booking. Null when unknown. Added 2026-08-19; absent on older deliveries. |
data.kind | string | slip or no_show. Determines which of the fields below are present. |
data.dedup_key | string | Stable per sailing and per disruption. A useful secondary idempotency key when one incident touches several of your containers. |
data.headline | string | One-line human summary, for example Sailing slipped ~3d vs schedule. |
data.delay_hours | number | The size of the disruption in hours, whichever kind it is. The one field to threshold on without branching. |
data.vessel_imo | string or null | IMO number of the vessel. Null means the matched sailing carried no IMO, not that the vessel is unknown to us. |
data.voyage_number | string or null | Carrier voyage number. Null means the sailing record had none. |
data.carrier_code | string or null | SCAC of the operating carrier. Null means the sailing record had none. |
data.published_departure | string or null | ISO 8601 UTC of the scheduled departure. Never null on a no_show. It can be null on a slip. |
data.observed_departure | string or null | ISO 8601 UTC of the actual departure. slip only. Null means the matched sailing recorded no departure time. |
data.variance_hours | number | How many hours late the departure was against schedule. slip only, and at least 48. |
data.overdue_hours | integer | Whole 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.
| Event | Fires when |
|---|---|
blank_sailing | A sailing you are watching has been cancelled, so the slot is no longer sold. |
cutoff_approaching | A documentation, VGM or gate cut-off on a watched sailing is coming due. |
sailing_slipped | A watched sailing’s published departure has moved later than it was. |
reliability_drop | A 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.
| Event | Fires when |
|---|---|
lane_rate_updated | The latest rate for a lane you are watching has changed. |
index_refreshed | The 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.
| Event | Fires when |
|---|---|
plan_created | A new load plan was created. |
plan_updated | An 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.