Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

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.


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_here

Dashboard 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_production

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

ValueResult
absentA key or desktop session uses its own environment. Any other credential uses env_default.
currentSame 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.

A REST error written by the API is a JSON object:

{"error": "run not found", "message": "run not found", "code": "not_found", "retryable": false}
FieldPresentMeaning
erroralwaysWhat went wrong.
messagealways*The same text as error.
codealways*The lowercase Connect code (not_found, invalid_argument, …), or a domain code a client can branch on, such as SIGNED_URLS_DISABLED.
detailssometimesStructured detail for that code.
retryablealways*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_afterwith Retry-AfterSeconds 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.

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", not 42.
  • Empty fields are omitted, not sent as "", 0, [] or null. 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.


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 C ctype, which folds only ASCII on PostgreSQL, so é does not find É.

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 [].

List events with keyset pagination (issue #697). Sorted newest-first by (timestamp, id). The endpoint always scopes to the caller’s environment.

Query Parameters:

ParameterTypeDescription
namestringFilter by event name (case-insensitive substring match, unindexed; see Case-insensitive search). % and _ are wildcards; \%, \_ and \\ match them literally.
namesstringFilter by exact event name (comma-separated, OR-ed, max 200). Distinct from name: entries must match in full. Populate from GET /events/names.
searchstringFilter by event ID (case-insensitive substring match; see Case-insensitive search). % and _ are wildcards; \%, \_ and \\ match them literally.
sourcestringFilter by source (comma-separated, max 200)
sincestringStart time (RFC3339)
untilstringEnd time (RFC3339)
limitintMax results per page (default 20, values above 200 are clamped to 200). A value that is not a positive integer returns 400.
cursorstringForward keyset token from a prior next_cursor. Mutex with before.
beforestringBackward 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_cursor are null/omitted when there is no further page in that direction.
  • has_next and has_prev are direction-aware. On the newest page (no cursor and no before), has_prev is always false. After paging forward with cursor=, has_prev is true. After paging backward with before=, has_next is true.
  • approx_total is the per-environment count from a capped subquery (LIMIT 10001). Below the cap the value is exact; above the cap the value is 10000 and approx_total_capped is true.
  • Each event’s timestamp and created_at are 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 with time.Parse(time.RFC3339, …) or JS new Date() are unaffected; only exact string comparison against the old second-resolution form breaks.

Errors:

  • 400 — invalid cursor / before token, or both supplied at once.
  • 400 — since or until is 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 in names or source (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 a 500. The limit matches the 200-name cap of GET /events/names, so a full facet selection is always accepted.

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:

ParameterTypeDescription
sourcestringFilter by source (comma-separated, max 200)
sincestringStart time (RFC3339)
untilstringEnd 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 whenever truncated is true. Rarer names may be absent entirely — GET /events still accepts any exact name in names, listed or not.
  • scanned is how many events the facet actually grouped over.
  • At most 200 distinct names are returned, ordered by descending count.
  • truncated is true when either cap was exceeded — more than 10,000 matching events, or more than 200 distinct names. An environment holding exactly 10,000 matching events reports truncated: false, because its counts are exact totals. The response does not distinguish which cap fired; compare scanned against scan_cap to 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 scanned and the sum of the returned names[].count values 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 source filter has no supporting index, so it scans to find its matches — the same on environment-scoped and environment-less requests alike.

Errors:

  • 400 — since or until is not RFC3339.
  • 400 — more than 200 comma-separated values in source.

Get a single event by ID. Returns 404 when the event does not exist in the caller’s environment.


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.

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.

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.

EventFires whenPayload beyond functionId, environmentId, timestamp
registereda schedule is installed, or its expression changesexpression, nextRun, replaced (true when it overwrote an existing schedule)
removedthe schedule is gonecause: triggers_changed (the cron trigger was edited away) or function_deleted
invalidthe cron expression fails to parseexpression (the spec that failed), error
missedschedule slots produced no runcount, 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 *.

The duplicate REST routes were removed. Send JSON POST requests to these Connect methods on ironflow.v1.IronflowService:

MethodPurpose
ListFunctionsList registered functions; accepts name, status, mode, limit, offset. name is a case-insensitive (rules) substring match: % and _ are wildcards; \%, \_ and \\ match them literally.
GetFunctionFetch one function by ID or slug.
InvokeFunctionInvoke 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.

HeaderDescription
Idempotency-KeyOptional. 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"
}
MethodPurpose
RegisterFunctionRegister or update a function definition.
UpdateFunctionStatusSet a function’s status (FUNCTION_STATUS_ACTIVE, FUNCTION_STATUS_PAUSED or FUNCTION_STATUS_ARCHIVED) by id. Returns the function.
DeleteFunctionDelete a function by id. See Function lifecycle events.
TriggerEmit an event. Same request as Emit.
TriggerBatchEmit 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.
TriggerSyncEmit 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.
InvokeFunctionSyncInvoke 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, InfoServer health and build information.

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.

POST /ironflow.v1.IronflowService/ListFunctionHistory

Request:

{
"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.

POST /ironflow.v1.IronflowService/GetFunctionAtVersion

Request:

{
"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"
}
}
POST /ironflow.v1.IronflowService/RollbackFunction

Restores 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).


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.

