Durable Workflows

A sequence of steps that survives a crash, a deploy, and a step that fails on its third attempt at four in the morning. You declare the steps as JSON; we persist the run and advance it one step at a time, so it picks up exactly where it left off.

PUT     /v1/projects/{ref}/workflows/{name}
POST    /v1/projects/{ref}/workflows/{name}/runs
GET     /v1/projects/{ref}/workflows/{name}/runs/{id}
POST    /v1/projects/{ref}/workflows/{name}/runs/{id}/signals
POST    /v1/projects/{ref}/workflows/{name}/runs/{id}/cancel
Gated per project. Workflows are off by default on every project. Ask us to enable it and we flip the per-project switch; until then every route on this page answers 403 (forbidden, “workflows are not enabled for this project”) even with a valid owner session, and the Workflows section of the dashboard stays empty.

What it is, and when to reach for it

A definition is a versioned, ordered list of steps. A run is one execution of a definition. Both a run and each of its steps are rows in our control plane, and the engine advances a run by exactly one bounded step per tick, writing the step's outcome and the run's new position in the same transaction. That single property is what the word durable buys you:

Reach for a workflow when you have a sequence with real state between the parts: charge → provision → email, an onboarding drip with days of waiting in it, a nightly ETL whose third stage sometimes times out, a saga that must be unwound if a later step fails. For a single scheduled side effect, a cron job is simpler and cheaper.

Authentication

Everything here is the project management API, not a data-plane API: it answers to an owner or admin session token for the project's organization, the same credential the dashboard and the CLI use. Project API keys (kbp_…, kbs_…) are not accepted.

Authorization: Bearer <your-session-token>

Every query is scoped by project, so a run id from another project, or from another workflow of the same project, is a 404, never a cross-tenant read or write.

Defining a workflow

A definition is published with PUT. The workflow's name is in the path and must match ^[a-z0-9][a-z0-9_-]{0,63}$. The body is { "enabled": true, "steps": [ … ] }; enabled defaults to true when omitted.

curl -X PUT https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "steps": [
      {
        "key": "charge",
        "kind": "webhook",
        "target": "https://payments.example.com/charge",
        "payload": {"amount": 4900, "currency": "eur"},
        "secret": "a-signing-secret",
        "max_attempts": 5,
        "timeout_seconds": 10,
        "compensate": {
          "kind": "webhook",
          "target": "https://payments.example.com/refund",
          "payload": {"amount": 4900, "currency": "eur"}
        }
      },
      {
        "key": "provision",
        "kind": "sql",
        "target": "update accounts set active = true where id = 42"
      },
      { "key": "cool_off", "kind": "sleep", "sleep_for": "30m" },
      {
        "key": "notify",
        "kind": "enqueue",
        "target": "emails",
        "payload": {"template": "welcome"}
      }
    ]
  }'

{"name":"order-pipeline","version":1,"enabled":true}

Versions are immutable

There is no edit. A PUT always publishes version max + 1, so a run that is already in flight keeps executing the exact version it started on and cannot be changed underneath itself. A new run resolves the highest enabled version at the moment it starts.

GET  /v1/projects/{ref}/workflows            # every workflow, newest version, step count
GET  /v1/projects/{ref}/workflows/{name}     # the newest version's full step list
PUT  /v1/projects/{ref}/workflows/{name}     # publish the next version  -> 201
PATCH /v1/projects/{ref}/workflows/{name}    # { "enabled": false }      -> 204

Step kinds

There are nine. Four do something (and are the only kinds allowed as a fan-out child or as a compensating action); the rest are control flow or waiting.

KindtargetWhat it doesStep output
enqueuea queue nameSends payload onto one of the project's pgmq queues.{"msg_id":42}
sqlone SQL statementRuns the statement in the project database as the constrained, non-superuser project role.{"rows_affected":1}
webhookan http(s) URLHMAC-signed POST of payload, through the same SSRF-guarded client cron webhooks use.{"delivered":true}
functiona function nameInvokes a deployed Edge Function with payload as the request body.the module's response body
sleepA durable timer: the run parks until sleep_for has elapsed.none
fanoutSpawns its children, which then progress independently.none
joinThe barrier: holds the run until every child of the preceding fan-out is finished.{"<child key>": <its output>, …}
wait_for_signalParks the run until a signal named signal is delivered to it.the signal's payload
subworkflowa workflow nameStarts another definition's run and awaits it.{"run_id":"…"}

