| --- |
| title: "Shipping v2 API" |
| description: "Chokepoint route-intelligence queries and webhook subscription management for supply-chain disruption alerts in the Shipping v2 API surface." |
| --- |
|
|
| 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> |
|
|
| ## Route intelligence |
|
|
| ### `GET /api/v2/shipping/route-intelligence` |
|
|
| Scores 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`)**: |
| ```json |
| { |
| "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` | |
|
|
| ## Webhook subscriptions |
|
|
| ### `POST /api/v2/shipping/webhooks` |
|
|
| Registers a webhook for chokepoint disruption alerts. Returns `200 OK`. |
|
|
| **Request**: |
| ```json |
| { |
| "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`)**: |
| ```json |
| { |
| "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. |
| - **TTL**: 30 days on both the subscriber record and the per-owner index set. Only **re-registration** refreshes both, via an atomic pipeline (`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. |
| - Ownership is tracked via SHA-256 of the caller's API key (never secret β stored as `ownerTag`). |
|
|
| Auth: `X-WorldMonitor-Key` (forceKey: true) + PRO. Returns `401` / `403` otherwise. |
|
|
| ### `GET /api/v2/shipping/webhooks` |
|
|
| Lists the caller's registered webhooks (filtered by the SHA-256 owner tag of the calling API key). |
|
|
| ```json |
| { |
| "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-secret` |
|
|
| Generates and returns a **new** secret. The record's `secret` is replaced in place |
|
|
| ```json |
| { "subscriberId": "wh_...", "secret": "new-64-char-hex", "rotatedAt": "2026-04-19T12:05:00Z" } |
| ``` |
|
|
| ### `POST /api/v2/shipping/webhooks/{subscriberId}/reactivate` |
|
|
| Flips `active: true` on the record (use after investigating and fixing a delivery failure that caused deactivation). |
|
|
| ```json |
| { "subscriberId": "wh_...", "active": true } |
| ``` |
|
|
| ### Delivery format |
|
|
| ``` |
| 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`. |
|
|
| ### Verifying deliveries |
|
|
| 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. |
|
|
| ```js |
| import { createHmac, timingSafeEqual } from 'node:crypto' |
|
|
| // rawBody: the exact request body bytes |
| // 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](https://worldmonitor.app/openapi.json). |
|
|
| #### Test your verification against a signed sample |
|
|
| A ready-to-verify sample delivery is published at [`/.well-known/webhook-sample.json`](https://www.worldmonitor.app/.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 |
|
|
| ```js |
| const s = await (await fetch('https://www.worldmonitor.app/.well-known/webhook-sample.json')).json() |
| verifyWorldMonitorWebhook(s.body, s.signature, s.secret) |
| ``` |
|
|