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
```