Pagination and idempotency
Cursor pagination, filtering, sorting, expansion, and how idempotency keys behave on retries.
Pagination
Every list endpoint is cursor-paginated and returns the same envelope.
curl -sS "$WB_BASE_URL/sessions?limit=50&state=running" \ -H "Authorization: Bearer $WB_API_KEY"
{
"object": "list",
"data": [
{ "object": "session", "id": "sess_2kQ9xwR4", "state": "running", "tier": 2 },
{ "object": "session", "id": "sess_8Pm1Zt6L", "state": "running", "tier": 1 }
],
"has_more": true,
"next_cursor": "eyJ0IjoxNzU5NzQxMjYyLCJpZCI6InNlc3NfOFBtMVp0NkwifQ"
}
curl -sS "$WB_BASE_URL/sessions?limit=50&state=running&cursor=eyJ0IjoxNzU5NzQxMjYy…" \ -H "Authorization: Bearer $WB_API_KEY"
| Parameter | Default | Max | Notes |
|---|---|---|---|
limit | 20 | 100 | |
cursor | — | — | Opaque; from next_cursor |
order | desc | — | asc or desc by creation time |
Rules:
- Cursors are opaque and signed. Never construct, decode or modify one.
- Changing any filter invalidates the cursor. Start the iteration over.
- Cursors expire after one hour. An expired cursor returns
invalid_field_value. - Iteration is stable for existing rows. New rows created during iteration appear only if the order places them in a page you have not fetched yet. This is why cursors exist instead of offsets, which skip and duplicate under concurrent writes.
async function* allSessions(filter: SessionFilter) {
let cursor: string | undefined
do {
const page = await wb.sessions.list({ ...filter, limit: 100, cursor })
yield* page.data
cursor = page.has_more ? page.next_cursor : undefined
} while (cursor)
}
Filtering
Filters are query parameters. Repeating one means OR; different parameters mean AND.
GET /v1/sessions
?state=running&state=provisioning state IN (running, provisioning)
&tier=2 AND tier = 2
&end_user_id=eu_customer_4821 AND end_user_id = …
&created_after=2026-10-01T00:00:00Z AND created_at >= …
&label.team=platform AND labels->>'team' = 'platform'
| Filter | Available on |
|---|---|
state, tier, region, project_id, end_user_id | Sessions |
created_after, created_before | Everything |
label.<key> | Sessions, volumes, snapshots |
status, session_id, kind | Runs |
meter, period, group_by | Usage |
Unknown filter parameters are an error, not ignored. A typo'd filter silently returning everything is how someone deletes the wrong sessions.
Expansion
GET /v1/sessions/sess_2kQ9xwR4?expand=volume,policy,end_user
{
"id": "sess_2kQ9xwR4",
"volume": { "object": "volume", "id": "vol_5Km3Xp", "size_gib": 50, "state": "attached" },
"policy": { "object": "policy", "id": "pol_build_tools", "version": 7 },
"end_user": { "object": "end_user", "id": "eu_customer_4821", "external_id": "cust-4821" }
}
One level deep, at most four fields per request. Expansion on a list applies to every item, so
GET /v1/sessions?limit=100&expand=volume is a hundred joins — use it deliberately.
Idempotency
Every unsafe method accepts Idempotency-Key.
curl -sS "$WB_BASE_URL/sessions" \
-H "Authorization: Bearer $WB_API_KEY" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{ "tier": 2, "template": "tpl_dev_polyglot" }'
Behaviour
first request with this key run the handler, store (status, body, key, request fingerprint), return retry, same key, same body return the stored response verbatim, with Idempotency-Replayed: true no second session, no second charge retry, same key, different body 409 idempotency_key_reused retry while the original is still running 409 idempotency_request_in_flight — retry shortly
| Property | Value |
|---|---|
| Scope | (api_key, route, key) |
| Retention | 24 hours |
| Maximum length | 255 characters |
| Recommended form | UUID v4 |
| Body comparison | SHA-256 of the canonicalised body |
Keys are scoped per API key, so two services using the same UUID do not collide.
Where it matters
| Operation | Without a key, a retry causes |
|---|---|
POST /sessions | A second session, billed |
POST /runs | A second execution, possibly with side effects |
POST /volumes | A duplicate volume, billed monthly until noticed |
POST /snapshots | A duplicate snapshot, billed monthly |
POST /tools/invoke | A duplicate side effect — a second email, a second charge |
Agent loops retry constantly, because models time out and networks fail. Sending an idempotency key is the difference between a retry and a duplicate bill.
// one key per logical operation, derived from something stable
const key = `${conversationId}:${turnIndex}:session-create`
await wb.sessions.create({ tier: 2 }, { idempotencyKey: key })
Deriving the key from stable application state is better than a fresh UUID per attempt — a fresh UUID on each retry defeats the whole mechanism.
What is not idempotent
DELETE is naturally idempotent and needs no key: deleting a stopped session returns 200 with the
existing object.
Streaming endpoints do not accept idempotency keys. A replayed stream is not meaningful; if a stream
drops, reconnect with Last-Event-Id instead.
Sorting
Lists sort by creation time, descending, by default. order=asc reverses it. There is no
sort-by-arbitrary-field, because a stable cursor requires a total order on an indexed column and
arbitrary sorting would make cursors either unstable or very expensive.
To sort by something else, page through with a filter narrow enough to hold in memory and sort client-side, or use the usage API's aggregation, which does support grouping and ordering by quantity.