Chapter 5: The APIs — Operations API, Records API, SDK and CLI

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.

5.1 Common conventions

Two base paths, one family

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.

MethodPathLayerPurpose
GET/v1/vehiclesOperationsList accessible vehicles (filters status, type, page, limit)
GET/v1/vehicles/{vehicleId}OperationsVehicle status with a telemetry snapshot and alerts
POST/v1/vehicles/{vehicleId}/commandsOperationsIssue a vehicle command
GET/v1/vehicles/{vehicleId}/commands/{commandId}OperationsCommand execution status
WSS/v1/telemetry/streamOperationsLive telemetry stream
GET/v1/telemetry/historyOperationsHistorical telemetry for a time range
POST / GET/v1/missions, /v1/missions/{missionId}OperationsCreate a mission; read its timeline and progress
POST/v1/vehicles/{vehicleId}/sensors/{sensorId}/configureOperationsSet sampling rate, calibration offsets and alarms
GET/v1/vehicles/{vehicleId}/sensors/{sensorId}/dataOperationsSensor readings (limit ≤ 10,000)
POST/v1/samplesOperationsRegister a sample at the moment of collection
GET / POST / PUT / DELETE/api/v1/expeditions[/{expeditionId}]RecordsExpedition CRUD
GET / POST / PUT/api/v1/expeditions/{id}/dives, /api/v1/dives[/{diveId}]RecordsDive logs
GET/api/v1/dives/{diveId}/observations|samples|trackRecordsPer-dive observations, samples and navigation track
GET / POST / PUT/api/v1/observations[/{observationId}]RecordsOrganism observations with filters
GET / POST / PUT/api/v1/samples[/{sampleId}], /api/v1/samples/availableRecordsSample catalogue and availability
POST/api/v1/samples/{sampleId}/requestRecordsRequest access to a sample
GET / POST / PUT/api/v1/vents[/{ventId}]RecordsHydrothermal vent database
POST / GET/api/v1/bathymetry/upload, /{surveyId}, /grid, /{surveyId}/3d-modelRecordsMultibeam survey upload, grids and 3-D models
WS/dives/{diveId}/liveRecordsLive dive tracking (§13.2)
POST / GET/api/v1/search, /api/v1/statisticsSDK/CLI onlyCross-type search; database statistics
GET / POST/api/v1/submersibles, /api/v1/geological/rocks, /api/v1/geological/cores, /api/v1/ednaSDK/CLI onlyVehicle registry, rock samples, sediment cores, eDNA sample records

Authentication

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.

Standard headers

HeaderDirectionSourceRule and notes
X-WIA-VersionBothPhase 2 §2.3; SDK; CLIThe 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-IDBothPhase 2 §2.3A 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-StandardRequestSDK; CLIAlways ENE-040. Not required by Phase 2. It is useful on a shared gateway that fronts several WIA standards.
X-RateLimit-Limit / -Remaining / -ResetResponsePhase 2 §2.3, §8Quota for the current window, calls left, and the reset time as Unix seconds.
Content-Type / AcceptBothPhase 2 §2.3application/json

The error envelope

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.

CodeHTTPMeaningClient should
VALIDATION_ERROR400Malformed body or parameter out of rangeFix the request; never retry unchanged
AUTHENTICATION_FAILED401Missing, expired or unknown credentialRefresh the token once, then stop
AUTHORIZATION_DENIED403Valid credential, insufficient role or embargoed recordDo not retry; request access
RESOURCE_NOT_FOUND404Unknown IDDo not retry
RATE_LIMIT_EXCEEDED429Quota used upWait until X-RateLimit-Reset, then retry
INTERNAL_ERROR500Server faultRetry only idempotent requests, with backoff
SERVICE_UNAVAILABLE503Overload or maintenanceRetry 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.

Pagination

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).

Idempotency on unreliable links

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.

5.2 The operations API: vehicles and commands

Listing and reading vehicles

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.

Issuing a command

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.

CommandGroupEffect of an accidental duplicate
NAVIGATE_TO_WAYPOINT, HOVER_AT_POSITIONMotionUsually harmless: the same target is set again
SURFACE, EMERGENCY_ASCENTRecoveryHarmless, but an emergency ascent should never wait behind a queue
ACTIVATE_SENSOR, DEACTIVATE_SENSORPayload stateHarmless if the vehicle treats them as "set state", not "toggle"
CAPTURE_IMAGE, RECORD_VIDEOImagingExtra media and storage use
COLLECT_SAMPLESamplingSerious: a second physical sample or a duplicated custody record
DEPLOY_INSTRUMENT, RETRIEVE_INSTRUMENTInstrumentsSerious: 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 command lifecycle

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:

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 ...) ==       |

5.3 Telemetry streaming

The Phase 2 stream

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.

Authenticating a WebSocket

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.

Gaps, duplicates and reconnection

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.

The records-layer live dive stream

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:

