- SDKs & APIs
- REST API
REST API
Ironflow exposes a REST API for reading events, managing environments, projects, keys, users, storage and workers, and operating the server. Emitting events and managing functions, runs, projections and schemas are ConnectRPC calls, described in Reading the ConnectRPC examples. The API overview lists every ConnectRPC service.
Base URL: http://localhost:9123/api/v1
The /api/v1/platform/* control-plane routes (platform users, roles, policy set,
tenants, audit) are not listed here — see the Platform API.
Authentication
Section titled “Authentication”All API endpoints (except /health, /ready, /metrics, /api/v1/capabilities, /api/v1/auth/login, /api/v1/auth/logout, /api/v1/auth/validate, /api/v1/platform/auth/login, /api/v1/files/signed and webhook ingest at /api/v1/webhooks/{provider}) require authentication. The last two use their own token instead.
API Key (SDK and programmatic access):
Authorization: Bearer ifkey_your_key_hereDashboard JWT (browser sessions):
Dashboard authentication uses the ironflow_dashboard_token cookie set by the login endpoint.
Environment Selection (optional):
X-Ironflow-Environment-ID: env_default_productionSend the environment ID (env_-prefixed) in the X-Ironflow-Environment-ID
header or the env_id query parameter. The header wins when both are present.
Find IDs with ironflow env list.
| Value | Result |
|---|---|
| absent | A key or desktop session uses its own environment. Any other credential uses env_default. |
current | Same as absent. current is a sentinel, not an environment name. |
env_default_production (an ID) | That environment. An ID that does not exist returns 400 unknown environment for a credential that is not pinned. |
production (a name) | 400. The server does not look up environment names. |
X-Ironflow-Environment or ?env= (the old spelling) | 400. The message names the new spelling. |
A credential cannot leave its scope. A key or desktop session answers 403 for
an ID that is not its own environment. An impersonating platform principal
answers 403 for an unknown ID and for an ID outside the impersonated
organization. The server checks the environment on every request, so a deleted
environment stops working on the next request. A database failure during the
check returns 500, not 400.
Errors
Section titled “Errors”A REST error written by the API is a JSON object:
{"error": "run not found", "message": "run not found", "code": "not_found", "retryable": false}| Field | Present | Meaning |
|---|---|---|
error | always | What went wrong. |
message | always* | The same text as error. |
code | always* | The lowercase Connect code (not_found, invalid_argument, …), or a domain code a client can branch on, such as SIGNED_URLS_DISABLED. |
details | sometimes | Structured detail for that code. |
retryable | always* | true only when sending the identical request again can succeed: 408, 429 and 5xx, except 507 (storage limit, which needs an operator) and errors the server knows are permanent, such as a missing offloaded blob (code blob_unavailable). 501 is not retryable: a missing route does not appear on retry. |
retry_after | with Retry-After | Seconds from the Retry-After header. When retryable is true, wait this long before retrying. On a non-retryable error, such as 507, it only says when a re-check is useful. |
* Three bodies keep an older shape and lack message, retryable and code: the pull-worker protocol’s 409 version conflict (its error field holds the fence code), and the /health and /ready probes.
The HTTP status is the error class. Retry a write only when it carries an idempotency key, so a retry after a lost response cannot apply it twice.
Reading the ConnectRPC examples
Section titled “Reading the ConnectRPC examples”Several capabilities below have no REST route any more — the duplicate was
removed in #1972 step 10 and the Connect method is the only surface. Those are
ordinary JSON over HTTP POST: send Content-Type: application/json to
/ironflow.v1.<Service>/<Method> with the same Authorization and
X-Ironflow-Environment-ID headers as any REST route.
Three encoding rules apply to every one of them, and the response examples in this page are written to follow them:
- Fields are camelCase, matching the Protobuf JSON mapping —
eventName,lastEventSeq,runId. - 64-bit integers are decimal strings:
"sequence": "42", not42. - Empty fields are omitted, not sent as
"",0,[]ornull. An all-empty response is{}. Read an absent key as the zero value; do not treat it as an error.
Errors are a JSON body with a Connect code rather than an HTTP status name:
{"code": "not_found", "message": "projection \"orders\" not found"}. The
codes this page names are invalid_argument, not_found, already_exists,
failed_precondition, aborted, unavailable and internal. An error written by the shared
REST error writer that has no domain code of its own (such as BUCKET_NOT_EMPTY)
answers the Connect code of its HTTP status, so one condition has one code. The
/health probe and the capacity fence errors (STALE_EXECUTION, in error) keep
their own shape. An unknown /api/v1/ path answers ROUTE_NOT_FOUND.
Case-insensitive search
Section titled “Case-insensitive search”Several filters match without regard to case: the event name and search
parameters, the ListRuns, ListStreams and ListFunctions search fields, the
projection partition q parameter and the projection query LIKE operator.
SQLite folds case with Unicode simple case mapping. PostgreSQL folds case by the
database collation and LC_CTYPE. The two match for the default UTF-8 locales:
é finds É on both. They differ in two cases:
- A locale-specific mapping, such as the Turkish dotted and dotless
i. - A
Cctype, which folds only ASCII on PostgreSQL, soédoes not findÉ.
Events
Section titled “Events”Emit an event
Section titled “Emit an event”POST /api/v1/events was removed. Use
POST /ironflow.v1.IronflowService/Emit.
Request:
{ "event": "order.placed", "data": { "orderId": "123", "total": 99.99 }, "version": 1, "metadata": { "source": "checkout", "traceId": "abc-123" }}The event name field is event, not name — TriggerRequest named it that
before the REST route was retired, and the two never matched.
The version field is optional and selects the event schema version to
validate against (#1955). Omit it for 1. It matters only when event-schema
enforcement is on: matching is exact, and once any version of an event name is
registered the name is governed, so an emit carrying a version with no
registered schema is rejected. A negative value returns invalid_argument. See
schema enforcement.
The metadata field is optional. It accepts any key-value pairs and is delivered to function handlers as event.metadata (TypeScript) or ctx.Event.Metadata (Go). Use it to pass tracing IDs, source labels, or any contextual information that shouldn’t be part of the event’s core data schema.
The idempotencyKey field is optional. A repeat call with the same key in the same environment returns the first call’s eventId and run IDs and stores no second event. dataValue carries a payload that is not a JSON object (a string, number, array or null), which data cannot hold. Trigger takes the same request as Emit.
An event name must be under 255 bytes (the limit counts the events: namespace
prefix the server adds, so 248 bytes of name), may not contain the NATS
wildcards * or >, and may not contain whitespace, a control character, or an
empty . segment (a..b, .a, a.). Each returns 400. Non-ASCII event
names are allowed. The same bound applies to events arriving on the internal
NATS ingest stream, which reject rather than store.
Response:
{ "eventId": "evt_abc123", "runIds": ["run_xyz789"]}An event that matches no function returns eventId alone — runIds is omitted
rather than sent as [].
GET /events
Section titled “GET /events”List events with keyset pagination (issue #697). Sorted newest-first by
(timestamp, id). The endpoint always scopes to the caller’s environment.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
name | string | Filter by event name (case-insensitive substring match, unindexed; see Case-insensitive search). % and _ are wildcards; \%, \_ and \\ match them literally. |
names | string | Filter by exact event name (comma-separated, OR-ed, max 200). Distinct from name: entries must match in full. Populate from GET /events/names. |
search | string | Filter by event ID (case-insensitive substring match; see Case-insensitive search). % and _ are wildcards; \%, \_ and \\ match them literally. |
source | string | Filter by source (comma-separated, max 200) |
since | string | Start time (RFC3339) |
until | string | End time (RFC3339) |
limit | int | Max results per page (default 20, values above 200 are clamped to 200). A value that is not a positive integer returns 400. |
cursor | string | Forward keyset token from a prior next_cursor. Mutex with before. |
before | string | Backward keyset token from a prior prev_cursor. Mutex with cursor. |
Response:
{ "events": [...], "count": 20, "limit": 20, "next_cursor": "MTcxMjMzNDQ1Njc4OXxldnRfMDFI...", "prev_cursor": "MTcxMjMzNDQ1NjAwMHxldnRfMDFI...", "has_next": true, "has_prev": false, "approx_total": 12400, "approx_total_capped": false}next_cursor/prev_cursorarenull/omitted when there is no further page in that direction.has_nextandhas_prevare direction-aware. On the newest page (no cursor and nobefore),has_previs alwaysfalse. After paging forward withcursor=,has_previstrue. After paging backward withbefore=,has_nextistrue.approx_totalis the per-environment count from a capped subquery (LIMIT 10001). Below the cap the value is exact; above the cap the value is10000andapprox_total_cappedistrue.- Each event’s
timestampandcreated_atare RFC3339 with milliseconds (2026-08-04T21:19:35.482Z). Sub-second precision was previously truncated, which made same-second events impossible to order. Clients parsing withtime.Parse(time.RFC3339, …)or JSnew Date()are unaffected; only exact string comparison against the old second-resolution form breaks.
Errors:
400— invalidcursor/beforetoken, or both supplied at once.400—sinceoruntilis not RFC3339. The message names the offending parameter (invalid 'since' parameter: must be RFC3339). A malformed value is rejected, never silently ignored.400— more than 200 comma-separated values innamesorsource(too many values in 'names' parameter: at most 200). Each value becomes one bind parameter in the underlying query, so an unbounded list would surface as a500. The limit matches the 200-name cap ofGET /events/names, so a full facet selection is always accepted.
GET /events/names
Section titled “GET /events/names”List distinct event names with counts. Backs the dashboard’s event-type picker
and feeds the names filter on GET /events. Scoped to the caller’s
environment.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
source | string | Filter by source (comma-separated, max 200) |
since | string | Start time (RFC3339) |
until | string | End time (RFC3339) |
Response:
{ "names": [ { "name": "order.placed", "count": 812 }, { "name": "order.shipped", "count": 344 } ], "scanned": 10000, "truncated": true, "scan_cap": 10000}- Counts are computed over at most the newest
scan_cap(10,000) matching events, so they are a recency window rather than environment totals whenevertruncatedistrue. Rarer names may be absent entirely —GET /eventsstill accepts any exact name innames, listed or not. scannedis how many events the facet actually grouped over.- At most 200 distinct names are returned, ordered by descending count.
truncatedistruewhen either cap was exceeded — more than 10,000 matching events, or more than 200 distinct names. An environment holding exactly 10,000 matching events reportstruncated: false, because its counts are exact totals. The response does not distinguish which cap fired; comparescannedagainstscan_capto tell them apart.- The two causes mean different things. Exceeding the scan cap makes the
counts a recency window rather than environment totals. Exceeding the
200-name cap leaves each returned count an exact total and only makes the
list of names incomplete. So
scannedand the sum of the returnednames[].countvalues can diverge for either reason — do not derive one from the other. - The cap bounds how many rows are grouped, not how many are scanned.
Unfiltered and time-filtered requests read an ordered index and stop at the
cap, so their cost does not grow with the events table. A highly selective
sourcefilter has no supporting index, so it scans to find its matches — the same on environment-scoped and environment-less requests alike.
Errors:
400—sinceoruntilis not RFC3339.400— more than 200 comma-separated values insource.
GET /events/{id}
Section titled “GET /events/{id}”Get a single event by ID. Returns 404 when the event does not exist in the caller’s environment.
Functions
Section titled “Functions”Function registration (POST /ironflow.v1.IronflowService/RegisterFunction) accepts an optional change_reason field (e.g. "deploy v2.1.0") that is recorded in the function’s history for every update. Has no effect on new registrations (no history event is written on first create).
A function ID is unique within its environment. Two environments of one engine can each
register a function with the same ID; they are separate functions with separate runs,
history and circuit breakers. Calls that address a function by ID act in the caller’s
environment, so a platform key must send X-Ironflow-Environment-ID.
Function lifecycle events
Section titled “Function lifecycle events”Subscribers see function lifecycle on system.function.{function_id}.{event}, where
{event} is registered, updated, or deleted (issue #1725). Every leaf carries the
same payload — id, name, status, preferredMode, createdAt, updatedAt,
timestamp — so system.function.> decodes to one shape, and a consumer that built its
list from registered / updated frames can now remove the entry on deleted. The
deleted frame is published after the row is gone; its payload is the last known state.
Only an explicit DeleteFunction RPC publishes it. Deleting an environment removes its
functions in bulk (DELETE FROM functions WHERE environment_id = …) with no per-function
frame; it publishes one system.environment.{environment_id}.deleted instead — see
Environment lifecycle events.
Cron schedule lifecycle events
Section titled “Cron schedule lifecycle events”A function with a cron trigger also publishes schedule lifecycle on
system.cron.{function_id}.{event}, where {event} is registered, removed,
invalid, or missed (issue #2010). Subscribe to system.cron.> for all cron
activity in the environment, or system.cron.{function_id}.> to follow one
schedule — with the > wildcard rather than a literal leaf.
This family is separate from system.function.* because the function family
cannot express it. Dropping a cron trigger is a function update — the
function still exists — and the function payload carries no triggers, so a
schedule deletion and a code-only redeploy are the same frame. These leaves say
what happened to the schedule specifically.
| Event | Fires when | Payload beyond functionId, environmentId, timestamp |
|---|---|---|
registered | a schedule is installed, or its expression changes | expression, nextRun, replaced (true when it overwrote an existing schedule) |
removed | the schedule is gone | cause: triggers_changed (the cron trigger was edited away) or function_deleted |
invalid | the cron expression fails to parse | expression (the spec that failed), error |
missed | schedule slots produced no run | count, cause (downtime), verified, windowStart, windowEnd |
There is no fired leaf. A cron slot that fires publishes
system.run.{run_id}.created like every other run producer (issue #1724).
registered announces a change, not a deployment. Re-registering a function
with an unchanged expression — what a worker restart does — publishes nothing,
and neither does server startup reloading the schedule set. A cron expression
that fails to parse is announced on invalid when it is registered, not again
on every restart; RegisterFunction logs the scheduler error and stores the
function anyway, so an unparseable expression is a successful registration with
no schedule behind it.
missed reports skipped downtime through the same signal as
ironflow_cron_slots_skipped_total, adding the window and cause. Pending slots
retained for retry are not reported as missed. Live admission failures retain
their slot instead of emitting stall or fire_failed.
verified is false when the reporting window was clamped. If NATS history
cannot be listed, no report is emitted. Each node computes its startup report
from the shared SYS_cron_triggers history, so a whole-cluster restart can
announce the same downtime gap once per node. Database cursors own slot
admission; NATS history is only evidence for this report.
A function ID may contain ., so an ID of a.b spans two subject segments.
system.cron.> and system.cron.{function_id}.> are unaffected, but a leaf
filter like system.cron.*.missed misses dotted IDs. Route on the trailing
segment of a system.cron.> subscription instead of filtering with *.
Reading and invoking functions
Section titled “Reading and invoking functions”The duplicate REST routes were removed. Send JSON POST requests to these
Connect methods on ironflow.v1.IronflowService:
| Method | Purpose |
|---|---|
ListFunctions | List registered functions; accepts name, status, mode, limit, offset. name is a case-insensitive (rules) substring match: % and _ are wildcards; \%, \_ and \\ match them literally. |
GetFunction | Fetch one function by ID or slug. |
InvokeFunction | Invoke a function directly, creating an event and a run. |
status and mode are enums on the wire — send FUNCTION_STATUS_ACTIVE,
EXECUTION_MODE_PULL and so on, or their integer values.
GetFunction response:
{ "id": "fn_abc123", "name": "process-order", "triggers": [{ "event": "order.placed" }], "retry": { "maxAttempts": 3, "initialDelayMs": 1000, "backoffFactor": 2.0 }, "timeoutMs": 30000, "preferredMode": "EXECUTION_MODE_PULL", "status": "FUNCTION_STATUS_ACTIVE", "version": 4, "createdAt": "2026-01-15T10:00:00Z", "updatedAt": "2026-01-15T10:00:00Z"}Returns not_found if no function matches.
Cron expressions use UTC calendar fields: 0 9 * * * means 09:00 UTC
regardless of the server or browser timezone. GetFunction and ListFunctions
include optional nextRun (an RFC3339 timestamp) for the first cron trigger
when the responding engine has that expression installed in the same environment.
It is a read-only snapshot and may be overdue. It is omitted for inactive
functions, whose slots are skipped, for unavailable schedules, older engines,
and historical function snapshots. The dashboard labels schedules
UTC and formats this instant in your selected display timezone.
InvokeFunction request:
{ "functionId": "fn_abc123", "data": { "orderId": "123" }}Headers:
The Idempotency-Key header is the same as the idempotencyKey body field. The body field wins when both are set.
| Header | Description |
|---|---|
Idempotency-Key | Optional. When supplied, a retry with the same key + same function in the same environment returns the original runId + eventId instead of creating a duplicate run. Reusing a key for a different function, or for a key already used by an Emit, returns already_exists — keys are scoped per (environment, key) and not shareable across flows. |
InvokeFunction response:
{ "runId": "run_abc", "eventId": "evt_xyz"}Other IronflowService methods
Section titled “Other IronflowService methods”| Method | Purpose |
|---|---|
RegisterFunction | Register or update a function definition. |
UpdateFunctionStatus | Set a function’s status (FUNCTION_STATUS_ACTIVE, FUNCTION_STATUS_PAUSED or FUNCTION_STATUS_ARCHIVED) by id. Returns the function. |
DeleteFunction | Delete a function by id. See Function lifecycle events. |
Trigger | Emit an event. Same request as Emit. |
TriggerBatch | Emit several events in one call: {"events": [ ...Emit requests... ]}. results has one entry per event. A failed entry carries an error with a Connect code; the other events still emit. |
TriggerSync | Emit an event and wait for the runs it starts. Takes the Emit request plus timeoutMs (default 30000). Returns eventId and one results entry per run. |
InvokeFunctionSync | Invoke one function by functionId and wait for its single run. Takes data, timeoutMs (default 30000), idempotencyKey and metadata. Returns one result. When timeoutMs ends first, the run keeps going and result.waitTimedOut is true. If the caller disconnects, the server cancels the run. |
Health, Info | Server health and build information. |
Function History
Section titled “Function History”Function registration history is exposed via the ConnectRPC API. Every update, status change, rollback, and deletion is recorded as an immutable snapshot against ironflow:fn:{id} in the entity stream.
See Function Registration History for a full explanation of the flows.
List Function History
Section titled “List Function History”POST /ironflow.v1.IronflowService/ListFunctionHistoryRequest:
{ "function_id": "my-function", "limit": 20, "from_version": 0}Use from_version (exclusive) for keyset pagination — pass the entityVersion of the last entry to get the next page. Responses are camelCase with 64-bit versions as decimal strings, as above; requests accept either spelling.
Response:
{ "entries": [ { "eventId": "evt_abc", "entityVersion": "3", "functionId": "my-function", "functionSnapshot": { ... }, "actorId": "ifkey_xyz", "changeReason": "deploy v2.1.0", "changeType": "update", "recordedAt": "2026-04-12T10:00:00Z" } ], "hasMore": false}changeType values: update, status_change, rollback, delete.
Get Function at Version
Section titled “Get Function at Version”POST /ironflow.v1.IronflowService/GetFunctionAtVersionRequest:
{ "function_id": "my-function", "version": 2}Response:
{ "entry": { "eventId": "evt_abc", "entityVersion": "2", "functionSnapshot": { ... }, "actorId": "ifkey_xyz", "changeType": "update", "recordedAt": "2026-04-10T08:00:00Z" }}Rollback Function
Section titled “Rollback Function”POST /ironflow.v1.IronflowService/RollbackFunctionRestores a function to the configuration captured at version. The rollback is itself recorded as a new history entry with change_type = "rollback". Returns CodeAborted on concurrent modification — retry in that case.
Request:
{ "function_id": "my-function", "version": 2, "change_reason": "revert bad deploy"}Response:
{ "function": { ... }}Cannot roll back to an archived state (CodeInvalidArgument).
Executable Deployments
Section titled “Executable Deployments”DeploymentService registers immutable executable deployments and reports how a function selects the executable for new runs (ADR 0081). A deployment identifies the code that serves a function. Function configuration history cannot do this: an unchanged registration returns the existing function version, so two different artifacts can serve one version. Registration only stages a deployment. It never changes which code serves new runs. These RPCs have no REST route. See Reading the ConnectRPC examples for the encoding rules.
| RPC | Permission | Purpose |
|---|---|---|
RegisterDeployment | functions:register | Store one immutable deployment |
GetDeployment | functions:read | Read one deployment |
ListDeployments | functions:read | List the deployments of a function, newest first |
GetFunctionExecutionRouting | functions:read | Read how a function routes new runs |
Every RPC acts in the caller’s environment. A request with no environment returns permission_denied. A request with an empty functionId returns invalid_argument.
RegisterDeployment
Section titled “RegisterDeployment”POST /ironflow.v1.DeploymentService/RegisterDeploymentRequest:
{ "functionId": "fn", "deploymentId": "probe-v1", "artifactDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "snapshot": { "triggers": [{"event": "x"}], "timeoutMs": 30000, "metadata": {"who": "José"} }, "mode": "EXECUTION_MODE_PULL", "snapshotSchemaVersion": 1}| Field | Required | Description |
|---|---|---|
functionId | yes | The function this deployment serves. The function must exist. |
deploymentId | yes | Your ID for the deployment. Case-sensitive and never reused. 1 to 128 ASCII letters, digits, dots, underscores or hyphens. It starts with a letter or digit. |
artifactDigest | yes | Your declaration of the complete artifact: sha256: plus 64 lowercase hex digits. Ironflow stores it. It does not prove which code a process runs. |
snapshot | no | The execution configuration frozen into the deployment: triggers, retry, timeoutMs, concurrency, cancelOn, debounce, circuitBreaker, throttle and metadata. Display metadata and pause state are not part of it. Omit it to store an empty snapshot. |
mode | yes | EXECUTION_MODE_PUSH or EXECUTION_MODE_PULL. An unset mode is rejected. |
endpointUrl | push only | An http or https URL, at most 2048 characters. Required for push. Must be empty for pull. |
snapshotSchemaVersion | no | The ExecutionSnapshot schema version the client was built for. The server accepts up to 2. Declare 2 when the snapshot carries circuitBreaker, throttle or retry.onExhausted, and 1 otherwise, so the snapshot still registers against an older server. The SDKs send it for you. Send it from your own client too. The server drops snapshot fields it does not know, and this field is how it detects a client that is newer than the server. |
Response:
{ "deployment": { "environmentId": "env_default", "functionId": "fn", "deploymentId": "probe-v1", "artifactDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "snapshot": { "triggers": [{"event": "x"}], "timeoutMs": 30000, "metadata": {"who": "José"} }, "mode": "EXECUTION_MODE_PULL", "createdBy": "ak_admin_bootstrap", "createdAt": "2026-10-16T19:09:07.509624Z" }, "created": true}created is true when this call stored the deployment. It is omitted when the identical deployment already existed, so read an absent created as false. A worker fleet that rolls out one build can register on every boot. The response always carries the stored snapshot, not the request snapshot. The response of GetDeployment has the same deployment object.
Errors:
| Code | When |
|---|---|
invalid_argument | A field fails validation: an ID or digest in the wrong form, an unset mode, a push deployment without endpointUrl, a pull deployment with endpointUrl, or an invalid URL. The reason is snapshot_schema_unsupported when snapshotSchemaVersion is newer than the server knows. |
not_found | The function does not exist. |
already_exists | The deploymentId is already registered with a different artifact digest, snapshot, mode or endpoint. Nothing is overwritten. Register the new configuration under a new deploymentId. The reason is deployment_identity_conflict. |
The server sets the reason in the Ironflow-Error-Reason response header. Branch on it, not on the message text. The server records each refused already_exists attempt in the audit log. Registering the identical deployment again is not an error.
GetDeployment
Section titled “GetDeployment”POST /ironflow.v1.DeploymentService/GetDeploymentRequest:
{"functionId": "fn", "deploymentId": "probe-v1"}Response: {"deployment": { ... }}, with the same deployment object as above. functionId and deploymentId are both required. A missing deployment returns not_found.
ListDeployments
Section titled “ListDeployments”POST /ironflow.v1.DeploymentService/ListDeploymentsRequest:
{"functionId": "fn", "limit": 20, "offset": 0}limit defaults to 50 and the server caps it at 500. offset skips that many rows. The server returns the newest deployment first.
Response:
{"deployments": [{"environmentId": "env_default", "functionId": "fn", "deploymentId": "probe-v1", "artifactDigest": "sha256:aaaa...", "snapshot": {"triggers": [{"event": "x"}], "timeoutMs": 30000, "metadata": {"who": "José"}}, "mode": "EXECUTION_MODE_PULL", "createdBy": "ak_admin_bootstrap", "createdAt": "2026-10-16T19:09:07.509624Z"}]}A function with no deployments, and a function that does not exist, both return {}. If the server cannot read the stored snapshot of one deployment, it still lists that deployment, without snapshot.
GetFunctionExecutionRouting
Section titled “GetFunctionExecutionRouting”POST /ironflow.v1.DeploymentService/GetFunctionExecutionRoutingRequest:
{"functionId": "fn"}Response:
{ "functionId": "fn", "mode": "FUNCTION_ROUTING_MODE_LEGACY", "updatedAt": "2026-10-16T19:08:34.107006Z"}| Field | Description |
|---|---|
mode | FUNCTION_ROUTING_MODE_LEGACY: dispatch resolves the live function row. Every function starts here. FUNCTION_ROUTING_MODE_ADOPTING: a transition is in progress, and dispatch holds while issued executions drain. FUNCTION_ROUTING_MODE_VERSIONED: every new run is pinned to the active deployment. |
activeDeploymentId | The active deployment. Set only when mode is versioned. |
routingRevision | A per-function counter, sent as a decimal string. Omitted when it is 0. |
transitionId, transitionStartedAt | Set only while mode is adopting. |
updatedAt | When the routing row last changed. |
A function that does not exist returns not_found.
The SDK methods are client.Deployments() in Go, client.deployments in Node and rpc.deployments in Python. The browser SDK has no deployment methods.
Reading runs
Section titled “Reading runs”The duplicate REST routes were removed. Send JSON POST requests to these
Connect methods on ironflow.v1.IronflowService:
| Method | Purpose |
|---|---|
ListRuns | List runs; accepts functionId, eventId, search, statuses, since, until, limit, offset. search is a case-insensitive (rules) pattern match on the run ID: % and _ are wildcards; \%, \_ and \\ match them literally. |
GetRun | Fetch one run by id. Includes attempts, the run-level retries. |
GetRunSteps | List the steps executed in a run (runId). Each step includes its attempts. |
statuses is a repeated enum — send ["RUN_STATUS_RUNNING", "RUN_STATUS_FAILED"] rather than a comma-separated string. since and
until are RFC3339 timestamps.
ListRuns response:
{ "runs": [ ... ], "nextCursor": "eyJ0cyI6...", "totalCount": 42}An environment with no matching runs answers {} — runs, nextCursor and
totalCount are all omitted at their zero values.
workerId identifies the worker assigned by the latest successful streaming or
REST poll claim. It remains on the run after the lease ends and changes on
reassignment. claimedBy identifies the engine node holding the current,
unexpired concurrency lease. Runs without an active lease omit claimedBy.
GetRun, ListRuns, and the run returned by cancel or resume use this lease
owner. MCP run results expose the same values as worker_id and claimed_by.
Attempt history. A retry no longer erases the failure before it. Each step
returned by GetRunSteps carries attempts, one entry per recorded execution,
oldest first; attempt on the step is the number of the current one, starting
at 1. The run returned by GetRun carries attempts too: one entry per failed
execution the engine retried, with runAttempt naming the run attempt that
failed. The failure that ended the run stays in the run’s error, not in this
list. ListRuns does not load the history.
{ "attempt": 1, "startedAt": "2026-10-28T10:00:00Z", "endedAt": "2026-10-28T10:00:01.250Z", "workerId": "worker-7", "nodeId": "node-a", "error": { "message": "card declined", "code": "CARD_DECLINED", "retryable": true }}- The engine keeps the first attempt and the last 10 of each step (and of the run), so the numbers can skip.
erroris the error as it was stored. It is absent when the attempt did not fail, and it is the redaction placeholder afterRedactSteporRedactRun.RedactStepalso scrubs the run-level entries, because the SDKs copy a step’s error into the run’s failure.RedactRunalso scrubs the run’s ownerrorand every child step’serror;RedactStepscrubs its ownerrorand the parent run’serror.endedAtis absent while an attempt runs, and on an earlier attempt whose worker never reported back before the step ran again. When the worker reports how long the step took,startedAtisendedAtminus that time; otherwise it is when the engine recorded the start.workerIdis set for pull workers when the engine knew the worker.startedAtis absent on run-level entries.- The history goes with its run: deleting the run, or its environment, deletes it.
GET /runs/{id}/streams
Section titled “GET /runs/{id}/streams”List entity streams that were read from or appended to during this run.
Response:
{ "entity_ids": ["order-123", "customer-9"] }Run audit trail
Section titled “Run audit trail”GET /api/v1/runs/{id}/audit was removed. Use
POST /ironflow.v1.AuditService/GetAuditTrail with runId, and the optional
eventType, fromTimestamp, toTimestamp, limit and cursor fields. The
two time bounds are fromTimestamp / toTimestamp on the wire, not from /
to as the REST query parameters were.
The environment-wide GET /api/v1/audit route below is unchanged and still
returns the same body shape.
Cancel a run
Section titled “Cancel a run”POST /api/v1/runs/{id}/cancel was removed. Use
POST /ironflow.v1.IronflowService/CancelRun.
Cancels a queued (waiting_for_capacity / waiting), running, or paused run. Routes through the engine’s
cancel helper: marks the run terminal with cancellation_cause = "user",
deletes any cancel_on_specs rows referencing the run, publishes a
cancel event to NATS with a stable msg-id (cancel-{runID}), and
notifies pubsub subscribers (#716).
Request:
{ "id": "run_abc", "reason": "No longer needed"}reason is optional.
The reason field is captured into the NATS payload and structured
log only — the persisted cancellation_cause column is always "user".
Long reasons are truncated server-side (rune-aware, 1024 rune cap).
Responses:
ok— run cancelled (idempotent: re-cancelling an already-cancelled run also succeeds and returns the cancelled run).not_found— run id does not exist.aborted— run reached a different terminal state during the cancel (race).failed_precondition— run is in a non-cancellable status (already completed/failed/cancelled).internal— unexpected store/engine failure.
Resume a run
Section titled “Resume a run”POST /api/v1/runs/resume was removed. Use
POST /ironflow.v1.IronflowService/ResumeRun.
Resumes a paused or failed run, optionally from a specific step. Routes
through the engine’s resume helper: flips status to running, publishes
a resume event to NATS with a stable msg-id (resume-{runID}), and
notifies pubsub subscribers. If the NATS publish fails the run status
is reverted to its prior paused/failed value so the run is not stuck
in running with no executor (#716).
Request:
{ "runId": "run_abc", "fromStep": "step_xyz"}fromStep is optional; omit to resume from the last completed step.
Resuming a run that is failed, or paused with pause_reason = "retries_exhausted",
also grants it a fresh retry budget: max_attempts becomes the run’s current
attempt plus the function’s resolved attempts minus one, so the resumed run gets
that many more executions. The attempt number never goes backwards, and the run keeps
its own on_exhausted policy.
Responses:
ok— run resumed; status flipped torunningand resume event published.invalid_argument— malformed body or missingrunId.not_found— run id does not exist, ORfromStepdoes not exist, ORfromStepbelongs to a different run.already_exists— a resume of this run was accepted inside the stream’s dedupe window (2 minutes, keyed onresume-{runID}), even if it has finished since. The server does not queue the second resume and leaves the run exactly as it was found. A retry after about 2 minutes works (#1963).failed_precondition— run is in a non-resumable status (must bepausedorfailed).internal— NATS publish failed (run is reverted to its prior status), or other unexpected failure.
Delete a run
Section titled “Delete a run”POST /ironflow.v1.IronflowService/DeleteRun permanently deletes one run and
its steps. There is no REST equivalent: #1972 moved every run route to
IronflowService.
The run must already be terminal (completed, failed or cancelled) —
cancel an in-flight run first. Steps, waiting_correlations and
cancel_on_specs cascade; concurrency_leases and dispatch_queue rows keyed
by the run are deleted explicitly. The triggering event row and registered
deployment records both stay. A child run’s parent_run_id is ON DELETE SET NULL,
so deleting a parent does not delete its children — it severs their link to it,
and they are left with no parent recorded. Once the row is gone the server
publishes a system.run.{id}.deleted frame and writes a run.deleted audit row.
Request:
{ "id": "run_abc"}Response: empty ({}).
Errors:
invalid_argument—idis empty.not_found— run id does not exist, or belongs to another environment.failed_precondition— the run is not terminal.aborted— the run left its terminal state between the read and the delete, or on PostgreSQL another call holds the run, a child run, a step, or its capacity. Nothing was deleted; re-read the run and retry if it is still terminal.permission_denied— the caller lacksruns:delete(admin-only by default), or the request carries no environment scope.internal— unexpected store failure. Nothing was deleted.
Delete many runs
Section titled “Delete many runs”POST /ironflow.v1.IronflowService/DeleteRuns deletes terminal runs in the
caller’s environment matching a filter, in batches of 1000, and returns the
count. One call stops at 10,000 runs so a wide filter cannot become a
single unbounded sweep. Re-issue the same filter until deleted comes back
0; deleted rows no longer match, so the same request resumes where it
stopped. Do not stop on a count below 10000 — a page comes back short
whenever another transaction holds some matching rows, so “fewer than the
cap” does not mean drained. ironflow run prune does this loop for you and
prints the total. Every row-level consequence is the single-run form’s: the same cascades
and explicit deletes, the same severed parent_run_id links on surviving
children, and a system.run.{id}.deleted frame plus a run.deleted audit row
per deleted run.
Request:
{ "functionId": "process-order", "status": "RUN_STATUS_COMPLETED", "until": "2026-01-01T00:00:00Z"}Every field is optional individually, but at least one must be set. status is
the protobuf enum name, not a lowercase string, and until is an RFC3339
timestamp matching runs created before it.
Response:
{ "deleted": "42"}Errors:
invalid_argument— no filter field was set, orstatusis not a terminal status. Nothing was deleted.permission_denied— the caller lacksruns:delete, or the request carries no environment scope. Nothing was deleted.internal— a batch failed partway through the sweep. Runs were already deleted. Each batch commits on its own, so the error message carries how many went before the failure (deleted N before failing: ...), and every one of those N runs got itssystem.run.{id}.deletedframe and audit row. Treat this as a partial success, not a no-op: re-issue the same filter to continue, rather than assuming the environment is untouched.
Run deleted frame
Section titled “Run deleted frame”DeleteRun and DeleteRuns each publish one system.run.{id}.deleted frame per deleted run,
after the delete transaction that removed it commits:
{ "id": "run_abc", "functionId": "process-order", "status": "deleted", "timestamp": "2026-09-21T04:00:00Z"}status carries the sentinel value "deleted", which is deliberately not a member of the
run-status enum — it is not in store.RunStatus, the JS/dashboard run-status schemas, or the
runs.status CHECK constraint on either backend, because the row it describes no longer exists
to have a status. A consumer must not validate this frame against the run schema; treat
status: "deleted" as its own case, the way system.function.{id}.deleted and
system.environment.{environment_id}.deleted carry identity fields rather than a
schema-shaped resource.
Best effort, like every other system.* frame (see Environment lifecycle
events): published directly to NATS after the delete commits,
not through the transactional outbox.
Redacting a stored payload
Section titled “Redacting a stored payload”Three RPCs irreversibly replace a stored payload with a placeholder. They cost
the events:redact action, which only the admin role holds by default.
| Method | Body | Replaces |
|---|---|---|
RedactEvent | eventId | events.data, plus its copies in pending outbox rows and outbox dead letters |
RedactStep | stepId (the step’s row id, as PatchStep takes) | the step’s output, original output and error, its parent run’s error, the error of each of its recorded attempts and of the run’s run-level retries, plus the payload of every audit row for that step |
RedactRun | runId | the run’s input and output, its error and every child step’s error, the error of every recorded attempt of the run and its steps, plus the payload of every audit row for that run and its steps |
curl -X POST localhost:9123/ironflow.v1.IronflowService/RedactRun \ -H 'Authorization: Bearer ifkey_your_key_here' \ -H 'Content-Type: application/json' \ -d '{"runId":"run_abc"}'Each replaced value becomes:
{ "$redacted": true, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "redactedAt": "2026-09-21T04:00:00.123456789Z"}sha256 is the hash of the original bytes. Record identity,
event sequence, entity version, event name, status and timestamps stay, so replay
ordering and entity-stream optimistic concurrency are preserved. Run and step
payload changes invalidate stale row versions, and permanent redaction markers
prevent later writes from restoring protected payloads. Reducers receive the
placeholder in the payload’s place. The
SDKs expose isRedacted(payload) so a reducer can tell it from real state, and
their upcaster chains skip a redacted payload rather than migrate it, so the
marker survives. All three calls are idempotent: a second call succeeds and leaves the
first sha256 alone.
Redacting an entity event also drops that stream’s snapshots, whose state
is derived from the bytes being removed; the next read re-reduces from the
events. RedactStep and RedactRun are refused with failed_precondition
while the run is non-terminal, because a memoized resume reads the step output
and a resume reads the run input. RedactEvent has no such guard: consumers
seeing the placeholder is the contract, so a pending publish delivering it is
correct. An event that is already published stays in its stream until the
stream’s retention window expires, at most 7 days. The worker reads each row
just before it publishes. A publish past that read when the redaction commits
counts as published, so it can still send the original.
A step’s output and original_output always go together, and a run’s input
and output always go together. The two halves of each pair share one
object-storage prefix, so removing one and sweeping the prefix would leave the
other pointing at bytes that are gone.
Errors are replaced as whole envelopes, including message, code, stack,
details, unknown fields and encrypted envelopes. Empty errors remain absent.
RedactStep always scrubs the parent run’s error because SDKs copy step failures
into it without reliable step provenance; sibling step errors are unchanged.
Stored error placeholders survive later writes, including retry and terminal
transitions after a resume and error-only scrubbing of a parent run or child
step. An absent error on an error-only row has no placeholder to protect; this operation does not prohibit a new error
from being recorded if that row is later reactivated.
Five things redaction does not reach:
- Projection state. A projection’s read model is yours; the engine never rewrites it, on a stream delete or a redaction. A projection that folded the removed payload into its state keeps it until you rebuild the projection.
- The runs an event triggered.
runs.inputholds a copy of the event’s payload, and so does therun.createdaudit row.audit_eventscarries noevent_id, soRedactEventscrubs no audit rows at all. CallRedactRunfor those. - A step’s payload columns, on a run redaction.
RedactRunscrubs step errors, but leaves step input, output and original output unchanged. CallRedactStepto scrub its output and original output. - A copy already published to a stream. It survives until its own retention window expires — seven days on PUBSUB; EVENTS has no age expiry and requires terminal reclamation. This includes a publish past its final read of the row when the redaction commits.
- Immediate removal of every object-storage copy. Run and step redaction commit permanent cleanup markers with the payload changes. A worker repeatedly sweeps their exact prefixes, including late uploads and failed cleanup attempts. Run markers cover only run payloads; step markers cover that step’s artifacts. Markers survive deletion of the corresponding records. Plaintext can remain until a later successful sweep; continued database, blob backend and worker availability and cleanup progress are required. The interval is not a removal deadline. These markers do not authorize cleanup of unmarked prefixes or removal of locked immutable archive objects. See the amended ADR 0092, which requires repository access.
Each call emits an event.redacted, step.redacted or run.redacted audit row
carrying the id only — an audit row echoing the removed bytes would put them
straight back — and publishes no system.* frame.
Redaction is the one operation that rewrites a stored audit_events payload,
which ADR 0085
otherwise forbids.
ADR 0092
supersedes that clause for this operation alone. These decision records require
repository access.
Patch a step
Section titled “Patch a step”POST /api/v1/steps/patch was removed. Use
POST /ironflow.v1.IronflowService/PatchStep to override a step’s output —
for example to fix a failed step before resuming.
Request:
{ "stepId": "step_abc", "output": { "result": "manual_fix" }, "reason": "Corrected API response"}Response: the patched step.
Errors:
invalid_argument— the output has a top-level$redactedmember. See Payload markers.not_found— step id does not exist.failed_precondition— the step is neithercompletednorfailed.aborted— another writer (a concurrent patch, orInjectStepOutput) won the step’s compare-and-swap. Nothing was written. Re-read the step and reissue; the request carries no expected version, so a blind retry would overwrite whatever landed. Answered withIronflow-Error-Reason: contendedand aRetry-After.
Patching a failed step to completed clears its error but does not unwind
side effects the failure already triggered — compensations that ran are not
rolled back.
A successful patch publishes system.run.{runId}.step.{stepId}.patched.
InjectStepOutput publishes the same event (#2065) — the two write the same
step columns, so a subscriber sees a rewritten step output from either surface.
Automatic sleep, event, invocation, retry, and recovery waits report waiting.
Pause creates an immediate explicit paused hold on a waiting run. Resume
preserves its owned wait cursor, condition and deadline; an unfinished wait
returns to waiting, while a completed or timed-out condition retains its
result. Waiting runs can be paused or cancelled and cannot be manually resumed.
Failed-run resume and explicit from-step replay retain their existing behavior,
except for a held run that already has a queued retry. Its replay uses that
queued execution, so it starts when the retry delay ends.
History Editing (Scoped Injection)
Section titled “History Editing (Scoped Injection)”Pause Run
Section titled “Pause Run”Pause a running workflow at the next step boundary for inspection and injection.
POST /ironflow.v1.IronflowService/PauseRunRequest:
{ "run_id": "run_abc123"}Response:
{ "status": "pause_requested"}Status is "pause_requested" for a running run that will pause at the next step boundary, or "paused" when a waiting or already-paused run gains an immediate explicit operator hold.
Get Paused State
Section titled “Get Paused State”Get completed and failed steps and their outputs for an injection-paused run.
POST /ironflow.v1.IronflowService/GetPausedStateRequest:
{ "run_id": "run_abc123"}Response:
{ "steps": [ { "id": "step_xyz", "name": "compute-total", "output": "eyJ0b3RhbCI6IDQyfQ==", "injected": false, "completedAt": "2026-03-08T00:00:00Z", "stepType": "invoke", "status": "completed" } ], "nextStepHint": "send-email", "pauseReason": "injection"}output (and error) are bytes fields, so Protobuf JSON carries them base64-encoded; decode to get the step’s JSON. status is completed or failed. error is set only on a failed step, so you can repair it with InjectStepOutput. stepType is the step kind (invoke, sleep, wait_for_event, compensate, invoke_function).
Only works on runs with status = "paused" and pause_reason = "injection".
A run can also be paused with pause_reason = "retries_exhausted": it used up its
retry attempts under on_exhausted = "pause". Its error holds the last failure, and
ended_at stays unset because the run has not ended. TriggerSync and
InvokeFunctionSync return such a run at once with status PAUSED and that error,
rather than waiting out timeout_ms.
Inject Step Output
Section titled “Inject Step Output”Modify the output of a completed step while a run is paused for injection.
POST /ironflow.v1.IronflowService/InjectStepOutputRequest:
{ "run_id": "run_abc123", "step_id": "step_xyz", "new_output": "eyJ0b3RhbCI6IDUwfQ==", "reason": "Correcting calculation error"}new_output is a bytes field: base64-encode the JSON output (the SDKs do this for you).
Response:
{ "stepId": "step_xyz", "previousOutput": "eyJ0b3RhbCI6IDQyfQ=="}Preserves the original output on first injection. Sets patched_at and patched_by on the step.
Environments
Section titled “Environments”GET /environments
Section titled “GET /environments”List the environments of the caller’s organization. Pass project_id to list one project’s environments; a project of another organization returns 403. Each item has id, project_id, name, color, created_at and updated_at.
POST /environments
Section titled “POST /environments”Create a new environment. Returns 201 with the environment.
Request:
{ "name": "staging", "project_id": "proj_default_default", "color": "3b82f6"}project_id is optional when the caller’s credential already names a project. An environment must belong to a project, so a call with neither returns 400. color is an optional hex colour without #. The server picks one when you omit it. A name is at most 64 characters of lowercase letters, digits and hyphens. It may not start or end with a hyphen, contain --, or be current. A name that is already in use returns 409.
PUT /environments/{id}
Section titled “PUT /environments/{id}”Update an environment (e.g., rename it). Returns the environment. name is required and follows the rules above. color is optional and keeps the stored value when omitted. A name already in use returns 409.
Request:
{ "name": "production"}DELETE /environments/{id}
Section titled “DELETE /environments/{id}”Delete an environment. The default environment (env_default) cannot be deleted and returns 403. A missing environment returns 404. Returns 200 with {"status": "deleted"}. On PostgreSQL, a write that commits during the delete can stop it. The answer is then 409 with code aborted. Nothing was deleted, so retry the delete. A deadlock gives the same answer. retryable is false on this answer, as on every aborted, so a client library does not resend it on its own. Use code to tell this failure from a 500, which reports retryable as true.
Both DELETE and PUT verify that the environment belongs to the caller’s organization,
resolved through the environment’s project. A cross-organization attempt answers 404 with
the same body as a genuine miss, so the status cannot be used to probe whether an id exists.
Platform callers bypass the check. An environment with no project is unattributable and
remains allowed for any caller — a documented gap (#1516) that needs a data backfill before
it can fail closed.
Environment lifecycle events
Section titled “Environment lifecycle events”A successful delete publishes system.environment.{environment_id}.deleted (issue #1748),
carrying id, name, projectId, timestamp. One leaf today; identity only, since the
row it describes no longer exists.
The frame is best effort, like every other system.* frame: it is published directly to
NATS after the delete transaction commits, not through the transactional outbox. A NATS
outage, an open circuit breaker, or a process crash in that window loses it with no retry
and no dead-letter. A consumer must therefore treat this as a hint that lets it drop state
early, not as a ledger it can rely on — seed from GET /environments and reconcile.
Read it as “drop everything scoped to this environment.” Teardown removes the
environment’s functions with a single bulk DELETE FROM functions WHERE environment_id = …,
so no per-function deleted frame fires and a consumer that built its list from
system.function.> would otherwise hold every entry until it re-polls ListFunctions.
One frame replaces that fan-out: N per-function frames would hit the deployment-wide
pub/sub stream at exactly the moment the environment is being torn down.
The frame is published into the deleted environment’s own scope, not the caller’s, and after the delete commits — a failed delete publishes nothing. It survives the operator hanging up: the publish context is stripped of cancellation, so a closed tab does not cost the frame. It is bounded at 2s, so a degraded NATS cannot hold the delete request open.
An environment ID that cannot be exactly one literal NATS subject token — one containing
., * or >, or longer than 110 bytes — is skipped rather than published, since such an
ID would either land the frame on a different environment’s subject or spend a failure on
the shared NATS-publish circuit breaker. Environment IDs minted through POST /environments
can never hit this (names are validated to [a-z0-9-]); only the platform-bootstrap path
can mint one, and such an environment is already broken for every other event it publishes.
Note that every subscription is single-environment, so its audience is a client still connected to that environment when an admin deletes it (a dashboard tab), which outlives the environment it watches. A subscriber scoped to a different environment does not receive it.
Cascading parent deletes publish it too (issue #1754). DELETE /projects/{id},
DELETE /orgs/{id} and DELETE /platform/tenants/{id} delete each environment and its
dependent data in the same transaction before removing the project or organization. The
transaction returns snapshots of the environments it committed, so blob cleanup and lifecycle
publication use the exact rows removed rather than a separate pre-delete listing. This explicit
cleanup works when an environment contains registered functions, whose foreign key does not
carry ON DELETE CASCADE.
After the transaction commits, the handler publishes one frame per environment removed, each in its own environment’s scope. The fan-out is bounded by environments-per-project or per-org, not by functions-per-environment. Publication remains best-effort after commit, but database errors return no deletion result and publish no frames.
The 2s bound above is per frame, and the frames are published in a loop on the request goroutine, so a cascading delete of N environments against a degraded NATS can hold the response for up to N × 2s. The delete itself has already committed by then; only the reply waits.
Projects
Section titled “Projects”GET /projects
Section titled “GET /projects”List all projects in the organization.
POST /projects
Section titled “POST /projects”Create a new project. Returns 201 with the project.
Request:
{ "name": "payments", "description": "Payment workflows"}name is at most 64 characters of lowercase letters, digits and hyphens, and may not start or end with a hyphen. description is optional, at most 1024 characters. A name already in use returns 409.
PUT /projects/{id}
Section titled “PUT /projects/{id}”Update a project. Send name, description or both. A request with neither returns 400. An omitted field keeps its stored value. Returns the project.
DELETE /projects/{id}
Section titled “DELETE /projects/{id}”Delete a project and its environments. The default project (proj_default_default) cannot be deleted and returns 403. Returns 200 with {"deleted": "<id>"}. A write that stops the delete on PostgreSQL returns 409 with code aborted, as for DELETE /environments/{id}.
Entity Streams (Event Sourcing)
Section titled “Entity Streams (Event Sourcing)”Entity streams use ironflow.v1.EntityStreamService. The duplicate REST routes
have been removed. Send JSON POST requests to these Connect methods:
| Method | Purpose |
|---|---|
ListStreams | List streams; accepts entity type, search, limit and offset. search is a case-insensitive (rules) substring match on the entity ID: % and _ are wildcards; \%, \_ and \\ match them literally. Tombstoned streams are omitted unless includeDeleted is set. |
GetStreamInfo | Read a stream’s current version and event count. deleted is true once the stream carries a tombstone. |
ReadStream | Read events by version, limit and direction. |
AppendEvent | Append an event with an optimistic-concurrency check. |
GetEntityHistory | Read events with their runs and steps; supports timestamp filters. |
CreateSnapshot | Store state at an existing stream version. |
GetSnapshot | Read the latest snapshot at or before a version. |
DeleteStream | Tombstone a stream, optionally purging its events. Costs streams:delete. |
The request body carries entityId. Protobuf JSON encodes 64-bit version fields
as decimal strings. An omitted expectedVersion means zero and requires an
empty stream. Use -1 to skip the concurrency check or a positive version to
require that exact current version.
curl -X POST localhost:9123/ironflow.v1.EntityStreamService/AppendEvent \ -H 'Authorization: Bearer ifkey_your_key_here' \ -H 'Content-Type: application/json' \ -d '{"entityId":"order-123","entityType":"order","eventName":"order.shipped","expectedVersion":"-1"}'AppendEvent also takes data (or dataValue for a payload that is not an object), idempotencyKey, version (the event schema version) and metadata.
A snapshot’s entityType must match the stream’s stored type. A mismatch
returns InvalidArgument. The stored snapshot and notification frame carry
the stream’s type.
Deleting a stream
Section titled “Deleting a stream”DeleteStream takes entityId and an optional purge, and returns
entityVersion — the version of the tombstone it appended.
curl -X POST localhost:9123/ironflow.v1.EntityStreamService/DeleteStream \ -H 'Authorization: Bearer ifkey_your_key_here' \ -H 'Content-Type: application/json' \ -d '{"entityId":"order-123","purge":false}'It appends a $stream.deleted event at entityVersion + 1 under the same
optimistic-concurrency check as any other append, and drops the stream’s
snapshots. $ is a reserved event-name prefix: AppendEvent rejects any name
starting with it as invalid_argument, so this admin-only RPC is the only way
a tombstone reaches a stream. The event’s payload is entityId, deletedAt (RFC 3339 UTC) and
purge. Every later AppendEvent on that stream returns
failed_precondition; the Connect protocol has no 410. See Reserved names.
purge: true additionally deletes every event row below the tombstone. The
tombstone row itself always survives — it is what makes the stream refuse
appends. The purge is a follow-up step after the tombstone has committed, not
part of the same transaction: if it fails, the response says so and re-issuing
DeleteStream with purge: true runs it again. Purging lets foreign-key
cascades take rows that reference those events with them; that is the caller’s
explicit choice, and the nightly retention job
(IRONFLOW_ENTITY_EVENT_RETENTION_DAYS) instead skips referenced rows.
Three bounds on that cascade:
- A purge is refused with
failed_preconditionwhile any run triggered by an event below the tombstone is still active (notcompleted,failedorcancelled). Nothing is deleted. The tombstone already stopped new appends, so retry once those runs finish. - On PostgreSQL, a purge does not wait on those runs or their rows. It is refused
with
abortedwhile another call holds one of them or a row of one, for example a step report in progress, a run invoked from one of them, or the lease a cancelled run still holds. Nothing is deleted. The write ends on its own, so retry the call. A purge can still wait on the stream’s event rows, for example for a run redaction to commit, before it answers. outbox_dead_letterrows naming a purged event are deleted in the same transaction. They carry no foreign key, so leaving them would strand rows whose requeue must re-insert into the FK-constrainedoutbox.
Calling DeleteStream on an already-deleted stream returns
failed_precondition when purge is false, and with purge true re-runs the
purge and returns the existing tombstone’s version.
Reads keep working after a delete. ReadStream and GetStreamInfo still
answer — GetStreamInfo reports deleted: true — and
ListStreams omits the stream unless the request sets includeDeleted.
CreateSnapshot does not: it returns failed_precondition on a tombstoned
stream, so a snapshot cannot restore state a purge removed.
Platform-internal streams (the reserved ironflow: prefix) are refused with
permission_denied, and an unknown stream with not_found. See Reserved names.
The tombstone travels the normal publish path, so a projection receives it only
if its registered event list matches $stream.deleted; delivery is filtered by
event name. The engine does not touch projection state, so a projection that
never registered the name keeps the state it built from the deleted stream.
Stream lifecycle events
Section titled “Stream lifecycle events”An append, a snapshot and a delete each publish a frame on
system.stream.{entity_id}.{event}, where {event} is appended (issue #1730),
snapshotted (issue #1749) or deleted. Subscribe to system.stream.> for all stream
activity, or system.stream.{entity_id}.> to follow one entity — with the >
wildcard rather than a literal leaf, so a future verb needs no client change.
The topic is keyed by entity ID — the same key these endpoints, the dashboard
URL and ironflow stream act on; the entity type is in the payload.
appended fires on every committed append, from any surface (ConnectRPC, CLI,
SDK), and carries entity_id, entity_type, entity_version, event_id,
event_name, run_id, and timestamp. run_id is set only when an in-flight
function wrote the event (the X-Ironflow-Run-ID header).
snapshotted fires on every committed snapshot, from
EntityStreamService/CreateSnapshot, and carries entity_id, entity_type, entity_version, snapshot_id, and
timestamp.
deleted fires after a committed EntityStreamService/DeleteStream, and
carries entity_id, entity_type, entity_version, purge, and
timestamp.
entity_version does not mean the same thing on the three
verbs. On appended it is the stream’s new head. On snapshotted it is the
version the snapshot was taken at, which may be far below the head — both
endpoints accept any version up to the current one, so backfilling a snapshot
at v5 of a v100 stream emits entity_version: 5. On deleted it is the
tombstone’s version, which is the stream’s final head. Do not read them off one
> subscription as if they were the same counter.
This is the operator view, not the data view. No frame carries the
event body or the snapshot state. The data view already exists and is unchanged:
an append also publishes entity:{entity_type}.{entity_id}.{event_name} with
the full event, and events:{event_name} for projections. Subscribe there if
you need the payload — pushing every append’s body to every system.>
subscriber would repeat the per-client marshal cost topicOnly exists to avoid.
A snapshot has no data view; read it back with
EntityStreamService/GetSnapshot.
These frames are a hint, not a ledger. They do not travel through the
outbox, so unlike the entity: publish they are not durably retried and not
dead-lettered; a dropped frame is gone. (The publish itself makes up to four
in-process attempts before giving up — that is a connection retry, not a
delivery guarantee, and it buys nothing once the process exits.) Reconcile
against EntityStreamService/ListStreams and EntityStreamService/ReadStream
rather than holding state from frames alone.
An entity ID may contain . (the allowed charset is A-Z a-z 0-9 - . _ : ~), so an ID of a.b spans
two subject segments. system.stream.> and system.stream.{entity_id}.> are
unaffected, but a leaf filter like system.stream.*.appended misses dotted IDs
and system.stream.a.> also matches entity a.b. Route on the trailing segment
of a system.stream.> subscription instead of filtering with *.
An entity ID may not have an empty segment — a..b, .a, a. are
rejected with invalid_argument on EntityStreamService/AppendEvent (issue #1746), because
the subject they build is one NATS rejects. Before #1746 the append succeeded
and only the publish failed, silently and repeatedly.
Two entity IDs still get no frame at all. A platform-internal ID (the reserved
ironflow: prefix) is never published, matching every read surface, which hides
those streams. An ID with an empty segment is skipped for the reason above — and
after #1746 that can only be a legacy row written before the rule existed,
reached through EntityStreamService/CreateSnapshot, which checks the reserved
prefix but not the full ID. Such a stream stays readable; new appends to it are refused.
Projections
Section titled “Projections”Projections use ironflow.v1.ProjectionService. The duplicate REST routes were
removed; two REST routes remain and are documented below. Send JSON POST
requests to these Connect methods:
| Method | Purpose |
|---|---|
ListProjections | List registered projections; accepts status, mode, limit, offset. |
GetProjection | Read a projection and its current state; accepts partition (default __global__). A managed projection whose last rebuild failed answers failed_precondition until a new rebuild starts. |
GetProjectionStatus | Read processing status. |
WaitProjectionCatchup | Wait until a projection reaches a minimum NATS sequence. A failed rebuild answers failed_precondition. |
WaitProjectionCatchupBatch | The same wait for several projections in one call. |
WaitForEvent | Wait until one specific event has been applied. |
RebuildProjection | Trigger a full or partial rebuild. |
GetRebuildJob | Read the status of an in-flight rebuild. |
CancelRebuild | Cancel an in-flight rebuild. |
PauseProjection | Stop consuming events. |
ResumeProjection | Resume a paused projection. |
Protobuf JSON encodes 64-bit sequence fields as decimal strings.
Projection status
Section titled “Projection status”GetProjectionStatus response:
{ "name": "order-totals", "status": "running", "mode": "managed", "lastEventSeq": "142", "updatedAt": "2026-03-06T12:00:00Z"}A healthy projection carries no errorMessage key at all; the field appears
only when there is a message. An unknown name answers
{"code": "not_found", ...}.
Whenever a projection’s health changes — pause, resume, rebuild start /
progress / completion, and handler errors — WebSocket subscribers receive the
same snapshot on system.projection_health.{name}. The frame carries name,
status, mode, last_event_seq, error_message, timestamp, and, only
while a rebuild is in flight, rebuild_target_seq, rebuild_start_cursor, and
rebuild_started_at.
lag is present only when the consumer could actually be read. If the consumer
is missing or NATS is unreachable, the key is absent rather than 0 —
zero is what a fully caught-up projection reports, so sending it for an
unreadable consumer would advertise perfect health during an incident. Treat
absence as “unknown”, not “none”. GetProjectionStatus still returns lag: "0"
in that case, because the field is a bare int64 on the wire with no absent
value.
That topic is deliberately not under system.projection.{name}.: >
matches one or more trailing segments, so a health subject there would be
delivered to everything subscribed to system.projection.{name}.> for state
updates — including subscribeToProjection in the browser SDK. Subscribe to
system.projection_health.> for all projections.
During a rebuild the status stays rebuilding, so progress is expressed as the
cursor advance: each applied batch publishes a frame whose last_event_seq has
moved against rebuild_start_cursor and rebuild_target_seq. Compute a
percentage as (last_event_seq - rebuild_start_cursor) / (rebuild_target_seq - rebuild_start_cursor). A replayed batch that does not advance the cursor
publishes nothing.
Health is published on transitions and rebuild cursor advances, not on a timer.
A projection that is stuck with growing lag but emits neither publishes nothing;
poll GetProjectionStatus for continuous lag monitoring.
GET /projections/{name}/partitions
Section titled “GET /projections/{name}/partitions”List partitions for a partitioned projection. A managed projection whose last rebuild failed answers failed_precondition until a new rebuild starts.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
q | string | Filter partitions by key prefix, case-insensitive (rules) (max 200 chars; longer returns 400) |
limit | int | Max results (default 50). Must be a positive integer. |
Response:
{ "partitions": ["tenant-1", "tenant-2"], "returned": 2 }An unknown projection returns 404.
Connect: projection wait
Section titled “Connect: projection wait”GET /api/v1/projections/{name}/catchup was removed. Use
POST /ironflow.v1.ProjectionService/WaitProjectionCatchup. Long-poll style;
returns when the projection is current or the wait times out.
Request:
{ "name": "order-totals", "minSeq": "142", "partition": "tenant-1", "timeout": "30s"}minSeq is required and, being a 64-bit field, is a decimal string. timeout
is a protobuf Duration string. It defaults to 30s. On the three unary wait methods a value under 100ms or over 60s returns invalid_argument. WaitProjectionCatchupStream is the streaming variant, with a limit of 300s.
Partition on the wait methods
Section titled “Partition on the wait methods”partition is optional on all three wait methods in this section and means
“wait on this one partition instead of the whole projection”. Omit it for a
global wait — __global__ is the server’s internal key for un-partitioned
state and is rejected as input (invalid_argument, partition "__global__" is reserved; omit partition for global waits).
A partition is rejected on an external projection (external cursors are
stream-level, so per-partition wait correctness is undefined) and on a
projection that declares no partition_key and holds no state row under that
partition, since such a wait can never catch up. A partition that no events
have reached yet on a partitioned projection is accepted — its cursor reads
as 0 until the projection writes it, which is what makes append-then-wait work
for a brand-new key.
Rejections surface as invalid_argument on WaitProjectionCatchup and
WaitForEvent. On WaitProjectionCatchupBatch they are per item: the call
itself succeeds and the offending entry carries an error string.
Because a partition on a partitioned projection cannot be existence-checked
without breaking the read-your-write path above, the cost of an invented one is
bounded instead: at most 64 distinct partitions of a single projection may
have a live wait at the same time. Joining a partition that already has one is
never counted. Over the cap the wait is rejected with resource_exhausted
(too many concurrent partition waits for this projection); on the batch method
it is a per-item error.
Connect: batch projection wait
Section titled “Connect: batch projection wait”The REST route was removed. Use POST /ironflow.v1.ProjectionService/WaitProjectionCatchupBatch.
Wait for multiple projections to catch up in one call — 1 to 16 items, all
sharing one timeout and one cap reservation. Each item’s optional partition
follows Partition on the wait methods.
results is the same length as items, in the same order.
Request:
{ "items": [ { "name": "order-summaries", "minSeq": "142", "partition": "tenant-1" }, { "name": "user-index", "minSeq": "142" } ], "timeout": "5s"}Connect: wait for event
Section titled “Connect: wait for event”The REST route was removed. Use POST /ironflow.v1.ProjectionService/WaitForEvent.
Block until a specific event has been applied by a projection. Useful after AppendEvent when AppendEventResponse.Sequence is 0. The optional partition follows Partition on the wait methods and is checked before the event is resolved, so a rejected one fails immediately rather than after the wait.
Request:
{ "eventId": "evt_abc", "projection": "order-totals", "partition": "tenant-1", "timeout": "5s"}Connect: rebuild, pause and resume
Section titled “Connect: rebuild, pause and resume”POST /api/v1/projections/{name}/rebuild, GET .../rebuild,
POST .../cancel, POST .../pause and POST .../resume were removed. Use
RebuildProjection, GetRebuildJob, CancelRebuild, PauseProjection and
ResumeProjection on ironflow.v1.ProjectionService. Each takes the
projection name.
RebuildProjection request:
{ "name": "order-totals", "fromEventId": "evt_start", "toEventId": "evt_end", "partition": "tenant-1", "dryRun": false}Every field but name is optional.
DELETE /projections/{name}
Section titled “DELETE /projections/{name}”Delete/unregister a projection. Returns 200 with {"status": "deleted"}. It calls ProjectionService/UnregisterProjection, so an unknown projection returns 404.
Pub/Sub
Section titled “Pub/Sub”Publish to a topic
Section titled “Publish to a topic”POST /api/v1/publish was removed. Use
POST /ironflow.v1.PubSubService/Publish to publish a message to a developer
pub/sub topic.
Request:
{ "topic": "notifications.email", "data": { "to": "user@example.com", "subject": "Hello" }, "idempotencyKey": "notif-123"}Response:
{ "eventId": "evt_abc", "sequence": "42"}duplicate is true when the call repeated an idempotencyKey the server had already seen on this topic in this environment; it is omitted otherwise. A duplicate response carries the first publish’s eventId and sequence. The server keeps each key for IRONFLOW_RUN_RETENTION_DAYS days after the first publish (kept forever when unset or 0). dataValue carries a payload that is not a JSON object.
The service answers unavailable until the server has a NATS bridge
configured.
Topics with reserved prefixes (events:, system., entity:, public., topic:) are rejected. See Reserved names.
A topic must also be under 255 bytes (the limit counts the topic: namespace prefix the
server adds, so 249 bytes of name), may not contain the NATS wildcards * or >, and may
not contain whitespace, a control character, or an empty . segment (a..b, .a, a.).
Each returns invalid_argument. Non-ASCII topic names are allowed.
idempotencyKey is optional. It must be at most 255 bytes, with no whitespace or
control characters; anything else returns invalid_argument.
The route is always mounted, but it needs the pub/sub bridge. An embedded server
started without one keeps answering here and returns unavailable (HTTP 503,
pubsub is not configured); ironflow serve always wires the bridge.
Event Schema Registry
Section titled “Event Schema Registry”The schema registry uses ironflow.v1.EventSchemaService. The duplicate REST
routes were removed. Send JSON POST requests to these Connect methods:
| Method | Purpose |
|---|---|
RegisterSchema | Register (or overwrite) a schema version. |
ListSchemas | List schemas; accepts eventName, limit, offset. |
GetSchema | Read one schema — the latest version, or version when given. |
DeleteSchema | Delete one (eventName, version) pair. |
TestUpcast | Dry-run the upcast chain (see below). |
Register a schema
Section titled “Register a schema”RegisterSchema request:
{ "eventName": "order.placed", "version": 2, "schemaJson": "{\"type\":\"object\",\"properties\":{...}}", "description": "Added shipping address field"}schemaJson must be a JSON Schema the server can compile (draft 2020-12); a
malformed document returns invalid_argument. changeReason is an optional
note recorded in the schema’s history. DeleteSchema takes the same optional
field.
Registration is an upsert on (eventName, version, environment). Both outcomes
succeed; the body reports which occurred:
| Outcome | Body |
|---|---|
| New version registered | {"status": "created"} |
| Existing version overwritten | {"status": "updated"} |
schemaJson is checked for compilability, not usefulness. An object of
unrecognized keywords — {"foo": "bar"} — is a valid JSON Schema that accepts
every payload, exactly as {} does, so registering the wrong JSON by mistake
succeeds and enforces nothing.
Whether a registered schema is enforced on the emit path is controlled by
IRONFLOW_EVENT_SCHEMA_ENFORCEMENT; it is off by default. See
Configuration.
Read and delete schemas
Section titled “Read and delete schemas”GetSchema returns the latest version for an eventName when version is
omitted, and that exact version when it is given; a version with no registered
schema answers not_found. DeleteSchema takes both fields and returns an
empty body.
Test the upcast chain
Section titled “Test the upcast chain”POST /api/v1/events/upcast was removed. Use the Connect endpoint
POST /ironflow.v1.EventSchemaService/TestUpcast with Content-Type: application/json:
{"eventName":"order.placed","fromVersion":1,"toVersion":2,"data":{"orderId":"123"}}The response contains the unchanged data and a stepsApplied array with schema
metadata for each version. This endpoint does not run SDK-defined transformations.
The Go, Node, and browser SDKs expose schemas.testUpcast or Schemas().TestUpcast.
Python exposes rpc.event_schemas.test_upcast. REST-only clients generated from
OpenAPI no longer expose this capability.
Each version in the span costs one schema lookup, so the span is capped:
to_version - from_version must be at most 200. A wider span answers invalid_argument
(upcast version span must not exceed 200) before any lookup runs.
SQL Query
Section titled “SQL Query”Raw SQL is a ConnectRPC capability. It has no REST route.
POST /sql was removed. Call ironflow.v1.QueryService/ExecuteSQL instead:
POST /ironflow.v1.QueryService/ExecuteSQLContent-Type: application/json
{"query": "SELECT * FROM order_totals WHERE total > 100", "timeoutMs": 5000, "maxRows": 100}Response:
{ "columns": ["id", "total"], "rows": [{"values": ["order-1", "150"]}], "totalRows": 1}timeoutMs defaults to 5000 and the server caps it at 30000. maxRows defaults to 1000 and the server caps it at 10000.
Every cell is a string, because the server stringifies every column type.
The SDK methods are client.Projections().ExecuteSQL(ctx, query) in Go,
rpc.query.execute_sql(...) in Python and ironflow.executeSQL(query, { timeoutMs, maxRows })
in the browser SDK. The Node SDK does not wrap raw SQL — query a projection instead.
A SQL NULL is the empty string, because every cell is a string.
KV Store
Section titled “KV Store”The KV routes are mounted only when the server has a NATS provider. ironflow serve
always does; an embedded server built without one does not register them at all,
so the calls below 404 rather than returning an error status.
POST /kv/buckets
Section titled “POST /kv/buckets”Create a KV bucket. Returns 201 with the bucket. A bucket that exists returns 409. A name with the SYS_ prefix or another reserved prefix returns 400.
Request:
{ "name": "session-cache", "description": "User session data", "ttl_seconds": 3600, "max_value_size": 65536, "max_bytes": 10485760, "history": 5}GET /kv/buckets
Section titled “GET /kv/buckets”List the KV buckets of your environment. Returns {"buckets": [...], "count": N}.
GET /kv/buckets/{bucket}
Section titled “GET /kv/buckets/{bucket}”Get bucket metadata.
DELETE /kv/buckets/{bucket}
Section titled “DELETE /kv/buckets/{bucket}”Delete a bucket. A system bucket returns 403.
GET /kv/buckets/{bucket}/keys
Section titled “GET /kv/buckets/{bucket}/keys”List keys in a bucket. Returns {"keys": [...], "count": N}.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
filter | string | Glob pattern to filter keys |
GET /kv/buckets/{bucket}/keys/{key}
Section titled “GET /kv/buckets/{bucket}/keys/{key}”Retrieve a key’s entry. Returns an ETag header with the revision. The body is a JSON entry with key, value (the stored bytes, base64-encoded), revision, created_at and operation. A missing bucket or key returns 404.
A key may use only letters, digits, /, -, _, = and .. It may not start or end with . or contain ... Any other key returns 400.
PUT /kv/buckets/{bucket}/keys/{key}
Section titled “PUT /kv/buckets/{bucket}/keys/{key}”Write a value. Returns 200 with {"revision": N} and an ETag header. Supports atomic operations via headers:
| Header | Effect |
|---|---|
If-None-Match: * | Create only. Returns 412 if the key exists. |
If-Match: <revision> | Compare-and-swap. Returns 412 if the revision differs, and 400 if it is not a number. |
Request body is the raw value (any content type).
DELETE /kv/buckets/{bucket}/keys/{key}
Section titled “DELETE /kv/buckets/{bucket}/keys/{key}”Delete a key. Returns 204. Add ?purge=true to remove the key and its history permanently.
GET /kv/buckets/{bucket}/watch
Section titled “GET /kv/buckets/{bucket}/watch”Watch a bucket for key changes over a WebSocket. Query: key limits the watch to a key
or a pattern such as user.* or user.>. Without it, the watch covers every key. The
connection rules, replay and errors are in Watch streams.
Each change arrives as one JSON text frame:
{ "type": "kv_update", "bucket": "sessions", "key": "user.123", "operation": "put", "revision": 4, "value": "eyJ0b2tlbiI6ImFiYyJ9"}| Field | Description |
|---|---|
operation | put or delete. A purge also arrives as delete. |
value | The stored bytes, base64-encoded. "" for a delete. |
revision | The bucket revision of this change. It increases across the whole bucket, not per key. |
Watch streams
Section titled “Watch streams”The two watch routes (/kv/buckets/{bucket}/watch and /config/{name}/watch) are
WebSocket upgrades, not JSON responses. They are mounted only with a NATS provider, and
the SDK generators skip them. The Go, Node and Python SDKs wrap both; the browser SDK
wraps the KV route.
Authentication. Send Authorization: Bearer <key>, or, where a client cannot set
headers (browsers), offer the subprotocols ironflow.v1 and
ironflow.auth.bearer.<base64url(key)>. The server answers with ironflow.v1.
Replay, then live. On connect the server first sends the current entry of every key the watch covers, delete markers included. Then it sends each change as it lands. The config route sends the current document, or nothing if the name does not exist yet.
Resume. There is no cursor. A new connection replays the current state again, so
changes made while you were disconnected arrive only as their latest value; the
intermediate revisions are gone. To catch up after a reconnect, compare each revision
with the last one you handled. A delete marker shows a key that went away while you were
offline.
Errors.
| When | What you get |
|---|---|
| Missing or invalid key, or no permission | The upgrade fails with 401 or 403 and a JSON error body. |
| The server has no NATS provider | 404: the route does not exist. |
| Reserved KV bucket name (a system bucket) | 404 bucket not found before the upgrade. |
| Config store unavailable | 500 before the upgrade. |
| KV bucket does not exist | The upgrade succeeds, then one frame {"type":"error","message":"bucket not found"}, then the server drops the connection. |
| The watch cannot start | One frame {"type":"error","message":"failed to start watch"} (failed to start config watch on the config route), then the server drops the connection. |
Clients only receive. The server ignores messages it gets, and closes the connection on a frame over 4 KiB. Close the socket to stop watching.
File Storage
Section titled “File Storage”Buckets, files and signed URLs. Object bodies are raw bytes with an ETag header. Limits, errors,
events and examples are in the Files guide.
The routes are mounted only when the server has a blob backend. Without one, every path below answers 404 with the code ROUTE_NOT_FOUND.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/files/buckets | Create a bucket. Body: name, optional maxObjectBytes, allowedContentTypes, emitEvents, allowSignedUrls. |
| GET | /api/v1/files/buckets | List buckets. |
| GET | /api/v1/files/buckets/{bucket} | Get a bucket. |
| PATCH | /api/v1/files/buckets/{bucket} | Update a bucket. |
| DELETE | /api/v1/files/buckets/{bucket} | Delete an empty bucket (409 if it has files). |
| PUT | /api/v1/files/buckets/{bucket}/objects/{path...} | Put a file. Raw body. Content-Type and Content-Length are required. X-Ironflow-Meta-{key} headers set metadata. If-None-Match: * and If-Match: <etag> make the write conditional. |
| GET | /api/v1/files/buckets/{bucket}/objects/{path...} | Get a file. Supports Range, If-Match and If-None-Match. |
| DELETE | /api/v1/files/buckets/{bucket}/objects/{path...} | Delete a file. |
| GET | /api/v1/files/buckets/{bucket}/info/{path...} | File metadata as JSON. |
| GET | /api/v1/files/buckets/{bucket}/objects | List files. Query: prefix, delimiter, cursor, limit. |
| POST | /api/v1/files/buckets/{bucket}/move | Move one file. Body: from, to, optional toBucket, ifMatch. |
| POST | /api/v1/files/buckets/{bucket}/copy | Copy one file. Body: from, to, optional toBucket. |
| POST | /api/v1/files/buckets/{bucket}/move-prefix | Directory move. Body: from, to (both end in /). Returns {count}. |
| POST | /api/v1/files/buckets/{bucket}/signed-urls/upload | Create an upload URL. Body: path, optional ttlSeconds, maxBytes, contentType, createOnly. |
| POST | /api/v1/files/buckets/{bucket}/signed-urls/download | Create a download URL. Body: path, optional ttlSeconds. |
| PUT | /api/v1/files/signed?token=... | Upload with a signed URL. No credentials. |
| GET | /api/v1/files/signed?token=... | Download with a signed URL. No credentials. |
Configuration
Section titled “Configuration”Like the KV routes above, the config routes are mounted only when the server has a NATS provider.
POST /config/{name}
Section titled “POST /config/{name}”Set or replace an entire config document.
Request: A JSON object, at most 4 MiB. Any other body returns 400, and a larger one returns 413.
Response:
{ "name": "app-settings", "revision": 1}The write is unconditional by default. Add a header to make it conditional. A failed condition returns 412, and a malformed or repeated header returns 400:
| Header | Effect |
|---|---|
If-None-Match: * | Create only. Fails if the document exists. |
If-Match: <revision> | Replace only that revision. The revision is a positive integer. |
PATCH /config/{name}
Section titled “PATCH /config/{name}”Shallow merge-patch a config document. Without If-Match, the server retries on a concurrent write and returns 409 if the retries run out. With If-Match: <revision>, it merges onto that revision once and returns 412 if the document changed.
Request: Partial JSON object with fields to merge.
GET /config/{name}
Section titled “GET /config/{name}”Get a config document.
Response:
{ "name": "app-settings", "data": { "theme": "dark" }, "revision": 3, "updatedAt": "2026-03-06T12:00:00Z"}GET /config
Section titled “GET /config”List all config documents. Returns {"configs": [{"name", "revision", "updatedAt"}]}. The list carries no document data.
DELETE /config/{name}
Section titled “DELETE /config/{name}”Delete a config document. Returns 204. With If-Match: <revision> it deletes only that revision and returns 412 if the document changed or is already gone.
GET /config/{name}/watch
Section titled “GET /config/{name}/watch”Watch one config document over a WebSocket. The connection rules, replay and errors are in Watch streams. Each change arrives as one JSON text frame:
{ "type": "config_update", "name": "app-settings", "data": { "theme": "dark" }, "revision": 3, "updatedAt": "2026-03-06T12:00:00Z"}data is the whole document after the change, and null after a delete. updatedAt is
UTC.
Secrets
Section titled “Secrets”The secrets routes are mounted only when the server has a secrets manager; the
standard one is NATS-backed. All secrets endpoints require the X-Ironflow-Environment-ID header. Secret values are write-only and never returned in responses.
GET /secrets
Section titled “GET /secrets”List secret metadata. Returns a JSON array. Each item has name, description, revision, created_at and updated_at. No item carries the value.
POST /secrets
Section titled “POST /secrets”Create a new secret. Returns 201 with the metadata. name and value are required. Posting a name that exists overwrites the value and keeps created_at.
Request:
{ "name": "STRIPE_KEY", "value": "sk_live_...", "description": "Production billing key"}GET /secrets/{name}
Section titled “GET /secrets/{name}”Get secret metadata (not the value).
PUT /secrets/{name}
Section titled “PUT /secrets/{name}”Update a secret’s value.
Request:
{ "value": "sk_live_new_...", "description": "Rotated key"}PATCH /secrets/{name}
Section titled “PATCH /secrets/{name}”Partial update: name (rename) and/or description. At least one must be set. A rename to a name that exists returns 409.
DELETE /secrets/{name}
Section titled “DELETE /secrets/{name}”Delete a secret. Returns 204.
Flow Map
Section titled “Flow Map”GET /flow/emit-edges
Section titled “GET /flow/emit-edges”List learned function→event causation edges for the Flow Map. Requires functions:list and is scoped to the caller’s environment.
GET /flow/stream-edges
Section titled “GET /flow/stream-edges”List learned event→entity-stream relationships for the Flow Map. Requires
streams:read and is scoped to the caller’s environment. An edge appears once
an event of that name has been appended to a stream of that entity type;
platform-owned entities (ironflow: prefix) are excluded, matching the streams
list.
Response:
{ "edges": [ { "event": "order.created", "entity_type": "order" } ]}GET /flow/topic-edges
Section titled “GET /flow/topic-edges”List learned function→topic publish edges for the Flow Map. Requires
events:subscribe — the response names developer pub/sub topics — and is scoped
to the caller’s environment. An edge appears once a publish carries the
publishing run’s X-Ironflow-Run-ID, which the SDKs attach to any publish made
inside a run; publishes with no attributed run (CLI, plain API calls) are not
recorded.
Response:
{ "edges": [ { "function_id": "notifier", "topic": "notifications.email" } ]}GET /flow/invoke-edges
Section titled “GET /flow/invoke-edges”List declared function→function call edges for the Flow Map. Requires
functions:list and is scoped to the caller’s environment. Unlike the learned
edges above this is not self-reported: both IDs are resolved by the engine when
the child run is created, so an edge cannot be forged. It is also uncapped —
both columns are registered function IDs, so the result is bounded by how many
functions exist.
Response:
{ "edges": [ { "parent_function_id": "checkout", "child_function_id": "charge-card" } ]}GET /flow/wait-edges
Section titled “GET /flow/wait-edges”List declared event→function resume edges — events a function parks on via
step.waitForEvent. Requires functions:list and is scoped to the caller’s
environment. These events are by definition not in the function’s triggers,
so the arrow means “resumes this”, not “starts it”, and it appears nowhere else
on the map. Written by the engine when the waiting step is created, and
uncapped for the same reason as invoke edges.
Response:
{ "edges": [ { "function_id": "process-order", "event_name": "payment.settled" } ]}GET /flow/webhooks
Section titled “GET /flow/webhooks”Return the inbound-webhook portion of Flow Map. Requires events:subscribe and
is scoped to the caller’s environment. Registered sources are returned even if
they have never received traffic. Delivery counts summarize the retained
30-day delivery history; distinct source→event relationships come from durable
event causation and remain after delivery cleanup.
The response is intentionally safe for topology views: it contains no signing secrets, ingest tokens, headers, bodies, payloads, external delivery IDs, or delivery errors.
Response:
{ "sources": [ { "id": "wh_abc", "name": "Stripe production", "event_prefix": "stripe", "source_type": "api", "delivery_counts": { "accepted": 42, "rejected": 2, "failed": 1 }, "latest_delivery_status": "accepted", "latest_delivery_at": "2026-08-06T12:00:00Z" } ], "edges": [ { "source_id": "wh_abc", "event": "stripe.invoice.paid" } ]}After each retained delivery record is persisted, WebSocket subscribers may
receive a sanitized frame on system.webhook.{source_id}.delivery.{outcome}. It
contains delivery_id, source_id, status, created_at, and, for accepted
attempts, event_id and event_name. Re-fetch this endpoint to reconcile any
missed frame.
{outcome} is success for accepted, failure for rejected and failed —
so system.webhook.*.delivery.failure delivers every failed attempt without
payload inspection. The precise status stays in the payload.
system.webhook.> still matches every delivery frame. A retry that deduplicates
against an earlier accepted delivery answers 200 {"status":"deduplicated"}
without recording a new delivery row, so it publishes no frame at all.
The * in that filter matches exactly one subject segment, and {source_id}
is always one segment, so the filter covers every source. Sources created since
migration 031 carry a server-generated wh_ + UUID and appear in the subject
verbatim. Sources predating that migration kept their original
operator-supplied ID, which no code path ever validated — migration 031
deliberately does not rewrite them, so shared ingest URLs keep working. Such an
ID is encoded into one segment for the subject only: each ., *, >, space
or control character becomes _, and a short checksum is appended, so
stripe.prod publishes on system.webhook.stripe_prod~4b50ae9b.delivery.failure.
The stored ID, the ingest URL and the source_id in the payload are all
unchanged — identify the source by the payload, not by the subject.
Webhooks
Section titled “Webhooks”POST /webhooks/{provider}?token={ingest_token}
Section titled “POST /webhooks/{provider}?token={ingest_token}”Receive a webhook from an external provider (e.g., GitHub, Stripe).
Supports signature verification and idempotency by external delivery ID. When the source has a signing secret, the request must carry a valid signature. A missing or invalid one returns 401 and records a rejected delivery. The source’s descriptor sets the digest (HMAC-SHA256 or HMAC-SHA1), its encoding (hex or base64) and the signed string: the raw body, or a template with {body}, {ts} and {id} placeholders (ADR 0049). A template with {ts} can also set a tolerance for stale timestamps.
Authentication. Sources created after migration 046 carry a per-source ingest token (ADR 0048) and authenticate with ?token=ifwh_... — an org API key is not accepted for those. The token is returned once, at create and rotate. Sources predating that migration continue to authenticate with an API key.
Limits. Request bodies are capped at 1 MB; larger payloads get 413.
The event name is composed from the source’s event_prefix and the payload’s
type field (or the source’s configured event_name_path), so it is bound by
the same rules as Emit an event above. A payload that produces an invalid name
gets 400 and a failed delivery record. event_prefix itself is checked
against the same rules when the source is created, so a prefix that could never
build a valid name is rejected up front rather than at delivery time.
Response:
{ "status": "accepted", "delivery_id": "gh_123", "event_id": "evt_abc", "run_ids": ["run_xyz"]}Workers (Pull Mode)
Section titled “Workers (Pull Mode)”GET /workers
Section titled “GET /workers”List all connected REST-polling workers.
POST /workers/{workerId}/register (also accepts PUT)
Section titled “POST /workers/{workerId}/register (also accepts PUT)”Register a worker for HTTP polling.
Request:
{ "worker_id": "worker-1", "hostname": "host.example.com", "function_ids": ["my-function"], "max_concurrent_jobs": 5, "labels": { "region": "us-east" }, "version": { "sdk": "0.8.0", "runtime": "node-22" }}POST /workers/{workerId}/heartbeat (also accepts PUT)
Section titled “POST /workers/{workerId}/heartbeat (also accepts PUT)”Send a heartbeat to keep the worker connection alive.
Request:
{ "worker_id": "worker-1", "active_jobs": 2}Whenever a worker’s presence changes, WebSocket subscribers receive a snapshot
on system.worker.{worker_id}.health, keyed like a worker row in
GET /cluster/health: id, hostname, functions, max_concurrent,
active_jobs, last_heartbeat, connected_since, heartbeat_age_ms,
draining, and health.
health on the frame is healthy or unhealthy only. A worker past the
timeout is deregistered on the spot, so stale — which the polled
/cluster/health row does report — never reaches the wire; treat the
disconnected event as the terminal signal.
The frame is edge-triggered — published on a health transition, and on
drain (draining: true) when the server shuts a worker down, never once per
heartbeat. Heartbeats are 30s, a worker turns unhealthy past 60s and is
evicted past 90s, so a dying worker produces one unhealthy frame and then a
disconnected event, not a stream of them. Do not treat frame arrival as a
heartbeat; a healthy worker is silent.
A transition that fails to publish is not recorded, so the next 30s sweep
republishes it. Two limits remain: recovery is reported at that sweep rather
than on the heartbeat that recovers it, and the degraded window is exactly one
sweep wide — under load a dropped tick can take a worker from healthy to
disconnected with no unhealthy frame in between. The drain frame is
genuinely best-effort: shutdown has no later sweep to retry it.
Use heartbeat_age_ms for freshness rather than diffing last_heartbeat
against a browser clock — the server measures the age monotonically.
Note the payload keys differ per leaf under system.worker.>:
connected / disconnected carry workerId and functionIds (camelCase);
health carries the snake_case shape above, whose worker id key is id.
GET /workers/{workerId}/jobs
Section titled “GET /workers/{workerId}/jobs”Poll for job assignments. Returns 204 No Content if no jobs are available.
Query parameters:
| Parameter | Description |
|---|---|
available | Free job slots on the worker. The server never assigns more than its own count of the worker’s free slots. |
wait | Seconds to hold the poll open for a job, 0 to 25. Larger values are capped at 25; a negative or non-integer value is 400. The server answers as soon as it assigns at least one job, or with 204 when the wait ends. Absent or 0 answers at once. |
Every 200 and 204 carries the Ironflow-Poll-Wait header. Its value is the number of seconds the server held an empty poll: the wait on a 204 that came after the full wait, and 0 otherwise (a 200, or a 204 that returned early). After a 204 with a value above 0, a worker can poll again immediately. With 0, or without the header (an older server), the worker should pause before the next poll.
Response (when jobs available):
{ "jobs": [ { "job_id": "job_abc", "run_id": "run_xyz", "function_id": "my-function", "attempt": 1, "event": { ... }, "completed_steps": [ ... ], "context": { "secrets": { ... } }, "execution_seq": 3, "lease_token": "...", "lease_expires_at": "2024-01-15T10:31:30Z" } ]}PUT /workers/{workerId}/jobs/{jobId}/ack
Section titled “PUT /workers/{workerId}/jobs/{jobId}/ack”Acknowledge delivery of a job assignment before executing user code. The body carries run_id, execution_seq, and lease_token; the engine validates the execution fence and treats the ack as a lease heartbeat. A wrong or missing lease token cannot ack another worker’s job.
PUT /workers/{workerId}/jobs/{jobId}
Section titled “PUT /workers/{workerId}/jobs/{jobId}”Report job completion, failure, yield, or a mid-run step checkpoint.
Request:
{ "status": "completed", "execution_seq": 3, "lease_token": "<lease_token from the job assignment>", "output": { "result": "done" }, "steps": [ ... ], "step_offset": 0}Every terminal or yield update must carry the assignment’s execution_seq and
lease_token; without lease_token the server answers 400
INCOMPATIBLE_EXECUTION_PROTOCOL, and a stale pair answers 409 STALE_EXECUTION.
Status values: completed, failed, yielded, progress.
A yielded update that the engine rejects, such as a wait_for_event with no
event_filter, fails the run with YIELD_REJECTED and no retry, releases the
lease, and answers 400 invalid_argument. Do not resend it. A store error
answers 500 and keeps the lease, so a retry is safe.
progress is the one non-terminal status: it persists steps from a job that
is still running, without transitioning the run or releasing the execution
lease. It validates the fence but does not extend the lease — checkpoints
are frequent, and lease liveness stays on the worker heartbeat.
progress requires capacity admission and a valid fence — send the
execution_seq and lease_token from the job assignment. At most 500 steps
per batch.
step_offset is the run-wide index of steps[0]. A worker reports each step
exactly once, either in a progress batch or in the terminal body, so the
offset must account for both prior checkpoint batches and any steps
already persisted by earlier executions of the run. Start from
step_sequence_base in the job assignment (absent on older servers, treat as
0) and add the number of steps already reported. It is 0 only for a run
with no prior steps.
A pause requested while the job runs takes effect at the worker’s next
durable boundary. On a progress update with steps, the steps are saved, the
run is paused, the lease is released, and the answer is 409 STALE_EXECUTION.
On a completed or failed update, the pause wins over the result: the steps
are saved, the run is paused, and the answer is 200 with
{"status":"paused"}. Resume starts a new execution that replays the saved
steps.
Responses for progress:
| Status | Body error | Meaning |
|---|---|---|
200 | — | Steps persisted (or no steps sent — accepted, but it does not extend the lease; heartbeat separately) |
400 | INCOMPATIBLE_EXECUTION_PROTOCOL | Missing lease_token |
400 | — | Negative step_offset, or more than 500 steps |
404 | — | Capacity admission disabled, or run not found |
409 | STALE_EXECUTION | This execution was recovered or superseded, or a pending pause took effect at this checkpoint (the steps in this batch are saved) — stop this execution |
409 | RUN_NOT_RUNNING | Fence is valid but the run is no longer running |
API Keys
Section titled “API Keys”POST /apikeys
Section titled “POST /apikeys”Create a new API key. Set "platform": true to create a platform key (requires platform:keys:manage).
Request:
{ "name": "CI Pipeline", "env_id": "env_default_production", "role_ids": ["role_admin"], "expires_in": "720h", "tool_scopes": ["billing-tools", "reporting-tools"]}tool_scopes is optional. A caller can grant only scopes it holds, unless it manages all keys (admin); otherwise the call returns 403. Each entry is trimmed. Empty entries are dropped. An entry with whitespace inside, or longer than 128 characters, returns 400. The response and GET /apikeys show the stored scopes.
Response includes the raw key (only shown once).
GET /apikeys
Section titled “GET /apikeys”List API keys for the caller’s organization. Pass ?platform=true to list platform API keys instead (requires platform:keys:read).
GET /apikeys/{id}
Section titled “GET /apikeys/{id}”Get a single API key by ID.
DELETE /apikeys/{id}
Section titled “DELETE /apikeys/{id}”Delete/revoke an API key. Returns 204. Desktop sessions minted from the key stop working at once.
POST /apikeys/{id}/rotate
Section titled “POST /apikeys/{id}/rotate”Rotate an API key (creates new key, revokes old). Returns the new raw key. Desktop sessions minted from the old key stop working at once.
PUT /apikeys/{id}/roles
Section titled “PUT /apikeys/{id}/roles”Replace the full set of role assignments on an API key.
Request:
{ "role_ids": ["role_admin", "role_custom_abc"]}Response: The updated API key object (without the raw key).
POST /users
Section titled “POST /users”Create a new dashboard user (admin only).
Role names resolve within the caller’s organization, so a custom role is given by its name. An unknown role returns 400 and no user is created. Without roles, the user gets viewer.
Request:
{ "email": "user@example.com", "password": "securepassword", "name": "Jane Doe", "roles": ["admin"]}GET /users
Section titled “GET /users”List all users in the caller’s organization (admin only).
GET /users/{id}
Section titled “GET /users/{id}”Get a single user (admin or self).
PATCH /users/{id}
Section titled “PATCH /users/{id}”Update user profile (admin only). roles replaces the user’s roles in one step. An unknown role returns 400 and changes nothing. Removing admin from the organization’s last admin returns 409. A role change applies to the user’s existing sessions on their next request.
Request:
{ "name": "Jane Smith", "email": "jane@example.com", "roles": ["admin", "viewer"]}DELETE /users/{id}
Section titled “DELETE /users/{id}”Delete a user (admin only, cannot delete self). Returns 204. The user’s sessions stop working at once. Deleting the organization’s last admin returns 409.
PATCH /users/{id}/password
Section titled “PATCH /users/{id}/password”Change own password. The route costs users:password, which every built-in role holds; a custom role needs it granted. The credential must be that user’s dashboard session (the JWT from POST /auth/login). Another user’s ID returns 403, and so does an API key, because an API key has no user. A wrong current_password returns 401. Returns 204.
Request:
{ "current_password": "old_password", "new_password": "new_password"}POST /auth/login
Section titled “POST /auth/login”Authenticate and receive a JWT token for the dashboard. Public endpoint. The response also sets the token in the HttpOnly ironflow_dashboard_token cookie. This cookie replaces the cookie of an earlier platform login. A cross-site browser post gets 403 and no cookie. A client that is not a browser sends no Sec-Fetch-Site or Origin header and is not affected.
Request:
{ "email": "admin@example.com", "password": "password"}Response:
{ "token": "eyJhbGci..."}POST /auth/logout
Section titled “POST /auth/logout”Expire the ironflow_dashboard_token cookie. Public endpoint: page JavaScript cannot delete an HttpOnly cookie, and an expired cookie must also be clearable. The JWT itself stays valid until its expiry time. A cross-site browser post gets 403, so another site cannot sign a user out. Returns 204.
GET /auth/validate
Section titled “GET /auth/validate”Validate the current JWT token. Public endpoint — used by the dashboard to detect stale sessions on load. The token’s account is checked, as on every request: a deleted user, or a deleted or rotated source API key, gets 401, and actions follow the account’s current roles.
Response (valid token):
{ "valid": true, "user_id": "usr_abc", "org_id": "org_default", "actions": { "files:read": true, "files:write": false, "files:manage": false }}actions maps files:read, files:write and files:manage to booleans. A value is true when
the token’s user can do that action. The dashboard uses the map to hide controls that the user
cannot use. The map is a hint for the user interface. The server checks each action without a
bucket or an environment. A policy that is specific to a bucket or an environment applies when
the request for that bucket runs.
For a platform token, the map follows the impersonation check. Send the organization in
X-Ironflow-Org. Without it, every value is false. With it, platform:impersonate:read gives
files:read, and platform:impersonate gives all three actions.
In dev mode, the response has no user_id or org_id, and every action is true.
Response (invalid, expired or revoked): 401 Unauthorized
POST /auth/desktop-session
Section titled “POST /auth/desktop-session”Mint a dashboard JWT from an API-key principal. Requires API key authentication (not in the public allowlist); the minted JWT carries the key’s organization, roles, and environment boundary. Each request with the JWT uses the key’s current roles, and the JWT stops working when the key is deleted, rotated or expires. Requests made with the JWT default to that environment and receive 403 Forbidden if they select another one. Used by Ironflow Desktop to obtain a browser-usable session without --dev.
Response:
{ "token": "eyJhbGci..."}Returns 401 when the caller is not authenticated with an API key (e.g., a dashboard JWT).
Circuit Breakers
Section titled “Circuit Breakers”GET /circuit-breakers
Section titled “GET /circuit-breakers”List circuit breaker states in the caller’s environment. Costs functions:list; the reset below costs functions:invoke. Returns a JSON array. A platform key with no X-Ironflow-Environment-ID gets every environment’s breakers; environment_id tells apart two environments’ breakers for the same function ID, which share a key.
Response:
[ { "key": "fn_abc|https://example.com/api/handler", "environment_id": "env_default", "function_id": "fn_abc", "endpoint": "https://example.com/api/handler", "state": "open", "consecutive_fails": 5, "last_failure": "2024-01-15T10:30:00Z" }]POST /circuit-breakers/{key}/reset
Section titled “POST /circuit-breakers/{key}/reset”Reset a circuit breaker to closed state. The composite key format is {functionID}|{endpointURL}; the key path segment must be base64url-encoded (unpadded base64.RawURLEncoding). An invalid encoding returns 400.
Breakers are keyed by environment and function ID, so a reset acts in the caller’s environment. A platform key must send X-Ironflow-Environment-ID; without it the reset returns 400 environment required: set X-Ironflow-Environment-ID.
Response: 200 OK with the reset breaker state ("state": "closed").
Debounce
Section titled “Debounce”Operator endpoints for pending debounce entries (issue #545). Debounce collapses rapid-fire events for a function into a single invocation after a quiet period. See the debounce how-to for background. The CLI ironflow debounce {list|cancel} wraps these endpoints. Listing costs runs:read and cancelling costs runs:cancel. The SDKs wrap them too: ListDebounceEntries and CancelDebounce in Go, client.debounce.list and client.debounce.cancel in Node, and debounce_list_entries and debounce_delete_entries in Python. Each cancel method takes the raw debounce key and base64url-encodes it for the path.
GET /debounce/entries
Section titled “GET /debounce/entries”List currently-armed debounce entries scoped to the request environment. Empty 200 when the engine is not wired for debounce (e.g., embedded/test mode without JetStream).
Response:
[ { "environment_id": "env_default", "function_id": "process-search", "debounce_key": "user-42", "event_id": "evt_01HW...", "function_version": 1, "period_ms": 5000, "armed_at": "2026-04-24T12:00:00.000Z", "fires_at": "2026-04-24T12:00:05.000Z" }]ArmedAt / FiresAt are ISO-8601 UTC millisecond timestamps. DebounceKey is the resolved key value (never base64-encoded in the response body — only the path parameter on cancel is encoded).
DELETE /debounce/entries/{envID}/{fnID}/{key}
Section titled “DELETE /debounce/entries/{envID}/{fnID}/{key}”Cancel one pending debounce entry without firing it. The key path segment MUST be base64url-encoded (base64.RawURLEncoding) because debounce keys can contain / or . which conflict with URL path semantics.
Path parameters:
| Parameter | Description |
|---|---|
envID | Environment ID owning the entry |
fnID | Function ID |
key | base64url-encoded debounce key |
Responses:
| Status | Meaning |
|---|---|
204 No Content | Entry cancelled, or did not exist (idempotent no-op) |
400 Bad Request | Path segments missing or key not valid base64url |
403 Forbidden | Tenant-scoped request whose authenticated env does not match envID in the path (cross-tenant guard) |
503 Service Unavailable | Debounce manager not configured on this server |
Tenant-scoped credentials can only cancel entries within their own environment. Platform-scoped credentials (empty RequestContext.EnvironmentID) may cancel any environment’s entry.
Dead letters
Section titled “Dead letters”GET /dead-letters
Section titled “GET /dead-letters”List rows stored in dead_letters, newest first. Automatic capture retains scoped routing and resume failures, projection decoding and SQL apply failures, and best-effort system event publish failures. Raw application bytes are omitted. JetStream advisories are logging only; transactional outbox failures use their separate table. An empty result does not rule out routing, projection, or publishing failures. Requires events:subscribe for GET and HEAD. The environment must be explicit through env_id or X-Ironflow-Environment-ID. The value current selects the API key’s environment.
| Query parameter | Default | Description |
|---|---|---|
env_id | none | Environment ID or current; required unless the header supplies it |
source | none | Exact source filter, such as event_router, projection, or publish |
limit | 50 | Maximum rows, 1 through 500 |
offset | 0 | Row offset, 0 through 10000 |
{ "items": [{ "id": "dl_poison", "environment_id": "env_acme_prod", "source": "projection", "subject": "orders.placed", "payload": "AP97eA==", "error": "failed to decode message", "attempts": 5, "created_at": "2026-10-30T12:00:00Z" }], "total_count": 1, "limit": 50, "offset": 0}payload is standard base64 of the stored bytes, even for malformed JSON or binary data. payload and error are omitted when empty and can contain application secrets. PostgreSQL’s existing JSONB storage accepts valid JSON only and normalizes its representation. This endpoint does not change that constraint.
total_count counts all rows matching the environment and source filter, before pagination. An empty page has items: []. Count and page are separate reads and can differ during concurrent writes. Equal creation timestamps have no promised order.
Missing or unresolved environment context and invalid pagination return 400. Environment names return 400. An API key selecting a foreign or unknown environment ID returns 403, as does a principal without events:subscribe. Storage failures return 500 without the database error text.
The CLI ironflow dead-letters list and MCP ironflow_dead_letter_list use this endpoint. SDK wrappers are intentionally excluded. Transactional outbox delivery failures use the separate endpoints below.
Outbox Dead-Letter
Section titled “Outbox Dead-Letter”Operator endpoints for the transactional outbox dead-letter table (issue #487, DLQ tooling from #496). Rows appear here after the outbox worker exhausts its retry budget (default 10 failed publishes of the row’s own). A NATS outage does not normally use the budget. The underlying row in events is untouched — these endpoints only affect the unpublished NATS delivery.
See the outbox explanation for context. The CLI ironflow outbox dlq {list|requeue|discard} wraps these endpoints.
Listing costs events:subscribe, because a row carries the event payload. Requeue and discard cost outbox:manage, which the admin and developer roles hold and the viewer role does not.
GET /outbox/dead-letter
Section titled “GET /outbox/dead-letter”List rows in the outbox dead-letter table for one environment, newest first. The environment must be declared — callers send either the X-Ironflow-Environment-ID header or the env_id query parameter (the CLI sends both). Missing both returns 400.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
env_id | string | — | Environment ID (required unless X-Ironflow-Environment-ID header is set) |
limit | int | 50 | Max rows to return (1-500; out-of-range values return 400) |
offset | int | 0 | Row offset for pagination (0-10000; out-of-range values return 400) |
Response:
{ "items": [ { "id": "ob_01HW...", "event_id": "evt_abc123", "entity_id": "order_42", "topic": "orders.placed", "kind": "events", "environment_id": "env_default_prod", "created_at": "2026-04-20T12:00:00Z", "dead_at": "2026-04-20T12:05:30Z", "attempts": 10, "last_error": "nats: max payload exceeded", "payload": "<base64>", "metadata": "<base64>" } ], "limit": 50, "offset": 0}payload and metadata are base64-encoded to keep the JSON body valid for binary content.
POST /outbox/dead-letter/{eventID}/requeue
Section titled “POST /outbox/dead-letter/{eventID}/requeue”Move every dead-letter row matching event_id back to the live outbox with attempts=0. The worker picks them up on the next tick.
Response: 200 OK
{ "event_id": "evt_abc123", "result": "requeued" }404 Not Found if no dead-letter entry exists for the given event_id.
DELETE /outbox/dead-letter/{eventID}
Section titled “DELETE /outbox/dead-letter/{eventID}”Permanently delete every dead-letter row matching event_id. The underlying events row is not deleted — only the unpublished outbox entry. Destructive; the CLI prompts for confirmation before calling this.
Response: 200 OK
{ "event_id": "evt_abc123", "result": "discarded" }404 Not Found if no dead-letter entry exists for the given event_id.
Dead-letter lifecycle events
Section titled “Dead-letter lifecycle events”Each of the three transitions above also publishes a WebSocket frame on
system.outbox.{event_id}.{event}, where {event} is dead_lettered,
requeued, or discarded (issue #1729). Subscribe to system.outbox.> for
every dead-letter event, or system.outbox.{event_id}.> to follow one row from
arrival through triage. The topic is keyed by event_id — the same key these
endpoints and ironflow outbox dlq act on — not by the outbox row ID.
dead_lettered fires when the worker exhausts its retry budget and carries
event_id, outbox_id, entity_id, topic, kind, environment_id,
attempts, last_error, and timestamp. requeued and discarded fire from
the endpoints above and carry event_id, environment_id, result, and
timestamp — enough for a subscriber to retire a row it already received in
full.
Neither frame carries the dead event’s payload. Consumers that need the body
fetch it from GET /outbox/dead-letter, which returns it base64-encoded;
pushing every dead event’s body to every subscriber would repeat the per-client
marshal cost that topicOnly exists to avoid.
These frames are a hint, not a ledger. The dead_lettered publish travels
over the same NATS connection whose failure produced the dead letter, so it is
least likely to arrive exactly when it matters most, and while the outbox
circuit breaker is open no publish is attempted at all. The dead-letter table
is the source of truth — treat a frame as a prompt to refresh, and reconcile
with GET /outbox/dead-letter rather than maintaining state from frames alone.
History Inspection (Audit Trail)
Section titled “History Inspection (Audit Trail)”GET /audit
Section titled “GET /audit”List history inspection (audit) events globally.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
run_id | string | Filter by run |
function_id | string | Filter by function |
event_type | string | Filter by audit event type. Accepts one value or a comma-separated list. |
action | string | Filter by action |
api_key_id | string | Filter by the API key that acted |
from | string | Start timestamp, RFC3339 or YYYY-MM-DD |
to | string | End timestamp, RFC3339 or YYYY-MM-DD |
limit | int | Max results (default 100). A value that is not a positive integer uses the default. |
cursor | string | Pagination cursor |
Response:
{ "events": [ ... ], "total_count": 1280, "next_cursor": "MTcxMjMz...", "retention_days": 30}next_cursor is an empty string when there is no further page. retention_days is the audit retention in force; 0 means automatic pruning is off. A bad cursor, or a from or to in another format, returns 400. Rows of platform scope never appear in this tenant feed.
The global payload policy affects only rows created after the server starts
with that policy. Selected fields are absent from each affected row’s payload
in this response and in exports. The row’s metadata.audit_redacted_fields
value is a sorted, comma-separated list of canonical field names that were
removed. It contains no removed values. For example, the public selector
debounce.cancelled.debounceKey removes the stored debounce_key field and
records debounceKey in the marker.
GET /audit/export
Section titled “GET /audit/export”Stream the complete filtered tenant audit feed as newline-delimited JSON
(Content-Type: application/x-ndjson, Content-Disposition: attachment; filename="audit-export.jsonl"). Accepts the same filters as GET /audit plus
category (all, workflow, security, administration) and
hide_successful (true/false); cursor is ignored, because the export
always walks every page. An invalid category, a hide_successful that is
neither true nor false, or a from/to that is neither RFC3339 nor
YYYY-MM-DD returns 400.
The body opens with a manifest line, then one audit_event record per row,
then a footer carrying the completion timestamp and the row count. to is
clamped to the time the request started, so the walk is timestamp-bounded
rather than a transaction snapshot — rows written during the export are not
included. A failure partway through appends a terminal error line instead of
aborting the already-sent body.
GET /audit/settings
Section titled “GET /audit/settings”Returns the effective, read-only audit policy loaded by this server process. Server-wide settings require a restart to change.
{ "capture_scope": "server-wide", "authz_successful_reads": false, "authz_excluded_actions": ["functions:list"], "kv_capture": true, "payload_redaction_enabled": true, "payload_redaction_fields": [ "run.created.input", "step.completed.output" ], "retention_days": 30, "function_recording": "per_function", "changes_require_restart": true, "delivery_guarantee": "best_effort_no_retry", "delivery_healthy": true, "delivery_failure_count": 0}payload_redaction_fields is normalized and sorted. It is an empty array when
payload_redaction_enabled is false. The policy is global and applies only to
the approved workflow selector catalog. Protected authorization, platform,
resource, control-plane, capacity, and scanner event families cannot be
selected. See the audit logging guide
for the complete catalog, persistence behavior, and time-travel limitations.
System
Section titled “System”GET /health
Section titled “GET /health”Liveness probe. Returns 200 with {"status": "healthy", "timestamp", "version"} when the database Ping succeeds, 503 otherwise. When IRONFLOW_SQLITE_MAX_BYTES is set, warnings reports a database at or above 90% of it and a stale size refresh. Does not check NATS or the streams, so a load balancer must route on this endpoint. Use /ready for full readiness. Public endpoint (no auth required).
GET /capabilities
Section titled “GET /capabilities”Returns server version, supported features, and transport information. Public endpoint.
The two lists are computed per process, not fixed: grpc-sse, grpc-bidirectional,
cel-filter and consumer-groups appear only when the pub/sub bridge is wired, and
prometheus-metrics only when metrics are enabled. auth_required is false in dev mode.
grpc-bidirectional means PubSubService/SubscribeBidirectional serves consumer-group joins and acks on one stream.
Response:
{ "version": "0.8.0", "auth_required": true, "transports": ["websocket", "grpc-sse", "grpc-bidirectional"], "features": ["replay", "wildcard-patterns", "cel-filter", "consumer-groups", "prometheus-metrics"], "files_max_object_bytes": 104857600}files_max_object_bytes is the server-wide cap for one file (files.maxObjectBytes). It is absent when file storage is not enabled.
GET /ready
Section titled “GET /ready”Readiness probe. 200 with {"status": "ready"} means this node can do everything: the database answers, NATS is connected, JetStream answers, the five engine streams (EVENTS, STEPS, RESULTS, PUBSUB, CANCEL) are serviceable, and every system KV bucket has a leader. Otherwise it returns 503 with {"status": "not ready", "issues": {...}}. Public endpoint.
issues key | Values |
|---|---|
postgres | connection failed. This key also names a failed SQLite check. |
nats | disconnected or jetstream unhealthy |
stream:<name> | no leader, no answer, missing, replaced or config pending |
stream:KV_SYS_<bucket>, stream:KV_APP_SYS_<bucket> | no leader or no answer. A system KV bucket, such as stream:KV_SYS_debounce_state. A secrets or config bucket reads stream:KV_SYS_secrets_<environment> or stream:KV_SYS_config_<environment>, with the environment ID masked. |
A stream issue holds the stream name and one fixed text. It comes from a snapshot the node refreshes every 5 seconds, so it adds no NATS request. A snapshot that no read has refreshed for 60 seconds reads no answer for every stream. A bucket issue reports and closes no door. A degraded audit writer adds a warnings entry but does not fail the probe.
Do not route traffic on /ready. A load balancer must route on /health. One stuck stream makes every node answer 503, so routing on /ready takes every node out while the API can still serve. Use /ready for alerts, for startup order and to check a node by hand. To read a stream issue and recover, see Recover a stuck JetStream stream.
GET /routes
Section titled “GET /routes”Returns the server route manifest. Used by SDK generation tooling (make sdk-manifest).
GET /openapi.json
Section titled “GET /openapi.json”Returns the committed OpenAPI 3.1 document describing this API. Generated from the same route manifest as /routes by make sdk-gen-openapi, which writes both this copy and the canonical api/openapi.json at the repo root (ADR 0054); make sdk-gen-verify regenerates and diffs them, so the served contract cannot drift from the repo artifact.
The document describes the request/response REST routes only. It leaves out:
- Streaming routes:
GET /wsand the KV and config watch routes (see Watch streams). A WebSocket cannot be described as a request/response operation. - ConnectRPC services (ADR 0079). Generate those clients from the
.protofiles inapi/proto/. - Internal routes, such as the health probes,
/metrics,/routes,/cluster/healthand/openapi.jsonitself.GET /routeslists every route with itscategory; the spec omits theinternalones.
The route needs an API key or session like any other route; without one it returns 401.
GET /overview
Section titled “GET /overview”Get a comprehensive system overview with stats and uptime.
stats.paused_exhausted_runs is how many runs are paused right now after using up their retries. stats.failed_runs_24h does not include them: a paused run is not failed.
recent_runs[].cancellation_cause is an optional string containing the stored cancellation cause. It is omitted when empty. Overview shows “Cancelled by user” for user and “Cancelled by event” for cancel-on-event; missing or other causes show “Cancelled”. Unknown stored values are returned unchanged. Non-cancelled runs keep their status label.
GET /analytics
Section titled “GET /analytics”Run and event counts for the caller’s environment, read from the Ironflow database. The range query parameter is 1h, 24h (default), 7d or 30d. Any other value returns 400, and so does a request with no environment selected.
The response has range, from, to, step_seconds, the three *_retention_days values, totals (runs, completed, failed, error_rate, p50_ms, p95_ms), run_buckets, event_buckets and a per-function functions list. The server caches each environment and range for 60 seconds.
GET /cluster/health
Section titled “GET /cluster/health”Returns cluster health status including node information. It works on a single node too. The worker list holds the workers of your environment. The node-wide fields (nats, streams, disk, and a failed_runs_last_hour count for the whole cluster) are returned only to a platform principal or an admin of the default organization.
POST /cluster/log-level
Section titled “POST /cluster/log-level”Set a temporary process log level, with ephemeral NATS fanout in multi-node mode. The generated Python client exposes cluster_log_level(body). Requires the instance action cluster:log-level, held by the default organization’s admin or a platform admin. A platform operator cannot obtain it through impersonation. Available only on the authenticated API listener.
Request fields are level and optional ttl. Levels are trace, debug, info, warn, and error. ttl is a Go duration string, positive and at most 4h; the default is 15m. Unknown fields, trailing JSON, and bodies larger than 4 KiB are rejected.
{"level":"debug","ttl":"15m"}A 200 response contains command_id, level, absolute expires_at, sorted applied_node_ids, and delivery. Delivery is local, best_effort, or publish_failed. The list reports only authenticated applied acknowledgements collected within one second, up to 1,024 replies. It is not proof of cluster-wide success, and a command may have expired or been superseded by response time. Missing nodes cannot be distinguished from signing-key mismatch or lost replies.
400 indicates an invalid body, level, or duration. Authentication and authorization return 401 or 403. 503 indicates unavailable auditing or control transport, or a multi-node configuration without an explicit shared JWT key. No operation audit, local mutation, or publication happens when the shared-key prerequisite fails. The cluster.log_level.requested audit row records an authorized attempt before mutation, never a preclaimed cluster success.
All nodes must use the same explicit auth.jwtSecret or IRONFLOW_JWT_SECRET and synchronized clocks. Commands and replies use the live JWT secret. Offline nodes miss ephemeral commands. Restart restores the boot level; expiry restores it on nodes that applied the override. See temporary logging.
POST /cluster/rotate-token
Section titled “POST /cluster/rotate-token”Rotate an operator credential (requires the cluster:rotate-token action, which only an admin of the default organization or a platform admin holds). The control plane calls it; it also works on a single node.
| Field | Required | Meaning |
|---|---|---|
kind | yes | engine_api_key, founder_api_key or dashboard_jwt_secret |
new_value | yes | The new credential. For the two API-key kinds, the full new key (for example ifkey_...). For dashboard_jwt_secret, the new secret as standard base64 of at least 16 raw bytes. |
remote_key_id | API-key kinds | The ID of the API key to rotate |
engine_api_key,founder_api_key: the key keeps its ID, name, roles, environment and expiry. Only its value changes: the old value stops working at once, andkey_prefixfollows the new value.dashboard_jwt_secret: the server signs and checks sessions (dashboard and platform login) with the new secret at once, so every existing session must log in again. The server writes the secret to.ironflow_jwt_secret, so it survives a restart. When the secret comes fromIRONFLOW_JWT_SECRETor the config file, that value wins at the next restart; update it too. The change applies only to the node that serves the request; other nodes keep their secret.
A success returns 200:
{ "status": "rotated", "rotated_at": "2026-10-16T12:00:00Z" }Send an Idempotency-Key header to make retries safe. A repeat of the same key within 5 minutes returns the first response with an Idempotent-Replay: 1 header and changes nothing, even when the body differs. The cache lives in the server’s memory: a restart or another node does not see it.
| Status | When |
|---|---|
400 | invalid json; new_value is required; invalid kind; remote_key_id is required for api key kinds; the key named is the one that sent the request (cannot rotate the authenticating key); a JWT secret that is not base64 or is shorter than 16 bytes; a body over 16 KiB |
403 | The caller lacks cluster:rotate-token |
404 | remote api key not found |
GET /export
Section titled “GET /export”Stream a full organization export as NDJSON (admin only, org:export action). Optional query parameters: include (comma-separated section list: org, projects, environments, audit_events; all four when omitted, and an unknown value returns 400), from / to (RFC3339 time bounds). Only one export may run per node at a time — a concurrent request returns 429 with a Retry-After header.
GET /ws
Section titled “GET /ws”Open a WebSocket connection for real-time event subscriptions.
GET /mcp, POST /mcp, DELETE /mcp
Section titled “GET /mcp, POST /mcp, DELETE /mcp”Remote MCP endpoint for hosted agents. The server mounts it only when remote MCP is on (IRONFLOW_MCP_REMOTE=true or spec.mcp.remote); otherwise the path returns the usual 404. The request must carry an Authorization: Bearer <api key> header. A cookie or ?token= query parameter gets 401. Each tool call runs with the permissions of that key. See Remote MCP.
GET /metrics
Section titled “GET /metrics”Prometheus metrics endpoint (only available when metrics are enabled). Public endpoint. When spec.observability.metrics.port is set, this port answers 404 and a dedicated listener serves /metrics. See Dedicated metrics listener.
Debounce resource labels contain only environment and function IDs. Keys, event IDs, run IDs and error text are never labels.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
ironflow_debounce_arms_total | Counter | environment_id, function_id, result | Arm outcomes created, reset, ceiling (CAS exhaustion), kv_error, replay_skip, error (other failures). |
ironflow_debounce_cas_retries_total | Counter | environment_id, function_id | Additional CAS attempts actually begun. The initial create collision and the final conflict without another attempt do not count. |
ironflow_debounce_fires_total | Counter | environment_id, function_id, source | Successful run creations from expired timers or recovery. |
ironflow_debounce_cancels_total | Counter | environment_id, function_id, source | Accepted successful REST cancellation requests from dashboard, cli or api, including missing-entry noops. |
ironflow_debounce_arm_duration_seconds | Histogram | environment_id, function_id | Whole arm call wall-clock duration, including errors and replay skips. |
ironflow_debounce_sweep_duration_seconds | Histogram | None | Whole sweep wall-clock duration across environments, including empty and failed ticks. |
ironflow_debounce_recovery_lag_seconds | Histogram | environment_id | Time from the original pending row’s claim to successful recovered run creation. |
ironflow_debounce_active_entries | Gauge | environment_id | Shared KV entries in the latest complete sample. |
ironflow_debounce_collector_errors_total | Counter | None | Failed active-entry samples. |
Fires count immediately after successful run creation, even if pending-row cleanup fails. Redispatch of an existing run, version skips, already-completed fires, CAS losers and creation failures do not count. Recovery lag uses the scheduler clock and the original ClaimedAt; claiming a recovery lease does not reset that timestamp.
Active entries are sampled every 15 seconds with a 2-second timeout. Scrapes read the cached sample without KV I/O. Failed samples clear the gauges and increment the collector error counter. Environments with no KV entries are omitted, including after the last entry is removed. The gauge includes expired entries awaiting a sweep and excludes pending SQL fires after their KV claim. Every replica samples the same shared entries. Aggregate this gauge with max by (environment_id), not sum.
Debugging
Section titled “Debugging”GET /debug/requests
Section titled “GET /debug/requests”Get recently captured HTTP requests (up to 50). You get the requests of your own organization and environment. An admin of the default organization and a platform admin get every request.
Credential-bearing headers (Authorization, Cookie, X-Api-Key,
Sec-WebSocket-Protocol, and any header named for a token or a secret) and
credential-bearing body fields (passwords, API keys, tokens, secrets, signatures)
are stored as [REDACTED]. Protobuf and gRPC bodies are not stored.
DELETE /debug/requests
Section titled “DELETE /debug/requests”Clear the captured debug requests that you can see.
Organization & Policy Set Endpoints
Section titled “Organization & Policy Set Endpoints”Organizations
Section titled “Organizations”| Method | Path | Description |
|---|---|---|
POST | /orgs | Create an organization |
GET | /orgs | List organizations |
GET | /orgs/{id} | Get an organization |
PATCH | /orgs/{id} | Update organization name (at most 255 characters) or preferences (default_timezone) |
DELETE | /orgs/{id} | Delete an organization. A write that stops the delete on PostgreSQL returns 409 with code aborted, as for DELETE /environments/{id}. |
Only a platform principal can create an organization. Use POST /platform/orgs with a platform credential (Platform API). POST /orgs always returns 403: a tenant credential cannot create an organization, and a platform credential on a tenant route needs X-Ironflow-Org. The SDK create methods call POST /platform/orgs. A tenant credential reads, updates and deletes only its own organization. You cannot delete org_default.
| Method | Path | Description |
|---|---|---|
POST | /roles | Create a custom role |
GET | /roles | List roles |
GET | /roles/{id} | Get a role |
PATCH | /roles/{id} | Update role name |
DELETE | /roles/{id} | Delete a role (not built-in) |
Policy Set
Section titled “Policy Set”The Rego policy set of the organization. It is authorization Layer 2 and can only deny. See Authorization policy sets.
| Method | Path | Description |
|---|---|---|
GET | /policy-set | Get the active set. Version 0 with no files means the organization has no set. The body has enforcement_active: false means the server stores the set but does not enforce it. |
PUT | /policy-set | Replace the whole set. Needs If-Match: <version>. |
GET | /policy-set/versions | List versions, without file contents. |
GET | /policy-set/versions/{n} | Get one version with its files. |
POST | /policy-set/versions/{n}/rollback | Store version n as a new version. Needs If-Match: <version>. The active mode stays. |
POST | /policy-set/validate | Validate a candidate set. |
POST | /policy-set/dry-run | Evaluate a candidate set, or the active set, for one input. |
The same seven routes exist under /api/v1/platform/policy-set for the platform organization’s set. They are listed in the Platform API. POST /api/v1/platform/orgs/{org}/policy-set/monitor is the platform break-glass that switches an organization’s set to monitor mode.
Set body (PUT, validate, and set in a dry-run):
{ "modules": { "authz.rego": "package ironflow.authz\n\ndeny contains \"no production\" if input.request.environment == \"env_prod\"\n" }, "mode": "monitor"}modulesmaps a file name to Rego source. Up to 500 files and 2 MiB of source.decisionis an optional rule path, for exampledata.envoy.authz.allow. When you leave it out, an update keeps the current rule. Send""to remove it.modeisenforceormonitor. When you leave it out, an update keeps the current mode and a new set starts inmonitor.
If-Match accepts 3, "3" and W/"3". The answers carry ETag: "<version>". A missing header answers 428, and a stale version answers 412. An update that equals the active set (same files, decision and mode) answers 200 with the current version and creates no version.
Dry-run body:
{ "set": { "modules": { "authz.rego": "..." } }, "input": { "request": { "action": "runs:delete" }, "subject": { "roles": ["member"] } }, "explain": true}Leave out set to evaluate the active set. input must have request and subject objects. Add attributes to the input to evaluate the decision rule. The answer has decision (allow or deny, the verdict of the set whatever its mode), mode, version, reasons, reasons_omitted, entrypoint, error (the error class) and explain.
Errors. Each 4xx answer has a code. A 503 policy_set_not_propagated means the write committed as the version in the message. Send the same request again with If-Match set to that version. See Policy set errors.
Limits per organization on each node: one write every 6 seconds with a burst of 5 (PUT and rollback), and one validate or dry-run call every second with a burst of 10. Above the limit the answer is 429 with Retry-After. At most 4 compiles run at once on a node. Above that the answer is 429 with policy_set_busy.
Tenants
Section titled “Tenants”| Method | Path | Description |
|---|---|---|
POST | /tenants/provision | Provision a tenant (org + env + admin key) |
GET | /tenants | List tenants with environment and key counts |
These two routes control the full instance, so only an admin of the bootstrap organization (org_default) can use them. An admin of a different organization gets 403.
Provision Request:
{ "org_name": "Acme Corp", "env_name": "production"}Provision Response:
{ "org": { "id": "org_abc", "name": "Acme Corp" }, "environment": { "id": "env_default_production", "name": "production" }, "api_key": { "key": "ifkey_...", "roles": ["admin"] }}Capacity Admin Endpoints
Section titled “Capacity Admin Endpoints”Read-only inspection of the unified capacity/dispatch subsystem (ADR 0037, #1206). Any platform principal (read-only) — requires a ifplatform_ API key or a platform JWT; the gate is “is-platform”, not a specific platform role, so platform_admin, operator, and viewer principals can all read these views. Tenant API keys return 403.
All endpoints accept the same optional query parameters:
| Parameter | Type | Description |
|---|---|---|
env_id | string | Filter by environment ID |
function | string | Filter by function ID (ignored for leases, credits, and stats) |
limit | int | Max rows to return (default 100, hard-capped at 1000). Values above 1000 are silently clamped to 1000; non-positive values use the default. Ignored by stats. |
The Go, Node and Python SDKs wrap these endpoints: ListCapacityLanes and the matching ListCapacity* and CapacityStats methods in Go, client.capacity.* in Node, and capacity_list_* in Python. The leases view always returns an empty function_id, so match a lease to its run by run_id.
Lane identities are returned as opaque HMAC digests. Lease entries carry lease_token_hash only — raw lane keys and lease tokens are never stored or returned.
GET /api/v1/capacity/lanes
Section titled “GET /api/v1/capacity/lanes”List concurrency_lanes rows. Each row carries the lane digest, environment, function, scope, limit, and the maintained active_reserved counter.
GET /api/v1/capacity/queue
Section titled “GET /api/v1/capacity/queue”List dispatch_queue segments. Returns queue position, run ID, execution sequence, execution mode, function, lane digest, blocking reason, and the eligible_at timestamp (when the segment becomes runnable).
GET /api/v1/capacity/leases
Section titled “GET /api/v1/capacity/leases”List active (unexpired) concurrency_leases rows. The function query parameter is silently ignored — concurrency_leases has no function_id column. The JSON response includes lease_token_hash (not the raw token); the function_id field is present but always empty ("") on this endpoint — the active-leases path does not JOIN runs, so the owning function is not resolved here.
GET /api/v1/capacity/buckets
Section titled “GET /api/v1/capacity/buckets”List dispatch_buckets fairness-ledger rows. Each row is keyed by (environment_id, function_id, lane_id) and carries last_dispatched_at.
GET /api/v1/capacity/sessions
Section titled “GET /api/v1/capacity/sessions”List worker_sessions rows for active pull workers. Supports env_id and function filters; function matches sessions advertising that function via worker_session_functions.
GET /api/v1/capacity/credits
Section titled “GET /api/v1/capacity/credits”List worker_credits rows (claimed and free slots). The function query parameter is silently ignored — worker_credits has no function_id column.
GET /api/v1/capacity/stats
Section titled “GET /api/v1/capacity/stats”Return a per-(environment, function) capacity snapshot: active leases, lane limit, queued segments, outstanding reservations, and oldest queue age in seconds. The env_id, function, and limit query parameters are all ignored — stats always returns the full cross-tenant snapshot (one row per active env/function; row count is bounded by that cardinality, not by limit).
Example response (stats):
[ { "environment_id": "env_default", "function_id": "my-fn", "active_leases": 2, "lane_limit": 5, "queued": 7, "reservations": 1, "oldest_queue_age_seconds": 12.4 }]Common Response Codes
Section titled “Common Response Codes”| Code | Meaning |
|---|---|
200 | Success |
201 | Resource created |
204 | Success, no content |
400 | Invalid request or validation error |
401 | Authentication required |
403 | Insufficient permissions |
404 | Resource not found |
409 | Conflict (duplicate resource or version mismatch) |
412 | Precondition failed (CAS mismatch) |
503 | Service unavailable |
Stored cancellation evidence
Section titled “Stored cancellation evidence”Run inspection responses include optional cancellation_event with string fields event_id, event_name, match_path and match_value. The value may be empty. These fields identify the cancellation winner independently of current function configuration or active specs. Connect’s Run message exposes the same fields as a typed CancellationEvent. Manual, legacy and redacted cancellations can omit the object. Redacting the trigger event, cancelling event or run clears it.