RPCPermissionPurpose
RegisterDeploymentfunctions:registerStore one immutable deployment
GetDeploymentfunctions:readRead one deployment
ListDeploymentsfunctions:readList the deployments of a function, newest first
GetFunctionExecutionRoutingfunctions:readRead 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.

POST /ironflow.v1.DeploymentService/RegisterDeployment

Request:

{
"functionId": "fn",
"deploymentId": "probe-v1",
"artifactDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"snapshot": {
"triggers": [{"event": "x"}],
"timeoutMs": 30000,
"metadata": {"who": "José"}
},
"mode": "EXECUTION_MODE_PULL",
"snapshotSchemaVersion": 1
}
FieldRequiredDescription
functionIdyesThe function this deployment serves. The function must exist.
deploymentIdyesYour 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.
artifactDigestyesYour declaration of the complete artifact: sha256: plus 64 lowercase hex digits. Ironflow stores it. It does not prove which code a process runs.
snapshotnoThe 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.
modeyesEXECUTION_MODE_PUSH or EXECUTION_MODE_PULL. An unset mode is rejected.
endpointUrlpush onlyAn http or https URL, at most 2048 characters. Required for push. Must be empty for pull.
snapshotSchemaVersionnoThe 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:

CodeWhen
invalid_argumentA 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_foundThe function does not exist.
already_existsThe 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.

POST /ironflow.v1.DeploymentService/GetDeployment

Request:

