Skip to content

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.


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_here

Dashboard JWT (browser sessions): Dashboard authentication uses the ironflow_dashboard_token cookie set by the login endpoint.

Environment Selection (optional):

X-Ironflow-Environment: env_production

The value may be an environment ID (env_-prefixed) or an environment name (production), and the two are treated differently on purpose:

ValueResolves toIf it doesn’t exist
env_production (ID)itself400 unknown environment
production (name)that environment’s IDfalls 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.

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


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

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 (substring match, unindexed)
namesstringFilter by exact event name (comma-separated, OR-ed, max 200). Distinct from name: entries must match in full. Populate from GET /events/names.
sourcestringFilter by source (comma-separated, max 200)
sincestringStart time (RFC3339)
untilstringEnd time (RFC3339)
limitintMax results per page (default 20)
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.
  • 400since 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:

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

Get a single event by ID.


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

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

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

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

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

POST /ironflow.v1.IronflowService/GetFunctionAtVersion

Request:

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


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.
GetRunFetch one run by id.
GetRunStepsList 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.

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

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.

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 — an identical resume is already in flight inside the stream’s dedupe window (2 minutes, keyed on resume-{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 be paused or failed).
  • internal — NATS publish failed (run is reverted to its prior status), or other unexpected failure.

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


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" 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 completed 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": "{\"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".

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


List all environments. API keys are masked in the response.

Create a new environment.

Request:

{
"name": "staging"
}

Update an environment (e.g., rename it).

Request:

{
"name": "production"
}

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.

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.


List all projects in the organization.

Create a new project.

Request:

{
"name": "payments"
}

Update a project.

Delete a project.


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.
GetStreamInfoRead a stream’s current version and event count.
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.

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

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.

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 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__).
GetProjectionStatusRead processing status.
WaitProjectionCatchupWait until a projection reaches a minimum NATS sequence.
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.

Query Parameters:

ParameterTypeDescription
qstringFilter partitions by substring (max 200 chars)
limitintMax results (default 50)

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


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.


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.

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 400 (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
}

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.


Create a KV bucket.

Request:

{
"name": "session-cache",
"description": "User session data",
"ttl_seconds": 3600,
"max_value_size": 65536,
"max_bytes": 10485760,
"history": 5
}

List all KV buckets.

Get bucket metadata.

Delete a bucket.

List keys in a bucket.

Query Parameters:

ParameterTypeDescription
filterstringGlob pattern to filter keys

Retrieve a key’s value. Returns an ETag header with the revision.

Write a value. Supports atomic operations via headers:

HeaderEffect
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 a key.

Watch a bucket for key changes via WebSocket upgrade.


Set or replace an entire config document.

Request: Any JSON object.

Response:

{
"name": "app-settings",
"revision": 1
}

Shallow merge-patch a config document with CAS retry.

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.

Delete a config document.

Watch a config document for real-time changes via WebSocket.


All secrets endpoints require the X-Ironflow-Environment header. Secret values are write-only and never returned in responses.

List secret metadata.

Create a new secret.

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 (description only).

Delete a secret.


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

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.

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

StatusBody errorMeaning
200Steps persisted (or no steps sent — a pure lease heartbeat)
400INCOMPATIBLE_PROTOCOLMissing lease_token
400Negative step_offset, or more than 500 steps
404Capacity admission disabled, or run not found
409STALE_EXECUTIONThis execution was recovered or superseded — stop checkpointing
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_production",
"role_ids": ["role_admin"],
"expires_in": "720h"
}

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.

Rotate an API key (creates new key, revokes old). Returns the new raw key.

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

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

Request:

{
"name": "Jane Smith",
"email": "jane@example.com",
"roles": ["admin", "viewer"]
}

Delete a user (admin only, cannot delete self). Returns 204.

Change own password.

Request:

{
"current_password": "old_password",
"new_password": "new_password"
}

Authenticate and receive a JWT token for the dashboard. Public endpoint.

Request:

{
"email": "admin@example.com",
"password": "password"
}

Response:

{
"token": "eyJhbGci..."
}

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

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


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

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


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.

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.


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.

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:

ParameterTypeDefaultDescription
envstringEnvironment name (required unless X-Ironflow-Environment 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_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 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.

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
fromstringStart timestamp
tostringEnd timestamp
limitintMax results
cursorstringPagination 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.

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 when the database Ping succeeds, 503 otherwise. Does not check NATS — use /ready for full readiness. Public endpoint (no auth required).

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

Readiness probe endpoint. Returns 200 when the server is ready to accept traffic (PostgreSQL and NATS connected). Used by Kubernetes readiness probes.

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.

Get a comprehensive system overview with stats and uptime.

Returns cluster health status including node information. Available in multi-node cluster mode.

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.

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.

Open a WebSocket connection for real-time event subscriptions.

Prometheus metrics endpoint (only available when metrics are enabled).


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.

Clear all captured debug requests.


MethodPathDescription
POST/orgsCreate an organization
GET/orgsList organizations
GET/orgs/{id}Get an organization
PATCH/orgs/{id}Update organization name
DELETE/orgs/{id}Delete an organization
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)
GET/roles/{id}/policiesList policies assigned to a role
POST/roles/{id}/policiesAssign a policy to a role
DELETE/roles/{id}/policies/{policy_id}Remove a policy from a role
MethodPathDescription
POST/policiesCreate a CEL authorization policy
GET/policiesList policies
GET/policies/{id}Get a policy
PATCH/policies/{id}Update a policy
DELETE/policies/{id}Delete a policy
POST/policies/dry-runEvaluate a candidate policy against sample subjects without persisting it (self-lockout preflight)
GET/policies/{id}/versionsList version history for a policy
POST/policies/{id}/rollback/{version}Forward-rollback a policy to a prior version (writes a new version+1 snapshot)
MethodPathDescription
GET/policy-templatesList installable CEL policy template bundles
POST/policy-templates/{id}/installInstall 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.

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

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

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