# Streams

> Create a stream, push items, read finalized results with a cursor, and close it.

A stream is a job that takes its items one push at a time and reports each item, once it finalizes, on an ordered results feed. For the concepts and a worked loop, see [Online RL with Streams](/docs/streams).

**Using online RL for your business?** [Contact us](mailto:sales@trydatapoint.com) before you start, so we can allocate rater bandwidth for your training runs.

Every endpoint below is under `https://api.trydatapoint.com/data-labelling/v1`, takes your API key in the `X-API-Key` header and counts against your key's [rate limit](/docs/rate-limits). A request without a valid key is answered `401` before any stream lookup, whatever its `stream_id`; only a body that isn't valid JSON is refused before that, with a `422`. A stream's `stream_id` is also a job id: streams appear in `GET /jobs`, and the endpoints below answer `404` for an ordinary job's id. Of the job endpoints, `POST /jobs/{stream_id}/pause` and `/resume` work on a stream; `/cancel` and `/complete` answer `400` with code `use_stream_close` (end a stream with [close](#close-a-stream) instead), and `/retry` answers `400`.

## Create a stream

<EndpointHeader method="POST" path="/streams" />

Creates a stream, or returns your existing stream with this `name` when its settings match, so a retried create is safe.

### Request body

<ParamTable
  params={[
    { name: "name", type: "string", required: true, description: "Unique across your jobs and streams." },
    { name: "instruction", type: "string", required: true, description: "The question raters see. Put `{context}` where an item's `context` should appear." },
    { name: "task_type", type: "string", required: false, description: "`comparison` (default), `i2v_comparison`, `rating`, `ranking`, `pairwise_ranking`, `multiple_choice` or `text_answer`." },
    { name: "response_options", type: "object", required: false, description: "Task-type options, as on `POST /jobs`, such as a rating scale or `free_text`." },
    { name: "max_responses_per_item", type: "int", required: false, description: "Votes each item collects, 1 to 10,000; default 10. For `pairwise_ranking` it is the item's whole vote budget, spread over its candidate pairs." },
    { name: "annotator_filter", type: "object", required: false, description: "Who may answer, as on `POST /jobs`." },
    { name: "serving_environment", type: '"all" | "sandbox" | "prod"', required: false, description: "`sandbox` is a free test pool (no credits charged); `prod` is the live annotator pool; `all` serves both. Defaults to the environment the request is made against." },
    { name: "item_ttl_seconds", type: "int", required: false, description: "Seconds an item may wait for its votes before it finalizes as `expired`, 30 to 3,600; default 600." },
    { name: "max_pending_items", type: "int", required: false, description: "Items that may be pending at once, 1 to 10,000; default 1,000." },
    { name: "budget_credits", type: "int", required: false, description: "Cap on the credits the stream may hold plus spend, 1 to 2,147,483,647. Omit for no cap." }
  ]}
/>

[Targeting](/docs/api/targeting) lists the filters `annotator_filter` takes.

`task_type`, `instruction`, `response_options`, `max_responses_per_item`, `annotator_filter` and `serving_environment` are fixed for the stream's life. Creating a stream again under the same name with any of them different is refused with `409 config_mismatch`, whose `fields` names them. `item_ttl_seconds`, `max_pending_items` and `budget_credits` apply only when the stream is new: an existing stream keeps its own, and [`PATCH`](#change-a-streams-limits) changes them.

Streams don't take chain `steps`, `dimensions`, custom task UIs or `datapoints`. An unknown field is a `422`. So is invalid text (a NUL character or an unpaired surrogate) in `name`, `instruction`, `response_options` or `annotator_filter`, and `NaN` or `Infinity` in `response_options`. A `name` held by one of your ordinary jobs is a `409 name_conflict`.

The existing stream comes back whatever its `status`, so creating `dpo-run-1` again after closing it returns the closed stream, and pushes to it get `409 stream_closed`. Use a new name for each run.

### Response

The [stream object](#the-stream-object).

<CodeTabs>
  <CodeTab lang="curl" label="curl">
```bash
curl -X POST https://api.trydatapoint.com/data-labelling/v1/streams \
  -H "X-API-Key: $DATAPOINT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "dpo-run-1",
    "instruction": "Which image better matches the prompt? {context}",
    "task_type": "comparison",
    "max_responses_per_item": 3,
    "serving_environment": "sandbox",
    "item_ttl_seconds": 120
  }'
```
  </CodeTab>
  <CodeTab lang="python" label="Python">
```python
import os

import requests

API = "https://api.trydatapoint.com/data-labelling/v1"
http = requests.Session()
http.headers["X-API-Key"] = os.environ["DATAPOINT_KEY"]

r = http.post(f"{API}/streams", json={
    "name": "dpo-run-1",
    "instruction": "Which image better matches the prompt? {context}",
    "task_type": "comparison",
    "max_responses_per_item": 3,
    "serving_environment": "sandbox",
    "item_ttl_seconds": 120,
})
r.raise_for_status()
stream = r.json()
```
  </CodeTab>
</CodeTabs>

## Push items

<EndpointHeader method="POST" path="/streams/{stream_id}/items" />

Adds 1 to 500 items to an open stream. Each one is served to raters and finalizes by its deadline.

### Request body

<ParamTable
  params={[
    { name: "items", type: "array of item objects (1–500)", required: true, description: "The items to add, in order. See the fields below." },
    { name: "ttl_seconds", type: "int", required: false, description: "Deadline for the items in this push, 30 to 3,600 seconds. Defaults to the stream's `item_ttl_seconds`." }
  ]}
/>

<ParamTable
  title="items[]"
  params={[
    { name: "media", type: "object", required: true, description: "The item's media, in the shape of a `POST /jobs` datapoint of the stream's task type." },
    { name: "external_id", type: "string", required: false, description: "Your id for the item, unique within the stream, at most 128 characters. Strongly recommended: it makes a retried push safe." },
    { name: "context", type: "string", required: false, description: "Shown to raters where the instruction has `{context}`." },
    { name: "metadata", type: "object", required: false, description: "Any JSON object of yours, at most 4,096 bytes, returned with the item's result." }
  ]}
/>

[Task types](/docs/task-types) gives the `media` shape of each task type.

### What happens to a push

The checks run in this order, and a refused push stores nothing:

0. **The request itself.** Before any of the checks below, the body must be valid JSON (`422`), and the request must carry a valid API key (`401`), be within your key's rate limit (`429`, with `Retry-After`) and match the request schema (`422`: no items or more than 500, an out-of-range `ttl_seconds`, a blank or over-long `external_id`, oversized `metadata`, invalid text or a `NaN` (see the [item rules](#item-rules)), an unknown field).
1. **The stream must be yours.** Otherwise `404`.
2. **No `external_id` may repeat within the push.** Otherwise `400 invalid_items`, naming each repeat.
3. **A stored `external_id` must keep its content.** Pushing a known `external_id` with the same `context` and `media` returns the stored item with `duplicate: true` and charges nothing. Different content is `409 external_id_conflict`, whatever state the stream is in.
4. **New items need an open, unpaused stream.** A push with any new item is refused with `409 stream_closed` on a closed or closing stream and `409 stream_paused` on a paused one. A stream is paused by you (`POST /jobs/{stream_id}/pause`, or Pause on its dashboard page) or by our team; resume it with [`POST /jobs/{stream_id}/resume`](/docs/api/jobs#resume-a-job). Pending items still finalize at their deadlines while it is paused. A push whose items are all already stored is answered with their duplicates even then, so a retry always learns what was stored.
5. **New items must fit under `max_pending_items`.** More new items than the cap could never fit, so each one past it is a `400 invalid_items`. Otherwise, if pending plus new items would pass the cap, the push is a `429 stream_backpressure` with a `Retry-After`.
6. **Every new item must be valid.** Otherwise `400 invalid_items`, listing every bad item (see the item rules below).
7. **Text review**, which is off unless enabled for your account ([contact us](mailto:sales@trydatapoint.com) to turn it on). Text that doesn't pass is a `422 content_blocked`, naming the items in `blocked_items`. Items are reviewed in groups, so `blocked_items` can include an item whose own text is fine; split the push to narrow it down. Media is reviewed, whether uploaded or fetched from a URL; an item whose media is blocked finalizes `blocked`.
8. **Credits.** `402 stream_budget_exceeded` past `budget_credits`, `409 stream_limit_reached` past the stream's lifetime limit on reserved credits, and `402` when your balance can't cover the push.

A push can wait behind other updates to your account's streams and jobs. One that waited too long is answered `503 stream_busy` with `Retry-After: 1`. It stored nothing: retry it.

### Item rules

- `context` reaches raters only through `{context}` in the stream's instruction; for `multiple_choice` it is the question itself. An item with `context` on a stream whose instruction has no `{context}` is refused, except on `multiple_choice`.
- `multiple_choice` and `text_answer` items take only uploaded media (`dp://` refs or `media_id`) in their media roles.
- A `pairwise_ranking` item needs a `max_responses_per_item` of at least its number of candidates (1 for two candidates).
- Option `on_select` actions are for chain jobs only.
- `external_id`, `context` and every string in `metadata` and `media`, keys included, must be valid text: no NUL character (`\u0000`) and no unpaired surrogate (such as `\ud800`). `metadata` and `media` can't hold `NaN` or `Infinity`. Any of these is a `422` whose field error names the item's position in `items`.
- When the push breaks a rule that no single item explains, it is refused as a whole: one error, on the first new item, reading "this push is invalid as a whole: …".

### Response (`200 OK`)

```json
{
  "stream_id": "job_5f2c9a7e1b3d",
  "items": [
    { "index": 0, "external_id": "dpo-run-1-0-0", "duplicate": false },
    { "index": 1, "external_id": "dpo-run-1-0-1", "duplicate": false }
  ],
  "pending_items": 2,
  "reserved_credits": 0
}
```

`dpo-run-1` is a sandbox stream, so its rate is 0 and the push holds nothing. On a `prod` stream with a rate of 5 credits per vote, the same push would hold 2 items × 3 votes × 5 = 30 credits.

<ParamTable
  params={[
    { name: "items", type: "array", description: "One entry per pushed item, in request order: the item's `index` in the stream, its `external_id`, and `duplicate`, true when the stream already stored it." },
    { name: "pending_items", type: "int", description: "Items pending on the stream after this push." },
    { name: "reserved_credits", type: "int", description: "Credits this push holds: `max_responses_per_item` × `credits.rate` for each new item. 0 for duplicates and for streams with a rate of 0, such as sandbox streams." }
  ]}
/>

Items whose media is ready, such as text or `dp://` uploads that passed review, can be served as soon as the push returns. Others, such as public http(s) URLs, are fetched and checked by background processing first, which adds latency.

<CodeTabs>
  <CodeTab lang="curl" label="curl">
```bash
curl -X POST https://api.trydatapoint.com/data-labelling/v1/streams/$STREAM_ID/items \
  -H "X-API-Key: $DATAPOINT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "external_id": "dpo-run-1-0-0",
        "context": "a warm sunset",
        "media": {"candidates": [{"url": "dp://b6a1cd3f1234/a.png"}, {"url": "dp://c8f3ae9b5678/b.png"}]},
        "metadata": {"step": 0}
      },
      {
        "external_id": "dpo-run-1-0-1",
        "context": "a city at night",
        "media": {"candidates": [{"url": "dp://d2e7bf0a9012/c.png"}, {"url": "dp://e4a9c1d03456/d.png"}]},
        "metadata": {"step": 0}
      }
    ]
  }'
```
  </CodeTab>
  <CodeTab lang="python" label="Python">
```python
r = http.post(f"{API}/streams/{stream['stream_id']}/items", json={"items": [
    {
        "external_id": "dpo-run-1-0-0",  # the same id if this push is sent again
        "context": "a warm sunset",
        "media": {"candidates": [{"url": "dp://b6a1cd3f1234/a.png"}, {"url": "dp://c8f3ae9b5678/b.png"}]},
        "metadata": {"step": 0},
    },
    {
        "external_id": "dpo-run-1-0-1",
        "context": "a city at night",
        "media": {"candidates": [{"url": "dp://d2e7bf0a9012/c.png"}, {"url": "dp://e4a9c1d03456/d.png"}]},
        "metadata": {"step": 0},
    },
]})
r.raise_for_status()
```
  </CodeTab>
</CodeTabs>

## Read results

<EndpointHeader method="GET" path="/streams/{stream_id}/results" />

Returns finalized items with a `seq` greater than `after`, in the order they finalized. Each finalized item gets the next `seq`, with no gaps, so paging with `after` sees each item exactly once.

### Query parameters

<ParamTable
  params={[
    { name: "after", type: "int", required: false, description: "Return items with `seq` greater than this. Default 0. Pass back `next_after`." },
    { name: "limit", type: "int", required: false, description: "At most this many items, 1 to 500; default 100. A stream with a high `max_responses_per_item` returns fewer per page; keep paging while `has_more` is true." },
    { name: "wait_seconds", type: "float", required: false, description: "Long-poll: wait up to this long, 0 to 20 seconds, for `min_items` items. Default 0, no wait." },
    { name: "min_items", type: "int", required: false, description: "Long-poll: return as soon as this many items are ready, or as soon as a page is full when a full page holds fewer; 1 to `limit`, default 1. Above `limit` is a `422`." },
    { name: "include_responses", type: "bool", required: false, description: "Add each item's counted votes under `responses`. Default false." }
  ]}
/>

### Response (`200 OK`)

```json
{
  "stream_id": "job_8d41e07c2a96",
  "results": [
    {
      "seq": 1,
      "index": 0,
      "external_id": "grpo-run-7-0-0",
      "metadata": null,
      "outcome": "completed",
      "pushed_at": "2026-10-20T14:03:11.204000Z",
      "expires_at": "2026-10-20T14:05:11.204000Z",
      "finalized_at": "2026-10-20T14:03:52.917000Z",
      "error": null,
      "ingested": true,
      "result": {
        "total_responses": 6,
        "comparisons": { "0": 3, "1": 3, "2": 3, "3": 3 },
        "wins": [[0, 1, 1, 1], [0, 0, 1, 1], [0, 0, 0, 1], [0, 0, 0, 0]],
        "win_rate": { "0": 1.0, "1": 0.667, "2": 0.333, "3": 0.0 },
        "scores": { "0": 1.2264, "1": 0.3783, "2": -0.3783, "3": -1.2264 },
        "ranking_order": ["0", "1", "2", "3"],
        "weighted_wins": [[0.0, 1.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 0.0]],
        "weighted_scores": { "0": 1.2264, "1": 0.3783, "2": -0.3783, "3": -1.2264 },
        "weighted_ranking_order": ["0", "1", "2", "3"]
      },
      "responses": null
    }
  ],
  "next_after": 1,
  "has_more": false
}
```

<ParamTable
  title="results[]"
  params={[
    { name: "seq", type: "int", description: "The item's place on the feed, from 1, with no gaps." },
    { name: "index", type: "int", description: "The item's index in the stream, as the push returned it." },
    { name: "external_id", type: "string | null", description: "Your id for the item." },
    { name: "metadata", type: "object | null", description: "The `metadata` you pushed." },
    { name: "outcome", type: '"completed" | "expired" | "failed" | "blocked"', description: "How the item finalized." },
    { name: "pushed_at", type: "datetime", description: "When the push stored the item." },
    { name: "expires_at", type: "datetime", description: "The item's deadline." },
    { name: "finalized_at", type: "datetime", description: "When the item finalized." },
    { name: "error", type: "string | null", description: "Why the item failed or was blocked. Such an item can still carry the votes it received first, in `result` and `responses`; it is never billed, so filter on `outcome` before training." },
    { name: "ingested", type: "bool", description: "False only when the item expired before it was ingested, so raters never saw it." },
    { name: "result", type: "object", description: "The aggregate for the stream's task type, with the same fields as a `GET /jobs/{job_id}/results` row: `votes`, `consensus` and `confidence` for comparison, `scores` and `wins` for pairwise ranking, and so on, plus their reliability-weighted forms." },
    { name: "responses", type: "array | null", description: "With `include_responses=true`, the item's counted votes (fields below); otherwise null." }
  ]}
/>

[Item lifecycle](/docs/streams#item-lifecycle) explains how an item reaches each `outcome`.

<ParamTable
  title="responses[]"
  params={[
    { name: "annotator_id", type: "string", description: "An anonymous id for the rater, the same across their votes." },
    { name: "response", type: "string | null", description: "The vote, as on `GET /jobs/{job_id}/responses`: `A` or `B` for comparison, `i>j` (candidate i beat j) for pairwise ranking." },
    { name: "free_text", type: "string | null", description: "What the rater wrote, when `response_options.free_text` is enabled; otherwise null." },
    { name: "response_time_ms", type: "int | null", description: "How long the rater took." },
    { name: "trust", type: "object | null", description: "The rater's reliability at the time of the vote: `estimate` and `lower_bound`, both null while the rater is not yet measured. `trust` itself is null only for a vote recorded without a reading." }
  ]}
/>

- With `wait_seconds`, the request returns once `min_items` items are ready, once a page is full when a full page holds fewer than `min_items`, or when the wait runs out. Each long-poll counts as one request against your rate limit; while items keep finalizing, a loop that asks again at once costs one request per batch, so raise `min_items` or space the requests out.
- Keep reading with `after` set to `next_after` while `has_more` is true. A page holds at most `limit` items, and fewer on a stream with a high `max_responses_per_item`.
- A `failed` or `blocked` item reports the votes it received before it failed or was blocked, but it isn't billed for them (`credits.spent` excludes them). Train only on `completed` and `expired` items.
- An item's `result` never changes once it has finalized.
- An item can finalize `completed` with fewer votes than `max_responses_per_item` when quality checks discarded some of its answers. `total_responses` is the number that counts.
- An item whose media was never stored, such as one that expired before it was ingested, has `total_responses` 0 and the task type's empty aggregate. For pairwise ranking that has no candidate positions: `comparisons`, `win_rate`, `scores` and `weighted_scores` are `{}`, and `wins` and `weighted_wins` are `[]`.
- This endpoint never returns `402`.

## Get a stream

<EndpointHeader method="GET" path="/streams/{stream_id}" />

Returns the [stream object](#the-stream-object), with recent latency figures.

## Change a stream's limits

<EndpointHeader method="PATCH" path="/streams/{stream_id}" />

Send only the fields you want to change, at least one. The new values apply to later pushes: items already pushed keep their deadlines, and a `max_pending_items` below what is already pending refuses new pushes until enough items finalize. Finalizing gives back unused holds but never lowers what the stream has spent, so a `budget_credits` below `credits.spent` stays exceeded until you raise it. A paused stream can be changed.

<ParamTable
  params={[
    { name: "budget_credits", type: "int | null", required: false, description: "New budget cap, 1 to 2,147,483,647; `null` removes it." },
    { name: "max_pending_items", type: "int", required: false, description: "New cap on pending items, 1 to 10,000. Can't be null." },
    { name: "item_ttl_seconds", type: "int", required: false, description: "New deadline for items pushed from now on, 30 to 3,600. Can't be null." }
  ]}
/>

Returns the stream object, with every `latency_seconds` field null; `GET /streams/{stream_id}` has the figures. A closed or closing stream answers `409 stream_closed`. A change that waited too long behind another update to the stream is answered `503 stream_busy` with `Retry-After: 1`; it changed nothing, so send it again.

## Expire items

<EndpointHeader method="POST" path="/streams/{stream_id}/items/expire" />

Finalizes named pending items now, each with the votes it has. Name them by `external_ids` or by `indices`, not both, 1 to 500 per request.

```json
{ "external_ids": ["dpo-run-1-0-0", "dpo-run-1-0-1"] }
```

Response: `{"expired": 2}`, the number of named items that were still pending; unknown and already finalized items are skipped.

- An item that never reached raters expires with no votes; one that already had all its votes finalizes `completed`.
- An item whose media failed or was blocked finalizes `failed` or `blocked`, not `expired`.
- Some named items can take a few seconds after the request returns to finalize; they reach the results feed as usual.
- These are a `422`: both fields or neither; no names or more than 500; an empty string in `external_ids`, or one holding a NUL character or an unpaired surrogate; an index that isn't an integer (`true`, `"3"` and `2.0` aren't) or is outside 0 to 2,147,483,647.
- A request that waited too long behind another update to the stream is answered `503 stream_busy` with `Retry-After: 1`. It expired nothing: send it again.
- Expiring is safe to retry, but `expired` counts only the items still pending when that request ran: a retry after a lost answer reports 0 for the items the first attempt expired.

## Close a stream

<EndpointHeader method="POST" path="/streams/{stream_id}/close" />

Stops the stream taking new items. The body is optional:

```json
{ "drain": true }
```

With `drain: true` (the default), pending items keep collecting votes until they complete or reach their deadlines; with `drain: false` they expire now. Closing again is safe, and `drain` can change from true to false, not back.

Returns the stream object, with `status` `closing` while items are pending and `closed` once none are. With `drain: false` the answer can still say `closing` for a few seconds before the stream reports `closed`. A close that waited too long behind another update to the stream is answered `503 stream_busy` with `Retry-After: 1`; it changed nothing, so send it again. A stream closes itself, without draining, 14 days after it was created. After closing, read the results feed until it is drained.

## The stream object

```json
{
  "stream_id": "job_8d41e07c2a96",
  "name": "grpo-run-7",
  "status": "open",
  "task_type": "pairwise_ranking",
  "instruction": "Which reply better answers the prompt? {context}",
  "max_responses_per_item": 6,
  "item_ttl_seconds": 120,
  "max_pending_items": 1000,
  "budget_credits": null,
  "serving_environment": "sandbox",
  "annotator_filter": null,
  "response_options": null,
  "is_paused": false,
  "items": { "pushed": 40, "pending": 12, "completed": 25, "expired": 3, "failed": 0, "blocked": 0 },
  "credits": { "rate": 0, "reserved": 0, "spent": 0 },
  "latency_seconds": { "ingest_p50": 0.412, "finalize_p50": 41.37, "finalize_p90": 88.912 },
  "last_seq": 28,
  "created_at": "2026-10-20T14:01:07.551000Z",
  "closed_at": null
}
```

<ParamTable
  params={[
    { name: "status", type: '"open" | "closing" | "closed"', description: "`closing` after a close while items are still pending; `closed` once none are." },
    { name: "is_paused", type: "bool", description: "True while the stream is paused, by you (`POST /jobs/{stream_id}/pause`, or Pause on its dashboard page) or by our team. It isn't served, pushes with new items get `409 stream_paused`, and retries of stored items still get their duplicates. Resume it with `POST /jobs/{stream_id}/resume`. Pending items still finalize at their deadlines while it is paused. `status` doesn't change." },
    { name: "items", type: "object", description: "Counts of items `pushed`, `pending`, `completed`, `expired`, `failed` and `blocked`." },
    { name: "credits.rate", type: "int", description: "Credits per counted vote, fixed at creation; 0 for streams that aren't billed, such as sandbox streams." },
    { name: "credits.reserved", type: "int", description: "Credits held for pending items." },
    { name: "credits.spent", type: "int", description: "Billed votes × `rate` so far: the counted votes of finalized items, leaving out `failed` and `blocked` items, which are never billed. If your account stops being billed after the stream was created, `spent` still shows this figure, and settlement doesn't charge it." },
    { name: "latency_seconds", type: "object", description: "Over recent finalized items: `ingest_p50`, the median time from push until the item was ingested, including items that then failed or were blocked; `finalize_p50` and `finalize_p90`, push to final result for completed items. Null until known, and on create, PATCH and close responses." },
    { name: "last_seq", type: "int", description: "The `seq` of the newest finalized item; 0 before any." },
    { name: "closed_at", type: "datetime | null", description: "When the stream was closed." }
  ]}
/>

The other fields echo the stream's settings.

## Errors

Error bodies use the site-wide [error envelope](/docs/errors#error-envelope). Its `detail` is an object with a `code` for most stream errors (the balance `402` is an object without one), a string for the rest, and an array of field errors for a schema `422`.

| Status | `code` | Endpoints | `Retry-After` | Meaning |
|---|---|---|---|---|
| `400` | `invalid_items` | push | — | Items failed validation. `errors` lists `{"item", "error"}`, where `item` is the position in the request. |
| `400` | — | create | — | A setting is invalid, such as an unknown `task_type` or bad `response_options`. |
| `401` | — | all | — | Missing or invalid API key. Checked before any stream lookup, so it comes before any `404`; only a body that isn't valid JSON is refused first, with a `422`. |
| `402` | `stream_budget_exceeded` | push | — | The push would pass `budget_credits`. Carries `needed_credits` and `remaining_budget_credits`. |
| `402` | — | push | — | Your balance can't cover the push. Carries `needed_credits` and `available_credits`. |
| `404` | — | all but create | — | No stream with this id belongs to you. |
| `409` | `name_conflict` | create | — | One of your jobs already has this name. |
| `409` | `config_mismatch` | create | — | A stream with this name exists with other settings; `fields` names them. |
| `409` | `external_id_conflict` | push | — | `items` lists positions whose `external_id` is stored with different content. |
| `409` | `stream_closed` | push, PATCH | — | The stream is closed or closing. |
| `409` | `stream_paused` | push | — | The stream is paused, by you or by our team, and the push has new items. Resume it with [`POST /jobs/{stream_id}/resume`](/docs/api/jobs#resume-a-job). |
| `409` | `stream_limit_reached` | push | — | The stream reached its lifetime limit on reserved credits. Close it and push to a new stream. |
| `422` | — | all | — | The body isn't valid JSON, or the request doesn't match the schema: an unknown field, a value out of range, invalid text (a NUL character or an unpaired surrogate), or `NaN` or `Infinity` in `media`, `metadata` or `response_options`. `detail` is the array of field errors. |
| `422` | — | results | — | `min_items` is above `limit`. `detail` is the string `"min_items must not exceed limit."` (above 500 it is a schema `422`). |
| `422` | `content_blocked` | create, push | — | Text didn't pass review (only when text review is enabled for your account). On a push, `blocked_items` lists positions. |
| `429` | `stream_backpressure` | push | 1–60 s, until the earliest pending deadline | The push would pass `max_pending_items`. Carries `pending_items` and `max_pending_items`. |
| `429` | — | all | until the minute window resets | Your key's rate limit. |
| `503` | `stream_busy` | push, PATCH, close, expire | 1 s | The request waited too long behind another update. Nothing was stored or changed; retry. |
| `503` | — | create, push | 30 s | Content review is temporarily unavailable (only when text review is enabled). Nothing was stored; retry. |

## Retrying safely

- Give every item an `external_id`, and make it deterministic, such as `{run}-{step}-{i}`, so a restarted process pushes the same ids.
- A refused push stores nothing. Fix it and send it again; after a `429` or `503`, wait `Retry-After` and send the same request.
- After a timeout or a `5xx`, send the same items with the same `external_id`s. Stored items come back with `duplicate: true` and aren't charged again, even if the stream was paused or closed in between.
- Items without an `external_id` can't be matched this way: a resent copy is a new item, charged again.
- New content under a stored `external_id` is refused (`409 external_id_conflict`). Each upload of a file gets a new `dp://` ref, so upload once and reuse the ref when you resend.

## Media

Upload files with [`POST /media`](/docs/api/media), several to a request, and push their `dp://` refs. Public http(s) URLs are fetched and checked by background processing, which adds latency before raters see the item. `multiple_choice` and `text_answer` items take only uploaded media.
