File size: 8,234 Bytes
d705bb5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | ---
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; the old secret stops validating immediately.
```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; 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](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; each live subscription gets its own `secret` from RegisterWebhook.)
```js
const s = await (await fetch('https://www.worldmonitor.app/.well-known/webhook-sample.json')).json();
verifyWorldMonitorWebhook(s.body, s.signature, s.secret); // β true
```
|