{"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.

POST /ironflow.v1.DeploymentService/ListDeployments

Request:

{"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.

POST /ironflow.v1.DeploymentService/GetFunctionExecutionRouting

Request:

{"functionId": "fn"}

Response:

{
"functionId": "fn",
"mode": "FUNCTION_ROUTING_MODE_LEGACY",
"updatedAt": "2026-10-16T19:08:34.107006Z"
}
FieldDescription
modeFUNCTION_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.
activeDeploymentIdThe active deployment. Set only when mode is versioned.
routingRevisionA per-function counter, sent as a decimal string. Omitted when it is 0.
transitionId, transitionStartedAtSet only while mode is adopting.
updatedAtWhen 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.


The duplicate REST routes were removed. Send JSON POST requests to these Connect methods on ironflow.v1.IronflowService:

MethodPurpose
ListRunsList 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.
GetRunFetch one run by id. Includes attempts, the run-level retries.
GetRunStepsList 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.
  • error is the error as it was stored. It is absent when the attempt did not fail, and it is the redaction placeholder after RedactStep or RedactRun. RedactStep also scrubs the run-level entries, because the SDKs copy a step’s error into the run’s failure. RedactRun also scrubs the run’s own error and every child step’s error; RedactStep scrubs its own error and the parent run’s error.
  • endedAt is 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, startedAt is endedAt minus that time; otherwise it is when the engine recorded the start.
  • workerId is set for pull workers when the engine knew the worker. startedAt is absent on run-level entries.
  • The history goes with its run: deleting the run, or its environment, deletes it.

List entity streams that were read from or appended to during this run.

Response:

{ "entity_ids": ["order-123", "customer-9"] }

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.

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.

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 to running and resume event published.
  • invalid_argument — malformed body or missing runId.
  • not_found — run id does not exist, OR fromStep does not exist, OR fromStep belongs to a different run.
  • already_exists — a resume of this run was accepted inside the stream’s dedupe window (2 minutes, keyed on resume-{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 be paused or failed).
  • internal — NATS publish failed (run is reverted to its prior status), or other unexpected failure.

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 — id is 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 lacks runs:delete (admin-only by default), or the request carries no environment scope.
  • internal — unexpected store failure. Nothing was deleted.

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, or status is not a terminal status. Nothing was deleted.
  • permission_denied — the caller lacks runs: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 its system.run.{id}.deleted frame 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.

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.

Three RPCs irreversibly replace a stored payload with a placeholder. They cost the events:redact action, which only the admin role holds by default.

MethodBodyReplaces
RedactEventeventIdevents.data, plus its copies in pending outbox rows and outbox dead letters
RedactStepstepId (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
RedactRunrunIdthe 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
Terminal window
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.input holds a copy of the event’s payload, and so does the run.created audit row. audit_events carries no event_id, so RedactEvent scrubs no audit rows at all. Call RedactRun for those.
  • A step’s payload columns, on a run redaction. RedactRun scrubs step errors, but leaves step input, output and original output unchanged. Call RedactStep to 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.

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 $redacted member. See Payload markers.
  • not_found — step id does not exist.
  • failed_precondition — the step is neither completed nor failed.
  • aborted — another writer (a concurrent patch, or InjectStepOutput) 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 with Ironflow-Error-Reason: contended and a Retry-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.

Pause a running workflow at the next step boundary for inspection and injection.

POST /ironflow.v1.IronflowService/PauseRun

Request:

{
"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 completed and failed steps and their outputs for an injection-paused run.

POST /ironflow.v1.IronflowService/GetPausedState

Request:

{
"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.

Modify the output of a completed step while a run is paused for injection.

POST /ironflow.v1.IronflowService/InjectStepOutput

Request:

{
"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.


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.

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.

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

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.


List all projects in the organization.

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.

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 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 use ironflow.v1.EntityStreamService. The duplicate REST routes have been removed. Send JSON POST requests to these Connect methods:

MethodPurpose
ListStreamsList 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.
GetStreamInfoRead a stream’s current version and event count. deleted is true once the stream carries a tombstone.
ReadStreamRead events by version, limit and direction.
AppendEventAppend an event with an optimistic-concurrency check.
GetEntityHistoryRead events with their runs and steps; supports timestamp filters.
CreateSnapshotStore state at an existing stream version.
GetSnapshotRead the latest snapshot at or before a version.
DeleteStreamTombstone 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.

Terminal window
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.

DeleteStream takes entityId and an optional purge, and returns entityVersion — the version of the tombstone it appended.

Terminal window
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_precondition while any run triggered by an event below the tombstone is still active (not completed, failed or cancelled). 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 aborted while 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_letter rows 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-constrained outbox.

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.

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

MethodPurpose
ListProjectionsList registered projections; accepts status, mode, limit, offset.
GetProjectionRead 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.
GetProjectionStatusRead processing status.
WaitProjectionCatchupWait until a projection reaches a minimum NATS sequence. A failed rebuild answers failed_precondition.
WaitProjectionCatchupBatchThe same wait for several projections in one call.
WaitForEventWait until one specific event has been applied.
RebuildProjectionTrigger a full or partial rebuild.
GetRebuildJobRead the status of an in-flight rebuild.
CancelRebuildCancel an in-flight rebuild.
PauseProjectionStop consuming events.
ResumeProjectionResume a paused projection.

Protobuf JSON encodes 64-bit sequence fields as decimal strings.

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.

List partitions for a partitioned projection. A managed projection whose last rebuild failed answers failed_precondition until a new rebuild starts.

Query Parameters:

ParameterTypeDescription
qstringFilter partitions by key prefix, case-insensitive (rules) (max 200 chars; longer returns 400)
limitintMax results (default 50). Must be a positive integer.

Response:

{ "partitions": ["tenant-1", "tenant-2"], "returned": 2 }

An unknown projection returns 404.

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

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"
}

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"
}

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/unregister a projection. Returns 200 with {"status": "deleted"}. It calls ProjectionService/UnregisterProjection, so an unknown projection returns 404.


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.


The schema registry uses ironflow.v1.EventSchemaService. The duplicate REST routes were removed. Send JSON POST requests to these Connect methods:

MethodPurpose
RegisterSchemaRegister (or overwrite) a schema version.
ListSchemasList schemas; accepts eventName, limit, offset.
GetSchemaRead one schema — the latest version, or version when given.
DeleteSchemaDelete one (eventName, version) pair.
TestUpcastDry-run the upcast chain (see below).

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:

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

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.

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.


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/ExecuteSQL
Content-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.


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.

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
}

List the KV buckets of your environment. Returns {"buckets": [...], "count": N}.

Get bucket metadata.

Delete a bucket. A system bucket returns 403.

List keys in a bucket. Returns {"keys": [...], "count": N}.

Query Parameters:

ParameterTypeDescription
filterstringGlob pattern to filter keys

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.

Write a value. Returns 200 with {"revision": N} and an ETag header. Supports atomic operations via headers:

HeaderEffect
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 a key. Returns 204. Add ?purge=true to remove the key and its history permanently.

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"
}
FieldDescription
operationput or delete. A purge also arrives as delete.
valueThe stored bytes, base64-encoded. "" for a delete.
revisionThe bucket revision of this change. It increases across the whole bucket, not per key.

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.

WhenWhat you get
Missing or invalid key, or no permissionThe upgrade fails with 401 or 403 and a JSON error body.
The server has no NATS provider404: the route does not exist.
Reserved KV bucket name (a system bucket)404 bucket not found before the upgrade.
Config store unavailable500 before the upgrade.
KV bucket does not existThe upgrade succeeds, then one frame {"type":"error","message":"bucket not found"}, then the server drops the connection.
The watch cannot startOne 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.


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.

MethodPathDescription
POST/api/v1/files/bucketsCreate a bucket. Body: name, optional maxObjectBytes, allowedContentTypes, emitEvents, allowSignedUrls.
GET/api/v1/files/bucketsList 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}/objectsList files. Query: prefix, delimiter, cursor, limit.
POST/api/v1/files/buckets/{bucket}/moveMove one file. Body: from, to, optional toBucket, ifMatch.
POST/api/v1/files/buckets/{bucket}/copyCopy one file. Body: from, to, optional toBucket.
POST/api/v1/files/buckets/{bucket}/move-prefixDirectory move. Body: from, to (both end in /). Returns {count}.
POST/api/v1/files/buckets/{bucket}/signed-urls/uploadCreate an upload URL. Body: path, optional ttlSeconds, maxBytes, contentType, createOnly.
POST/api/v1/files/buckets/{bucket}/signed-urls/downloadCreate 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.

Like the KV routes above, the config routes are mounted only when the server has a NATS provider.

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:

HeaderEffect
If-None-Match: *Create only. Fails if the document exists.
If-Match: <revision>Replace only that revision. The revision is a positive integer.

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 a config document.

Response:

{
"name": "app-settings",
"data": { "theme": "dark" },
"revision": 3,
"updatedAt": "2026-03-06T12:00:00Z"
}

List all config documents. Returns {"configs": [{"name", "revision", "updatedAt"}]}. The list carries no document data.

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.

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.


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.

List secret metadata. Returns a JSON array. Each item has name, description, revision, created_at and updated_at. No item carries the value.

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 secret metadata (not the value).

Update a secret’s value.

Request:

{
"value": "sk_live_new_...",
"description": "Rotated key"
}

Partial update: name (rename) and/or description. At least one must be set. A rename to a name that exists returns 409.

Delete a secret. Returns 204.


List learned function→event causation edges for the Flow Map. Requires functions:list and is scoped to the caller’s environment.

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" }
]
}

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" }
]
}

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" }
]
}

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" }
]
}

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.

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"]
}

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.

