Skip to content

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.


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-AttemptYesAttempt number. Increases on engine-initiated retry.
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.

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


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:

  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,
"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_..." }
}
FieldTypeNotes
run_idstringAlso the memoization key prefix. See Step IDs.
function_idstringReturn 404 if you do not host it.
attemptintegerStarts at 1.
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.
stepsarraySteps already completed in earlier segments of this run. Empty on first invocation.
resumeobject or nullPresent when the run resumes from a yield. See Resume.
secretsobject or nullSecrets the function declared. 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.


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, invoke_function_async, compensate. An ordinary step.run is invoke, not run.
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 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.


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

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