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.
Using online RL for your business? Contact us 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. 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 instead), and /retry answers 400.
Create a stream
Creates a stream, or returns your existing stream with this name when its settings match, so a retried create is safe.
Request body
| Field | Type | Description |
|---|---|---|
name* | string | Unique across your jobs and streams. |
instruction* | string | The question raters see. Put `{context}` where an item's `context` should appear. |
task_type | string | `comparison` (default), `i2v_comparison`, `rating`, `ranking`, `pairwise_ranking`, `multiple_choice` or `text_answer`. |
response_options | object | Task-type options, as on `POST /jobs`, such as a rating scale or `free_text`. |
max_responses_per_item | int | 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. |
annotator_filter | object | Who may answer, as on `POST /jobs`. |
serving_environment | "all" | "sandbox" | "prod" | `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. |
item_ttl_seconds | int | Seconds an item may wait for its votes before it finalizes as `expired`, 30 to 3,600; default 600. |
max_pending_items | int | Items that may be pending at once, 1 to 10,000; default 1,000. |
budget_credits | int | Cap on the credits the stream may hold plus spend, 1 to 2,147,483,647. Omit for no cap. |
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 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.
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
}'Push items
Adds 1 to 500 items to an open stream. Each one is served to raters and finalizes by its deadline.
Request body
| Field | Type | Description |
|---|---|---|
items* | array of item objects (1–500) | The items to add, in order. See the fields below. |
ttl_seconds | int | Deadline for the items in this push, 30 to 3,600 seconds. Defaults to the stream's `item_ttl_seconds`. |
items[]
| Field | Type | Description |
|---|---|---|
media* | object | The item's media, in the shape of a `POST /jobs` datapoint of the stream's task type. |
external_id | string | Your id for the item, unique within the stream, at most 128 characters. Strongly recommended: it makes a retried push safe. |
context | string | Shown to raters where the instruction has `{context}`. |
metadata | object | Any JSON object of yours, at most 4,096 bytes, returned with the item's result. |
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:
- 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, withRetry-After) and match the request schema (422: no items or more than 500, an out-of-rangettl_seconds, a blank or over-longexternal_id, oversizedmetadata, invalid text or aNaN(see the item rules), an unknown field). - The stream must be yours. Otherwise
404. - No
external_idmay repeat within the push. Otherwise400 invalid_items, naming each repeat. - A stored
external_idmust keep its content. Pushing a knownexternal_idwith the samecontextandmediareturns the stored item withduplicate: trueand charges nothing. Different content is409 external_id_conflict, whatever state the stream is in. - New items need an open, unpaused stream. A push with any new item is refused with
409 stream_closedon a closed or closing stream and409 stream_pausedon 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 withPOST /jobs/{stream_id}/resume. 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. - New items must fit under
max_pending_items. More new items than the cap could never fit, so each one past it is a400 invalid_items. Otherwise, if pending plus new items would pass the cap, the push is a429 stream_backpressurewith aRetry-After. - Every new item must be valid. Otherwise
400 invalid_items, listing every bad item (see the item rules below). - Text review, which is off unless enabled for your account (contact us to turn it on). Text that doesn't pass is a
422 content_blocked, naming the items inblocked_items. Items are reviewed in groups, soblocked_itemscan 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 finalizesblocked. - Credits.
402 stream_budget_exceededpastbudget_credits,409 stream_limit_reachedpast the stream's lifetime limit on reserved credits, and402when 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
contextreaches raters only through{context}in the stream's instruction; formultiple_choiceit is the question itself. An item withcontexton a stream whose instruction has no{context}is refused, except onmultiple_choice.multiple_choiceandtext_answeritems take only uploaded media (dp://refs ormedia_id) in their media roles.- A
pairwise_rankingitem needs amax_responses_per_itemof at least its number of candidates (1 for two candidates). - Option
on_selectactions are for chain jobs only. external_id,contextand every string inmetadataandmedia, keys included, must be valid text: no NUL character (\u0000) and no unpaired surrogate (such as\ud800).metadataandmediacan't holdNaNorInfinity. Any of these is a422whose field error names the item's position initems.- 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)
{
"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.
| Field | Type | Description |
|---|---|---|
items | array | 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. |
pending_items | int | Items pending on the stream after this push. |
reserved_credits | int | 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.
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}
}
]
}'Read 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
| Field | Type | Description |
|---|---|---|
after | int | Return items with `seq` greater than this. Default 0. Pass back `next_after`. |
limit | int | 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. |
wait_seconds | float | Long-poll: wait up to this long, 0 to 20 seconds, for `min_items` items. Default 0, no wait. |
min_items | int | 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`. |
include_responses | bool | Add each item's counted votes under `responses`. Default false. |
Response (200 OK)
{
"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
}results[]
| Field | Type | Description |
|---|---|---|
seq | int | The item's place on the feed, from 1, with no gaps. |
index | int | The item's index in the stream, as the push returned it. |
external_id | string | null | Your id for the item. |
metadata | object | null | The `metadata` you pushed. |
outcome | "completed" | "expired" | "failed" | "blocked" | How the item finalized. |
pushed_at | datetime | When the push stored the item. |
expires_at | datetime | The item's deadline. |
finalized_at | datetime | When the item finalized. |
error | string | null | 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. |
ingested | bool | False only when the item expired before it was ingested, so raters never saw it. |
result | object | 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. |
responses | array | null | With `include_responses=true`, the item's counted votes (fields below); otherwise null. |
Item lifecycle explains how an item reaches each outcome.
responses[]
| Field | Type | Description |
|---|---|---|
annotator_id | string | An anonymous id for the rater, the same across their votes. |
response | string | null | The vote, as on `GET /jobs/{job_id}/responses`: `A` or `B` for comparison, `i>j` (candidate i beat j) for pairwise ranking. |
free_text | string | null | What the rater wrote, when `response_options.free_text` is enabled; otherwise null. |
response_time_ms | int | null | How long the rater took. |
trust | object | null | 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 oncemin_itemsitems are ready, once a page is full when a full page holds fewer thanmin_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 raisemin_itemsor space the requests out. - Keep reading with
afterset tonext_afterwhilehas_moreis true. A page holds at mostlimititems, and fewer on a stream with a highmax_responses_per_item. - A
failedorblockeditem reports the votes it received before it failed or was blocked, but it isn't billed for them (credits.spentexcludes them). Train only oncompletedandexpireditems. - An item's
resultnever changes once it has finalized. - An item can finalize
completedwith fewer votes thanmax_responses_per_itemwhen quality checks discarded some of its answers.total_responsesis the number that counts. - An item whose media was never stored, such as one that expired before it was ingested, has
total_responses0 and the task type's empty aggregate. For pairwise ranking that has no candidate positions:comparisons,win_rate,scoresandweighted_scoresare{}, andwinsandweighted_winsare[]. - This endpoint never returns
402.
Get a stream
Returns the stream object, with recent latency figures.
Change a stream's limits
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.
| Field | Type | Description |
|---|---|---|
budget_credits | int | null | New budget cap, 1 to 2,147,483,647; `null` removes it. |
max_pending_items | int | New cap on pending items, 1 to 10,000. Can't be null. |
item_ttl_seconds | int | 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
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.
{ "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
failedorblocked, notexpired. - 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 inexternal_ids, or one holding a NUL character or an unpaired surrogate; an index that isn't an integer (true,"3"and2.0aren'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_busywithRetry-After: 1. It expired nothing: send it again. - Expiring is safe to retry, but
expiredcounts 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
Stops the stream taking new items. The body is optional:
{ "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
{
"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
}| Field | Type | Description |
|---|---|---|
status | "open" | "closing" | "closed" | `closing` after a close while items are still pending; `closed` once none are. |
is_paused | bool | 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. |
items | object | Counts of items `pushed`, `pending`, `completed`, `expired`, `failed` and `blocked`. |
credits.rate | int | Credits per counted vote, fixed at creation; 0 for streams that aren't billed, such as sandbox streams. |
credits.reserved | int | Credits held for pending items. |
credits.spent | int | 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. |
latency_seconds | object | 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. |
last_seq | int | The `seq` of the newest finalized item; 0 before any. |
closed_at | datetime | null | When the stream was closed. |
The other fields echo the stream's settings.
Errors
Error bodies use the site-wide 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. |
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
429or503, waitRetry-Afterand send the same request. - After a timeout or a
5xx, send the same items with the sameexternal_ids. Stored items come back withduplicate: trueand aren't charged again, even if the stream was paused or closed in between. - Items without an
external_idcan't be matched this way: a resent copy is a new item, charged again. - New content under a stored
external_idis refused (409 external_id_conflict). Each upload of a file gets a newdp://ref, so upload once and reuse the ref when you resend.
Media
Upload files with POST /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.