- Other Languages
- 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 and Node SDKs implement this contract for you; the Python SDK is client-only and has no worker runtime, so it does not. For the task-level walkthrough, read Use Ironflow from any language.
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 | Attempt number. Increases on engine-initiated retry. |
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. |
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–499 | Records a client error. Retries under the function retry policy. |
500+ | Records a server error. Retries under the function retry policy. |
Any non-2xx status is retried, 4xx included. The engine does not treat a
client error as permanent — the reasoning is that the endpoint may become
available later — so it retries within the request (up to the function’s attempt
count) and then durably re-enqueues the run while attempt < max_attempts. A
400 from a malformed payload will therefore be sent again, unchanged, until
attempts are exhausted.
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.
Signature verification
Section titled “Signature verification”The engine sets X-Ironflow-Signature when the server has a webhook 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, "event": { "id": "evt_01HX...", "name": "order.placed", "version": 1, "data": { "orderId": "ord_123", "total": 4200 }, "timestamp": "2026-08-25T10:15:00Z", "source": "api", "metadata": {} }, "steps": [], "resume": null, "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 | Starts at 1. |
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. |
steps | array | Steps already completed in earlier segments of this run. Empty on first invocation. |
resume | object or null | Present when the run resumes from a yield. See Resume. |
secrets | object or null | Secrets the function declared. 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.
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, invoke_function_async, compensate. An ordinary step.run is invoke, not run. |
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 those three fields and nothing else. A code or
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.
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. - 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.
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 type — sleep, wait_for_event, invoke_function or
invoke_function_async — not a separate resume vocabulary.
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. 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.