Poll for job assignments. Returns 204 No Content if no jobs are available.

Query parameters:

ParameterDescription
availableFree job slots on the worker. The server never assigns more than its own count of the worker’s free slots.
waitSeconds 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"
}
]
}

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.

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:

StatusBody errorMeaning
200—Steps persisted (or no steps sent — accepted, but it does not extend the lease; heartbeat separately)
400INCOMPATIBLE_EXECUTION_PROTOCOLMissing lease_token
400—Negative step_offset, or more than 500 steps
404—Capacity admission disabled, or run not found
409STALE_EXECUTIONThis execution was recovered or superseded, or a pending pause took effect at this checkpoint (the steps in this batch are saved) — stop this execution
409RUN_NOT_RUNNINGFence is valid but the run is no longer running

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

List API keys for the caller’s organization. Pass ?platform=true to list platform API keys instead (requires platform:keys:read).

Get a single API key by ID.

Delete/revoke an API key. Returns 204. Desktop sessions minted from the key stop working at once.

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.

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


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"]
}

List all users in the caller’s organization (admin only).

Get a single user (admin or self).

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

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"
}

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..."
}

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.

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

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


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"
}
]

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").


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.

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:

ParameterDescription
envIDEnvironment ID owning the entry
fnIDFunction ID
keybase64url-encoded debounce key

