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

# REST reference

> Every /workflows/v1 route, its keys, lists, idempotency, the live stream and every error code.

Durable workflows for a project: send events, start and inspect runs, follow
them live, schedule them and wait on outside input. Your code runs in your own
worker, which connects to the project and runs each step.

Base URL: `https://<project slug>.<project domain>/workflows/v1`.

## Keys

| Key | What it may do |
| - | - |
| Secret (`dtn_sk_`) | Everything on this page, and connect a worker |
| Worker (`dtn_wk_`) | Connect a worker only |
| Publishable (`dtn_pk_`) | Nothing here: `403 api_key_kind_not_allowed` |

Send the key in the `apikey` header. Never put a key in a URL.

## Routes

| Route | Answer |
| - | - |
| `POST /events` | `202` with one result for an object body, `202 { data: [...] }` for an array |
| `GET /events` | `{ data, nextCursor }` of the events the project received, newest first; filters `name`, `app`, `source` (`api`, `emit` or `webhook`), `since` (included), `until` (excluded) |
| `GET /events/{id}` | One received event; `404 not_found` for an id the project never received |
| `GET /workflows` | `{ data, nextCursor }` of the workflows your workers registered |
| `GET /workflows/{app}/{name}` | One workflow |
| `POST /workflows/{app}/{name}/runs` | `201` with the new run; body `{ input?, startAfter?, ttlSeconds?, tags?, dedupeId? }`; `429 quota_exceeded` while the project holds `workflows.max_queued_runs` queued runs, with no `Retry-After`: retry once some have started |
| `GET /runs` | `{ data, nextCursor }`, newest first; filters `app`, `workflow`, `status`, `runType`, `eventId`, `since`, `until`, `buildId`, `tag.<key>=<value>` (repeatable) |
| `GET /runs/{id}` | One run |
| `GET /runs/{id}/steps` | `{ data, nextCursor }` of its steps, each with its `kind` |
| `GET /runs/{id}/logs` | `{ data, nextCursor }` of the lines its worker logged, newest first |
| `GET /runs/{id}/stream` | The run's live timeline, Server-Sent Events |
| `GET /runs/stream` | Every run's status changes, Server-Sent Events (below) |
| `POST /runs/{id}/cancel` | The cancelled run; body `{ reason? }` |
| `POST /runs/{id}/pause` | The paused run, with `pausedBy`; no body |
| `POST /runs/{id}/resume` | The run, `queued` again; no body |
| `POST /runs/{id}/replay` | `201` with the new run; body `{ fromStep?, input? }` |
| `POST /runs/bulk_cancel` | `{ matched, cancelled, skipped, failed, capped }`, at most 500 runs a call; body `app`, `workflow`, `status`, `runType`, `since`, `tags` |
| `POST /runs/bulk_replay` | `{ matched, replayed, skipped, failed, capped }`; body `app`, `workflow`, `status`, `runType`, `since` and `fromStep?`, never `input` (`422`); a run without that step counts in `failed` |
| `GET, POST /schedules`; `GET, PATCH, DELETE /schedules/{id}`; `POST /schedules/{id}/pause`, `/resume` | Schedules; a create while the project holds `workflows.max_schedules` is `429 quota_exceeded`, with no `Retry-After` |
| `POST /cron_previews` | `{ data: [...] }`, the next fire times for `{ cron, timezone?, count? }` (at most 10) |
| `POST /tokens`; `GET /tokens`, `/tokens/{id}`; `POST /tokens/{id}/complete` | Wait tokens |
| `GET /runners` | The workers connected now, a list |
| `GET /limits` | `{ limits, counts }`, the project's workflow limits and what they measure now |
| `GET /usage` | `{ from, to, totals }`, the project's workflows and webhooks usage over `from` (included) to `to` (excluded), both required |
| `GET, POST /webhook_endpoints`; `GET, PATCH, DELETE /webhook_endpoints/{id}` | Webhook endpoints (see Webhooks); an endpoint slowed after its receiver answered `429`, `502` or `504` carries `throttleDelayMs` (above 0) and `throttledUntil` |
| `GET /connect` | A worker's WebSocket, subprotocol `duraton.connect.v0` |

Lists take `limit` (1 to 100, default 20) and an opaque `cursor`; only
`nextCursor: null` ends a list. There is no total.

Every change takes an optional `Idempotency-Key` header: the same key and body
answers the first result again; another body under the same key is
`422 idempotency_key_reused`.

A body field this page does not name is ignored, not refused: it has no
effect, so never rely on one. The exceptions are on the bulk calls, so one
never reaches runs you did not name: `input` on `POST /runs/bulk_replay` is
`422`, and a `GET /runs` filter a bulk call cannot select by (`eventId`,
`until` and `buildId` on both, `tags` on a replay) is
`422 workflow_bulk_filter_unsupported`, its `fields` naming them.