AspectPhase 2 /v1/telemetry/stream§13.2 /dives/{diveId}/live
Keyed byvehicleIddiveId
Scheme in the spec examplewss:// (encrypted)ws://; deploy as wss://
Position keysposition.latitude / longitude, depth inside positionposition.lat / lon, depth at top level
OrderingsequenceNumberNone; order by timestamp only
Extra fieldsPower blockspeed, camera, dive status such as on_bottom
UnitsImplicitImplicit

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.

5.4 Missions, sensors, samples, webhooks and rate limits

Missions

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).

Sensors

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.

Registering samples at sea

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.

Webhooks

Phase 2 §9 defines six event types and one payload shape: eventId, eventType, timestamp and a data object carrying the relevant IDs.

EventFired whenTypical subscriber
vehicle.status.changedA vehicle moves between OPERATIONAL, DEPLOYED and MAINTENANCEFleet dashboard, maintenance tracker
mission.started / mission.completedA mission's actual start or end is recordedData-management pipeline (start archiving, then close the archive)
sample.collectedA sample is registeredSample curator, label printer, records-layer sync
alert.criticalA CRITICAL alert is raised by a vehicleOn-call notification
command.completedA command reaches a terminal stateThe 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.

Rate limits

Phase 2 §8 limits each credential by tier:

TierRequests per hourBurst limit
Free1,000100/min
Research10,000500/min
Institution100,0002,000/min
EnterpriseUnlimitedCustom

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.

5.5 The records API (ENE-040 §13)

Resources and relationships

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.

Filters

CollectionFilterExampleNotes
ObservationsdepthZone, taxon?depthZone=hadal&taxon=AmphipodaSee the zone-scheme warning below
ObservationsminDepth, maxDepth?minDepth=6000&maxDepth=11000Metres, positive downward
ObservationsdiveId, habitat?habitat=trenchSDK filters; HabitatType values
SamplesexpeditionId, sampleType, availability/api/v1/samples/availableAvailability also has its own path
Ventsregion, ventType, minTemp, maxTemp?minTemp=300&ventType=black_smoker°C; define whether it is maximum or last measured fluid temperature
Bathymetrybbox, resolution/grid?bbox=lon1,lat1,lon2,lat2&resolution=10mLongitude 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.

Requesting a sample

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.

Search, statistics and bathymetry

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.

5.6 The TypeScript SDK @wia/ene-040

Package and configuration

The 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.

The response contract

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.

Behaviour to know before relying on it

Reading version 1.0.0 line by line shows seven behaviours that differ from what its configuration suggests, each with a simple workaround.

  1. 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.
  2. The server's error code is not surfaced. The SDK reads 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.
  3. Any non-JSON body becomes 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.
  4. IDs are not URL-encoded. Paths are built by string interpolation. IDs that follow the Chapter 4 conventions (letters, digits, hyphens) are safe; anything else should go through encodeURIComponent first.
  5. Pagination parameters are mandatory in the types. PaginationParams declares page and limit as required, so a filter object must include both to type-check.
  6. debug: true logs request bodies. These can contain restricted or confidential coordinates. Keep debug off in production, or redact before logging.
  7. 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.

Methods

AreaMethodsPath
ExpeditionslistExpeditions, getExpedition, createExpedition, updateExpedition, deleteExpedition/api/v1/expeditions
DiveslistDives, getDive, createDive, updateDive, getDiveTrack/api/v1/dives
ObservationslistObservations, getObservation, submitObservation, updateObservation, getDiveObservations/api/v1/observations
SampleslistSamples, getSample, registerSample, updateSample, requestSample, getAvailableSamples/api/v1/samples
VentslistVents, getVent, registerVent, updateVent/api/v1/vents
GeologyregisterRockSample, getRockSample, registerSedimentCore, getSedimentCore/api/v1/geological/…
BathymetryuploadBathymetry, getBathymetrySurvey, getBathymetryGrid, get3DModel/api/v1/bathymetry
eDNA recordsregisterEDNASample, getEDNASample, submitEDNAAnalysis/api/v1/edna
VehiclesregisterSubmersible, getSubmersible, listSubmersibles/api/v1/submersibles
Cross-cuttingsearch, 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.

Helper functions

Example: hadal observations with error handling

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;
  }
}

5.7 The CLI deep-sea-exploration.sh

Configuration

The 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.

Commands

CommandArgumentsRequest
config(interactive)None; writes the config file
expeditions list | get[PAGE] [LIMIT] | EXPEDITION_IDGET /api/v1/expeditions…
dives list | getEXPEDITION_ID [PAGE] [LIMIT] | DIVE_IDGET …/expeditions/{id}/dives, GET /api/v1/dives/{id}
observations list | get[DEPTH_ZONE] [PAGE] [LIMIT] | OBSERVATION_IDGET /api/v1/observations…
samples list | get[PAGE] [LIMIT] | SAMPLE_IDGET /api/v1/samples…
vents list | get[REGION] [PAGE] [LIMIT] | VENT_IDGET /api/v1/vents…
submersibles list[TYPE] [PAGE] [LIMIT]GET /api/v1/submersibles…
searchQUERYPOST /api/v1/search
statsnoneGET /api/v1/statistics
help, versionnoneNone

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.

A session

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

Scripting with jq, and three pitfalls

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 '.'

Chapter summary

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.