# 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

```bash
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:

```text
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](https://onekey.dev/docs/search-multi.md#rank-fusion-fuse-rrf)). |
| `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:

```javascript
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):

```text
hackernews: 200, 10 results, 0 credits
bluesky: 200, 10 results, 0 credits
fused: 20 items, first from hackernews
done: 0 credits charged, 772.8 left
```

For 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](https://onekey.dev/docs/idempotency.md) for the general rules.

## Edge cases

- **Proxies must not buffer.** The response carries `X-Accel-Buffering: no` for 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 `leg` frame and `done`.
- **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`](https://onekey.dev/docs/errors.md#invalid-request) (bad batch body, or an `Idempotency-Key` on a streamed `GET`), [`UNSUPPORTED_PLATFORM`](https://onekey.dev/docs/errors.md#unsupported-platform), [`INVALID_PARAMS`](https://onekey.dev/docs/errors.md#invalid-params), [`INSUFFICIENT_CREDITS`](https://onekey.dev/docs/errors.md#insufficient-credits), [`KEY_BUDGET_EXCEEDED`](https://onekey.dev/docs/errors.md#key-budget-exceeded), [`UNAUTHORIZED`](https://onekey.dev/docs/errors.md#unauthorized), [`RATE_LIMITED`](https://onekey.dev/docs/errors.md#rate-limited), and for a batch with a key the [idempotency](https://onekey.dev/docs/idempotency.md) answers. Inside a `leg` frame, `error.type` can be anything a single call returns. See [Errors](https://onekey.dev/docs/errors.md) for the full list.