Responses:

StatusMeaning
204 No ContentEntry cancelled, or did not exist (idempotent no-op)
400 Bad RequestPath segments missing or key not valid base64url
403 ForbiddenTenant-scoped request whose authenticated env does not match envID in the path (cross-tenant guard)
503 Service UnavailableDebounce 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.


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 parameterDefaultDescription
env_idnoneEnvironment ID or current; required unless the header supplies it
sourcenoneExact source filter, such as event_router, projection, or publish
limit50Maximum rows, 1 through 500
offset0Row 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.


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.

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:

ParameterTypeDefaultDescription
env_idstring—Environment ID (required unless X-Ironflow-Environment-ID header is set)
limitint50Max rows to return (1-500; out-of-range values return 400)
offsetint0Row 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.

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.

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.


List history inspection (audit) events globally.

Query Parameters:

ParameterTypeDescription
run_idstringFilter by run
function_idstringFilter by function
event_typestringFilter by audit event type. Accepts one value or a comma-separated list.
actionstringFilter by action
api_key_idstringFilter by the API key that acted
fromstringStart timestamp, RFC3339 or YYYY-MM-DD
tostringEnd timestamp, RFC3339 or YYYY-MM-DD
limitintMax results (default 100). A value that is not a positive integer uses the default.
cursorstringPagination 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.

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.

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.


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

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.

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 keyValues
postgresconnection failed. This key also names a failed SQLite check.
natsdisconnected 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.

Returns the server route manifest. Used by SDK generation tooling (make sdk-manifest).

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 /ws and 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 .proto files in api/proto/.
  • Internal routes, such as the health probes, /metrics, /routes, /cluster/health and /openapi.json itself. GET /routes lists every route with its category; the spec omits the internal ones.

The route needs an API key or session like any other route; without one it returns 401.

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.

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.

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.

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.

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.