A run's output is the output of the last step that produced one, so the final step's result is what a completed run reports.

Attempts and timeouts

FieldMeaningDefault
max_attemptsHow many times the step may be attempted before it dead-letters. 1 means no retry.3
timeout_secondsWall-clock bound on one attempt of a side-effecting step.30
timeout_seconds on wait_for_signal / subworkflowBounds the wait, not an attempt; see Waiting.7 days
sleep_forDuration string on a sleep step: "45s", "30m", "1h30m", "72h".required
secretHMAC-SHA256 signing secret for a webhook step. Write-only: a read reports "secret_set": true and never the value. Re-send it on PUT to change it.unsigned
payloadAny JSON value, delivered verbatim to the step. Up to 256 KiB.null
Step payloads are static. A step sends the payload you wrote into the definition. The run's own input is recorded on the run and visible in its history, but it is not substituted into step payloads, and one step's output is not piped into the next one's input; there is no template language. When a step needs run-specific data, put the work in a function or sql step that reads it from your own tables.

What validation enforces

The whole definition is checked before it is stored; a violation is a 400 (invalid_definition) naming the offending step, so a workflow the engine could not execute is refused at authoring time rather than halfway through a run.

Fan-out and join

A fanout spawns its children, and the paired join holds the run until every one of them is finished. The children are unordered and each carries its own attempt budget; the engine advances up to eight runnable children per tick, so a wide fan-out drains over several ticks rather than all at once.

{
  "steps": [
    {
      "key": "notify_all",
      "kind": "fanout",
      "children": [
        {"key": "email",  "kind": "enqueue",  "target": "emails", "payload": {"template": "receipt"}},
        {"key": "crm",    "kind": "webhook",  "target": "https://crm.example.com/hooks/order"},
        {"key": "search", "kind": "function", "target": "reindex-order", "max_attempts": 5}
      ]
    },
    {"key": "notified", "kind": "join"}
  ]
}

When the barrier clears, the join's output is an object keyed by child step key: {"email":{"msg_id":7},"crm":{"delivered":true},"search":…}. If any child exhausts its attempts, the join fails and the run fails; there is no partial-success policy.

Running a workflow

Four things can start a run. All four resolve the workflow's highest enabled version at start time, and all four write the same kind of run row.

1. The API

curl -X POST https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline/runs \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{"input": {"order_id": 42}, "idempotency_key": "order-42"}'

{"id":"6f1c…-…","status":"pending"}

Both fields are optional: input defaults to {} and must be a JSON value; idempotency_key is at most 200 characters.

2. Cron

A cron job with target_kind: "workflow" starts a run of the named workflow on every fire, with the job's payload as the run input. Each scheduled fire is deliberately a distinct run (no idempotency key), so a nightly job produces one run per night.

curl -X POST https://api.kethosbase.com/v1/projects/<ref>/cron-jobs \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-etl",
    "schedule": "17 2 * * *",
    "target_kind": "workflow",
    "target": "order-pipeline",
    "payload": {"mode": "full"}
  }'

3. A queue message

Bind one of the project's pgmq queues to a workflow and every message on that queue starts a run, with the message body as the run input.

curl -X POST https://api.kethosbase.com/v1/projects/<ref>/workflow-queue-triggers \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{"queue": "orders", "workflow": "order-pipeline"}'

{"id":"…","queue":"orders","workflow":"order-pipeline","enabled":true,"created_at":"…"}
GET    /v1/projects/{ref}/workflow-queue-triggers
POST   /v1/projects/{ref}/workflow-queue-triggers
DELETE /v1/projects/{ref}/workflow-queue-triggers/{id}

A queue may be bound to only one workflow (a second binding is a 409), and names beginning kethosbase_ are reserved for the platform's own queues. Delivery is at-least-once with the queue's visibility timeout; the message id is used as the run's idempotency key, so a redelivered message resolves to the run that already exists. A message that cannot start a run, most often because the workflow has no enabled version yet, is retried and then archived to the queue's archive table. While Workflows is switched off for the project the bound queue is simply not drained: your messages stay where they are and start runs if the capability is switched back on.

4. A row change

Bind a table to a workflow and an insert, update or delete starts a run. Creating the binding installs a capture trigger on the table, so the table must already exist; deleting the binding removes it.

curl -X POST https://api.kethosbase.com/v1/projects/<ref>/workflow-row-triggers \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "table": "orders",
    "events": ["insert", "update"],
    "filter": {"status": "paid"},
    "workflow": "order-pipeline"
  }'
