> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fiveninelabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Streaming, long-poll, or webhooks

> Three equivalent ways to watch a run that takes minutes. Pick by how your app is shaped, not by data availability — the record is durable either way.

A run legitimately takes tens of minutes ([measured 11–68 minutes](/quickstart)), and
a dropped connection never stops it: the run continues server-side and every event
lands in a durable, replayable log. So the three ways to watch differ only in
ergonomics:

|                      | Best for                         | Latency to first signal | Connection held                        |
| -------------------- | -------------------------------- | ----------------------- | -------------------------------------- |
| **SSE stream**       | UIs, progress bars, live rows    | immediate               | for the whole run                      |
| **Long-poll result** | Backend jobs that want one call  | at terminal             | up to `timeout` (use ≤ 240 s), re-poll |
| **Webhooks**         | Fire-and-forget, fan-in services | at terminal             | none                                   |

Use them together freely — e.g. create with a `webhook_url` *and* stream the same run.

## SSE streaming

Two ways to get the stream:

* Create with `Accept: text/event-stream` — the `POST /v1/sessions` response *is* the
  stream (one-call shape).
* Or create plain (`201` + id), then `GET /v1/sessions/{id}/stream`.

Each SSE message carries `id:` (the event's sequence number) and `data:` (one JSON
event from [the event vocabulary](/guides/events)). The first message of a live run is
the meta envelope:

```
id: 0
data: {"kind":"meta","reqId":"sess_9m2kd","sessionId":"sess_9m2kd"}
```

**Resume is built in.** Sequence numbers are global per session and monotonic across
turns. If your connection drops, reconnect with `Last-Event-ID`:

```bash theme={null}
curl -N https://api.fiveninelabs.com/v1/sessions/sess_9m2kd/stream \
  -H "Authorization: Bearer $FIVENINE_API_KEY" \
  -H "Last-Event-ID: 41"
```

You get every event after 41 — replayed instantly from the log if the run has moved
on, live from the wire if it hasn't. A dropped stream is an inconvenience, never data
loss.

**Reload recovery: rebuild, then attach.** `Last-Event-ID` alone assumes your
in-memory state survived the drop. After a full page reload (or process restart) it
didn't — resuming the stream from your stored sequence number gives you the tail with
none of the state that came before it. The recipe every client needs:

1. **Rebuild** from the durable log: page `GET /v1/sessions/{id}/events?after=0`
   (then `?after=<next>` while `has_more`) and replay every event through your
   normal handler. The paged envelope serves the same event objects **flat, with
   `seq` merged in** — `{"data":[{"seq":2,"phase":"search-intent",…}],
   "has_more":false,"next":6}`; the event is not nested under a `data` or `event`
   member, and `seq` is the SSE `id:` for that event.
2. **Attach** live: connect `GET …/stream` with `Last-Event-ID` set to the last
   `seq` you replayed. The stream continues from there — same coordinate system,
   nothing lost, nothing duplicated.

Persist the last seen `seq` (and the session id) somewhere that survives reload;
that pair is the whole resume state. Reads are free and unthrottled, so rebuilding
from 0 on every reload is a correct, cheap default.

**Stream lifetime:** the stream stays open only while a run is in flight; it closes
after the turn's terminal envelope (`session-turn`). Connecting to a session with no
run in flight replays the log from your `Last-Event-ID` (or 0) and closes — it does not
hang open awaiting future turns. Each spending turn gets its own stream connection.

**Expect quiet stretches.** Between the fan-out plan and the first harvested rows the
agent is driving real browsers; the only traffic may be `orchestrate` decision events
and heartbeat pulses. Any event — including phases you don't recognize — means the
run is alive. Render nothing for unknown phases; never error on them.

**Streaming from a browser?** `EventSource` cannot connect (Bearer-only auth), CORS
is open, and shipping a live key to a page is the one thing you must not do — the
whole story, including the recommended patterns, is in
[Streaming in the browser](/guides/browser).

## Long-poll: one blocking call

`GET /v1/sessions/{id}/result?timeout=240` blocks server-side until the run reaches a
terminal or the timeout lapses:

* **`200`** — always and only a terminal. The body is the result: `status`,
  `qualified`, `rows`, `unchecked`, `spend_usd`, receipt ids.
