docs/api-shipping-v2.mdx
The v2 shipping API is a PRO-gated read + webhook-subscription surface on top of WorldMonitor's chokepoint registry and AIS tracking data.
<Info> All v2 shipping endpoints require `X-WorldMonitor-Key` (server-to-server). Browser origins are **not** trusted here — `validateApiKey` runs with `forceKey: true`. </Info>GET /api/v2/shipping/route-intelligenceScores a country-pair trade route for chokepoint exposure and current disruption risk.
Query parameters:
| Param | Required | Description |
|---|---|---|
fromIso2 | yes | Origin country, ISO-3166-1 alpha-2 (uppercase). |
toIso2 | yes | Destination country, ISO-3166-1 alpha-2 (uppercase). |
cargoType | no | One of container (default), tanker, bulk, roro. |
hs2 | no | 2-digit HS commodity code (default 27 — mineral fuels). |
Example:
GET /api/v2/shipping/route-intelligence?fromIso2=AE&toIso2=NL&cargoType=tanker&hs2=27
Response (200):
{
"fromIso2": "AE",
"toIso2": "NL",
"cargoType": "tanker",
"hs2": "27",
"primaryRouteId": "ae-to-eu-via-hormuz-suez",
"chokepointExposures": [
{ "chokepointId": "hormuz_strait", "chokepointName": "Strait of Hormuz", "exposurePct": 100 },
{ "chokepointId": "suez", "chokepointName": "Suez Canal", "exposurePct": 100 }
],
"bypassOptions": [
{
"id": "cape-of-good-hope",
"name": "Cape of Good Hope",
"type": "maritime_detour",
"addedTransitDays": 12,
"addedCostMultiplier": 1.35,
"activationThreshold": "DISRUPTION_SCORE_60"
}
],
"warRiskTier": "WAR_RISK_TIER_ELEVATED",
"disruptionScore": 68,
"fetchedAt": "2026-04-19T12:00:00Z"
}
disruptionScore is 0-100 on the primary chokepoint for the route (higher = more disruption).warRiskTier is one of the WAR_RISK_TIER_* enum values from the chokepoint status feed.bypassOptions are filtered to those whose suitableCargoTypes includes cargoType (or is unset).Caching: Cache-Control: public, max-age=60, stale-while-revalidate=120.
Errors:
| Status | Cause |
|---|---|
| 400 | fromIso2 or toIso2 missing/malformed |
| 401 | API key required or invalid |
| 403 | PRO subscription required |
| 405 | Method other than GET |
POST /api/v2/shipping/webhooksRegisters a webhook for chokepoint disruption alerts. Returns 200 OK.
Request:
{
"callbackUrl": "https://hooks.example.com/shipping-alerts",
"chokepointIds": ["hormuz_strait", "suez", "bab_el_mandeb"],
"alertThreshold": 60
}
callbackUrl — required, HTTPS only, must not resolve to a private/loopback address (SSRF guard at registration).chokepointIds — optional. Omitting or passing an empty array subscribes to all registered chokepoints. Unknown IDs return 400.alertThreshold — numeric 0-100 (default 50). Values outside that range return a 400 validation response with description alertThreshold must be between 0 and 100.Response (200):
{
"subscriberId": "wh_a1b2c3d4e5f6a7b8c9d0e1f2",
"secret": "64-char-lowercase-hex-string"
}
subscriberId — wh_ prefix + 24 hex chars (12 random bytes).secret — raw 64-char lowercase hex (32 random bytes). There is no whsec_ prefix. Persist it — the server never returns it again except on rotation.SET record with EX, SADD + EXPIRE on the owner index). rotate-secret and reactivate refresh the record's TTL only — they do not touch the owner-index set's expiry, so the owner index can expire independently if a caller only ever rotates or reactivates within a 30-day window. Re-register to keep both alive.ownerTag).Auth: X-WorldMonitor-Key (forceKey: true) + PRO. Returns 401 / 403 otherwise.
GET /api/v2/shipping/webhooksLists the caller's registered webhooks (filtered by the SHA-256 owner tag of the calling API key).
{
"webhooks": [
{
"subscriberId": "wh_...",
"callbackUrl": "https://hooks.example.com/...",
"chokepointIds": ["hormuz_strait", "suez"],
"alertThreshold": 60,
"createdAt": "2026-04-19T12:00:00Z",
"active": true
}
]
}
The secret is intentionally omitted from list and status responses.
GET /api/v2/shipping/webhooks/{subscriberId}Status read for a single webhook. Returns the same record shape as in GET /webhooks (no secret). 404 if unknown, 403 if owned by a different API key.
POST /api/v2/shipping/webhooks/{subscriberId}/rotate-secretGenerates and returns a new secret. The record's secret is replaced in place; the old secret stops validating immediately.
{ "subscriberId": "wh_...", "secret": "new-64-char-hex", "rotatedAt": "2026-04-19T12:05:00Z" }
POST /api/v2/shipping/webhooks/{subscriberId}/reactivateFlips active: true on the record (use after investigating and fixing a delivery failure that caused deactivation).
{ "subscriberId": "wh_...", "active": true }
POST <callbackUrl>
Content-Type: application/json
X-WM-Signature: sha256=<HMAC-SHA256(body, secret)>
X-WM-Delivery-Id: whd_<32 lowercase hex chars>
X-WM-Event: chokepoint.disruption
{
"subscriberId": "wh_...",
"chokepointId": "hormuz_strait",
"score": 74,
"alertThreshold": 60,
"triggeredAt": "2026-04-19T12:03:00Z",
"reason": "ais_congestion_spike",
"details": { ... }
}
The delivery worker re-resolves callbackUrl before each send and re-checks against PRIVATE_HOSTNAME_PATTERNS to mitigate DNS rebinding. Delivery is at-least-once — consumers must handle duplicates via X-WM-Delivery-Id.
Every delivery is signed so you can confirm it genuinely came from WorldMonitor. X-WM-Signature is sha256=<hex>, where <hex> is the lowercase-hex HMAC-SHA256 of the exact raw request body, keyed by the secret returned at registration.
To verify: recompute sha256= + hex(HMAC_SHA256(key=secret, message=rawBody)) over the bytes exactly as received (do not re-serialize the JSON), and compare against X-WM-Signature in constant time. Use the secret string verbatim as the HMAC key — do not hex-decode it. Reject the delivery if the signatures differ.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the exact request body bytes; header: the X-WM-Signature value;
// secret: the value returned by RegisterWebhook (used verbatim as the key).
function verifyWorldMonitorWebhook(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(header ?? '');
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
The signature contract is also published machine-readably as the chokepoint.disruption entry under webhooks in the OpenAPI spec.
A ready-to-verify sample delivery is published at /.well-known/webhook-sample.json. It carries a fixed sample secret, the exact raw body string, and the resulting signature. Recompute sha256= + hex(HMAC_SHA256(key=secret, message=body)) over the exact bytes of body and confirm it equals signature — if it matches, your verification will accept real deliveries. (The sample secret is a fixture; each live subscription gets its own secret from RegisterWebhook.)
const s = await (await fetch('https://www.worldmonitor.app/.well-known/webhook-sample.json')).json();
verifyWorldMonitorWebhook(s.body, s.signature, s.secret); // → true