GET    /v1/projects/{ref}/workflow-row-triggers
POST   /v1/projects/{ref}/workflow-row-triggers
DELETE /v1/projects/{ref}/workflow-row-triggers/{id}

events is a subset of insert, update, delete. filter is optional: a flat JSON object of column → scalar (string, number, boolean or null), at most 16 keys and 4 KiB, matched by containment against the row: the post-change row, except on a delete, where it is the row that was removed. A change that does not match never leaves your database. The run input is the captured change:

{
  "trigger_id": "…",
  "action": "insert",
  "table": "orders",
  "record": { … the new row … },
  "old": null,
  "ts": 1785000000.123
}

A single table can be bound to several workflows (one binding each); the same table bound twice to the same workflow is a 409. A change whose captured payload would exceed 1 MB is replaced by a marker carrying "oversized": true instead of the row, so a large write never fails and the run still starts.

Idempotency keys

An idempotency key makes “start this workflow once” safe under an at-least-once trigger. Starting the same (workflow, key) twice returns the id of the existing run instead of starting a second one. Without a key, every start is a new run. The queue and row-change triggers set one for you (derived from the message id, and from the binding plus the message id, respectively); the API takes yours; cron deliberately sets none.

Waiting

sleep

A sleep step parks the run until the delay elapses. It is a wake-up time on a row, so a long sleep costs nothing while it waits, up to a maximum of 30 days per step. Resolution is the engine tick, so treat a sleep as “at least this long”, not as an alarm clock.

{"key": "wait_a_day", "kind": "sleep", "sleep_for": "24h"}

wait_for_signal

A wait_for_signal step parks the run until something outside it delivers a signal of that name: an approval, a delivery confirmation, a human clicking a button. The signal's payload becomes the step's output, and the run's.

{"key": "await_approval", "kind": "wait_for_signal", "signal": "approved", "timeout_seconds": 86400}
curl -X POST https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline/runs/<run-id>/signals \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{"signal": "approved", "payload": {"by": "u_123"}, "idempotency_key": "approval-42"}'

{"id":"…","run_id":"…","signal":"approved","created_at":"…"}

Sub-workflows

A subworkflow step starts another of the project's workflows and waits for it. The child is an ordinary run: it appears in that workflow's run list, is advanced and retried by the same engine, and can be inspected and cancelled on its own.

{
  "key": "fulfil",
  "kind": "subworkflow",
  "target": "fulfilment",
  "payload": {"warehouse": "eu-1"},
  "max_attempts": 2,
  "timeout_seconds": 3600
}

When a step fails

Retries and backoff

A failed attempt bumps the step's attempt counter and re-parks it with an exponential backoff (2s, 4s, 8s, 16s…, capped at one hour) and the run's wake-up time follows the step's. Nothing is held open in between. Each fan-out child retries on its own budget, independently of its siblings.

Dead-lettering

When a step's max_attempts are spent, the step is marked failed and the run fails, carrying that step's error as the run's last_error. A failed run is terminal: there is no resume and no manual retry, so start a new run once you have fixed the cause. A handful of errors skip the retries entirely because repeating them could not help: a wait that timed out, a sub-workflow child that was cancelled, a step whose static configuration cannot succeed.

Compensation (saga rollback)

A step may declare a compensate action: the side effect that undoes it. When a run fails after earlier steps have already succeeded, the engine does not leave the world half-changed; it walks backwards from the failure point and runs each completed step's compensating action.

{
  "key": "charge",
  "kind": "webhook",
  "target": "https://payments.example.com/charge",
  "payload": {"amount": 4900},
  "compensate": {
    "kind": "webhook",
    "target": "https://payments.example.com/refund",
    "payload": {"amount": 4900}
  }
}

What it does, precisely:

A run that fails for a structural reason, a definition that will not load, a step ceiling breached, fails terminally without compensating: a definition that cannot be read forwards cannot be trusted to be read backwards.

Inspecting runs

curl https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline/runs \
  -H "Authorization: Bearer <your-session-token>"

curl https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline/runs/<run-id> \
  -H "Authorization: Bearer <your-session-token>"