* **`202`** — the timeout lapsed first. The body is the session object (so you can show
  progress), with a `Retry-After` header. Poll again; a naive loop of
  `GET …/result` calls is the correct implementation.

```bash theme={null}
until curl -sf "https://api.fiveninelabs.com/v1/sessions/sess_9m2kd/result?timeout=240" \
  -H "Authorization: Bearer $FIVENINE_API_KEY" -o result.json; do sleep 5; done
```

<Warning>
  **Use `timeout ≤ 240` and loop on the `202`.** The server accepts up to `timeout=600`,
  but stock Node `fetch` (undici) times out response headers at **300 seconds by
  default** — a single `timeout=600` call dies at exactly 5 minutes with an opaque
  `TypeError: fetch failed` while the run continues server-side (nothing is lost; the
  record is durable — but your harvest path just broke). `≤ 240` leaves headroom for
  every mainstream HTTP client and proxy; the `202 + Retry-After` loop is the designed
  pattern, not a workaround. If you must hold longer in Node, configure an undici
  `Agent` with `headersTimeout: 0`.
</Warning>

`?format=ndjson` streams the rows one per line for bulk ingestion. Read GETs are not
rate-limited — polling is never the thing we throttle.

## Webhooks: no connection at all

Pass `webhook_url` on create (or register endpoints via
[`/v1/webhooks`](/guides/webhooks)) and the terminal comes to you:
`session.answered`, `session.abstained`, `session.error`, `session.stopped` — plus
`session.proposal` when a follow-up turn needs confirmation. Payloads are summaries
with ids; fetch rows via the API. Deliveries are signed per the Standard Webhooks spec
and retried for 24 hours. Full catalog, signing, and verification:
[Webhooks](/guides/webhooks).

## Stopping a run — in-flight passes drain

`POST /v1/sessions/{id}/stop` is honored immediately in one specific sense: **nothing
new is dispatched after the 200**. But it is not an abort — set the right expectation
at the moment you call it:

* **In-flight passes drain to completion.** A source pass that is already spending
  finishes (each up to its own pass budget, \~10 minutes), and several can be in
  flight at once. In a measured run, a stop posted mid-harvest took **\~20 minutes**
  to reach the terminal, and spend continued for the draining work ($0.06 at the
  stop call, $0.20 at terminal).
* **The 200 body still says `running`** until the drain finishes; the terminal
  arrives as `status: "stopped"` with `stop: "stopped"` on `search-done`. Watch it
  like any other terminal (stream, long-poll, webhook).
* **Losing the stop-vs-terminal race is harmless**: a stop posted after the run ended
  returns `409 session_terminal` — treat it as success and read the terminal from
  `status`.

If your UI has a stop button, say "finishing in-flight work — this can take
minutes" the moment it's pressed, not when the terminal lands.

## Reading a finished run (free, forever within retention)

The record is durable and free to re-read for at least 90 days:

* `GET /v1/sessions/{id}` — status, spend, turn count.
* `GET /v1/sessions/{id}/events?after=n` — the full typed event log, paged on the same
  sequence numbers as the SSE `id:`.
* `GET /v1/sessions/{id}/rows` — the complete corpus, paged. The stream's
  `search-source.rows` are bounded previews (500 rows / 1.5 MB per source, stated in
  `truncated`); this endpoint is the real population.
* `GET /v1/sessions/{id}/result` — the terminal answer, instantly (`200`).

Re-reading never re-runs and never re-bills. If you want *fresh* data for the same
question, that's [refresh](/guides/refresh-and-monitors) — a separate, explicit,
priced action.

## Statuses, mapped honestly

`running → judging → answered | abstained | error | stopped` (open enum — tolerate
unknown values).

One subtlety worth stating twice: a stream that has shown `search-done` is a run still
**judging**, not a lost answer — the adjudicated `search-answer` and the terminal
envelope follow. Wait for the terminal; `GET …/result` does this correctly for you.

A run that starts and ends badly — `abstained`, `error`, `stopped` — is a **status**,
not an HTTP error. The ledger settles only for signed receipts and the rest of the
reservation is released. HTTP errors are reserved for "this request did not start a
run" — see [Errors](/errors).
