Streaming
The three compound endpoints, POST /v1/batch, GET /v1/search/multi and GET /v1/creator/card, can answer as a stream of Server-Sent Events instead of one JSON body. Send Accept: text/event-stream and each leg arrives the moment it settles, followed by a final frame with the totals. Everything else, the rows, the prices and the rate limit, is the same as the JSON form.
Single endpoints (GET /v1/{platform}/{endpoint}) do not stream; they return JSON whatever Accept says.
When to use it#
- A batch or a fan-out mixes fast platforms with slow ones, and you want to show the fast answers straight away.
- You process rows as they come instead of waiting for the slowest leg.
If you only want the complete answer, the JSON form is simpler and, for GET calls, the only form that supports Idempotency-Key (below).
The request#
curl -N "https://api.onekey.dev/v1/search/multi?query=rust&platforms=hackernews,bluesky" \
-H "x-api-key: YOUR_API_KEY" \
-H "accept: text/event-stream"curl -N turns off output buffering so frames show as they arrive. The response, recorded from the live API and shortened:
HTTP/1.1 200 OK
content-type: text/event-stream; charset=utf-8
x-accel-buffering: no
x-request-id: a98126ebced944a1b660feae99e0e55d
transfer-encoding: chunked
event: leg
data: {"index":0,"platform":"hackernews","endpoint":"search","params":{"q":"rust"},"status":200,"data":[...],"error":null,"credits_charged":5.0,"cached":false,"pagination":{"next_cursor":"...","has_more":true,"page_size":10}}
event: leg
data: {"index":1,"platform":"bluesky","endpoint":"search","params":{"q":"rust"},"status":200,"data":[...],"error":null,"credits_charged":5.0,"cached":false,"pagination":{"next_cursor":"...","has_more":true,"page_size":10}}
event: done
data: {"recipe":"search_multi","query":"rust","platforms":["hackernews","bluesky"],"legs":2,"succeeded":2,"failed":0,"credits_charged":10.0,"request_id":"a98126ebced944a1b660feae99e0e55d","idempotent_replay":false,"credits_remaining":773.8}Each frame is event: <type>, one data: line holding one JSON document, and a blank line. There are no id: or retry: lines. The frames are the same for a streamed POST /v1/batch; only the done payload differs (a batch has no recipe, query or platforms).
Frame types#
| Event | How many | Payload |
|---|---|---|
leg | One per leg, as each settles, so not in request order | Exactly one row of the JSON form: index, platform, endpoint, params, status, data, error, credits_charged, cached, and pagination when the endpoint pages. Use index (batch) or platform (search, creator card) to place it. A failed leg is a leg frame with its status and error, like any row. |
fused | One, only for /v1/search/multi with fuse=rrf, after the last leg | The complete data.fused list (see Rank fusion). |
done | One, last | The JSON form's metadata (legs, succeeded, failed, credits_charged, request_id, idempotent_replay, plus recipe, query or handle, platforms and fuse for the two recipes) with one extra key, credits_remaining: your balance after the call, in credits. |
Treat done as the end of the answer. A stream that closes without done was interrupted; see below.
What is not a stream#
Anything refused before the legs start is an ordinary JSON error, not a stream, with the usual status: a malformed batch body (400 INVALID_REQUEST), an unsupported platform (422 UNSUPPORTED_PLATFORM), missing parameters (422 INVALID_PARAMS), a worst case above your balance (402), a bad key (401) or a rate limit (429). Check the Content-Type before you parse: application/json is a refusal, text/event-stream is the answer.
One difference from the JSON form: a search or creator card whose legs all fail still streams every leg frame and ends with done (succeeded: 0), where the JSON form would answer with the first leg's error. Read the status of each leg.
Reading it in JavaScript#
The browser's EventSource cannot send an x-api-key header, so read the stream with fetch. This is the reader we run against the API; it splits the body on blank lines and parses each frame:
async function* events(url, init = {}) {
const res = await fetch(url, { ...init, headers: { ...init.headers, accept: "text/event-stream" } });
if (!res.headers.get("content-type")?.startsWith("text/event-stream")) throw new Error((await res.json()).error.type); // refused up front
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) return;
buf += dec.decode(value, { stream: true });
let end;
while ((end = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, end);
buf = buf.slice(end + 2);
const event = /^event: (.*)$/m.exec(frame)?.[1];
const data = /^data: (.*)$/m.exec(frame)?.[1];
if (event && data) yield { event, data: JSON.parse(data) };
}
}
}
const q = new URLSearchParams({ query: "rust", platforms: "hackernews,bluesky", fuse: "rrf" });
for await (const { event, data } of events(`https://api.onekey.dev/v1/search/multi?${q}`, { headers: { "x-api-key": process.env.ONEKEY_API_KEY } })) {
if (event === "leg") console.log(`${data.platform}: ${data.status}, ${Array.isArray(data.data) ? data.data.length : 0} results, ${data.credits_charged} credits`);
else if (event === "fused") console.log(`fused: ${data.length} items, first from ${data[0]?.platform}`);
else if (event === "done") console.log(`done: ${data.credits_charged} credits charged, ${data.credits_remaining} left`);
}Run with Node 18 or later, it prints (both legs were cache hits here):
hackernews: 200, 10 results, 0 credits
bluesky: 200, 10 results, 0 credits
fused: 20 items, first from hackernews
done: 0 credits charged, 772.8 leftFor a streamed batch, use method: "POST", a content-type: application/json header and the batch body; the frames are the same.
What it costs#
The same as the JSON form: each leg with status: 200 is charged at its endpoint's price (endpoints cost 1 to 8 credits; cache hits free, a cached response over 100 KB 0.2 credit), failed legs are refunded, and done.credits_charged is the total. A stream carries no X-Credits-Remaining header, because the balance is not known when the headers are sent; read done.credits_remaining instead. A stream is one request against the per-minute rate limit, like the JSON form.
If you disconnect early: legs that had already settled stay settled and, if they succeeded, charged. Legs still running are cancelled and refunded, like a cancelled single call. Legs that had not started reserve nothing. So closing a stream after two of ten legs costs at most those two.
Idempotency on streams#
| Call | With Idempotency-Key |
|---|---|
Streamed GET /v1/search/multi or GET /v1/creator/card | Refused: 400 INVALID_REQUEST (Idempotency-Key cannot be combined with a streamed GET; use the JSON form). Nothing is billed and the key is not consumed, so you can reuse it on the JSON form. |
Streamed POST /v1/batch | Accepted. The first run stores its outcome once done is sent; a replay within 24 hours, streamed or JSON, returns the stored rows (in request order) with credits_charged: 0 and the headers X-Idempotent-Replay: true and X-Cache: HIT. A replayed stream's done frame is the stored metadata with idempotent_replay: true and no credits_remaining. |
A stream interrupted before done | Nothing is stored; the key is released and a retry with it runs for real. |
See Idempotency for the general rules.
Edge cases#
- Proxies must not buffer. The response carries
X-Accel-Buffering: nofor proxies that honour it. If you put your own proxy in front, make sure it passes chunks through. - Order is by completion. A cached leg usually arrives first. Never assume the first frame is index 0.
- A single fast platform still streams. With one leg you get one
legframe anddone. - Headers show no cost. Unlike the JSON form, the price is only in the frames.
- The rate-limit headers are present on the stream's response headers as on any key-authenticated response.
Errors it can return#
Before the stream starts (JSON, nothing charged): INVALID_REQUEST (bad batch body, or an Idempotency-Key on a streamed GET), UNSUPPORTED_PLATFORM, INVALID_PARAMS, INSUFFICIENT_CREDITS, KEY_BUDGET_EXCEEDED, UNAUTHORIZED, RATE_LIMITED, and for a batch with a key the idempotency answers. Inside a leg frame, error.type can be anything a single call returns. See Errors for the full list.