## Pause and resume a run

`POST /runs/{id}/pause` holds a `queued`, `running` or `waiting` run where it
is: it keeps every finished step, and nothing more of it runs until it is
resumed. A step already running finishes first. The run answers
`status: "paused"` and `pausedBy: { reason: "manual", since }`. Pausing a
paused run answers it again with a new `since`.

`POST /runs/{id}/resume` queues it again and clears `pausedBy`; it carries on
from the step it stopped at. A run waiting on an event stays `queued` until the
event arrives.

| Refusal | When |
| - | - |
| `409 workflow_run_finished` | Pausing a run that has finished |
| `409 conflict` | Pausing a run that is waiting for a decision (`needs_attention`) |
| `409 workflow_run_not_paused` | Resuming a run that is not paused |
| `409 workflows_suspended` | Resuming while the project's workflows are off; pausing still works |
| `404 workflow_run_not_found` | A run the project does not have |

## Run logs

`GET /runs/{id}/logs` lists what the run's worker logged with the handler's `log`, newest
first. Each line is `{ seq, ts, level, message, fields?, scope, attempt }`:
`scope` is the step that logged it, or `@root` outside a step, and `attempt` is
that step's attempt.

## Steps

Each step carries `kind`, what it does: `run`, `sleep`, `wait`, `invoke`,
`emit`, `webhook` or `approval`. A step recorded before kinds existed has
none.

## Following runs live

`GET /runs/stream?kinds=run_status,step_status&since=<time>` answers
`text/event-stream`. Each change is one event:

```
event: transition
id: 2026-10-30T10:00:00.123456789Z
data: {"kind":"run_status","seq":1,"ts":"2026-10-30T10:00:00.123456789Z","runId":"run_...","status":"running"}
```