FieldRequiredMeaning
kindyesengine_api_key, founder_api_key or dashboard_jwt_secret
new_valueyesThe 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_idAPI-key kindsThe 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, and key_prefix follows 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 from IRONFLOW_JWT_SECRET or 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.

StatusWhen
400invalid 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
403The caller lacks cluster:rotate-token
404remote api key not found

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.

Open a WebSocket connection for real-time event subscriptions.

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.

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.

MetricTypeLabelsMeaning
ironflow_debounce_arms_totalCounterenvironment_id, function_id, resultArm outcomes created, reset, ceiling (CAS exhaustion), kv_error, replay_skip, error (other failures).
ironflow_debounce_cas_retries_totalCounterenvironment_id, function_idAdditional CAS attempts actually begun. The initial create collision and the final conflict without another attempt do not count.
ironflow_debounce_fires_totalCounterenvironment_id, function_id, sourceSuccessful run creations from expired timers or recovery.
ironflow_debounce_cancels_totalCounterenvironment_id, function_id, sourceAccepted successful REST cancellation requests from dashboard, cli or api, including missing-entry noops.
ironflow_debounce_arm_duration_secondsHistogramenvironment_id, function_idWhole arm call wall-clock duration, including errors and replay skips.
ironflow_debounce_sweep_duration_secondsHistogramNoneWhole sweep wall-clock duration across environments, including empty and failed ticks.
ironflow_debounce_recovery_lag_secondsHistogramenvironment_idTime from the original pending row’s claim to successful recovered run creation.
ironflow_debounce_active_entriesGaugeenvironment_idShared KV entries in the latest complete sample.
ironflow_debounce_collector_errors_totalCounterNoneFailed 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.


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.

Clear the captured debug requests that you can see.


MethodPathDescription
POST/orgsCreate an organization
GET/orgsList 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.

MethodPathDescription
POST/rolesCreate a custom role
GET/rolesList roles
GET/roles/{id}Get a role
PATCH/roles/{id}Update role name
DELETE/roles/{id}Delete a role (not built-in)

The Rego policy set of the organization. It is authorization Layer 2 and can only deny. See Authorization policy sets.

MethodPathDescription
GET/policy-setGet 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-setReplace the whole set. Needs If-Match: <version>.
GET/policy-set/versionsList versions, without file contents.
GET/policy-set/versions/{n}Get one version with its files.
POST/policy-set/versions/{n}/rollbackStore version n as a new version. Needs If-Match: <version>. The active mode stays.
POST/policy-set/validateValidate a candidate set.
POST/policy-set/dry-runEvaluate 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"
}
  • modules maps a file name to Rego source. Up to 500 files and 2 MiB of source.
  • decision is an optional rule path, for example data.envoy.authz.allow. When you leave it out, an update keeps the current rule. Send "" to remove it.
  • mode is enforce or monitor. When you leave it out, an update keeps the current mode and a new set starts in monitor.

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.

MethodPathDescription
POST/tenants/provisionProvision a tenant (org + env + admin key)
GET/tenantsList 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"] }
}

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:

ParameterTypeDescription
env_idstringFilter by environment ID
functionstringFilter by function ID (ignored for leases, credits, and stats)
limitintMax 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.

List concurrency_lanes rows. Each row carries the lane digest, environment, function, scope, limit, and the maintained active_reserved counter.

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

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.

List dispatch_buckets fairness-ledger rows. Each row is keyed by (environment_id, function_id, lane_id) and carries last_dispatched_at.

List worker_sessions rows for active pull workers. Supports env_id and function filters; function matches sessions advertising that function via worker_session_functions.

List worker_credits rows (claimed and free slots). The function query parameter is silently ignored — worker_credits has no function_id column.

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
}
]

CodeMeaning
200Success
201Resource created
204Success, no content
400Invalid request or validation error
401Authentication required
403Insufficient permissions
404Resource not found
409Conflict (duplicate resource or version mismatch)
412Precondition failed (CAS mismatch)
503Service unavailable

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.