Skip to main content
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

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

Routes

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.

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:
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:
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, Schedules, Wait tokens, Workers, Limits, Usage.

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. 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: 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: