Chapters 3 and 4 defined what WIA-ENE-040 data looks like at rest: the Phase 1 message envelope and the science records (expeditions, dives, observations, samples, vents and surveys). This chapter covers how that data moves between programs. The standard specifies two HTTP interfaces. The first is the Phase 2 operations API, which lists vehicles, sends them commands, streams their telemetry and registers samples while a dive is under way. The second is the records API in ENE-040 §13, which stores and queries the long-lived scientific record. The standard also ships a TypeScript SDK (@wia/ene-040) and a Bash CLI (deep-sea-exploration.sh) that call the records API.
The chapter reads all four sources side by side: the Phase 2 document, §13 of the core specification, the SDK source and the CLI script. They agree on most things. Where they disagree, this chapter shows both versions and recommends a resolution. The host names in the sources (api.wia-dse.org, api.wia.org/ene-040) are examples, not registered services, so the examples here use https://api.example.org and fictional identifiers.
The two APIs are separated by their path prefix. Operations resources live under /v1/… (for example /v1/vehicles). Records resources live under /api/v1/… (for example /api/v1/expeditions). This chapter assumes one host serves both. Both put the major version in the path (Phase 2 §1.2), so a future /v2 can run beside /v1 during a migration.
The table below lists every endpoint named in the sources. The "Layer" column says which of the two record layers from Chapter 2 the endpoint belongs to. The last group lists paths that the SDK or CLI calls but §13 does not list. Those are extensions of the shipped tooling and are not part of the specification text.
| Method | Path | Layer | Purpose |
|---|---|---|---|
| GET | /v1/vehicles | Operations | List accessible vehicles (filters status, type, page, limit) |
| GET | /v1/vehicles/{vehicleId} | Operations | Vehicle status with a telemetry snapshot and alerts |
| POST | /v1/vehicles/{vehicleId}/commands | Operations | Issue a vehicle command |
| GET | /v1/vehicles/{vehicleId}/commands/{commandId} | Operations | Command execution status |
| WSS | /v1/telemetry/stream | Operations | Live telemetry stream |
| GET | /v1/telemetry/history | Operations | Historical telemetry for a time range |
| POST / GET | /v1/missions, /v1/missions/{missionId} | Operations | Create a mission; read its timeline and progress |
| POST | /v1/vehicles/{vehicleId}/sensors/{sensorId}/configure | Operations | Set sampling rate, calibration offsets and alarms |
| GET | /v1/vehicles/{vehicleId}/sensors/{sensorId}/data | Operations | Sensor readings (limit ≤ 10,000) |
| POST | /v1/samples | Operations | Register a sample at the moment of collection |
| GET / POST / PUT / DELETE | /api/v1/expeditions[/{expeditionId}] | Records | Expedition CRUD |
| GET / POST / PUT | /api/v1/expeditions/{id}/dives, /api/v1/dives[/{diveId}] | Records | Dive logs |
| GET | /api/v1/dives/{diveId}/observations|samples|track | Records | Per-dive observations, samples and navigation track |
| GET / POST / PUT | /api/v1/observations[/{observationId}] | Records | Organism observations with filters |
| GET / POST / PUT | /api/v1/samples[/{sampleId}], /api/v1/samples/available | Records | Sample catalogue and availability |
| POST | /api/v1/samples/{sampleId}/request | Records | Request access to a sample |
| GET / POST / PUT | /api/v1/vents[/{ventId}] | Records | Hydrothermal vent database |
| POST / GET | /api/v1/bathymetry/upload, /{surveyId}, /grid, /{surveyId}/3d-model | Records | Multibeam survey upload, grids and 3-D models |
| WS | /dives/{diveId}/live | Records | Live dive tracking (§13.2) |
| POST / GET | /api/v1/search, /api/v1/statistics | SDK/CLI only | Cross-type search; database statistics |
| GET / POST | /api/v1/submersibles, /api/v1/geological/rocks, /api/v1/geological/cores, /api/v1/edna | SDK/CLI only | Vehicle registry, rock samples, sediment cores, eDNA sample records |
Phase 2 §2.2 requires every request to carry one of two credentials. The first is an OAuth 2.0 bearer token in Authorization: Bearer …. The second is a static key in X-API-Key: …. Bearer tokens suit interactive users and institutional single sign-on, because they expire and can carry scopes that map to the RBAC roles of Chapter 7. API keys suit unattended jobs such as a shipboard logger or a nightly export.
The shipped tooling uses only one of the two. Both the SDK and the CLI put the configured API key into Authorization: Bearer <key> and never send X-API-Key. A server that wants to work with the official tools must therefore accept an API key presented as a bearer token. The simplest approach is to recognise the key prefix (the specification's example keys begin wia_dse_ak_) and route the value to key validation instead of JWT validation. A server that accepts only X-API-Key for keys will reject every SDK call with 401.
| Header | Direction | Source | Rule and notes |
|---|---|---|---|
X-WIA-Version | Both | Phase 2 §2.3; SDK; CLI | The spec sends 1.0; the SDK and CLI send 1.0.0. Servers should compare the major version only and treat both as version 1. |
X-Request-ID | Both | Phase 2 §2.3 | A UUID v4 generated by the client and echoed by the server; it also appears as requestId in error bodies. The SDK does not send it, so servers should generate one when it is missing. |
X-WIA-Standard | Request | SDK; CLI | Always ENE-040. Not required by Phase 2. It is useful on a shared gateway that fronts several WIA standards. |
X-RateLimit-Limit / -Remaining / -Reset | Response | Phase 2 §2.3, §8 | Quota for the current window, calls left, and the reset time as Unix seconds. |
Content-Type / Accept | Both | Phase 2 §2.3 | application/json |
Every failure returns one JSON object whose single key is error. That object contains a machine-readable code, a human-readable message, a free-form details object, the timestamp, the requestId and a documentation link. Phase 2 lists seven standard codes. The table adds retry guidance, because on a link that may be a ship's satellite connection, the most important thing a client must decide is whether trying again can help.
| Code | HTTP | Meaning | Client should |
|---|---|---|---|
VALIDATION_ERROR | 400 | Malformed body or parameter out of range | Fix the request; never retry unchanged |
AUTHENTICATION_FAILED | 401 | Missing, expired or unknown credential | Refresh the token once, then stop |
AUTHORIZATION_DENIED | 403 | Valid credential, insufficient role or embargoed record | Do not retry; request access |
RESOURCE_NOT_FOUND | 404 | Unknown ID | Do not retry |
RATE_LIMIT_EXCEEDED | 429 | Quota used up | Wait until X-RateLimit-Reset, then retry |
INTERNAL_ERROR | 500 | Server fault | Retry only idempotent requests, with backoff |
SERVICE_UNAVAILABLE | 503 | Overload or maintenance | Retry with exponential backoff and jitter |
The specification's own example uses an eighth code, INVALID_DEPTH_PARAMETER, which does not appear in the list and has no HTTP status. This book recommends treating domain-specific codes like this one as refinements of VALIDATION_ERROR, returned with status 400. A client that knows only the seven generic codes then still classifies the error correctly by status, and a client that knows the specific code can show a precise message. Section 5.2 shows the full body.
List endpoints take page (starting at 1) and limit. For /v1/vehicles, Phase 2 sets a default of 50 and a maximum of 100. The two layers disagree on the response shape. Phase 2 returns {"data": [...], "pagination": {"page", "limit", "total", "totalPages"}}. The SDK types every records list as PaginatedResponse<T>, which is {"items", "total", "page", "limit", "hasMore"}. Because §13 shows no response bodies, the SDK shape is the only concrete records contract. Records servers should return it, operations servers the Phase 2 shape, and a client using both should normalise at its boundary (hasMore = page < totalPages).
Phase 2 lists "idempotent: safe retry semantics for unreliable networks" as a design principle, but it defines no mechanism. HTTP already makes GET, PUT and DELETE idempotent by definition (RFC 9110). POST is not, and the most consequential calls in the operations API are POSTs: issuing a command, creating a mission and registering a sample.
Suppose a shore-side client sends COLLECT_SAMPLE, the command reaches the vehicle, and the reply is lost when the ship's link drops. The client cannot tell "never arrived" from "reply lost", so it retries, and the vehicle may collect twice. The established fix is for the client to generate a unique key per logical operation and send it with every attempt. The server stores the key with the first response for a retention window and replays that stored response for any repeat. The IETF HTTPAPI working group has drafted an Idempotency-Key request header for exactly this purpose, and this book recommends that ENE-040 servers accept it on every POST. For commands, the server should also refuse to deliver a second copy to the vehicle while the first is still in a non-terminal state.
GET /v1/vehicles returns the vehicles the caller may see. Each entry has an identity block (vehicleId, name, type, manufacturer and model), status, the last known location, a capabilities block (maxDepth, manipulators, cameras, sensor list, payload capacity with explicit units), currentMission and health. The status filter takes OPERATIONAL, MAINTENANCE or DEPLOYED.
The type filter is where the two layers diverge. Phase 2 uses upper-case ROV, AUV, SUBMERSIBLE and GLIDER. The SDK's SubmersibleType enum, used by the records API, is lower-case hov, rov, auv, lander and crawler. Only ROV and AUV appear in both. "SUBMERSIBLE" roughly corresponds to hov, and gliders, landers and crawlers each exist in only one list. An integration that copies vehicles between the registries needs an explicit mapping table; adding glider to SubmersibleType is on the Chapter 8 errata list.
GET /v1/vehicles/{vehicleId} adds a telemetry snapshot (position with heading, pitch and roll; environment; power; subsystem states) and an alerts array. Unlike Phase 1 measurements, these values have no units. In the specification example, a depth of 3547.2 comes with a pressure of 354.72, exactly depth divided by ten, so the unit is presumably bar from the rough rule. Chapter 1's hydrostatic calculation gives about 356.6 bar gauge (357.6 bar absolute) there. Treat this snapshot as a display value; measurements of record arrive as Phase 1 OCEANOGRAPHIC_DATA with unit, sensorId and calibrationDate. Servers should name the unit, for example with a pressureUnit field like the existing maxDepthUnit.
POST /v1/vehicles/{vehicleId}/commands takes a command name, a parameters object, a priority (the same LOW/NORMAL/HIGH/CRITICAL scale as the Phase 1 envelope) and acknowledgementRequired. A navigation request looks like this:
POST /v1/vehicles/ROV-EXAMPLE-01/commands
Authorization: Bearer <token>
X-WIA-Version: 1.0
X-Request-ID: 6f1c2a4e-8b1d-4c5e-9a47-2d0f3b8e7c11
Idempotency-Key: 0b8f7d2e-3c9a-4f61-a2d4-5e6b7c8d9f01
Content-Type: application/json
{
"command": "NAVIGATE_TO_WAYPOINT",
"parameters": {
"waypoint": { "latitude": 12.3456, "longitude": 143.2101, "depth": 3550.0, "altitude": 10.0 },
"speed": 0.5,
"speedUnit": "m/s",
"obstacleAvoidance": true,
"timeout": 300,
"timeoutUnit": "seconds"
},
"priority": "NORMAL",
"acknowledgementRequired": true
}
The specification defines eleven commands. They differ in how harmful an accidental duplicate would be. The last column below is this book's classification, meant to guide retry and confirmation policy, and is not part of the specification.
| Command | Group | Effect of an accidental duplicate |
|---|---|---|
NAVIGATE_TO_WAYPOINT, HOVER_AT_POSITION | Motion | Usually harmless: the same target is set again |
SURFACE, EMERGENCY_ASCENT | Recovery | Harmless, but an emergency ascent should never wait behind a queue |
ACTIVATE_SENSOR, DEACTIVATE_SENSOR | Payload state | Harmless if the vehicle treats them as "set state", not "toggle" |
CAPTURE_IMAGE, RECORD_VIDEO | Imaging | Extra media and storage use |
COLLECT_SAMPLE | Sampling | Serious: a second physical sample or a duplicated custody record |
DEPLOY_INSTRUMENT, RETRIEVE_INSTRUMENT | Instruments | Serious: a second deployment or a grab at an empty location |
Before forwarding a command, the server validates it against the vehicle's capabilities. The specification's example error shows what a depth check returns:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "INVALID_DEPTH_PARAMETER",
"message": "Depth value exceeds vehicle maximum operating depth",
"details": { "requestedDepth": 7000, "maxDepth": 6000, "unit": "meters" },
"timestamp": "2026-03-04T02:10:00.000Z",
"requestId": "6f1c2a4e-8b1d-4c5e-9a47-2d0f3b8e7c11",
"documentation": "https://api.example.org/docs/errors/INVALID_DEPTH_PARAMETER"
}
}
The specification shows two states. The immediate response to the POST returns ACKNOWLEDGED with a commandId, an estimatedCompletionTime and an acknowledgement block that records when the vehicle received the command, whether it validated it and when execution began. A later GET …/commands/{commandId} returns IN_PROGRESS with a progress percentage, a currentStep text and an errors array. The specification names no terminal states. Clients therefore cannot know when to stop polling, and webhook consumers cannot tell success from failure in command.completed. This book proposes the following closed set and flags it as an addition, not current text:
ACKNOWLEDGED: the vehicle has received and validated the command.IN_PROGRESS: execution has started; progress is meaningful.COMPLETED: the goal was reached.FAILED: execution stopped with an entry in errors.CANCELLED: superseded by an operator or by a higher-priority command.TIMED_OUT: the command's own timeout expired before completion.There is also a timing problem with acknowledgementRequired: true. If the server holds the HTTP response open until the vehicle acknowledges, the request is only as fast as the path to the vehicle. On a fibre-tethered ROV that is quick. On an AUV reached through an acoustic modem, Chapter 6 shows that one round trip over several kilometres takes seconds, and a lost frame adds a retransmission on top. For those vehicles the server should reply at once with 202 Accepted and a pending state (for example QUEUED, again a proposal), then move to ACKNOWLEDGED when the modem delivers the vehicle's reply. The client learns the rest by polling or by webhook:
Client Operations API Topside gateway Vehicle
| POST /commands | | |
|----------------------->| validate vs capabilities |
| |----------------------->| tether or acoustic |
| | |--------------------->|
| | |<------- ACK ---------|
|<-- ACKNOWLEDGED, commandId (or 202 + QUEUED) ---| |
| | | |
| GET /commands/{id} | | |
|----------------------->| | |
|<-- IN_PROGRESS, progress 45 --------------------- |
| |<------------- progress / result --------------|
| | | |
|<== webhook: command.completed (status COMPLETED | FAILED ...) == |
Live telemetry is a WebSocket at wss://…/v1/telemetry/stream, opened with query parameters vehicleId and dataRate (the specification's example asks for 10Hz). Each frame is a JSON object with streamId, vehicleId, timestamp, sequenceNumber and a data block (position, sensors, power). The frame is not a Phase 1 envelope: it has no messageType, sourceId, priority or checksum, and its values are unit-less like the vehicle snapshot. It is a display feed. Data of record reaches the archive as Phase 1 messages (Chapter 3).
The requested rate is a ceiling, not a promise. A tethered ROV can supply 10 Hz over its fibre; an AUV reached acoustically reports only when the Phase 3 MAC schedule gives it a slot, and the Chapter 6 superframe lasts several seconds. The server should forward what it receives with the vehicle-side timestamp and never interpolate positions to fill the rate.
Phase 2 does not say how the stream authenticates, and browsers cannot attach an Authorization header to a WebSocket handshake. Never put a long-lived API key in the query string, where proxy logs will keep it. Use a short-lived single-use ticket from an authenticated REST call, a token in Sec-WebSocket-Protocol, or a session cookie; non-browser clients can send the normal Authorization header.
The sequenceNumber lets a client detect three conditions. If the new value is exactly one more than the last, the frame is in order. If it is less than or equal to the last, the frame is a duplicate or a replay after reconnection and should be dropped. If it jumps by more than one, frames were lost. For a gap, the client asks the history endpoint for the missing interval instead of ignoring it. Phase 1 requires sequence numbers to increase monotonically per source. A vehicle reboot can still reset its counter, so the pair (streamId, sequenceNumber) is a safer key than the number alone. When the streamId changes, the client resets its state.
// Stream consumer with gap backfill and capped exponential reconnection.
// getStreamTicket() is deployment-specific (see "Authenticating a WebSocket").
type Frame = { streamId: string; vehicleId: string; timestamp: string; sequenceNumber: number; data: unknown };
const BASE = 'https://api.example.org';
let streamId: string | null = null;
let lastSeq = -1;
let lastTs: string | null = null;
let attempt = 0;
async function backfill(vehicleId: string, from: string, to: string): Promise<void> {
const q = new URLSearchParams({ vehicleId, startTime: from, endTime: to });
const res = await fetch(`${BASE}/v1/telemetry/history?${q}`, {
headers: { Authorization: `Bearer ${process.env.WIA_TOKEN}`, 'X-WIA-Version': '1.0' },
});
if (res.ok) mergeHistory((await res.json()).data); // mergeHistory: application code
}
async function connect(vehicleId: string): Promise<void> {
const ticket = await getStreamTicket(vehicleId);
const url = `wss://api.example.org/v1/telemetry/stream?vehicleId=${encodeURIComponent(vehicleId)}` +
`&dataRate=10Hz&ticket=${encodeURIComponent(ticket)}`;
const ws = new WebSocket(url);
ws.onopen = () => { attempt = 0; };
ws.onmessage = (ev) => {
const f: Frame = JSON.parse(String(ev.data));
if (f.streamId !== streamId) { streamId = f.streamId; lastSeq = -1; } // new stream: reset
if (f.sequenceNumber <= lastSeq) return; // duplicate or replay
if (lastSeq >= 0 && f.sequenceNumber > lastSeq + 1 && lastTs) {
void backfill(vehicleId, lastTs, f.timestamp); // fill the hole
}
lastSeq = f.sequenceNumber;
lastTs = f.timestamp;
render(f); // application code
};
ws.onclose = () => {
const delay = Math.min(30_000, 500 * 2 ** attempt++) * (0.5 + Math.random() / 2);
setTimeout(() => void connect(vehicleId), delay);
};
}
GET /v1/telemetry/history takes vehicleId, startTime and endTime (all required), plus optional parameters (a list such as depth,temperature) and interval in whole seconds. The response carries the rows and a downloadUrl for a CSV export. Because interval is an integer number of seconds, a sub-second backfill of a 10 Hz feed is only possible by omitting it and receiving whatever native resolution the server keeps. The specification does not state that resolution, so a server should document it.
ENE-040 §13.2 defines a second live feed, /dives/{diveId}/live, for following a dive rather than a vehicle. It is meant for outreach displays and scientists ashore. The two streams have different shapes, and a client that consumes both needs an adapter:
| Aspect | Phase 2 /v1/telemetry/stream | §13.2 /dives/{diveId}/live |
|---|---|---|
| Keyed by | vehicleId | diveId |
| Scheme in the spec example | wss:// (encrypted) | ws://; deploy as wss:// |
| Position keys | position.latitude / longitude, depth inside position | position.lat / lon, depth at top level |
| Ordering | sequenceNumber | None; order by timestamp only |
| Extra fields | Power block | speed, camera, dive status such as on_bottom |
| Units | Implicit | Implicit |
Two recommendations follow. Serve both feeds only over TLS, because the dive stream can reveal the position of a sensitive habitat in real time, and Chapter 4 places such coordinates in the confidential tier. Also add a sequence number to the dive stream so that its consumers can detect loss with the same logic as above.
POST /v1/missions creates a mission from a name, description, vehicleId, planned start and end, missionType (the example uses SCIENTIFIC_RESEARCH), a list of objectives, ordered waypoints each carrying a task list (SYSTEM_CHECK, BATHYMETRIC_SURVEY, SAMPLE_COLLECTION), and a permissions block with the chief scientist and permit identifiers. The response returns a missionId in state PLANNED. GET /v1/missions/{missionId} then reports planned and actual timelines, waypoint progress and data counters.
Three points deserve attention. First, the mission body has no expeditionId or diveId, the IDs that link the operations layer to the science records (Chapter 2). Add them as extension fields; otherwise telemetry and dive logs can only be joined by vehicle and time window. Second, permissions.permits should reuse the expedition's permits[] identifiers (Chapter 4). Third, the example contains the misspelled key imagesCaptures; servers should emit imagesCaptured and accept both during a transition (Chapter 8 errata).
POST …/sensors/{sensorId}/configure sets samplingRate with its unit, an averagingWindow, calibration offsets and coefficients, and alarms. The alarm thresholds in the example (temperatureMax: 50, pressureMax: 650) have no units. The server should require units, or at least document that alarms use the Phase 1 units (degrees Celsius and bar). A calibration change made here must also update the calibrationDate that later measurements report, or the Chapter 3 provenance chain breaks.
GET …/sensors/{sensorId}/data takes startTime, endTime and limit with a maximum of 10,000. At 10 Hz, 10,000 readings cover under 17 minutes, so a client exporting a dive must page through time windows. The simplest correct pattern requests a window, takes the timestamp of the last row as the next startTime, and removes the one overlapping row.
POST /v1/samples is the moment a physical object enters the record. The body carries sampleType, the missionId and vehicleId, collectionTimestamp, location, a description, the containerId in the vehicle's sample drawer, the collector (name and ORCID) and the first chainOfCustody entry (for example action COLLECTED by the vehicle pilot). The response assigns a sampleId and returns a qrCode image URL and a trackingUrl, so a label can be printed before the sample leaves the deck.
The records API has its own /api/v1/samples. The two must not create two identities for one object. The recommended flow is to register once through the operations API during the dive. The shipboard system then creates the records-layer entry (the custody record described in Chapter 4) with the same sampleId, adding storage location, curator and availability. Every later custody transfer appends to the chain rather than replacing it.
Phase 2 §9 defines six event types and one payload shape: eventId, eventType, timestamp and a data object carrying the relevant IDs.
| Event | Fired when | Typical subscriber |
|---|---|---|
vehicle.status.changed | A vehicle moves between OPERATIONAL, DEPLOYED and MAINTENANCE | Fleet dashboard, maintenance tracker |
mission.started / mission.completed | A mission's actual start or end is recorded | Data-management pipeline (start archiving, then close the archive) |
sample.collected | A sample is registered | Sample curator, label printer, records-layer sync |
alert.critical | A CRITICAL alert is raised by a vehicle | On-call notification |
command.completed | A command reaches a terminal state | The client that issued it (instead of polling) |
The specification does not say how to subscribe, how deliveries are authenticated or what happens when a receiver is down. This book recommends the following profile. Treat delivery as at-least-once and deduplicate on eventId. Sign each delivery with an HMAC-SHA256 over a timestamp and the raw body, using a per-subscription secret, and reject deliveries whose timestamp is too old. Have the receiver answer 2xx quickly and queue the work. Have the sender retry non-2xx answers with backoff for a bounded period. Never put confidential coordinates in a webhook payload: send IDs and let the receiver fetch details with its own credentials, so the access rules of Chapter 7 still apply.
Phase 2 §8 limits each credential by tier:
| Tier | Requests per hour | Burst limit |
|---|---|---|
| Free | 1,000 | 100/min |
| Research | 10,000 | 500/min |
| Institution | 100,000 | 2,000/min |
| Enterprise | Unlimited | Custom |
A quick calculation shows why webhooks matter. A dashboard polling one command's status every 2 seconds makes 1,800 requests an hour, which already exceeds the Free tier with a single command and nothing else. One command.completed subscription replaces all of those calls. On 429 a client should sleep until the X-RateLimit-Reset time (Unix seconds) plus random jitter, so that many clients do not return in the same second.
The records API mirrors the model of Chapter 4. An expedition owns dives (GET /api/v1/expeditions/{id}/dives). A dive exposes its observations, samples and GeoJSON track as sub-resources. Observations, samples and vents are also top-level collections, because most questions cut across expeditions, such as "all black smokers hotter than 300 °C". Only expeditions have DELETE in §13; the other records should be corrected by PUT, with a version history kept, not deleted.
| Collection | Filter | Example | Notes |
|---|---|---|---|
| Observations | depthZone, taxon | ?depthZone=hadal&taxon=Amphipoda | See the zone-scheme warning below |
| Observations | minDepth, maxDepth | ?minDepth=6000&maxDepth=11000 | Metres, positive downward |
| Observations | diveId, habitat | ?habitat=trench | SDK filters; HabitatType values |
| Samples | expeditionId, sampleType, availability | /api/v1/samples/available | Availability also has its own path |
| Vents | region, ventType, minTemp, maxTemp | ?minTemp=300&ventType=black_smoker | °C; define whether it is maximum or last measured fluid temperature |
| Bathymetry | bbox, resolution | /grid?bbox=lon1,lat1,lon2,lat2&resolution=10m | Longitude first; resolution is limited by access tier |
The depthZone filter needs care. Chapter 1 explained that the standard carries two zoning schemes. The §13 example filters on hadal, a benthic zone, and the §12 observation record stores "depthZone": "hadal". The SDK's DepthZone enum and the CLI help list only the pelagic values (hadopelagic, abyssopelagic and so on). A server must say which scheme it indexes, and ideally accept both by mapping them to depth ranges. A client that wants reliable results should send a depth range as well: depthZone=hadopelagic&minDepth=6000 returns hadal-depth records whichever vocabulary the server stored.
For bbox, the order is minimum longitude, minimum latitude, maximum longitude, maximum latitude, the same order GeoJSON uses. For boxes crossing the antimeridian (relevant in the western Pacific trenches), the usual convention is a minimum longitude larger than the maximum; a server that does not support this should return VALIDATION_ERROR, not an empty result. The resolution parameter interacts with the access tiers in Chapter 4: a public caller asking for 10m should receive the public grid resolution with a note, or a 403, but never the restricted grid.
POST /api/v1/samples/{sampleId}/request is the only workflow endpoint in §13. The SDK sends requester, institution and purpose. The response shape is not specified. A useful server returns a request identifier and a state (submitted, under review, approved, declined) and notifies the curator. Here the legal frame of Chapter 2 becomes operational: if access-and-benefit-sharing terms apply, approval records the terms that travel with the material. Shipping and laboratory handling are outside the standard.
The SDK and CLI call two endpoints that §13 does not list. The first, POST /api/v1/search, has a body of {"query": "...", "type", "dateRange", "depthRange", "geographic"}, where type is one of expedition, dive, observation, sample or vent. The second is GET /api/v1/statistics. The official tools depend on them, but their response bodies are defined nowhere; Chapter 8 lists them for a revision. The bathymetry endpoints upload a MultibeamSurvey, return it by surveyId, serve gridded depth for a bounding box and provide a 3-D model whose format is unspecified; a server should declare it through the content type.
@wia/ene-040The SDK is published as @wia/ene-040 version 1.0.0 under the MIT licence and requires Node.js 18 or later, since it uses the built-in fetch and AbortSignal.timeout. It has no runtime dependencies. The project README describes a different package, @wia/deep-sea-exploration, with a WIADeepSeaClient class and methods such as client.vehicles.navigateTo. None of these exists in the shipped code, so this book documents the code. The SDK covers the records API only and has no method for any /v1/… operations endpoint.
The constructor takes { apiKey, endpoint, timeout?, retries?, debug? } with defaults of 30,000 ms, 3 and false. endpoint must be the host root, such as https://api.example.org, because every method appends a path that already begins with /api/v1. The client sends four fixed headers: Content-Type: application/json, Authorization: Bearer <apiKey>, X-WIA-Standard: ENE-040 and X-WIA-Version: 1.0.0.
No method throws on an API or network failure. Each resolves to ApiResponse<T>: { success, data?, error?: { code, message, details? }, metadata?: { timestamp, version } }. An HTTP error becomes code: "HTTP_<status>", for example HTTP_404. Anything that throws inside the request (DNS failure, timeout, abort) becomes NETWORK_ERROR. Code built on the SDK should branch on success and never read data without checking it.
Reading version 1.0.0 line by line shows seven behaviours that differ from what its configuration suggests, each with a simple workaround.
retries is accepted but never used. Each call makes exactly one attempt. Wrap calls yourself (see the example below), and retry only idempotent methods unless the server supports idempotency keys.data.message, but the Phase 2 envelope nests it as data.error.message, so error.message usually falls back to the HTTP status text. The full body is kept in error.details, and details.error.code holds the specific code, such as RATE_LIMIT_EXCEEDED.NETWORK_ERROR. The response is parsed with response.json() before the status is checked. A 204 No Content from a successful DELETE, or an HTML error page from a proxy, makes parsing throw, and the call reports a network error. deleteExpedition can therefore report failure after it has succeeded, so confirm with a follow-up GET.encodeURIComponent first.PaginationParams declares page and limit as required, so a filter object must include both to type-check.debug: true logs request bodies. These can contain restricted or confidential coordinates. Keep debug off in production, or redact before logging.calculatePressure() is wrong, as Chapter 1 showed. The mpa value is ten times too small, and the bar value treats one atmosphere per 10 m as exact. Use the corrected pressureAt() from Chapter 1 instead.| Area | Methods | Path |
|---|---|---|
| Expeditions | listExpeditions, getExpedition, createExpedition, updateExpedition, deleteExpedition | /api/v1/expeditions |
| Dives | listDives, getDive, createDive, updateDive, getDiveTrack | /api/v1/dives |
| Observations | listObservations, getObservation, submitObservation, updateObservation, getDiveObservations | /api/v1/observations |
| Samples | listSamples, getSample, registerSample, updateSample, requestSample, getAvailableSamples | /api/v1/samples |
| Vents | listVents, getVent, registerVent, updateVent | /api/v1/vents |
| Geology | registerRockSample, getRockSample, registerSedimentCore, getSedimentCore | /api/v1/geological/… |
| Bathymetry | uploadBathymetry, getBathymetrySurvey, getBathymetryGrid, get3DModel | /api/v1/bathymetry |
| eDNA records | registerEDNASample, getEDNASample, submitEDNAAnalysis | /api/v1/edna |
| Vehicles | registerSubmersible, getSubmersible, listSubmersibles | /api/v1/submersibles |
| Cross-cutting | search, getStatistics | /api/v1/search, /api/v1/statistics |
Note that listSamples and getSample are typed as BiologicalSample, although the collection can also hold other sample kinds. Code that handles rock or sediment entries from this list should narrow the type at run time instead of trusting the declaration. There is no method for GET /api/v1/dives/{diveId}/samples; use listSamples with a filter or call the path directly.
getDepthZone(depthM) returns a pelagic DepthZone. Below 200 m it returns epipelagic, which lies outside the standard's deep-sea scope, so the caller should decide whether such a record belongs in the dataset at all.calculateDistance(lat1, lon1, lat2, lon2) is the Haversine great-circle distance in kilometres (sphere radius 6,371 km). It ignores depth; for nearby points at different depths, combine it with the depth difference by Pythagoras in one unit.isValidCoordinates(lat, lon) checks −90…90 and −180…180. It accepts 0,0, a common sign of a missing fix, so treat that value separately.calculateDescentTime(depthM, rate?) returns minutes at a default 1,000 m per hour (6,000 m gives 360 minutes). That default is a planning assumption; pass the vehicle's real rate.isValidSubmersibleType, isValidDepthZone and isValidHabitatType check strings against the enums.import { DeepSeaExplorationSDK, DepthZone, ApiResponse, OrganismObservation } from '@wia/ene-040';
const sdk = new DeepSeaExplorationSDK({
apiKey: process.env.WIA_API_KEY ?? '',
endpoint: 'https://api.example.org', // host root: SDK paths already start with /api/v1
timeout: 20_000,
});
// The SDK ignores `retries`, so retry here. GET only: safe to repeat.
const RETRYABLE = new Set(['NETWORK_ERROR', 'HTTP_429', 'HTTP_502', 'HTTP_503', 'HTTP_504']);
async function withRetry<T>(call: () => Promise<ApiResponse<T>>, attempts = 4): Promise<ApiResponse<T>> {
let res = await call();
for (let i = 1; i < attempts && !res.success && RETRYABLE.has(res.error?.code ?? ''); i++) {
const wait = Math.min(30_000, 1_000 * 2 ** i) * (0.5 + Math.random() / 2);
await new Promise((r) => setTimeout(r, wait));
res = await call();
}
return res;
}
// The Phase 2 envelope nests the specific code; the SDK keeps the body in `details`.
const serverCode = (res: ApiResponse<unknown>): string =>
res.error?.details?.error?.code ?? res.error?.code ?? 'UNKNOWN';
export async function hadalObservations(): Promise<OrganismObservation[]> {
const out: OrganismObservation[] = [];
for (let page = 1; ; page++) {
const res = await withRetry(() =>
sdk.listObservations({
depthZone: DepthZone.HADOPELAGIC,
minDepth: 6000, // robust even if the server stored benthic 'hadal'
page,
limit: 100,
})
);
if (!res.success || !res.data) {
throw new Error(`observations page ${page}: ${serverCode(res)} (${res.error?.message})`);
}
out.push(...res.data.items);
if (!res.data.hasMore) return out;
}
}
deep-sea-exploration.shThe CLI is a single Bash script that wraps curl and pretty-prints with jq when it is installed. It reads WIA_API_ENDPOINT and WIA_API_KEY from the environment. The config command prompts for both (the key without echo) and writes them to ~/.wia/deep-sea-exploration.conf with mode 600. The script sources that file on every run, so it is executed as shell code: keep it private and never copy one from an untrusted source. Saved values override the environment.
There is one trap in the defaults. The built-in endpoint is https://api.wia.org/ene-040/v1, but every command appends a path beginning with /api/v1/. The resulting URL contains the version twice (…/ene-040/v1/api/v1/expeditions), and it will not match a server laid out as in §13. Set the endpoint to the host root, exactly as for the SDK: export WIA_API_ENDPOINT=https://api.example.org.
| Command | Arguments | Request |
|---|---|---|
config | (interactive) | None; writes the config file |
expeditions list | get | [PAGE] [LIMIT] | EXPEDITION_ID | GET /api/v1/expeditions… |
dives list | get | EXPEDITION_ID [PAGE] [LIMIT] | DIVE_ID | GET …/expeditions/{id}/dives, GET /api/v1/dives/{id} |
observations list | get | [DEPTH_ZONE] [PAGE] [LIMIT] | OBSERVATION_ID | GET /api/v1/observations… |
samples list | get | [PAGE] [LIMIT] | SAMPLE_ID | GET /api/v1/samples… |
vents list | get | [REGION] [PAGE] [LIMIT] | VENT_ID | GET /api/v1/vents… |
submersibles list | [TYPE] [PAGE] [LIMIT] | GET /api/v1/submersibles… |
search | QUERY | POST /api/v1/search |
stats | none | GET /api/v1/statistics |
help, version | none | None |
Lists default to page 1 with 10 items. The script header also advertises a bathymetry command, but no such command is implemented, and the help text for submersibles omits crawler even though the enum includes it.
The transcript below is illustrative. The IDs are fictional, and the response bodies depend on the server.
$ export WIA_API_ENDPOINT=https://api.example.org
$ ./deep-sea-exploration.sh config # prompts; key is not echoed
API Endpoint [https://api.example.org]:
API Key:
[✓] Configuration saved to /home/analyst/.wia/deep-sea-exploration.conf
[✓] Configuration complete!
$ ./deep-sea-exploration.sh dives list EXP-2026-017 1 2
[ℹ] Listing dives for expedition: EXP-2026-017
{
"items": [
{ "diveId": "DIVE-ROV-2026-031", "timeline": { "bottomTime": 14220 }, "navigation": { "maxDepth": 5980.4 } },
{ "diveId": "DIVE-ROV-2026-032", "timeline": { "bottomTime": 12960 }, "navigation": { "maxDepth": 6210.9 } }
],
"total": 7,
"page": 1,
"limit": 2,
"hasMore": true
}
$ ./deep-sea-exploration.sh observations list hadopelagic 1 50 > hadal.txt
In scripts, three properties bite. First, the status line ([ℹ] Listing …) goes to standard output ahead of the JSON, so piping straight into jq fails to parse. Drop the first line, or better, patch print_info to write to standard error. Second, curl runs with -s but without -f, and the result is captured with local response=$(…), which hides the exit status. A 401 or 500 therefore still exits 0, with the error JSON printed as if it were data, and set -e does not help. Check for an error key yourself. Third, search builds its JSON body by pasting the query into a template, so a query containing a double quote produces invalid JSON. For anything beyond simple words, build the body with jq and call the endpoint directly.
# 1) Tab-separated vent list, skipping the status line and failing on API errors
./deep-sea-exploration.sh vents list "" 1 100 \
| tail -n +2 \
| jq -er 'if .error then error(.error.code) else .items[] | [.ventId, .ventField, .ventType, .location.depth] | @tsv end'
# 2) Safe search: jq builds the JSON, curl -f turns HTTP errors into a non-zero exit
jq -n --arg q 'Amphipoda "hadal" trench' '{query: $q, type: "observation"}' \
| curl -sf -X POST "$WIA_API_ENDPOINT/api/v1/search" \
-H "Authorization: Bearer $WIA_API_KEY" \
-H 'Content-Type: application/json' \
-H 'X-WIA-Standard: ENE-040' -H 'X-WIA-Version: 1.0.0' \
--data-binary @- \
| jq '.'
WIA-ENE-040 has two HTTP interfaces that share conventions but serve different moments in a record's life. The operations API (/v1) handles the dive itself: vehicles, commands, live telemetry, missions, sensors and on-deck sample registration. The records API (/api/v1) keeps the scientific record: expeditions, dives, observations, samples, vents and surveys. The SDK and CLI implement the records side only.
Chapter 6 goes below the topside gateway to the Phase 3 acoustic stack that carries these commands and telemetry through the water column.