Deep Water API
A deterministic research pipeline you call over REST. Submit a query and get back a report whose citations link to the sources behind it.
Base URL
https://api.deepwater.live
Auth
Authorization: Bearer <key>
Status
LiveAuthentication
Every request is authenticated with an API key. Create a key from the dashboard at admin.deepwater.live. Keys are scoped per user and can be revoked at any time without affecting other keys.
curl https://api.deepwater.live/v1/research \
-H "Authorization: Bearer dw_live_…" \
-H "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
-H "Content-Type: application/json" \
-d '{"query":"market share of electric vehicles in the EU in 2025","depth":"standard","public":false}'
Server-to-server connectors can validate a credential without starting a research run:
curl https://api.deepwater.live/v1/key-check \
-H "Authorization: Bearer dw_live_…"
Treat keys like passwords. Never embed a key in front-end code — use the dashboard to create per-environment keys and rotate on a schedule.
Quickstart
- Create an API key in the dashboard.
- POST a query to
/v1/researchwith a newIdempotency-Key. You’ll get back202 Acceptedand the run’s id. - Follow progress over the WebSocket (stream), let your account’s webhook tell you each time the run changes state, or poll
GET /v1/research/:idevery minute or two. - Read the report from
GET /v1/research/:idoncestatusandfull_report_statusare bothcomplete.
Create research
POST /v1/research
Starts a research run. The response returns immediately with 202 Accepted and the run’s id — the research itself runs asynchronously. This example asks for a report about Prague, written in English for a reader in the United States:
curl https://api.deepwater.live/v1/research \
-H "Authorization: Bearer dw_live_…" \
-H "Idempotency-Key: 7f1c2a9e-4b3d-4e5f-8a6b-0c1d2e3f4a5b" \
-H "Content-Type: application/json" \
-d '{
"query": "Office rents in Prague, 2024-2026",
"depth": "standard",
"public": false,
"output_language": "en",
"reader_locale": {
"region": "US",
"currency": "USD",
"measurement_system": "us"
}
}'
Headers
| Header | Notes |
|---|---|
Authorization | Required. Bearer and your API key. |
Idempotency-Key | Required. A new lowercase UUID v4 for every run. Reuse it only to retry that same request (see Retries). |
Content-Type | Required. application/json. |
Request body
| Field | Type | Notes |
|---|---|---|
query | string | Required. Plain-language research question. |
public | boolean | Required. false keeps the report private (your plan must include private research); true publishes it to the public research archive. |
depth | string | Optional. light, standard (the default), deep or heavy. See Depth presets. |
search_quality | string | Optional. standard (the default) or premium, which searches more deeply. |
output_language | string | Optional. The language the summary and the full report are written in: en (the default), cs, es, de, it, zh (Simplified Chinese), ja, fr, pt, ar, ru or ko. |
reader_locale | object | Optional. Who the report is written for (see Report language and reader). Omitted, every figure stays in its source currency and units. |
report_length | string | Optional. standard (the default), or dissertation for a much longer report — aiming for 150+ pages when the evidence permits, written after the summary. Independent of depth. |
include_hypotheses | boolean | Optional. true adds a separate section exploring possible connections between findings. They are assumptions, not verified conclusions. Defaults to false. |
Report language and reader
output_language sets the language of the report. reader_locale describes the reader — not the place being researched: a report about Prague can be written in Czech, or in English for a reader in the United States. Research geography, report language and reader are independent.
A reader has exactly three fields, all required when reader_locale is sent:
region— the reader’s country or region as an uppercase ISO 3166-1 alpha-2 code, such asUS;nullfor an international reader.currency— the currency for comparison figures as an uppercase ISO 4217 code, such asUSD;nullkeeps every figure in its original currency.measurement_system—metric(kg, km, °C),us(US customary: lb, mi, °F) oruk(metric, with miles for distances and speeds).
The report keeps each figure in its source currency and units first and, where the reader’s differ, adds the equivalent in parentheses — CZK 50,000 (≈ USD 2,200), 15 kg (≈ 33 lb). Currency equivalents are approximate and use dated reference rates; the report states their date, and says so when no rate exists for a currency rather than guessing one. A figure from a different market keeps its market: a US benchmark in a Prague report stays a US benchmark.
Omit reader_locale and the report uses source currencies and units only, with no equivalents; omit output_language and it is written in English. Nothing is filled in from your dashboard settings. Codes are never corrected: us is refused rather than read as US, and an unsupported language, region or currency is a 400 whose detail names the field.
Response
HTTP/1.1 202 Accepted
{
"id": "3c9a1d2e-7b4f-4e6a-8c5d-1f2a3b4c5d6e"
}
Poll GET /v1/research/:id with that id. For the first minutes after launch it can answer 404 while the run is set up; if it still does after about 10 minutes, contact support.
Retries
If the request times out, the connection drops or the answer is a 5xx, send the same request again with the same Idempotency-Key: you get the original run back instead of a second paid one. Any other 4xx is final — the API’s own are 400, 401, 402, 403, 422 and 429 — and means nothing started, so correct the request and use a new key. 409 is different: the key was already used for a launch with a different body, and that run exists and may be running. Resending the original body with that key returns it; a new run needs a new key.
Hosted research always writes a full report and does not accept webhook_url: follow a run over the WebSocket, by polling, or through the webhook on your account.
Get research
GET /v1/research/:id
Fetches the run’s snapshot: its status, sub-questions, sources, how many sources it read and rounds it gathered, and — once written — the markdown report. report is the full report once full_report_status is complete, and the shorter summary until then. output_language and reader_locale are the run’s settings (reader_locale is null when none was given); report_presentation records what the returned report was actually written for.
{
"id": "3c9a1d2e-7b4f-4e6a-8c5d-1f2a3b4c5d6e",
"status": "complete",
"full_report_status": "complete",
"query": "Office rents in Prague, 2024-2026",
"output_language": "en",
"reader_locale": { "region": "US", "currency": "USD", "measurement_system": "us" },
"report": "# Office rents in Prague ...",
"report_presentation": {
"version": 1,
"output_language": "en",
"reader_locale": { "region": "US", "currency": "USD", "measurement_system": "us" },
"conversion_policy_version": "reader-units.v1",
"fx_as_of": "2026-09-22"
},
"sources": [ { "url": "https://...", "title": "..." } ],
"sub_questions": [ { "id": "sq_1", "question": "...", "status": "complete" } ],
"usage": {
"urls_fetched": 487,
"gather_rounds": 6
}
}
Cancel research
POST /v1/research/:id/cancel
Cancels a run that is still in progress. You’ll only be charged for work already completed. A run that has already finished, failed or been cancelled answers 409 Conflict.
Depth presets
Choose how deep each run goes. A deeper run reads more of the web and writes a longer, more structured report, so it takes longer and costs more.
| Depth | What you get |
|---|---|
light | A quick answer. |
standard | A proper summary. The default. |
deep | The full picture. |
heavy | Everything we can find. |
Your team’s tariff and completed usage are shown in Billing.
WebSocket progress
Open a WebSocket to watch a job in real time. As the engine transitions between phases (scope → gather → synthesise → verify) and completes gather rounds, you’ll receive JSON events on the socket. The stream closes on its own when the job reaches a terminal status.
GET wss://api.deepwater.live/v1/research/:id/stream
Connecting
// Browser
const socket = new WebSocket(
`wss://api.deepwater.live/v1/research/${jobId}/stream`,
);
socket.onmessage = (event) => {
const payload = JSON.parse(event.data);
console.log(payload.kind, payload.status, payload.message);
};
socket.onclose = () => {
console.log('stream closed, fetch final report');
};
Event schema
{
"jobId": "res_01H9XZY5",
"kind": "phase_enter" | "phase_exit" | "scope_outline" | "gather_round" | "synthesis_token" | "terminal",
"phase": "scope" | "gather" | "synthesise" | "verify",
"status": "queued" | "scoping" | "gathering" | "synthesising" | "verifying" | "complete" | "failed",
"message": "Searching, fetching and extracting sources",
"data": { "urls": 487, "rounds": 6 },
"at": "2026-04-11T02:31:04.221Z"
}
Event kinds
| Kind | When it’s emitted |
|---|---|
phase_enter | Engine has transitioned into a new phase. |
scope_outline | Sub-questions have been identified. data.outline contains them. |
gather_round | Gather phase completed. data.urls, data.rounds, data.artifacts. |
synthesis_token | Reserved for token-level streaming in a later release. |
terminal | Job reached complete, failed, or cancelled. The server closes the socket immediately after. |
Reconnection
If the socket drops, re-open it against the same job id — you’ll immediately receive a phase_enter event reflecting the current phase, followed by any future events. If the job already finished while you were disconnected, the socket will send a final terminal event and close. Always use GET /v1/research/:id as the source of truth for the final report.
Webhook callbacks
Save a webhook on your account (dashboard → Account → Webhook) and DeepWater calls it each time a run you start changes state: from queued through each research phase to complete, failed or cancelled, and then through the full report’s own states to complete or failed. A run is finished when it fails or is cancelled, or when its full_report_status is complete or failed. The webhook covers every run you start, from the dashboard, an API key or an agent, in every team you work in. The address must be a public https URL. Each run keeps the address it started with, and removing the webhook stops every run’s calls.
A launch never carries a callback. webhook_url on POST /v1/research is refused with 400, because a launch’s retry record must never hold a credential and callback URLs often carry one in their path.
What a call looks like
POST /your/webhook
Content-Type: application/json
X-DeepWater-Event: research.state_changed
X-DeepWater-Event-Id: 0e5c9f0e-6d0b-4b9f-9d7e-2b0f6f8c1a11
X-DeepWater-Signature: t=1758636300,v1=5f2b…
{
"id": "0e5c9f0e-6d0b-4b9f-9d7e-2b0f6f8c1a11",
"type": "research.state_changed",
"created_at": "2026-09-23T14:05:00.000Z",
"sequence": 5,
"research": {
"id": "3c9a1d2e-7b4f-4e6a-8c5d-1f2a3b4c5d6e",
"status": "complete",
"previous_status": "verifying",
"full_report_status": "pending",
"previous_full_report_status": null
},
"requested_by": {
"user_id": "…",
"organization_id": "…",
"team_id": "…"
}
}
sequence counts a run’s calls from 1, in the order its states changed. requested_by names the person who started the run and the team and organisation it ran in, by their sign-in ids. A call never carries the report or why a run failed: read those from GET /v1/research/:id with a key for that team.
Checking the signature
When you first save the webhook you get its signing secret (whsec_…). It is shown once; rotate it from the same page if you lose it. Each call’s X-DeepWater-Signature is t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of <t>.<raw body> under your secret. Check it against the raw bytes, before parsing, and refuse an old t:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isFromDeepWater(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')));
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const recent = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return recent && v1?.length === expected.length
&& timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
Delivery
Answer with any 2xx within 10 seconds. Anything else is tried again after 30 seconds, 2, 10 and 30 minutes and 1 hour, then every 2 hours, for up to 24 hours after the state changed. Redirects are not followed. A call can arrive more than once, so drop repeats by id. A run’s calls arrive in order: a later state waits until the earlier one is delivered or given up.
CLI
The Deep Water CLI is a thin wrapper around the REST API. Install globally and point it at your key.
npm install -g @unlikeotherai/deepwater
export DEEPWATER_API_KEY=dw_live_…
deepwater research "recent breakthroughs in room-temp superconductors" --public --depth deep
Every launch says who can see the report: --public publishes it to the public research archive and --private keeps it private (your plan must include private research). The command waits until the full report is written, printing progress on stderr, then prints the report; --no-wait prints the run id instead, and deepwater status <run-id> reads the run later.
Each launch carries its own Idempotency-Key. When the outcome is unknown the CLI exits with status 75 and prints the exact command to retry with that key, which returns the original run instead of starting a second one.
The CLI has no report language or reader options yet, and it infers neither: a run it starts uses the defaults — English, with figures in their source currencies and units. To choose a language or a reader, use the API or an MCP client.
MCP server
The MCP integration is in private beta. It lets an MCP-aware assistant scope research and work with existing reports. A public installer is not available yet.
Billing
Your team shares one credit balance. The Billing view shows the current tariff, remaining credits, and completed usage.
Errors
Errors follow the RFC 7807 Problem Details format.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://deepwater.live/problems/invalid-request",
"title": "Invalid request",
"status": 400,
"detail": "depth must be one of light, standard, deep, heavy"
}