{
  "id": "6f1c…",
  "workflow": "order-pipeline",
  "version": 1,
  "status": "failed",
  "cursor": 1,
  "output": {"delivered": true},
  "compensation": {"state": "compensated"},
  "last_error": "ERROR: relation \"accounts\" does not exist (SQLSTATE 42P01)",
  "steps": [
    {"key":"charge","kind":"webhook","status":"succeeded","attempt":1,"max_attempts":5,"output":{"delivered":true}},
    {"key":"compensate:charge","kind":"webhook","status":"succeeded","attempt":1,"max_attempts":3,"compensating":true},
    {"key":"provision","kind":"sql","status":"failed","attempt":3,"max_attempts":3,"error":"ERROR: relation \"accounts\" does not exist (SQLSTATE 42P01)"}
  ]
}

The run list returns the 100 most recent runs of that workflow, newest first. A single run returns every step row, including fan-out children (which carry a parent_step_id) and compensation rows (which carry "compensating": true, so you never have to parse a key to tell them apart).

Run statusMeaning
pendingStarted, not yet picked up.
runningBeing advanced, or unwinding, if compensation.state is running.
waitingSleeping, awaiting a signal, or awaiting a sub-workflow.
succeeded / failed / cancelledTerminal, and final in both directions.
Step statusMeaning
pendingNot attempted yet, or waiting out a retry backoff.
runningIn flight, or parked on a wait.
succeeded / failed / skippedFinished. failed means the attempt budget was spent.
compensation.stateMeaning
noneThe run never entered a rollback: it succeeded, was cancelled, or failed with nothing declared to undo.
runningThe run failed and is unwinding right now.
compensatedThe walk ran off the front: every completed step with a compensating action was undone.
incompleteThe walk stopped. cursor names the step that was not undone and last_error says why.

If the project has a log drain configured, run and step transitions are shipped to it under the workflow source: run.started, run.succeeded, run.failed, run.cancelled, step.failed (an attempt that will be retried) and step.dead_lettered (the budget spent). Step starts and successes are deliberately not emitted, and no event carries a payload; the full history stays behind this API.

Stopping things

Cancel a run

curl -X POST https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline/runs/<run-id>/cancel \
  -H "Authorization: Bearer <your-session-token>"

{"id":"6f1c…","status":"cancelled"}

Only a live run (pending, running or waiting) can be cancelled; an already-finished run is a 409 (run_finished) rather than a silent success, so a caller retrying a cancel learns the run had already ended.

Disable a definition

curl -X PATCH https://api.kethosbase.com/v1/projects/<ref>/workflows/order-pipeline \
  -H "Authorization: Bearer <your-session-token>" \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'

Disabling stops new runs from every trigger: API, cron, a bound queue, a bound table, because all four resolve the highest enabled version and there no longer is one. Runs already in flight are untouched and finish on the version they pinned; to stop one of those, cancel it.

The flag is applied to every version of the name, on purpose: disabling only the newest would silently resurrect an older one. PATCH with {"enabled": true} reverses it. There is deliberately no DELETE: run history references a definition by name and version, and dropping one out from under an in-flight run would fail it on its next advance.

Limits and defaults

LimitValue
Steps per definition200
Step payload / step output256 KiB each
Signal payload32 KiB
Idempotency key200 characters
Recorded error texttruncated at 2,000 characters
Default attempts per step3
Retry backoff2s, doubling, capped at 1 hour
Default attempt timeout30 seconds
Default wait (signal / sub-workflow)7 days; maximum 30 days
Maximum sleep_for30 days
Sub-workflow nesting depth5
Fan-out children advanced per tick8
Concurrently-advancing runs per project10
Engine tick15 seconds
Run history retentionfinished runs and their steps are removed 30 days after they finish

The per-project concurrency cap and the tick are shared-box fairness limits: a burst of runs is drained steadily rather than all at once, so treat the tick as the floor on how quickly a run moves between steps.

Errors

StatusCodeWhen
400invalid_definitionThe step graph broke a validation rule, or a sleep_for is not a duration. The message names the step.
400invalid_name, invalid_workflow, invalid_queue, invalid_table, invalid_events, invalid_filter, invalid_signalA malformed name, queue, table, event list, row filter or signal name.
400invalid_requestMalformed JSON, a missing enabled on PATCH, a non-JSON input or payload, or an over-long idempotency key.
401unauthorizedMissing bearer token, or an invalid or expired session.
403forbiddenWorkflows are not enabled for the project, or you are not an owner or admin of it.
404not_foundNo such workflow, run, or trigger binding for this project, including a run id that belongs to another workflow, and a start whose workflow has no enabled version.
409run_finishedCancelling or signalling a run that has already finished.
409queue_bound, table_boundThat queue is already bound to a workflow, or that table is already bound to this workflow.

Limits of the model