- SDKs & APIs
- Push Protocol
Push Protocol
This page is the wire contract for push mode: the engine sends an HTTP request to your endpoint, and your endpoint answers with a result. It is the reverse direction from the REST API, where your code calls the engine.
You need this page only if you implement a function handler in a language that has no
Ironflow SDK. The Go, Node and Python SDKs implement this contract for you (Python
through ironflow.serve). For the
task-level walkthrough, read
Use Ironflow from any language.
To check a handler against this contract, run
ironflow conformance push.
Transport
Section titled “Transport”The engine sends POST to the function’s endpoint_url with
Content-Type: application/json. Any other method must return 405.
Request headers
Section titled “Request headers”| Header | Always sent | Meaning |
|---|---|---|
X-Ironflow-Run-ID | Yes | The run this invocation belongs to. |
X-Ironflow-Function-ID | Yes | The function to execute. |
X-Ironflow-Attempt | Yes | The run’s attempt number, the same value as attempt in the body. It increases when the engine re-enqueues the run for a retry. The one immediate retry after a dropped connection (see Response status codes) sends the same number. |
Idempotency-Key | Yes | The run ID. Stable across all retries, including retries that increase X-Ironflow-Attempt. |
X-Ironflow-Signature | Only when a signing key is configured | See Signature verification. |
traceparent, tracestate | When tracing is enabled | W3C trace context. |
Ironflow-Execution-Seq | Only on capacity-gated runs | Monotonic per-run execution sequence. |
Ironflow-Lease-Token | Only on capacity-gated runs | Raw lease fence token. |
Ironflow-Lease-Expires-At | Only on capacity-gated runs | Lease expiry, RFC3339 with nanoseconds. |
Your handler does not need to read or echo the three Ironflow-* fencing headers.
The engine checks the fence itself when your response arrives, and PushResponse
has no field for it. The Go, Node and Python serve handlers ignore these headers.
Use Idempotency-Key to make downstream calls safe. It stays the same when the
engine retries a run, so a payment API or a mailer can reject the duplicate. Step
memoization does not help here, because it protects steps, not the third-party calls
your handler makes inside a step.
Response status codes
Section titled “Response status codes”| Your status | Engine behaviour |
|---|---|
Below 400 | Reads the body as a PushResponse. A body that does not parse is an error, and is retried like any other. |
400, 401, 403, 405, 422 | Fails the run at once. These statuses say the request itself is wrong, so sending it again cannot help. The run ends failed with a non-retryable error, and the attempt still counts as a failure for the circuit breaker. |
Any other 4xx (404, 408, 409, 425, 429, …) | Records a client error and retries under the run’s retry policy. A 404 is often a deploy still rolling out. |
5xx | Records a server error and retries under the run’s retry policy. |
The list of statuses that fail at once is [400, 401, 403, 405, 422]. Replace it
for the whole server with spec.engine.retry.nonRetryableStatuses in the
YAML configuration; an empty list
retries every status.
An HTTP response is never retried inside the request. A retryable status
re-enqueues the run durably, after the backoff, and the next attempt arrives with a
higher attempt. One durable attempt is one HTTP call. The only retry inside a
request is for a failure with no HTTP response at all: connection refused or reset,
a DNS or TLS failure, or the connection closing before the response headers. The
engine retries that once, after about 100 ms, and never retries a timeout. A run
with a budget of 25 attempts therefore makes 25 calls to an endpoint that is down,
not 625.
To fail without retrying, return 200 with "status": "failed" and a
non-retryable error in the body — see Errors. Use 5xx only when your
service itself is broken.
Choosing the delay: Retry-After
Section titled “Choosing the delay: Retry-After”The engine waits initialDelay × backoff^(n-1) before attempt n, capped at
maxDelay, with ±10% jitter. Your endpoint can set the delay itself:
Retry-Afterheader on a429or503, as delta-seconds (Retry-After: 30) or an HTTP-date. A negative or unparseable value is ignored, and a date already past or0means “as soon as allowed”: the engine never retries back to back, so it waits the smaller of 1 second and the backoff for attempt 2. The header is not read on any other status.retry_after_mson theerrorof a200"status": "failed"response, for a retryable failure. See Errors.
Either replaces the computed backoff for the next attempt only. The engine raises it
to that minimum and caps it at the run’s maxDelay, so an endpoint can neither park a
run for days nor force an immediate retry.
When the attempts run out
Section titled “When the attempts run out”A retryable failure on the last attempt (attempt >= max_attempts) follows the run’s
exhaustion policy, which the engine sends as on_exhausted:
pause(the default): the run becomespausedwith pause reasonretries_exhaustedand keeps its last error. Nothing is dispatched until an operator runsironflow run resume, which grants a fresh budget.fail: the run becomesfailed. A run created before the upgrade that has no recorded policy also takes this branch: it was created underfailsemantics.
A non-retryable failure ends the run failed at once, under either policy.
Signature verification
Section titled “Signature verification”The engine sets X-Ironflow-Signature when the server has a signing key
(IRONFLOW_SIGNING_KEY). Verify it before you parse the body.
The header format is:
X-Ironflow-Signature: t=<unix-seconds>,v1=<hex>The signed message is the timestamp, a literal ., and the raw request body:
message = "{t}.{rawBody}"signature = hex( HMAC-SHA256( signingKey, message ) )Four rules, all of them load-bearing:
- Sign the raw bytes you received. Do not parse the JSON and serialize it again. Re-serialization changes key order and whitespace, and the signature fails.
- Parse the header as comma-separated
key=valuepairs. Split each pair on the first=only. Ignore any pair you do not know. Morev<n>schemes can appear later. - Reject the request if
tdiffers from the current time by more than 5 minutes in either direction. The comparison is on the absolute difference. - Compare the decoded signature bytes with a constant-time comparison. Do not
compare the hex strings with
==.
Return 401 when verification fails.
PushRequest
Section titled “PushRequest”The body the engine sends.
{ "run_id": "run_01HX...", "function_id": "process-order", "attempt": 1, "max_attempts": 25, "on_exhausted": "pause", "event": { "id": "evt_01HX...", "name": "order.placed", "version": 1, "data": { "orderId": "ord_123", "total": 4200 }, "timestamp": "2026-08-25T10:15:00Z", "source": "api", "metadata": {} }, "secrets": { "STRIPE_KEY": "sk_..." }}| Field | Type | Notes |
|---|---|---|
run_id | string | Also the memoization key prefix. See Step IDs. |
function_id | string | Return 404 if you do not host it. |
attempt | integer | The run’s attempt. Starts at 1 and increases on each durable retry, so attempt >= max_attempts marks the final attempt. |
max_attempts | integer | The run’s own retry budget, fixed when the run was created. Absent when zero, so treat a missing key as “unknown”, not as 1. |
on_exhausted | string | pause or fail: what happens to the run if the attempt that is about to run fails with a retryable error and attempt >= max_attempts. With fail that failure is final, so a handler that runs compensations should run them now; with pause the run is not over, so it must not. Absent from an older engine, which always fails the run. |
event.data | any JSON | Your payload. The engine never inspects it. |
event.version | integer | Event schema version. Default 1. |
event.source | string | Event origin: api, cron or webhook. Omitted when empty. |
event.metadata | object | The metadata the emitter sent. Read a missing key, null and {} as “no metadata”. |
steps | array | Steps already completed in earlier segments of this run. Absent on first invocation, so read a missing key as an empty array. |
resume | object | Present only when the run resumes from a yield. See Resume. |
secrets | object | Secrets the function declared. Absent when it declared none. Never log this field. |
Completed step entries
Section titled “Completed step entries”{ "id": "run_01HX...:charge-card:0", "name": "charge-card", "status": "completed", "output": { "chargeId": "ch_456" }}error is a string, and the key is absent unless the step failed.
Treat an entry as memoized only when status is exactly "completed". Any other
value means the step must run again, with one exception: a failed invoke or invokeAsync
step.
The engine sends that entry with "status": "failed". Its error string holds a JSON
object:
{ "id": "run_01HX...:charge-card:0", "name": "charge-card", "status": "failed", "error": "{\"message\":\"invoked function 'charge-card' failed\",\"function_id\":\"charge-card\",\"child_run_id\":\"run_01HY...\",\"cause\":\"card declined\",\"retryable\":false}"}A failed invokeAsync entry has the same shape. Its error holds only message and
retryable, because the engine started no child (for example, the target function
does not exist).
When the handler reaches that invoke, it must raise the child’s error. It must not yield the invoke again. A second yield starts no second child, so the run never ends.
PushResponse
Section titled “PushResponse”The body you return, with HTTP 200.
{ "status": "completed", "steps": [ { "id": "run_01HX...:charge-card:0", "name": "charge-card", "type": "invoke", "status": "completed", "started_at": "2026-08-25T10:15:00Z", "ended_at": "2026-08-25T10:15:00Z", "output": { "chargeId": "ch_456" } } ], "result": { "ok": true }, "error": null, "yield": null}| Field | Type | Notes |
|---|---|---|
status | string | completed, yielded or failed. |
steps | array | Only the steps this segment executed. Do not re-send memoized steps you replayed. |
result | any JSON | The function return value. Only for completed. |
error | object or null | Only for failed. |
yield | object or null | Required for yielded. See Yields. |
Step results
Section titled “Step results”| Field | Type | Notes |
|---|---|---|
id | string | The step ID you computed. |
name | string | The step name your handler used. |
type | string | invoke, sleep, wait_for_event, invoke_function, compensate. An ordinary step.run is invoke, not run. invoke_function_async is a yield type only, never a step result type. |
status | string | completed or failed. |
started_at | string | RFC3339. The engine falls back to its own clock when this is missing or unparseable. |
ended_at | string | Optional, RFC3339. The engine derives the step’s stored duration from ended_at - started_at, so omitting it leaves the step with no duration. There is no duration_ms field on this side of the wire — pull mode has one, push mode does not. |
output | any JSON | Optional. |
error | object | Optional. { "message", "stack", "retryable" }. |
compensation_for | string | Optional. The step ID this compensates. |
Errors
Section titled “Errors”{ "status": "failed", "steps": [], "error": { "message": "card declined", "step_id": "run_01HX...:charge-card:0", "retryable": false }}The top-level error carries message, step_id, retryable and optional code
and retry_after_ms. The engine stores code with the run’s error and does not act
on it. A stack sent here is dropped — stack exists only on the per-step error
object above.
Set retryable to false for a permanent failure. The engine stops retrying and
fails the run, whatever its exhaustion policy.
Set retry_after_ms (integer milliseconds) on a retryable failure to ask for a
specific delay before the next attempt, for example when you read a rate-limit
reset from an upstream API. It replaces the computed backoff and is capped at the
run’s maxDelay. A value of zero or less is ignored.
Step IDs
Section titled “Step IDs”The step ID is the memoization key on both sides of the wire. The engine stores it, and your handler recomputes it on every invocation. If the two do not match byte for byte, the engine sends back a memo your handler cannot find, and the step runs a second time. For a charge or a publish, that is a duplicate side effect.
The formula
Section titled “The formula”For a top-level step:
stepID = "{runID}:{escape(name)}:{index}"Inside a parallel branch:
scope = "{runID}:{escape(parallelName)}:{branchIndex}"stepID = "{scope}:{escape(name)}:{index}"index is a per-name counter, starting at 0, incremented in call order. Two
steps named charge-card in the same scope get index 0 and index 1. The counter
is per scope: a branch starts its own counters at 0.
The escape function
Section titled “The escape function”escape(part): for each namespace in ["compensate:", "publish:"]: if part starts with namespace: return namespace + replace(part without namespace) return replace(part)
replace(s): s = s.replace("\", "\\") # backslash first s = s.replace(":", "\:")Three details decide correctness:
- Replace the backslash before the colon. The reverse order double-escapes.
Perform both replacements in a single pass over the string, or replace
\first. - A name with no
:and no\is returned unchanged. This keeps IDs stable for runs that started before escaping existed. - The
compensate:andpublish:prefixes are structure, not user input. The SDK adds them. Escape only the part after the prefix. If you escape the prefix colon, every compensation and publish step changes ID, and the next resume repeats the side effect.
Escaping exists because the ID is colon-separated and names may contain colons.
Unescaped, a top-level step named a:0:b at index 0, and a step named b at index
0 inside parallel a branch 0, both render as run:a:0:b:0 — one key for two
different steps.
Memoization rules
Section titled “Memoization rules”On every invocation, run your handler from the top. For each step:
- Compute the step ID.
- Look it up in the
stepsarray from the request. - If found and
statusis"completed", return the storedoutput. Do not execute the step body, and do not add it to your responsesteps. - If found, the step is an invoke or invokeAsync and
statusis"failed", raise the stored error. See Completed step entries. - Otherwise execute the step, and add a step result to your response
steps.
The response steps array is therefore the delta for this segment, not the full
history. The engine already holds the earlier steps.
Your handler must be deterministic up to the point of the last completed step. The same code path must produce the same step names in the same order, or the counters drift and every ID after the drift point is wrong.
Yields
Section titled “Yields”A yield suspends the run. Your handler stops, returns "status": "yielded" with a
yield object, and the engine schedules the wake-up. The engine later sends a new
PushRequest whose steps array includes the resolved step.
Implement yields only after plain steps work. They are a second state machine.
{ "status": "yielded", "steps": [ ... ], "yield": { "step_id": "run_01HX...:wait-for-payment:0", "type": "wait_for_event", "event_filter": { "event": "payment.settled", "match": "data.orderId", "timeout": "1h" } }}match is a dotted path string, not an object. It names a field in the data
of the event that started this run; the engine reads the value at that path and
correlates it against the same path in the awaited event. A leading $. or
data. is stripped. Sending an object here fails the whole response parse, which
the engine treats as an error and retries.
The value is read from the triggering event by default, and the yield fails
if the path is missing or null there. To correlate on a value your handler
computed instead, send match_value alongside match: the engine uses it
verbatim and skips the lookup. match_value without match is rejected — the
path is still what the awaited event is read at. event_filter also accepts
payload, any JSON, which is persisted as the waiting step’s input and is
otherwise inert.
event_filter.on_timeout accepts "fail" (the default) or "return-null".
Unknown values reject the yield. With "return-null", a deadline completes the
step with JSON null and resumes the run after replaying matching accepted
events through the deadline. The policy persists on the step. The matching
event and timeout paths use the same status/version CAS, so only one wins.
A restart between completion and redispatch is repaired by the waiting-run
reconciler.
type | Extra fields |
|---|---|
sleep | until — RFC3339 wake time. |
wait_for_event | event_filter. |
invoke_function | function_id, input, invoke_timeout_ms. |
invoke_function_async | function_id, input. |
Resume
Section titled “Resume”The next request carries a resume object:
{ "resume": { "step_id": "run_01HX...:wait-for-payment:0", "type": "wait_for_event", "data": { "id": "evt_01HX...", "name": "payment.settled", "data": { "settledAt": "2026-08-25T11:00:00Z" }, "timestamp": "2026-08-25T11:00:00Z" } }}type is the step’s own stored type — sleep, wait_for_event or
invoke_function — not a separate resume vocabulary. A fire-and-forget
invoke_function_async yield is stored as an invoke_function step, so it
resumes as invoke_function.
On wait_for_event, data is the whole matched event, not its payload:
id, name, data, timestamp, and idempotencyKey when the event carries
one. Read the payload at resume.data.data. A wait settled by an opted-in
timeout has an authoritative completed step with output: null. Its convenience
resume.data may be absent. Check completed steps first; a completed null output
is a present memoized result and must not yield again.
On the other yield types data is absent.
Your handler replays from the top. When it reaches a yielding step whose ID and
type both match resume, it returns resume.data instead of yielding again.
Related
Section titled “Related”- Use Ironflow from any language — the guide that uses this contract.
- REST API — the other direction, and the surface the OpenAPI spec covers.
- SDK comparison — the tier model and what each language supports.
- Push conformance — check your handler against this contract.