- SDK Reference
- REST API
REST API
Ironflow exposes a REST API for managing functions, emitting events, querying runs, and more.
Base URL: http://localhost:9123/api/v1
The /api/v1/platform/* control-plane routes (platform users, roles, policies,
tenants, audit) are not listed here — see the Platform API.
Authentication
Section titled “Authentication”All API endpoints (except /health, /ready, /metrics, /api/v1/capabilities, /api/v1/auth/login, /api/v1/auth/validate, and /api/v1/platform/auth/login) require authentication.
API Key (SDK and programmatic access):
Authorization: Bearer ifkey_your_key_hereDashboard JWT (browser sessions):
Dashboard authentication uses the ironflow_dashboard_token cookie set by the login endpoint.
Environment Selection (optional):
X-Ironflow-Environment: env_productionThe value may be an environment ID (env_-prefixed) or an environment
name (production), and the two are treated differently on purpose:
| Value | Resolves to | If it doesn’t exist |
|---|---|---|
env_production (ID) | itself | 400 unknown environment |
production (name) | that environment’s ID | falls back to env_default |
An ID is a claim to be one specific existing environment, so an unrecognised one
is rejected rather than silently rescoped. A name is a lookup key that may not
resolve yet, so it keeps the historical fallback. A lookup that fails for any
other reason (database unavailable) returns 500, not 400.
Omitting the header entirely uses env_default.
Reading the ConnectRPC examples
Section titled “Reading the ConnectRPC examples”Several capabilities below have no REST route any more — the duplicate was
removed in #1972 step 10 and the Connect method is the only surface. Those are
ordinary JSON over HTTP POST: send Content-Type: application/json to
/ironflow.v1.<Service>/<Method> with the same Authorization and
X-Ironflow-Environment headers as any REST route.
Three encoding rules apply to every one of them, and the response examples in this page are written to follow them:
- Fields are camelCase, matching the Protobuf JSON mapping —
eventName,lastEventSeq,runId. - 64-bit integers are decimal strings:
"sequence": "42", not42. - Empty fields are omitted, not sent as
"",0,[]ornull. An all-empty response is{}. Read an absent key as the zero value; do not treat it as an error.
Errors are a JSON body with a Connect code rather than an HTTP status name:
{"code": "not_found", "message": "projection \"orders\" not found"}. The
codes this page names are invalid_argument, not_found, already_exists,
failed_precondition, aborted, unavailable and internal.
Events
Section titled “Events”Emit an event
Section titled “Emit an event”POST /api/v1/events was removed. Use
POST /ironflow.v1.IronflowService/Emit.
Request:
{ "event": "order.placed", "data": { "orderId": "123", "total": 99.99 }, "version": 1, "metadata": { "source": "checkout", "traceId": "abc-123" }}The event name field is event, not name — TriggerRequest named it that
before the REST route was retired, and the two never matched.
The version field is optional and selects the event schema version to
validate against (#1955). Omit it for 1. It matters only when event-schema
enforcement is on: matching is exact, and once any version of an event name is
registered the name is governed, so an emit carrying a version with no
registered schema is rejected. A negative value returns 400. 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.
An event name must be under 255 bytes (the limit counts the events: namespace
prefix the server adds, so 248 bytes of name), may not contain the NATS
wildcards * or >, and may not contain whitespace, a control character, or an
empty . segment (a..b, .a, a.). Each returns 400. Non-ASCII event
names are allowed. The same bound applies to events arriving on the internal
NATS ingest stream, which reject rather than store.
Response:
{ "eventId": "evt_abc123", "runIds": ["run_xyz789"]}An event that matches no function returns eventId alone — runIds is omitted
rather than sent as [].
GET /events
Section titled “GET /events”List events with keyset pagination (issue #697). Sorted newest-first by
(timestamp, id). The endpoint always scopes to the caller’s environment.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
name | string | Filter by event name (substring match, unindexed) |
names | string | Filter by exact event name (comma-separated, OR-ed, max 200). Distinct from name: entries must match in full. Populate from GET /events/names. |
source | string | Filter by source (comma-separated, max 200) |
since | string | Start time (RFC3339) |
until | string | End time (RFC3339) |
limit | int | Max results per page (default 20) |
cursor | string | Forward keyset token from a prior next_cursor. Mutex with before. |
before | string | Backward keyset token from a prior prev_cursor. Mutex with cursor. |
Response:
{ "events": [...], "count": 20, "limit": 20, "next_cursor": "MTcxMjMzNDQ1Njc4OXxldnRfMDFI...", "prev_cursor": "MTcxMjMzNDQ1NjAwMHxldnRfMDFI...", "has_next": true, "has_prev": false, "approx_total": 12400, "approx_total_capped": false}next_cursor/prev_cursorarenull/omitted when there is no further page in that direction.has_nextandhas_prevare direction-aware. On the newest page (no cursor and nobefore),has_previs alwaysfalse. After paging forward withcursor=,has_previstrue. After paging backward withbefore=,has_nextistrue.approx_totalis the per-environment count from a capped subquery (LIMIT 10001). Below the cap the value is exact; above the cap the value is10000andapprox_total_cappedistrue.- Each event’s
timestampandcreated_atare RFC3339 with milliseconds (2026-08-04T21:19:35.482Z). Sub-second precision was previously truncated, which made same-second events impossible to order. Clients parsing withtime.Parse(time.RFC3339, …)or JSnew Date()are unaffected; only exact string comparison against the old second-resolution form breaks.
Errors:
400— invalidcursor/beforetoken, or both supplied at once.400—sinceoruntilis not RFC3339. The message names the offending parameter (invalid 'since' parameter: must be RFC3339). A malformed value is rejected, never silently ignored.400— more than 200 comma-separated values innamesorsource(too many values in 'names' parameter: at most 200). Each value becomes one bind parameter in the underlying query, so an unbounded list would surface as a500. The limit matches the 200-name cap ofGET /events/names, so a full facet selection is always accepted.
GET /events/names
Section titled “GET /events/names”List distinct event names with counts. Backs the dashboard’s event-type picker
and feeds the names filter on GET /events. Scoped to the caller’s
environment.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
source | string | Filter by source (comma-separated, max 200) |
since | string | Start time (RFC3339) |
until | string | End time (RFC3339) |
Response:
{ "names": [ { "name": "order.placed", "count": 812 }, { "name": "order.shipped", "count": 344 } ], "scanned": 10000, "truncated": true, "scan_cap": 10000}- Counts are computed over at most the newest
scan_cap(10,000) matching events, so they are a recency window rather than environment totals whenevertruncatedistrue. Rarer names may be absent entirely —GET /eventsstill accepts any exact name innames, listed or not. scannedis how many events the facet actually grouped over.- At most 200 distinct names are returned, ordered by descending count.
truncatedistruewhen either cap was exceeded — more than 10,000 matching events, or more than 200 distinct names. An environment holding exactly 10,000 matching events reportstruncated: false, because its counts are exact totals. The response does not distinguish which cap fired; comparescannedagainstscan_capto tell them apart.- The two causes mean different things. Exceeding the scan cap makes the
counts a recency window rather than environment totals. Exceeding the
200-name cap leaves each returned count an exact total and only makes the
list of names incomplete. So
scannedand the sum of the returnednames[].countvalues can diverge for either reason — do not derive one from the other. - The cap bounds how many rows are grouped, not how many are scanned.
Unfiltered and time-filtered requests read an ordered index and stop at the
cap, so their cost does not grow with the events table. A highly selective
sourcefilter has no supporting index, so it scans to find its matches — the same on environment-scoped and environment-less requests alike.
Errors:
400—sinceoruntilis not RFC3339.400— more than 200 comma-separated values insource.
GET /events/{id}
Section titled “GET /events/{id}”Get a single event by ID.
Functions
Section titled “Functions”Function registration (POST /ironflow.v1.IronflowService/RegisterFunction) accepts an optional change_reason field (e.g. "deploy v2.1.0") that is recorded in the function’s history for every update. Has no effect on new registrations (no history event is written on first create).
Function lifecycle events
Section titled “Function lifecycle events”Subscribers see function lifecycle on system.function.{function_id}.{event}, where
{event} is registered, updated, or deleted (issue #1725). Every leaf carries the
same payload — id, name, status, preferredMode, createdAt, updatedAt,
timestamp — so system.function.> decodes to one shape, and a consumer that built its
list from registered / updated frames can now remove the entry on deleted. The
deleted frame is published after the row is gone; its payload is the last known state.
Only an explicit DeleteFunction RPC publishes it. Deleting an environment removes its
functions in bulk (DELETE FROM functions WHERE environment_id = …) with no per-function
frame; it publishes one system.environment.{environment_id}.deleted instead — see
Environment lifecycle events.
Cron schedule lifecycle events
Section titled “Cron schedule lifecycle events”A function with a cron trigger also publishes schedule lifecycle on
system.cron.{function_id}.{event}, where {event} is registered, removed,
invalid, or missed (issue #2010). Subscribe to system.cron.> for all cron
activity in the environment, or system.cron.{function_id}.> to follow one
schedule — with the > wildcard rather than a literal leaf.
This family is separate from system.function.* because the function family
cannot express it. Dropping a cron trigger is a function update — the
function still exists — and the function payload carries no triggers, so a
schedule deletion and a code-only redeploy are the same frame. These leaves say
what happened to the schedule specifically.
| Event | Fires when | Payload beyond functionId, environmentId, timestamp |
|---|---|---|
registered | a schedule is installed, or its expression changes | expression, nextRun, replaced (true when it overwrote an existing schedule) |
removed | the schedule is gone | cause: triggers_changed (the cron trigger was edited away) or function_deleted |
invalid | the cron expression fails to parse | expression (the spec that failed), error |
missed | schedule slots produced no run | count, cause (downtime, stall, fire_failed), 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 is the narratable form of the ironflow_cron_slots_skipped_total
metric. Nothing is backfilled — firing a burst of catch-up runs after an outage
is worse than firing none — but the frame carries the window and the cause the
counter cannot. verified is false when the count is a floor rather than
exact: the dedup bucket was unreachable, the per-slot lookup budget ran out, or
the window was clamped. Duplicates are possible on cause: "downtime" and
only there. Every node computes that report at startup from the shared,
cluster-wide dedup bucket, so a whole-cluster restart announces the same gap
once per node. The stall and fire_failed causes are published only by the
node that owns the slot’s claim.
A function ID may contain ., so an ID of a.b spans two subject segments.
system.cron.> and system.cron.{function_id}.> are unaffected, but a leaf
filter like system.cron.*.missed misses dotted IDs. Route on the trailing
segment of a system.cron.> subscription instead of filtering with *.
Reading and invoking functions
Section titled “Reading and invoking functions”The duplicate REST routes were removed. Send JSON POST requests to these
Connect methods on ironflow.v1.IronflowService:
| Method | Purpose |
|---|---|
ListFunctions | List registered functions; accepts name, status, mode, limit, offset. |
GetFunction | Fetch one function by ID or slug. |
InvokeFunction | Invoke a function directly, creating an event and a run. |
status and mode are enums on the wire — send FUNCTION_STATUS_ACTIVE,
EXECUTION_MODE_PULL and so on, or their integer values.
GetFunction response:
{ "id": "fn_abc123", "slug": "process-order", "triggers": [{ "event": "order.placed" }], "retryAttempts": 3, "retryDelayMs": 1000, "retryBackoff": 2.0, "timeoutMs": 30000, "executionMode": "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.
InvokeFunction request:
{ "functionId": "fn_abc123", "data": { "orderId": "123" }}Headers:
| Header | Description |
|---|---|
Idempotency-Key | Optional. When supplied, a retry with the same key + same function in the same environment returns the original runId + eventId instead of creating a duplicate run. Reusing a key for a different function, or for a key already used by an Emit, returns already_exists — keys are scoped per (environment, key) and not shareable across flows. |
InvokeFunction response:
{ "runId": "run_abc", "eventId": "evt_xyz"}Function History
Section titled “Function History”Function registration history is exposed via the ConnectRPC API. Every update, status change, rollback, and deletion is recorded as an immutable snapshot against ironflow:fn:{id} in the entity stream.
See Function Registration History for a full explanation of the flows.
List Function History
Section titled “List Function History”POST /ironflow.v1.IronflowService/ListFunctionHistoryRequest:
{ "function_id": "my-function", "limit": 20, "from_version": 0}Use from_version (exclusive) for keyset pagination — pass the entity_version of the last entry to get the next page.
Response:
{ "entries": [ { "event_id": "evt_abc", "entity_version": 3, "function_id": "my-function", "function_snapshot": { ... }, "actor_id": "ifkey_xyz", "change_reason": "deploy v2.1.0", "change_type": "update", "recorded_at": "2026-04-12T10:00:00Z" } ], "has_more": false}change_type values: update, status_change, rollback, delete.
Get Function at Version
Section titled “Get Function at Version”POST /ironflow.v1.IronflowService/GetFunctionAtVersionRequest:
{ "function_id": "my-function", "version": 2}Response:
{ "entry": { "event_id": "evt_abc", "entity_version": 2, "function_snapshot": { ... }, "actor_id": "ifkey_xyz", "change_type": "update", "recorded_at": "2026-04-10T08:00:00Z" }}Rollback Function
Section titled “Rollback Function”POST /ironflow.v1.IronflowService/RollbackFunctionRestores a function to the configuration captured at version. The rollback is itself recorded as a new history entry with change_type = "rollback". Returns CodeAborted on concurrent modification — retry in that case.
Request:
{ "function_id": "my-function", "version": 2, "change_reason": "revert bad deploy"}Response:
{ "function": { ... }}Cannot roll back to an archived state (CodeInvalidArgument).
Reading runs
Section titled “Reading runs”The duplicate REST routes were removed. Send JSON POST requests to these
Connect methods on ironflow.v1.IronflowService:
| Method | Purpose |
|---|---|
ListRuns | List runs; accepts functionId, eventId, search, statuses, since, until, limit, offset. |
GetRun | Fetch one run by id. |
GetRunSteps | List the steps executed in a run (runId). |
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.
GET /runs/{id}/streams
Section titled “GET /runs/{id}/streams”List entity streams that were read from or appended to during this run.
Run audit trail
Section titled “Run audit trail”GET /api/v1/runs/{id}/audit was removed. Use
POST /ironflow.v1.AuditService/GetAuditTrail with runId, and the optional
eventType, fromTimestamp, toTimestamp, limit and cursor fields. The
two time bounds are fromTimestamp / toTimestamp on the wire, not from /
to as the REST query parameters were.
The environment-wide GET /api/v1/audit route below is unchanged and still
returns the same body shape.
Cancel a run
Section titled “Cancel a run”POST /api/v1/runs/{id}/cancel was removed. Use
POST /ironflow.v1.IronflowService/CancelRun.
Cancels a queued (waiting_for_capacity / waiting), running, or paused run. Routes through the engine’s
cancel helper: marks the run terminal with cancellation_cause = "user",
deletes any cancel_on_specs rows referencing the run, publishes a
cancel event to NATS with a stable msg-id (cancel-{runID}), and
notifies pubsub subscribers (#716).
Request:
{ "id": "run_abc", "reason": "No longer needed"}reason is optional.
The reason field is captured into the NATS payload and structured
log only — the persisted cancellation_cause column is always "user".
Long reasons are truncated server-side (rune-aware, 1024 rune cap).
Responses:
ok— run cancelled (idempotent: re-cancelling an already-cancelled run also succeeds and returns the cancelled run).not_found— run id does not exist.aborted— run reached a different terminal state during the cancel (race).failed_precondition— run is in a non-cancellable status (already completed/failed/cancelled).internal— unexpected store/engine failure.
Resume a run
Section titled “Resume a run”POST /api/v1/runs/resume was removed. Use
POST /ironflow.v1.IronflowService/ResumeRun.
Resumes a paused or failed run, optionally from a specific step. Routes
through the engine’s resume helper: flips status to running, publishes
a resume event to NATS with a stable msg-id (resume-{runID}), and
notifies pubsub subscribers. If the NATS publish fails the run status
is reverted to its prior paused/failed value so the run is not stuck
in running with no executor (#716).
Request:
{ "runId": "run_abc", "fromStep": "step_xyz"}fromStep is optional; omit to resume from the last completed step.
Responses:
ok— run resumed; status flipped torunningand resume event published.invalid_argument— malformed body or missingrunId.not_found— run id does not exist, ORfromStepdoes not exist, ORfromStepbelongs to a different run.already_exists— an identical resume is already in flight inside the stream’s dedupe window (2 minutes, keyed onresume-{runID}). The run is left exactly as it was found; wait for the first resume to land rather than retrying (#1963).failed_precondition— run is in a non-resumable status (must bepausedorfailed).internal— NATS publish failed (run is reverted to its prior status), or other unexpected failure.
Patch a step
Section titled “Patch a step”POST /api/v1/steps/patch was removed. Use
POST /ironflow.v1.IronflowService/PatchStep to override a step’s output —
for example to fix a failed step before resuming.
Request:
{ "stepId": "step_abc", "output": { "result": "manual_fix" }, "reason": "Corrected API response"}Response: the patched step.
Errors:
not_found— step id does not exist.failed_precondition— the step is neithercompletednorfailed.aborted— another writer (a concurrent patch, orInjectStepOutput) won the step’s compare-and-swap. Nothing was written. Re-read the step and reissue; the request carries no expected version, so a blind retry would overwrite whatever landed. Answered withIronflow-Error-Reason: contendedand aRetry-After.
Patching a failed step to completed clears its error but does not unwind
side effects the failure already triggered — compensations that ran are not
rolled back.
A successful patch publishes system.run.{runId}.step.{stepId}.patched.
InjectStepOutput publishes the same event (#2065) — the two write the same
step columns, so a subscriber sees a rewritten step output from either surface.
History Editing (Scoped Injection)
Section titled “History Editing (Scoped Injection)”Pause Run
Section titled “Pause Run”Pause a running workflow at the next step boundary for inspection and injection.
POST /ironflow.v1.IronflowService/PauseRunRequest:
{ "run_id": "run_abc123"}Response:
{ "status": "pause_requested"}Status is "pause_requested" if the run is running (will pause at next step boundary), or "paused" if the run was already paused (sleep/wait) and was upgraded to injection pause.
Get Paused State
Section titled “Get Paused State”Get completed steps and their outputs for an injection-paused run.
POST /ironflow.v1.IronflowService/GetPausedStateRequest:
{ "run_id": "run_abc123"}Response:
{ "steps": [ { "id": "step_xyz", "name": "compute-total", "output": "{\"total\": 42}", "injected": false, "completed_at": "2026-03-08T00:00:00Z" } ], "next_step_hint": "send-email", "pause_reason": "injection"}Only works on runs with status = "paused" and pause_reason = "injection".
Inject Step Output
Section titled “Inject Step Output”Modify the output of a completed step while a run is paused for injection.
POST /ironflow.v1.IronflowService/InjectStepOutputRequest:
{ "run_id": "run_abc123", "step_id": "step_xyz", "new_output": "{\"total\": 50}", "reason": "Correcting calculation error"}Response:
{ "step_id": "step_xyz", "previous_output": "{\"total\": 42}"}Preserves the original output on first injection. Sets patched_at and patched_by on the step.
Environments
Section titled “Environments”GET /environments
Section titled “GET /environments”List all environments. API keys are masked in the response.
POST /environments
Section titled “POST /environments”Create a new environment.
Request:
{ "name": "staging"}PUT /environments/{id}
Section titled “PUT /environments/{id}”Update an environment (e.g., rename it).
Request:
{ "name": "production"}DELETE /environments/{id}
Section titled “DELETE /environments/{id}”Delete an environment. The default environment cannot be deleted.
Both DELETE and PUT verify that the environment belongs to the caller’s organization,
resolved through the environment’s project. A cross-organization attempt answers 404 with
the same body as a genuine miss, so the status cannot be used to probe whether an id exists.
Platform callers bypass the check. An environment with no project is unattributable and
remains allowed for any caller — a documented gap (#1516) that needs a data backfill before
it can fail closed.
Environment lifecycle events
Section titled “Environment lifecycle events”A successful delete publishes system.environment.{environment_id}.deleted (issue #1748),
carrying id, name, projectId, timestamp. One leaf today; identity only, since the
row it describes no longer exists.
The frame is best effort, like every other system.* frame: it is published directly to
NATS after the delete transaction commits, not through the transactional outbox. A NATS
outage, an open circuit breaker, or a process crash in that window loses it with no retry
and no dead-letter. A consumer must therefore treat this as a hint that lets it drop state
early, not as a ledger it can rely on — seed from GET /environments and reconcile.
Read it as “drop everything scoped to this environment.” Teardown removes the
environment’s functions with a single bulk DELETE FROM functions WHERE environment_id = …,
so no per-function deleted frame fires and a consumer that built its list from
system.function.> would otherwise hold every entry until it re-polls ListFunctions.
One frame replaces that fan-out: N per-function frames would hit the deployment-wide
pub/sub stream at exactly the moment the environment is being torn down.
The frame is published into the deleted environment’s own scope, not the caller’s, and after the delete commits — a failed delete publishes nothing. It survives the operator hanging up: the publish context is stripped of cancellation, so a closed tab does not cost the frame. It is bounded at 2s, so a degraded NATS cannot hold the delete request open.
An environment ID that cannot be exactly one literal NATS subject token — one containing
., * or >, or longer than 110 bytes — is skipped rather than published, since such an
ID would either land the frame on a different environment’s subject or spend a failure on
the shared NATS-publish circuit breaker. Environment IDs minted through POST /environments
can never hit this (names are validated to [a-z0-9-]); only the platform-bootstrap path
can mint one, and such an environment is already broken for every other event it publishes.
Note that every subscription is single-environment, so its audience is a client still connected to that environment when an admin deletes it (a dashboard tab, a Desktop channel), which outlives the environment it watches. A subscriber scoped to a different environment does not receive it.
Cascading parent deletes publish it too (issue #1754). DELETE /projects/{id},
DELETE /orgs/{id} and DELETE /platform/tenants/{id} delete each environment and its
dependent data in the same transaction before removing the project or organization. The
transaction returns snapshots of the environments it committed, so blob cleanup and lifecycle
publication use the exact rows removed rather than a separate pre-delete listing. This explicit
cleanup works when an environment contains registered functions, whose foreign key does not
carry ON DELETE CASCADE.
After the transaction commits, the handler publishes one frame per environment removed, each in its own environment’s scope. The fan-out is bounded by environments-per-project or per-org, not by functions-per-environment. Publication remains best-effort after commit, but database errors return no deletion result and publish no frames.
The 2s bound above is per frame, and the frames are published in a loop on the request goroutine, so a cascading delete of N environments against a degraded NATS can hold the response for up to N × 2s. The delete itself has already committed by then; only the reply waits.
Projects
Section titled “Projects”GET /projects
Section titled “GET /projects”List all projects in the organization.
POST /projects
Section titled “POST /projects”Create a new project.
Request:
{ "name": "payments"}PUT /projects/{id}
Section titled “PUT /projects/{id}”Update a project.
DELETE /projects/{id}
Section titled “DELETE /projects/{id}”Delete a project.
Entity Streams (Event Sourcing)
Section titled “Entity Streams (Event Sourcing)”Entity streams use ironflow.v1.EntityStreamService. The duplicate REST routes
have been removed. Send JSON POST requests to these Connect methods:
| Method | Purpose |
|---|---|
ListStreams | List streams; accepts entity type, search, limit and offset. |
GetStreamInfo | Read a stream’s current version and event count. |
ReadStream | Read events by version, limit and direction. |
AppendEvent | Append an event with an optimistic-concurrency check. |
GetEntityHistory | Read events with their runs and steps; supports timestamp filters. |
CreateSnapshot | Store state at an existing stream version. |
GetSnapshot | Read the latest snapshot at or before a version. |
The request body carries entityId. Protobuf JSON encodes 64-bit version fields
as decimal strings. An omitted expectedVersion means zero and requires an
empty stream. Use -1 to skip the concurrency check or a positive version to
require that exact current version.
curl -X POST localhost:9123/ironflow.v1.EntityStreamService/AppendEvent \ -H 'Authorization: Bearer ifkey_your_key_here' \ -H 'Content-Type: application/json' \ -d '{"entityId":"order-123","entityType":"order","eventName":"order.shipped","expectedVersion":"-1"}'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.
Stream lifecycle events
Section titled “Stream lifecycle events”An append and a snapshot each publish a frame on
system.stream.{entity_id}.{event}, where {event} is appended (issue #1730)
or snapshotted (issue #1749). 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. entity_version does not mean the same thing on the two
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. Do not read the two off one
> subscription as if they were the same counter.
This is the operator view, not the data view. Neither 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 400 on EntityStreamService/AppendEvent (issue #1746), because
the subject they build is one NATS rejects. Before #1746 the append succeeded
and only the publish failed, silently and repeatedly.
Two entity IDs still get no frame at all. A platform-internal ID (the reserved
ironflow: prefix) is never published, matching every read surface, which hides
those streams. An ID with an empty segment is skipped for the reason above — and
after #1746 that can only be a legacy row written before the rule existed,
reached through EntityStreamService/CreateSnapshot, which checks the reserved
prefix but not the full ID. Such a stream stays readable; new appends to it are refused.
Projections
Section titled “Projections”Projections use ironflow.v1.ProjectionService. The duplicate REST routes were
removed; two REST routes remain and are documented below. Send JSON POST
requests to these Connect methods:
| Method | Purpose |
|---|---|
ListProjections | List registered projections; accepts status, mode, limit, offset. |
GetProjection | Read a projection and its current state; accepts partition (default __global__). |
GetProjectionStatus | Read processing status. |
WaitProjectionCatchup | Wait until a projection reaches a minimum NATS sequence. |
WaitProjectionCatchupBatch | The same wait for several projections in one call. |
WaitForEvent | Wait until one specific event has been applied. |
RebuildProjection | Trigger a full or partial rebuild. |
GetRebuildJob | Read the status of an in-flight rebuild. |
CancelRebuild | Cancel an in-flight rebuild. |
PauseProjection | Stop consuming events. |
ResumeProjection | Resume a paused projection. |
Protobuf JSON encodes 64-bit sequence fields as decimal strings.
Projection status
Section titled “Projection status”GetProjectionStatus response:
{ "name": "order-totals", "status": "running", "mode": "managed", "lastEventSeq": "142", "updatedAt": "2026-03-06T12:00:00Z"}A healthy projection carries no errorMessage key at all; the field appears
only when there is a message. An unknown name answers
{"code": "not_found", ...}.
Whenever a projection’s health changes — pause, resume, rebuild start /
progress / completion, and handler errors — WebSocket subscribers receive the
same snapshot on system.projection_health.{name}. The frame carries name,
status, mode, last_event_seq, error_message, timestamp, and, only
while a rebuild is in flight, rebuild_target_seq, rebuild_start_cursor, and
rebuild_started_at.
lag is present only when the consumer could actually be read. If the consumer
is missing or NATS is unreachable, the key is absent rather than 0 —
zero is what a fully caught-up projection reports, so sending it for an
unreadable consumer would advertise perfect health during an incident. Treat
absence as “unknown”, not “none”. GetProjectionStatus still returns lag: "0"
in that case, because the field is a bare int64 on the wire with no absent
value.
That topic is deliberately not under system.projection.{name}.: >
matches one or more trailing segments, so a health subject there would be
delivered to everything subscribed to system.projection.{name}.> for state
updates — including subscribeToProjection in the browser SDK. Subscribe to
system.projection_health.> for all projections.
During a rebuild the status stays rebuilding, so progress is expressed as the
cursor advance: each applied batch publishes a frame whose last_event_seq has
moved against rebuild_start_cursor and rebuild_target_seq. Compute a
percentage as (last_event_seq - rebuild_start_cursor) / (rebuild_target_seq - rebuild_start_cursor). A replayed batch that does not advance the cursor
publishes nothing.
Health is published on transitions and rebuild cursor advances, not on a timer.
A projection that is stuck with growing lag but emits neither publishes nothing;
poll GetProjectionStatus for continuous lag monitoring.
GET /projections/{name}/partitions
Section titled “GET /projections/{name}/partitions”List partitions for a partitioned projection.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
q | string | Filter partitions by substring (max 200 chars) |
limit | int | Max results (default 50) |
Connect: projection wait
Section titled “Connect: projection wait”GET /api/v1/projections/{name}/catchup was removed. Use
POST /ironflow.v1.ProjectionService/WaitProjectionCatchup. Long-poll style;
returns when the projection is current or the wait times out.
Request:
{ "name": "order-totals", "minSeq": "142", "partition": "tenant-1", "timeout": "30s"}minSeq is required and, being a 64-bit field, is a decimal string. timeout
is a protobuf Duration string and defaults to 30s.
Partition on the wait methods
Section titled “Partition on the wait methods”partition is optional on all three wait methods in this section and means
“wait on this one partition instead of the whole projection”. Omit it for a
global wait — __global__ is the server’s internal key for un-partitioned
state and is rejected as input (invalid_argument, partition "__global__" is reserved; omit partition for global waits).
A partition is rejected on an external projection (external cursors are
stream-level, so per-partition wait correctness is undefined) and on a
projection that declares no partition_key and holds no state row under that
partition, since such a wait can never catch up. A partition that no events
have reached yet on a partitioned projection is accepted — its cursor reads
as 0 until the projection writes it, which is what makes append-then-wait work
for a brand-new key.
Rejections surface as invalid_argument on WaitProjectionCatchup and
WaitForEvent. On WaitProjectionCatchupBatch they are per item: the call
itself succeeds and the offending entry carries an error string.
Because a partition on a partitioned projection cannot be existence-checked
without breaking the read-your-write path above, the cost of an invented one is
bounded instead: at most 64 distinct partitions of a single projection may
have a live wait at the same time. Joining a partition that already has one is
never counted. Over the cap the wait is rejected with resource_exhausted
(too many concurrent partition waits for this projection); on the batch method
it is a per-item error.
Connect: batch projection wait
Section titled “Connect: batch projection wait”The REST route was removed. Use POST /ironflow.v1.ProjectionService/WaitProjectionCatchupBatch.
Wait for multiple projections to catch up in one call — 1 to 16 items, all
sharing one timeout and one cap reservation. Each item’s optional partition
follows Partition on the wait methods.
results is the same length as items, in the same order.
Request:
{ "items": [ { "name": "order-summaries", "minSeq": "142", "partition": "tenant-1" }, { "name": "user-index", "minSeq": "142" } ], "timeout": "5s"}Connect: wait for event
Section titled “Connect: wait for event”The REST route was removed. Use POST /ironflow.v1.ProjectionService/WaitForEvent.
Block until a specific event has been applied by a projection. Useful after AppendEvent when AppendEventResponse.Sequence is 0. The optional partition follows Partition on the wait methods and is checked before the event is resolved, so a rejected one fails immediately rather than after the wait.
Request:
{ "eventId": "evt_abc", "projection": "order-totals", "partition": "tenant-1", "timeout": "5s"}Connect: rebuild, pause and resume
Section titled “Connect: rebuild, pause and resume”POST /api/v1/projections/{name}/rebuild, GET .../rebuild,
POST .../cancel, POST .../pause and POST .../resume were removed. Use
RebuildProjection, GetRebuildJob, CancelRebuild, PauseProjection and
ResumeProjection on ironflow.v1.ProjectionService. Each takes the
projection name.
RebuildProjection request:
{ "name": "order-totals", "fromEventId": "evt_start", "toEventId": "evt_end", "partition": "tenant-1", "dryRun": false}Every field but name is optional.
DELETE /projections/{name}
Section titled “DELETE /projections/{name}”Delete/unregister a projection.
Pub/Sub
Section titled “Pub/Sub”Publish to a topic
Section titled “Publish to a topic”POST /api/v1/publish was removed. Use
POST /ironflow.v1.PubSubService/Publish to publish a message to a developer
pub/sub topic.
Request:
{ "topic": "notifications.email", "data": { "to": "user@example.com", "subject": "Hello" }, "idempotencyKey": "notif-123"}Response:
{ "eventId": "evt_abc", "sequence": "42"}The service answers unavailable until the server has a NATS bridge
configured.
Topics with reserved prefixes (events:, system., entity:, public., topic:) are rejected.
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.
The route is always mounted, but it needs the pub/sub bridge. An embedded server
started without one keeps answering here and returns 503 pub/sub service not configured; ironflow serve always wires the bridge.
Event Schema Registry
Section titled “Event Schema Registry”The schema registry uses ironflow.v1.EventSchemaService. The duplicate REST
routes were removed. Send JSON POST requests to these Connect methods:
| Method | Purpose |
|---|---|
RegisterSchema | Register (or overwrite) a schema version. |
ListSchemas | List schemas; accepts eventName, limit, offset. |
GetSchema | Read one schema — the latest version, or version when given. |
DeleteSchema | Delete one (eventName, version) pair. |
TestUpcast | Dry-run the upcast chain (see below). |
Register a schema
Section titled “Register a schema”RegisterSchema request:
{ "eventName": "order.placed", "version": 2, "schemaJson": "{\"type\":\"object\",\"properties\":{...}}", "description": "Added shipping address field"}schemaJson must be a JSON Schema the server can compile (draft 2020-12); a
malformed document returns invalid_argument.
Registration is an upsert on (eventName, version, environment). Both outcomes
succeed; the body reports which occurred:
| Outcome | Body |
|---|---|
| New version registered | {"status": "created"} |
| Existing version overwritten | {"status": "updated"} |
schemaJson is checked for compilability, not usefulness. An object of
unrecognized keywords — {"foo": "bar"} — is a valid JSON Schema that accepts
every payload, exactly as {} does, so registering the wrong JSON by mistake
succeeds and enforces nothing.
Whether a registered schema is enforced on the emit path is controlled by
IRONFLOW_EVENT_SCHEMA_ENFORCEMENT; it is off by default. See
Configuration.
Read and delete schemas
Section titled “Read and delete schemas”GetSchema returns the latest version for an eventName when version is
omitted, and that exact version when it is given; a version with no registered
schema answers not_found. DeleteSchema takes both fields and returns an
empty body.
Test the upcast chain
Section titled “Test the upcast chain”POST /api/v1/events/upcast was removed. Use the Connect endpoint
POST /ironflow.v1.EventSchemaService/TestUpcast with Content-Type: application/json:
{"eventName":"order.placed","fromVersion":1,"toVersion":2,"data":{"orderId":"123"}}The response contains the unchanged data and a stepsApplied array with schema
metadata for each version. This endpoint does not run SDK-defined transformations.
The Go, Node, and browser SDKs expose schemas.testUpcast or Schemas().TestUpcast.
Python exposes rpc.event_schemas.test_upcast. REST-only clients generated from
OpenAPI no longer expose this capability.
Each version in the span costs one schema lookup, so the span is capped:
to_version - from_version must be at most 200. A wider span answers 400
(upcast version span must not exceed 200) before any lookup runs.
SQL Query
Section titled “SQL Query”Raw SQL is a ConnectRPC capability. It has no REST route.
POST /sql was removed. Call ironflow.v1.QueryService/ExecuteSQL instead:
POST /ironflow.v1.QueryService/ExecuteSQLContent-Type: application/json
{"query": "SELECT * FROM order_totals WHERE total > 100", "timeoutMs": 5000, "maxRows": 100}Response:
{ "columns": ["id", "total"], "rows": [{"values": ["order-1", "150"]}], "totalRows": 1}Every cell is a string, because the server stringifies every column type.
The SDK methods are client.Projections().ExecuteSQL(ctx, query) in Go and
rpc.query.execute_sql(...) in Python. The Node and browser SDKs do not wrap
raw SQL — query a projection instead.
KV Store
Section titled “KV Store”POST /kv/buckets
Section titled “POST /kv/buckets”Create a KV bucket.
Request:
{ "name": "session-cache", "description": "User session data", "ttl_seconds": 3600, "max_value_size": 65536, "max_bytes": 10485760, "history": 5}GET /kv/buckets
Section titled “GET /kv/buckets”List all KV buckets.
GET /kv/buckets/{bucket}
Section titled “GET /kv/buckets/{bucket}”Get bucket metadata.
DELETE /kv/buckets/{bucket}
Section titled “DELETE /kv/buckets/{bucket}”Delete a bucket.
GET /kv/buckets/{bucket}/keys
Section titled “GET /kv/buckets/{bucket}/keys”List keys in a bucket.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
filter | string | Glob pattern to filter keys |
GET /kv/buckets/{bucket}/keys/{key}
Section titled “GET /kv/buckets/{bucket}/keys/{key}”Retrieve a key’s value. Returns an ETag header with the revision.
PUT /kv/buckets/{bucket}/keys/{key}
Section titled “PUT /kv/buckets/{bucket}/keys/{key}”Write a value. Supports atomic operations via headers:
| Header | Effect |
|---|---|
If-None-Match: * | Create only (fails if key exists) |
If-Match: <revision> | Compare-and-swap (fails if revision differs) |
Request body is the raw value (any content type).
DELETE /kv/buckets/{bucket}/keys/{key}
Section titled “DELETE /kv/buckets/{bucket}/keys/{key}”Delete a key.
GET /kv/buckets/{bucket}/watch
Section titled “GET /kv/buckets/{bucket}/watch”Watch a bucket for key changes via WebSocket upgrade.
Configuration
Section titled “Configuration”POST /config/{name}
Section titled “POST /config/{name}”Set or replace an entire config document.
Request: Any JSON object.
Response:
{ "name": "app-settings", "revision": 1}PATCH /config/{name}
Section titled “PATCH /config/{name}”Shallow merge-patch a config document with CAS retry.
Request: Partial JSON object with fields to merge.
GET /config/{name}
Section titled “GET /config/{name}”Get a config document.
Response:
{ "name": "app-settings", "data": { "theme": "dark" }, "revision": 3, "updatedAt": "2026-03-06T12:00:00Z"}GET /config
Section titled “GET /config”List all config documents.
DELETE /config/{name}
Section titled “DELETE /config/{name}”Delete a config document.
GET /config/{name}/watch
Section titled “GET /config/{name}/watch”Watch a config document for real-time changes via WebSocket.
Secrets
Section titled “Secrets”All secrets endpoints require the X-Ironflow-Environment header. Secret values are write-only and never returned in responses.
GET /secrets
Section titled “GET /secrets”List secret metadata.
POST /secrets
Section titled “POST /secrets”Create a new secret.
Request:
{ "name": "STRIPE_KEY", "value": "sk_live_...", "description": "Production billing key"}GET /secrets/{name}
Section titled “GET /secrets/{name}”Get secret metadata (not the value).
PUT /secrets/{name}
Section titled “PUT /secrets/{name}”Update a secret’s value.
Request:
{ "value": "sk_live_new_...", "description": "Rotated key"}PATCH /secrets/{name}
Section titled “PATCH /secrets/{name}”Partial update (description only).
DELETE /secrets/{name}
Section titled “DELETE /secrets/{name}”Delete a secret.
Webhooks
Section titled “Webhooks”GET /flow/emit-edges
Section titled “GET /flow/emit-edges”List learned function→event causation edges for the Flow Map. Requires functions:list and is scoped to the caller’s environment.
GET /flow/stream-edges
Section titled “GET /flow/stream-edges”List learned event→entity-stream relationships for the Flow Map. Requires
streams:read and is scoped to the caller’s environment. An edge appears once
an event of that name has been appended to a stream of that entity type;
platform-owned entities (ironflow: prefix) are excluded, matching the streams
list.
Response:
{ "edges": [ { "event": "order.created", "entity_type": "order" } ]}GET /flow/topic-edges
Section titled “GET /flow/topic-edges”List learned function→topic publish edges for the Flow Map. Requires
events:subscribe — the response names developer pub/sub topics — and is scoped
to the caller’s environment. An edge appears once a publish carries the
publishing run’s X-Ironflow-Run-ID, which the SDKs attach to any publish made
inside a run; publishes with no attributed run (CLI, plain API calls) are not
recorded.
Response:
{ "edges": [ { "function_id": "notifier", "topic": "notifications.email" } ]}GET /flow/invoke-edges
Section titled “GET /flow/invoke-edges”List declared function→function call edges for the Flow Map. Requires
functions:list and is scoped to the caller’s environment. Unlike the learned
edges above this is not self-reported: both IDs are resolved by the engine when
the child run is created, so an edge cannot be forged. It is also uncapped —
both columns are registered function IDs, so the result is bounded by how many
functions exist.
Response:
{ "edges": [ { "parent_function_id": "checkout", "child_function_id": "charge-card" } ]}GET /flow/wait-edges
Section titled “GET /flow/wait-edges”List declared event→function resume edges — events a function parks on via
step.waitForEvent. Requires functions:list and is scoped to the caller’s
environment. These events are by definition not in the function’s triggers,
so the arrow means “resumes this”, not “starts it”, and it appears nowhere else
on the map. Written by the engine when the waiting step is created, and
uncapped for the same reason as invoke edges.
Response:
{ "edges": [ { "function_id": "process-order", "event_name": "payment.settled" } ]}GET /flow/webhooks
Section titled “GET /flow/webhooks”Return the inbound-webhook portion of Flow Map. Requires events:subscribe and
is scoped to the caller’s environment. Registered sources are returned even if
they have never received traffic. Delivery counts summarize the retained
30-day delivery history; distinct source→event relationships come from durable
event causation and remain after delivery cleanup.
The response is intentionally safe for topology views: it contains no signing secrets, ingest tokens, headers, bodies, payloads, external delivery IDs, or delivery errors.
Response:
{ "sources": [ { "id": "wh_abc", "name": "Stripe production", "event_prefix": "stripe", "source_type": "api", "delivery_counts": { "accepted": 42, "rejected": 2, "failed": 1 }, "latest_delivery_status": "accepted", "latest_delivery_at": "2026-08-06T12:00:00Z" } ], "edges": [ { "source_id": "wh_abc", "event": "stripe.invoice.paid" } ]}After each retained delivery record is persisted, WebSocket subscribers may
receive a sanitized frame on system.webhook.{source_id}.delivery.{outcome}. It
contains delivery_id, source_id, status, created_at, and, for accepted
attempts, event_id and event_name. Re-fetch this endpoint to reconcile any
missed frame.
{outcome} is success for accepted, failure for rejected and failed —
so system.webhook.*.delivery.failure delivers every failed attempt without
payload inspection. The precise status stays in the payload.
system.webhook.> still matches every delivery frame. A retry that deduplicates
against an earlier accepted delivery answers 200 {"status":"deduplicated"}
without recording a new delivery row, so it publishes no frame at all.
The * in that filter matches exactly one subject segment, and {source_id}
is always one segment, so the filter covers every source. Sources created since
migration 031 carry a server-generated wh_ + UUID and appear in the subject
verbatim. Sources predating that migration kept their original
operator-supplied ID, which no code path ever validated — migration 031
deliberately does not rewrite them, so shared ingest URLs keep working. Such an
ID is encoded into one segment for the subject only: each ., *, >, space
or control character becomes _, and a short checksum is appended, so
stripe.prod publishes on system.webhook.stripe_prod~4b50ae9b.delivery.failure.
The stored ID, the ingest URL and the source_id in the payload are all
unchanged — identify the source by the payload, not by the subject.
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 (HMAC-SHA256/SHA1, hex-encoded over the raw body) and idempotency by external delivery ID.
Authentication. Sources created after migration 046 carry a per-source ingest token (ADR 0048) and authenticate with ?token=ifwh_... — an org API key is not accepted for those. The token is returned once, at create and rotate. Sources predating that migration continue to authenticate with an API key.
Limits. Request bodies are capped at 1 MB; larger payloads get 413.
The event name is composed from the source’s event_prefix and the payload’s
type field (or the source’s configured event_name_path), so it is bound by
the same rules as Emit an event above. A payload that produces an invalid name
gets 400 and a failed delivery record. event_prefix itself is checked
against the same rules when the source is created, so a prefix that could never
build a valid name is rejected up front rather than at delivery time.
Response:
{ "status": "accepted", "delivery_id": "gh_123", "event_id": "evt_abc", "run_ids": ["run_xyz"]}Workers (Pull Mode)
Section titled “Workers (Pull Mode)”GET /workers
Section titled “GET /workers”List all connected REST-polling workers.
POST /workers/{workerId}/register (also accepts PUT)
Section titled “POST /workers/{workerId}/register (also accepts PUT)”Register a worker for HTTP polling.
Request:
{ "worker_id": "worker-1", "hostname": "host.example.com", "function_ids": ["my-function"], "max_concurrent_jobs": 5, "labels": { "region": "us-east" }, "version": { "sdk": "0.8.0", "runtime": "node-22" }}POST /workers/{workerId}/heartbeat (also accepts PUT)
Section titled “POST /workers/{workerId}/heartbeat (also accepts PUT)”Send a heartbeat to keep the worker connection alive.
Request:
{ "worker_id": "worker-1", "active_jobs": 2}Whenever a worker’s presence changes, WebSocket subscribers receive a snapshot
on system.worker.{worker_id}.health, keyed like a worker row in
GET /cluster/health: id, hostname, functions, max_concurrent,
active_jobs, last_heartbeat, connected_since, heartbeat_age_ms,
draining, and health.
health on the frame is healthy or unhealthy only. A worker past the
timeout is deregistered on the spot, so stale — which the polled
/cluster/health row does report — never reaches the wire; treat the
disconnected event as the terminal signal.
The frame is edge-triggered — published on a health transition, and on
drain (draining: true) when the server shuts a worker down, never once per
heartbeat. Heartbeats are 30s, a worker turns unhealthy past 60s and is
evicted past 90s, so a dying worker produces one unhealthy frame and then a
disconnected event, not a stream of them. Do not treat frame arrival as a
heartbeat; a healthy worker is silent.
A transition that fails to publish is not recorded, so the next 30s sweep
republishes it. Two limits remain: recovery is reported at that sweep rather
than on the heartbeat that recovers it, and the degraded window is exactly one
sweep wide — under load a dropped tick can take a worker from healthy to
disconnected with no unhealthy frame in between. The drain frame is
genuinely best-effort: shutdown has no later sweep to retry it.
Use heartbeat_age_ms for freshness rather than diffing last_heartbeat
against a browser clock — the server measures the age monotonically.
Note the payload keys differ per leaf under system.worker.>:
connected / disconnected carry workerId and functionIds (camelCase);
health carries the snake_case shape above, whose worker id key is id.
GET /workers/{workerId}/jobs
Section titled “GET /workers/{workerId}/jobs”Poll for job assignments. Returns 204 No Content if no jobs are available.
Response (when jobs available):
{ "jobs": [ { "job_id": "job_abc", "run_id": "run_xyz", "function_id": "my-function", "attempt": 1, "event": { ... }, "completed_steps": [ ... ], "context": { "secrets": { ... } }, "execution_seq": 3, "lease_token": "...", "lease_expires_at": "2024-01-15T10:31:30Z" } ]}PUT /workers/{workerId}/jobs/{jobId}/ack
Section titled “PUT /workers/{workerId}/jobs/{jobId}/ack”Acknowledge delivery of a job assignment before executing user code. The body carries run_id, execution_seq, and lease_token; the engine validates the execution fence and treats the ack as a lease heartbeat. A wrong or missing lease token cannot ack another worker’s job.
PUT /workers/{workerId}/jobs/{jobId}
Section titled “PUT /workers/{workerId}/jobs/{jobId}”Report job completion, failure, yield, or a mid-run step checkpoint.
Request:
{ "status": "completed", "output": { "result": "done" }, "steps": [ ... ], "step_offset": 0}Status values: completed, failed, yielded, progress.
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.
Responses for progress:
| Status | Body error | Meaning |
|---|---|---|
200 | — | Steps persisted (or no steps sent — a pure lease heartbeat) |
400 | INCOMPATIBLE_PROTOCOL | Missing lease_token |
400 | — | Negative step_offset, or more than 500 steps |
404 | — | Capacity admission disabled, or run not found |
409 | STALE_EXECUTION | This execution was recovered or superseded — stop checkpointing |
409 | RUN_NOT_RUNNING | Fence is valid but the run is no longer running |
API Keys
Section titled “API Keys”POST /apikeys
Section titled “POST /apikeys”Create a new API key. Set "platform": true to create a platform key (requires platform:keys:manage).
Request:
{ "name": "CI Pipeline", "env_id": "env_production", "role_ids": ["role_admin"], "expires_in": "720h"}Response includes the raw key (only shown once).
GET /apikeys
Section titled “GET /apikeys”List API keys for the caller’s organization. Pass ?platform=true to list platform API keys instead (requires platform:keys:read).
GET /apikeys/{id}
Section titled “GET /apikeys/{id}”Get a single API key by ID.
DELETE /apikeys/{id}
Section titled “DELETE /apikeys/{id}”Delete/revoke an API key. Returns 204.
POST /apikeys/{id}/rotate
Section titled “POST /apikeys/{id}/rotate”Rotate an API key (creates new key, revokes old). Returns the new raw key.
PUT /apikeys/{id}/roles
Section titled “PUT /apikeys/{id}/roles”Replace the full set of role assignments on an API key.
Request:
{ "role_ids": ["role_admin", "role_custom_abc"]}Response: The updated API key object (without the raw key).
POST /users
Section titled “POST /users”Create a new dashboard user (admin only).
Request:
{ "email": "user@example.com", "password": "securepassword", "name": "Jane Doe", "roles": ["admin"]}GET /users
Section titled “GET /users”List all users in the caller’s organization (admin only).
GET /users/{id}
Section titled “GET /users/{id}”Get a single user (admin or self).
PATCH /users/{id}
Section titled “PATCH /users/{id}”Update user profile (admin only).
Request:
{ "name": "Jane Smith", "email": "jane@example.com", "roles": ["admin", "viewer"]}DELETE /users/{id}
Section titled “DELETE /users/{id}”Delete a user (admin only, cannot delete self). Returns 204.
PATCH /users/{id}/password
Section titled “PATCH /users/{id}/password”Change own password.
Request:
{ "current_password": "old_password", "new_password": "new_password"}POST /auth/login
Section titled “POST /auth/login”Authenticate and receive a JWT token for the dashboard. Public endpoint.
Request:
{ "email": "admin@example.com", "password": "password"}Response:
{ "token": "eyJhbGci..."}GET /auth/validate
Section titled “GET /auth/validate”Validate the current JWT token. Public endpoint — used by the dashboard to detect stale sessions on load.
Response (valid token):
{ "valid": true, "email": "admin@example.com"}Response (invalid/expired): 401 Unauthorized
POST /auth/desktop-session
Section titled “POST /auth/desktop-session”Mint a dashboard JWT from an API-key principal. Requires API key authentication (not in the public allowlist); the minted JWT carries the key’s organization, roles, and environment boundary. Requests made with the JWT default to that environment and receive 403 Forbidden if they select another one. Used by Ironflow Desktop to obtain a browser-usable session without --dev.
Response:
{ "token": "eyJhbGci..."}Returns 401 when the caller is not authenticated with an API key (e.g., a dashboard JWT).
Circuit Breakers
Section titled “Circuit Breakers”GET /circuit-breakers
Section titled “GET /circuit-breakers”List all circuit breaker states. Returns a JSON array.
Response:
[ { "key": "fn_abc|https://example.com/api/handler", "function_id": "fn_abc", "endpoint": "https://example.com/api/handler", "state": "open", "consecutive_fails": 5, "last_failure": "2024-01-15T10:30:00Z" }]POST /circuit-breakers/{key}/reset
Section titled “POST /circuit-breakers/{key}/reset”Reset a circuit breaker to closed state. The composite key format is {functionID}|{endpointURL}; the key path segment must be base64url-encoded (unpadded base64.RawURLEncoding). An invalid encoding returns 400.
Response: 200 OK with the reset breaker state ("state": "closed").
Debounce
Section titled “Debounce”Operator endpoints for pending debounce entries (issue #545). Debounce collapses rapid-fire events for a function into a single invocation after a quiet period. See the debounce how-to for background. The CLI ironflow debounce {list|cancel} wraps these endpoints.
GET /debounce/entries
Section titled “GET /debounce/entries”List currently-armed debounce entries scoped to the request environment. Empty 200 when the engine is not wired for debounce (e.g., embedded/test mode without JetStream).
Response:
[ { "environment_id": "env_default", "function_id": "process-search", "debounce_key": "user-42", "event_id": "evt_01HW...", "function_version": 1, "period_ms": 5000, "armed_at": "2026-04-24T12:00:00.000Z", "fires_at": "2026-04-24T12:00:05.000Z" }]ArmedAt / FiresAt are ISO-8601 UTC millisecond timestamps. DebounceKey is the resolved key value (never base64-encoded in the response body — only the path parameter on cancel is encoded).
DELETE /debounce/entries/{envID}/{fnID}/{key}
Section titled “DELETE /debounce/entries/{envID}/{fnID}/{key}”Cancel one pending debounce entry without firing it. The key path segment MUST be base64url-encoded (base64.RawURLEncoding) because debounce keys can contain / or . which conflict with URL path semantics.
Path parameters:
| Parameter | Description |
|---|---|
envID | Environment ID owning the entry |
fnID | Function ID |
key | base64url-encoded debounce key |
Responses:
| Status | Meaning |
|---|---|
204 No Content | Entry cancelled, or did not exist (idempotent no-op) |
400 Bad Request | Path segments missing or key not valid base64url |
403 Forbidden | Tenant-scoped request whose authenticated env does not match envID in the path (cross-tenant guard) |
503 Service Unavailable | Debounce manager not configured on this server |
Tenant-scoped credentials can only cancel entries within their own environment. Platform-scoped credentials (empty RequestContext.EnvironmentID) may cancel any environment’s entry.
Outbox Dead-Letter
Section titled “Outbox Dead-Letter”Operator endpoints for the transactional outbox dead-letter table (issue #487, DLQ tooling from #496). Rows appear here after the outbox worker exhausts its retry budget (default 10 attempts). 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.
GET /outbox/dead-letter
Section titled “GET /outbox/dead-letter”List rows in the outbox dead-letter table for one environment, newest first. The environment must be declared — callers send either the X-Ironflow-Environment header or the env query parameter (the CLI sends both). Missing both returns 400.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
env | string | — | Environment name (required unless X-Ironflow-Environment header is set) |
limit | int | 50 | Max rows to return (1-500; out-of-range values return 400) |
offset | int | 0 | Row offset for pagination (0-10000; out-of-range values return 400) |
Response:
{ "items": [ { "id": "ob_01HW...", "event_id": "evt_abc123", "entity_id": "order_42", "topic": "orders.placed", "kind": "events", "environment_id": "env_prod", "created_at": "2026-04-20T12:00:00Z", "dead_at": "2026-04-20T12:05:30Z", "attempts": 10, "last_error": "nats: max payload exceeded", "payload": "<base64>", "metadata": "<base64>" } ], "limit": 50, "offset": 0}payload and metadata are base64-encoded to keep the JSON body valid for binary content.
POST /outbox/dead-letter/{eventID}/requeue
Section titled “POST /outbox/dead-letter/{eventID}/requeue”Move every dead-letter row matching event_id back to the live outbox with attempts=0. The worker picks them up on the next tick.
Response: 200 OK
{ "event_id": "evt_abc123", "result": "requeued" }404 Not Found if no dead-letter entry exists for the given event_id.
DELETE /outbox/dead-letter/{eventID}
Section titled “DELETE /outbox/dead-letter/{eventID}”Permanently delete a dead-letter row. The underlying events row is not deleted — only the unpublished outbox entry. Destructive; the CLI prompts for confirmation before calling this.
Response: 200 OK
{ "event_id": "evt_abc123", "result": "discarded" }404 Not Found if no dead-letter entry exists for the given event_id.
Dead-letter lifecycle events
Section titled “Dead-letter lifecycle events”Each of the three transitions above also publishes a WebSocket frame on
system.outbox.{event_id}.{event}, where {event} is dead_lettered,
requeued, or discarded (issue #1729). Subscribe to system.outbox.> for
every dead-letter event, or system.outbox.{event_id}.> to follow one row from
arrival through triage. The topic is keyed by event_id — the same key these
endpoints and ironflow outbox dlq act on — not by the outbox row ID.
dead_lettered fires when the worker exhausts its retry budget and carries
event_id, outbox_id, entity_id, topic, kind, environment_id,
attempts, last_error, and timestamp. requeued and discarded fire from
the endpoints above and carry event_id, environment_id, result, and
timestamp — enough for a subscriber to retire a row it already received in
full.
Neither frame carries the dead event’s payload. Consumers that need the body
fetch it from GET /outbox/dead-letter, which returns it base64-encoded;
pushing every dead event’s body to every subscriber would repeat the per-client
marshal cost that topicOnly exists to avoid.
These frames are a hint, not a ledger. The dead_lettered publish travels
over the same NATS connection whose failure produced the dead letter, so it is
least likely to arrive exactly when it matters most, and while the outbox
circuit breaker is open no publish is attempted at all. The dead-letter table
is the source of truth — treat a frame as a prompt to refresh, and reconcile
with GET /outbox/dead-letter rather than maintaining state from frames alone.
History Inspection (Audit Trail)
Section titled “History Inspection (Audit Trail)”GET /audit
Section titled “GET /audit”List history inspection (audit) events globally.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
run_id | string | Filter by run |
function_id | string | Filter by function |
event_type | string | Filter by audit event type |
from | string | Start timestamp |
to | string | End timestamp |
limit | int | Max results |
cursor | string | Pagination cursor |
The global payload policy affects only rows created after the server starts
with that policy. Selected fields are absent from each affected row’s payload
in this response and in exports. The row’s metadata.audit_redacted_fields
value is a sorted, comma-separated list of canonical field names that were
removed. It contains no removed values. For example, the public selector
debounce.cancelled.debounceKey removes the stored debounce_key field and
records debounceKey in the marker.
GET /audit/settings
Section titled “GET /audit/settings”Returns the effective, read-only audit policy loaded by this server process. Server-wide settings require a restart to change.
{ "capture_scope": "server-wide", "authz_successful_reads": false, "authz_excluded_actions": ["functions:list"], "kv_capture": true, "payload_redaction_enabled": true, "payload_redaction_fields": [ "run.created.input", "step.completed.output" ], "retention_days": 30, "function_recording": "per_function", "changes_require_restart": true, "delivery_guarantee": "best_effort_no_retry", "delivery_healthy": true, "delivery_failure_count": 0}payload_redaction_fields is normalized and sorted. It is an empty array when
payload_redaction_enabled is false. The policy is global and applies only to
the approved workflow selector catalog. Protected authorization, platform,
resource, control-plane, capacity, and scanner event families cannot be
selected. See the audit logging guide
for the complete catalog, persistence behavior, and time-travel limitations.
System
Section titled “System”GET /health
Section titled “GET /health”Liveness probe. Returns 200 when the database Ping succeeds, 503 otherwise. Does not check NATS — use /ready for full readiness. Public endpoint (no auth required).
GET /capabilities
Section titled “GET /capabilities”Returns server version, supported features, and transport information. Public endpoint.
Response:
{ "version": "0.8.0", "auth_required": true, "transports": ["websocket", "grpc-sse", "grpc-bidirectional"], "features": ["replay", "wildcard-patterns", "cel-filter", "consumer-groups", "prometheus-metrics"]}GET /ready
Section titled “GET /ready”Readiness probe endpoint. Returns 200 when the server is ready to accept traffic (PostgreSQL and NATS connected). Used by Kubernetes readiness probes.
GET /routes
Section titled “GET /routes”Returns the server route manifest. Used by SDK generation tooling (make sdk-manifest).
GET /openapi.json
Section titled “GET /openapi.json”Returns the committed OpenAPI 3.1 document describing this API. Generated from the same route manifest as /routes by make sdk-gen-openapi, which writes both this copy and the canonical api/openapi.json at the repo root (ADR 0054); make sdk-gen-verify regenerates and diffs them, so the served contract cannot drift from the repo artifact.
GET /overview
Section titled “GET /overview”Get a comprehensive system overview with stats and uptime.
GET /cluster/health
Section titled “GET /cluster/health”Returns cluster health status including node information. Available in multi-node cluster mode.
POST /cluster/rotate-token
Section titled “POST /cluster/rotate-token”Rotate an operator credential (requires the cluster:rotate-token action). The body carries kind and new_value (plus remote_key_id for API-key kinds). Supports an Idempotency-Key header — a replayed request returns the cached response with an Idempotent-Replay: 1 header.
GET /export
Section titled “GET /export”Stream a full organization export as NDJSON (admin only, org:export action). Optional query parameters: include (comma-separated section list), from / to (RFC3339 time bounds). Only one export may run per node at a time — a concurrent request returns 429 with a Retry-After header.
GET /ws
Section titled “GET /ws”Open a WebSocket connection for real-time event subscriptions.
GET /metrics
Section titled “GET /metrics”Prometheus metrics endpoint (only available when metrics are enabled).
Debugging
Section titled “Debugging”GET /debug/requests
Section titled “GET /debug/requests”Get recently captured HTTP requests (up to 100). Credential-bearing request headers
(Authorization, Cookie, X-Api-Key, Proxy-Authorization) are stored as
[REDACTED] so the inspector buffer can never be used to harvest a caller’s credentials.
DELETE /debug/requests
Section titled “DELETE /debug/requests”Clear all captured debug requests.
Organization & Policy Endpoints
Section titled “Organization & Policy Endpoints”Organizations
Section titled “Organizations”| Method | Path | Description |
|---|---|---|
POST | /orgs | Create an organization |
GET | /orgs | List organizations |
GET | /orgs/{id} | Get an organization |
PATCH | /orgs/{id} | Update organization name |
DELETE | /orgs/{id} | Delete an organization |
| Method | Path | Description |
|---|---|---|
POST | /roles | Create a custom role |
GET | /roles | List roles |
GET | /roles/{id} | Get a role |
PATCH | /roles/{id} | Update role name |
DELETE | /roles/{id} | Delete a role (not built-in) |
GET | /roles/{id}/policies | List policies assigned to a role |
POST | /roles/{id}/policies | Assign a policy to a role |
DELETE | /roles/{id}/policies/{policy_id} | Remove a policy from a role |
Policies
Section titled “Policies”| Method | Path | Description |
|---|---|---|
POST | /policies | Create a CEL authorization policy |
GET | /policies | List policies |
GET | /policies/{id} | Get a policy |
PATCH | /policies/{id} | Update a policy |
DELETE | /policies/{id} | Delete a policy |
POST | /policies/dry-run | Evaluate a candidate policy against sample subjects without persisting it (self-lockout preflight) |
GET | /policies/{id}/versions | List version history for a policy |
POST | /policies/{id}/rollback/{version} | Forward-rollback a policy to a prior version (writes a new version+1 snapshot) |
Policy Templates
Section titled “Policy Templates”| Method | Path | Description |
|---|---|---|
GET | /policy-templates | List installable CEL policy template bundles |
POST | /policy-templates/{id}/install | Install a template bundle into the caller’s tenant (runs LintTemplate first) |
Policy Request:
{ "name": "deny-prod-deletes", "effect": "deny", "actions": "events:emit", "resources": "irn:ironflow:*:*:event:env_prod:*", "condition": "request.environment == 'env_prod'"}effect is deny only (#943, ADR 0016 T2). Submitting effect: "allow" returns 400 policy_effect_allow_deprecated. CEL policies are subtractive over the RBAC layer — to grant capability, edit role assignment, not write an allow policy.
Tenants
Section titled “Tenants”| Method | Path | Description |
|---|---|---|
POST | /tenants/provision | Provision a tenant (org + env + admin key) |
GET | /tenants | List tenants with environment and key counts |
Provision Request:
{ "org_name": "Acme Corp", "env_name": "production"}Provision Response:
{ "org": { "id": "org_abc", "name": "Acme Corp" }, "environment": { "id": "env_production", "name": "production" }, "api_key": { "key": "ifkey_...", "roles": ["admin"] }}Capacity Admin Endpoints
Section titled “Capacity Admin Endpoints”Read-only inspection of the unified capacity/dispatch subsystem (ADR 0037, #1206). Any platform principal (read-only) — requires a ifplatform_ API key or a platform JWT; the gate is “is-platform”, not a specific platform role, so platform_admin, operator, and viewer principals can all read these views. Tenant API keys return 403.
All endpoints accept the same optional query parameters:
| Parameter | Type | Description |
|---|---|---|
env | string | Filter by environment ID |
function | string | Filter by function ID (ignored for leases, credits, and stats) |
limit | int | Max rows to return (default 100, hard-capped at 1000). Values above 1000 are silently clamped to 1000; non-positive values use the default. Ignored by stats. |
Lane identities are returned as opaque HMAC digests. Lease entries carry lease_token_hash only — raw lane keys and lease tokens are never stored or returned.
GET /api/v1/capacity/lanes
Section titled “GET /api/v1/capacity/lanes”List concurrency_lanes rows. Each row carries the lane digest, environment, function, scope, limit, and the maintained active_reserved counter.
GET /api/v1/capacity/queue
Section titled “GET /api/v1/capacity/queue”List dispatch_queue segments. Returns queue position, run ID, execution sequence, execution mode, function, lane digest, blocking reason, and the eligible_at timestamp (when the segment becomes runnable).
GET /api/v1/capacity/leases
Section titled “GET /api/v1/capacity/leases”List active (unexpired) concurrency_leases rows. The function query parameter is silently ignored — concurrency_leases has no function_id column. The JSON response includes lease_token_hash (not the raw token); the function_id field is present but always empty ("") on this endpoint — the active-leases path does not JOIN runs, so the owning function is not resolved here.
GET /api/v1/capacity/buckets
Section titled “GET /api/v1/capacity/buckets”List dispatch_buckets fairness-ledger rows. Each row is keyed by (environment_id, function_id, lane_id) and carries last_dispatched_at.
GET /api/v1/capacity/sessions
Section titled “GET /api/v1/capacity/sessions”List worker_sessions rows for active pull workers. Supports env and function filters; function matches sessions advertising that function via worker_session_functions.
GET /api/v1/capacity/credits
Section titled “GET /api/v1/capacity/credits”List worker_credits rows (claimed and free slots). The function query parameter is silently ignored — worker_credits has no function_id column.
GET /api/v1/capacity/stats
Section titled “GET /api/v1/capacity/stats”Return a per-(environment, function) capacity snapshot: active leases, lane limit, queued segments, outstanding reservations, and oldest queue age in seconds. The env, function, and limit query parameters are all ignored — stats always returns the full cross-tenant snapshot (one row per active env/function; row count is bounded by that cardinality, not by limit).
Example response (stats):
[ { "environment_id": "env_default", "function_id": "my-fn", "active_leases": 2, "lane_limit": 5, "queued": 7, "reservations": 1, "oldest_queue_age_seconds": 12.4 }]Common Response Codes
Section titled “Common Response Codes”| Code | Meaning |
|---|---|
200 | Success |
201 | Resource created |
204 | Success, no content |
400 | Invalid request or validation error |
401 | Authentication required |
403 | Insufficient permissions |
404 | Resource not found |
409 | Conflict (duplicate resource or version mismatch) |
412 | Precondition failed (CAS mismatch) |
503 | Service unavailable |