Resume with `since` (or the `Last-Event-ID` header) set to the last `id` you
read; the boundary event may repeat, so skip any `(runId, seq)` you have
seen. A `since` older than the project's look-back is `422
transition_cursor_expired`: list the runs again and follow from now. A comment
line `: keepalive` arrives every `workflows.stream_keepalive_seconds` (15 by
default, at most 300; 0 sends none).

This stream and `GET /runs/{id}/stream` each stay open for the project's
`workflows.stream_max_seconds` (an hour by default), then end with one last
event and close:

```
event: end
data: {}
```

Open a new stream from where you were: `since` set to the last `id` you
read here, `from` on a run's timeline. The SDK's
`runs.watchTransitions` returns at `end`; call it again to follow on.

Events, schedules, wait tokens, workers, limits and usage each have their own
page: [Events](https://docs.duraton.dev/workflows/events.md), [Schedules](https://docs.duraton.dev/workflows/schedules.md),
[Wait tokens](https://docs.duraton.dev/workflows/wait-tokens.md), [Workers](https://docs.duraton.dev/workflows/workers.md),
[Limits](https://docs.duraton.dev/workflows/limits.md), [Usage](https://docs.duraton.dev/workflows/usage.md).

## On the console's address

The console and your own tools reach the same routes on the console's address,
signed in or with an OAuth access token, each naming the project with
`?projectId=<id>`. A project's admins and developers read with `duraton:read`
and change with `duraton:write`; a viewer is refused with `403 forbidden`.

| Route | Same as |
| - | - |
| `GET /workflows`, `GET /workflows/{app}/{name}` | `GET /workflows`, `GET /workflows/{app}/{name}` |
| `GET, POST /workflow_runs`; `GET /workflow_runs/{id}`, `/steps`, `/logs`, `/stream`; `GET /workflow_runs/stream` | The runs routes; `POST /workflow_runs` names `app` and `name` in its body |
| `POST /workflow_runs/{id}/cancel`, `/pause`, `/resume`, `/replay`; `POST /workflow_runs/bulk_cancel`, `/bulk_replay` | The run changes |
| `GET, POST /workflow_events`; `GET /workflow_events/{id}` | `GET, POST /events`, `GET /events/{id}` |
| `GET, POST /workflow_schedules`; `GET, PATCH, DELETE /workflow_schedules/{id}`; `POST /workflow_schedules/{id}/pause`, `/resume` | The schedules routes |
| `POST /workflow_cron_previews` | `POST /cron_previews`; it only reads, so `duraton:read` is enough |
| `GET, POST /workflow_tokens`; `GET /workflow_tokens/{id}`; `POST /workflow_tokens/{id}/complete` | The wait token routes |
| `GET /workflow_runners` | `GET /runners` |
| `GET /workflow_limits` | `GET /limits` |
| `GET /workflow_usage` | `GET /usage` |

`GET /workflow_runs/{id}/stream?projectId=<id>&from=<seq>` is the run's
timeline: every status change of the run and its steps since `from`, then each
new one as it happens, as Server-Sent Events. It ends once the run has
finished, or after `workflows.stream_max_seconds`.

## SDK reads

`createClient({ url, apiKey })` from `@duraton/sdk/client` reads these routes:

| Call | Route | Answer |
| - | - | - |
| `client.workflows.list({ limit?, cursor? })` | `GET /workflows` | `{ data, nextCursor }` |
| `client.workflows.get(app, name)` | `GET /workflows/{app}/{name}` | One workflow |
| `client.runs.pause(id)`, `client.runs.resume(id)` | `POST /runs/{id}/pause`, `/resume` | The run |
| `client.runs.logs(id, { limit?, cursor? })` | `GET /runs/{id}/logs` | `{ data, nextCursor }` |
| `client.runs.timeline(id, { from? })` | `GET /runs/{id}/stream` | Each change of the run, until it finishes |
| `client.events.list({ name?, app?, source?, since?, until?, limit?, cursor? })` | `GET /events` | `{ data, nextCursor }` |
| `client.events.get(id)` | `GET /events/{id}` | One received event |
| `client.runners.list()` | `GET /runners` | The workers connected now |
| `client.limits.get()` | `GET /limits` | `{ limits, counts }`: the limits, by name, and what they measure now |
| `client.usage.get({ from, to })` | `GET /usage` | `{ from, to, totals }`: each meter's total over the window |

A workflow carries `app`, `name`, `inputSchema` (a JSON Schema, or `null`),
`triggers` (each an `event` with its `if`, or a `cron` with `runOnStart`) and
`liveBuilds`, the builds with a worker connected now. An answer of another
shape is refused with a `TypeError`, never passed on.

## Errors

Every refusal is an RFC 9457 problem with a stable `code`:

| Code | Status | Detail |
| - | - | - |
| `workflow_not_found` | 404 | The workflow was not found. |
| `workflow_run_not_found` | 404 | The run was not found. |
| `workflow_run_finished` | 409 | The run has already finished. |
| `workflow_run_not_paused` | 409 | The run is not paused. |
| `workflow_step_not_found` | 404 | The step was not found in this run. |
| `workflow_input_invalid` | 422 | The input does not match the workflow's input schema. |
| `workflows_suspended` | 409 | Workflows are turned off for this project. |
| `event_name_reserved` | 422 | Event names starting with this prefix are reserved. |
| `event_payload_too_large` | 413 | The event data is too large. |
| `schedule_not_found` | 404 | The schedule was not found. |
| `schedule_external_id_taken` | 409 | A schedule with this external id already exists. |
| `cron_expression_invalid` | 422 | The cron expression is not valid. |
| `timezone_invalid` | 422 | The time zone is not a valid IANA name. |
| `token_not_found` | 404 | The token was not found. |
| `token_not_waiting` | 409 | The token has already been completed, timed out or cancelled. |
| `transition_cursor_expired` | 422 | The cursor is older than the stream keeps. List runs and tail from now. |
| `workflow_bulk_filter_unsupported` | 422 | A bulk cancel or replay cannot select runs by this filter. Filter by app, workflow, status, run type or since, and by tags on a cancel. |
| `webhook_url_invalid` | 422 | The endpoint URL is not allowed. Use an https URL on a public host name, with no user, password or fragment. |
| `webhook_secret_invalid` | 422 | The signing secret is not valid. Send whsec\_ followed by the base64 of 24 to 64 random bytes. |
| `not_found` | 404 | The requested resource was not found. |
| `conflict` | 409 | The request conflicts with the current state. |
| `unauthenticated` | 401 | Authentication is required. |
| `api_key_kind_not_allowed` | 403 | This kind of API key cannot be used here. |
| `service_not_enabled` | 404 | This service is not enabled for the project. |
| `method_not_allowed` | 405 | This method is not allowed on this resource. |
| `quota_exceeded` | 429 | The project has used its quota for this period. It resets at the time in Retry-After. |
| `cursor_invalid` | 400 | The cursor is not valid for this list. Start again from the first page. |
| `unprocessable` | 422 | Validation failed. |
| `idempotency_key_reused` | 422 | This Idempotency-Key was already used with a different request. Send a new key for a new request. |
| `not_implemented` | 501 | This feature is not implemented yet. |
| `unavailable` | 503 | The service is temporarily unavailable. |
| `internal` | 500 | Something went wrong. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.