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

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.


The engine sends POST to the function’s endpoint_url with Content-Type: application/json. Any other method must return 405.

HeaderAlways sentMeaning
X-Ironflow-Run-IDYesThe run this invocation belongs to.
X-Ironflow-Function-IDYesThe function to execute.
X-Ironflow-AttemptYesThe 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-KeyYesThe run ID. Stable across all retries, including retries that increase X-Ironflow-Attempt.
X-Ironflow-SignatureOnly when a signing key is configuredSee Signature verification.
traceparent, tracestateWhen tracing is enabledW3C trace context.
Ironflow-Execution-SeqOnly on capacity-gated runsMonotonic per-run execution sequence.
Ironflow-Lease-TokenOnly on capacity-gated runsRaw lease fence token.
Ironflow-Lease-Expires-AtOnly on capacity-gated runsLease 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.

Your statusEngine behaviour
Below 400Reads the body as a PushResponse. A body that does not parse is an error, and is retried like any other.
400, 401, 403, 405, 422Fails 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.
5xxRecords 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.

The engine waits initialDelay × backoff^(n-1) before attempt n, capped at maxDelay, with ±10% jitter. Your endpoint can set the delay itself:

  • Retry-After header on a 429 or 503, as delta-seconds (Retry-After: 30) or an HTTP-date. A negative or unparseable value is ignored, and a date already past or 0 means “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_ms on the error of a 200 "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.

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 becomes paused with pause reason retries_exhausted and keeps its last error. Nothing is dispatched until an operator runs ironflow run resume, which grants a fresh budget.
  • fail: the run becomes failed. A run created before the upgrade that has no recorded policy also takes this branch: it was created under fail semantics.

A non-retryable failure ends the run failed at once, under either policy.


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:

  1. 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.
  2. Parse the header as comma-separated key=value pairs. Split each pair on the first = only. Ignore any pair you do not know. More v<n> schemes can appear later.
  3. Reject the request if t differs from the current time by more than 5 minutes in either direction. The comparison is on the absolute difference.
  4. Compare the decoded signature bytes with a constant-time comparison. Do not compare the hex strings with ==.

Return 401 when verification fails.


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_..." }
}
FieldTypeNotes
run_idstringAlso the memoization key prefix. See Step IDs.
function_idstringReturn 404 if you do not host it.
attemptintegerThe run’s attempt. Starts at 1 and increases on each durable retry, so attempt >= max_attempts marks the final attempt.
max_attemptsintegerThe 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_exhaustedstringpause 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.dataany JSONYour payload. The engine never inspects it.
event.versionintegerEvent schema version. Default 1.
event.sourcestringEvent origin: api, cron or webhook. Omitted when empty.
event.metadataobjectThe metadata the emitter sent. Read a missing key, null and {} as “no metadata”.
stepsarraySteps already completed in earlier segments of this run. Absent on first invocation, so read a missing key as an empty array.
resumeobjectPresent only when the run resumes from a yield. See Resume.
secretsobjectSecrets the function declared. Absent when it declared none. Never log this field.
{
"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.


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
}
FieldTypeNotes
statusstringcompleted, yielded or failed.
stepsarrayOnly the steps this segment executed. Do not re-send memoized steps you replayed.
resultany JSONThe function return value. Only for completed.
errorobject or nullOnly for failed.
yieldobject or nullRequired for yielded. See Yields.
FieldTypeNotes
idstringThe step ID you computed.
namestringThe step name your handler used.
typestringinvoke, 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.
statusstringcompleted or failed.
started_atstringRFC3339. The engine falls back to its own clock when this is missing or unparseable.
ended_atstringOptional, 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.
outputany JSONOptional.
errorobjectOptional. { "message", "stack", "retryable" }.
compensation_forstringOptional. The step ID this compensates.
{
"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.


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.

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.

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:

  1. Replace the backslash before the colon. The reverse order double-escapes. Perform both replacements in a single pass over the string, or replace \ first.
  2. A name with no : and no \ is returned unchanged. This keeps IDs stable for runs that started before escaping existed.
  3. The compensate: and publish: 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.

On every invocation, run your handler from the top. For each step:

  1. Compute the step ID.
  2. Look it up in the steps array from the request.
  3. If found and status is "completed", return the stored output. Do not execute the step body, and do not add it to your response steps.
  4. If found, the step is an invoke or invokeAsync and status is "failed", raise the stored error. See Completed step entries.
  5. 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.


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.

typeExtra fields
sleepuntil — RFC3339 wake time.
wait_for_eventevent_filter.
invoke_functionfunction_id, input, invoke_timeout_ms.
invoke_function_asyncfunction_id, input.

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.