> ## Documentation Index
> Fetch the complete documentation index at: https://www.worldmonitor.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Shipping v2 API

> 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 theme={null}
{
  "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**: not CDN-cached — the route is premium-gated, so the gateway emits `Cache-Control: private, max-age=300, stale-while-revalidate=60, stale-if-error=1800` and no shared-cache header.

**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 theme={null}
{
  "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 theme={null}
{
  "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 a non-transactional 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.
* Registration and listing each scan one owner-index page and check at most 100 subscriber records for expiry. Confirmed expired members are removed only if the record is still absent. Scan progress resumes on later calls. Redis may return more than 100 IDs in a scan page; listing still enumerates the full index. Redis read or cleanup failures return 503 and preserve ambiguous members.
* Registration returns `503` if any of the three writes cannot be confirmed. Partial records or index entries can remain; a retry can create another registration. No rollback is guaranteed.
* 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 theme={null}
{
  "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.

<Note>
  The three `{subscriberId}` sub-routes below are hand-written edge handlers, not generated gateway RPCs: they do not appear in the published OpenAPI spec (`ShippingV2Service`) or in generated clients, and their `403` message uses the literal `PRO subscription required` (all-caps), unlike the gateway routes' `Pro subscription required`.
</Note>

### `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 theme={null}
{ "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 theme={null}
{ "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 theme={null}
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://www.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 theme={null}
const s = await (await fetch('https://www.worldmonitor.app/.well-known/webhook-sample.json')).json();
verifyWorldMonitorWebhook(s.body, s.signature, s.secret); // → true
```
