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

Write a Push Handler in Rust, Then Prove It Works

Your team writes Rust. Ironflow has SDKs for Go, Node and Python, but push mode is plain HTTP. The engine sends a signed JSON request to your endpoint, and your endpoint answers with JSON. Any language with an HTTP server can do that.

Getting a 200 back is the easy part. The hard part is the memo. Your handler computes a step ID on every call, and the engine stores the same ID. If the two differ by one byte, a completed step runs again. For a card charge, that is a second charge.

ironflow conformance push, added in Ironflow v0.43.1, checks a push handler against the push protocol. It sends the requests the engine sends and checks each answer. It needs no Ironflow server. This walkthrough builds a handler in Rust, one level at a time, and runs the suite after each level. Every report below comes from a real run against Ironflow v0.44.1.

You need Rust 1.88 or newer, the floor set by sha2 0.11, and the ironflow CLI. The installation guide has the CLI downloads. The commands below use fish.

Terminal window
cargo new push-rust
cd push-rust
cargo add axum@0.8 serde_json@1 hmac@0.13 sha2@0.11 hex@0.4 humantime@2
cargo add tokio@1 --features macros,rt-multi-thread
set -gx IRONFLOW_SIGNING_KEY dev-signing-key

The engine signs push requests with IRONFLOW_SIGNING_KEY. The conformance suite reads the same variable, and so does the handler in this post. Set it once in each terminal.

The suite cannot know what your real functions return. Your endpoint must therefore host four fixture functions with fixed behavior. The push conformance page defines them, and each level of the suite needs more of them.

Level 1 needs two fixtures. conformance-echo returns the run fields it received. conformance-fail fails without a retry. Here is my first attempt. Save it as src/main.rs:

use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{Json, Router, http::HeaderMap, http::StatusCode, routing::post};
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{Value, json};
use sha2::Sha256;
static KEY: LazyLock<String> =
LazyLock::new(|| std::env::var("IRONFLOW_SIGNING_KEY").expect("set IRONFLOW_SIGNING_KEY"));
#[tokio::main]
async fn main() {
LazyLock::force(&KEY);
let app = Router::new().route("/ironflow", post(handle));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8099").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handle(headers: HeaderMap, Json(req): Json<Value>) -> (StatusCode, Json<Value>) {
let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok());
let body = serde_json::to_vec(&req).unwrap();
if !verify(signature.unwrap_or(""), &body) {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" })));
}
let response = match req["function_id"].as_str().unwrap_or("") {
"conformance-echo" => json!({
"status": "completed",
"result": {
"run_id": req["run_id"],
"function_id": req["function_id"],
"attempt": req["attempt"],
"data": req["event"]["data"],
},
}),
"conformance-fail" => json!({
"status": "failed",
"error": { "message": req["event"]["data"]["message"], "retryable": false },
}),
_ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))),
};
(StatusCode::OK, Json(response))
}
fn verify(header: &str, body: &[u8]) -> bool {
let (mut t, mut v1) = (None, None);
for pair in header.split(',') {
match pair.split_once('=') {
Some(("t", value)) => t = Some(value),
Some(("v1", value)) => v1 = hex::decode(value).ok(),
_ => {}
}
}
let (Some(t), Some(v1)) = (t, v1) else { return false };
let Ok(sent) = t.parse::<i64>() else { return false };
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
if (now - sent).abs() > 300 {
return false;
}
let mut mac = Hmac::<Sha256>::new_from_slice(KEY.as_bytes()).unwrap();
mac.update(t.as_bytes());
mac.update(b".");
mac.update(body);
mac.verify_slice(&v1).is_ok()
}

verify follows the four signature rules. It splits each header pair on the first = only and ignores pairs it does not know. It rejects a timestamp more than five minutes from now. verify_slice compares the bytes in constant time. A GET gets 405 because the route accepts only post.

Run the handler in one terminal and the suite in a second terminal:

Terminal window
cargo run
Terminal window
ironflow conformance push --url http://127.0.0.1:8099/ironflow --level 1
PASS L1 transport.method-not-allowed
FAIL L1 transport.unknown-function
function_id "conformance-missing" returned HTTP 401, want 404 (if the handler verifies signatures, pass --signing-key)
contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequest
FAIL L1 transport.echo
HTTP 401, want 200: {"error":"bad signature"} (if the handler verifies signatures, pass --signing-key)
contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequest
FAIL L1 transport.unknown-fields
HTTP 401, want 200: {"error":"bad signature"} (if the handler verifies signatures, pass --signing-key)
contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequest
FAIL L1 transport.fail-encoding
HTTP 401: the engine retries every non-2xx answer, 4xx included. Return 200 with status "failed" and retryable false
contract: https://docs.ironflow.run/reference/api/push-protocol/#errors
FAIL L1 signature.valid
HTTP 401, want 200: {"error":"bad signature"}
contract: https://docs.ironflow.run/reference/api/push-protocol/#signature-verification
PASS L1 signature.missing
PASS L1 signature.tampered-body
PASS L1 signature.wrong-key
PASS L1 signature.skew-past
PASS L1 signature.skew-future
FAIL L1 signature.unknown-scheme
HTTP 401, want 200: {"error":"bad signature"}
contract: https://docs.ironflow.run/reference/api/push-protocol/#signature-verification
PASS L1 signature.malformed
SKIP L2 steps.first-run (above --level 1)
...
7 passed, 6 failed, 8 skipped
Error: push conformance: one or more checks failed

The suite reads the key from IRONFLOW_SIGNING_KEY, so the hint about --signing-key does not apply here. The key is correct. The handler rejects every signed request.

Look at what passed. A handler that rejects everything passes every check that expects a rejection: missing signature, tampered body, wrong key, old and future timestamps. A unit test that sends only bad signatures would pass too. signature.valid is the check that proves the handler accepts a good request.

The bug is the Json<Value> extractor. It parses the body, and the handler then serializes the value again to verify it. Those are not the bytes the engine signed. serde_json writes object keys in sorted order by default. The engine encodes <a&b> as <a&b>, and serde_json writes it back as plain <a&b>. The echo fixture puts those characters in event.data to catch this bug.

The fix is to take the raw bytes, verify them, and only then parse. Replace the use axum line, and the start of handle up to and including the signature check, with:

use axum::{Json, Router, body::Bytes, http::HeaderMap, http::StatusCode, routing::post};
async fn handle(headers: HeaderMap, body: Bytes) -> (StatusCode, Json<Value>) {
let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok());
if !verify(signature.unwrap_or(""), &body) {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" })));
}
let Ok(req) = serde_json::from_slice::<Value>(&body) else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "body is not JSON" })));
};
// The match on function_id is unchanged.

Restart the handler and run level 1 again. Every level 1 check passes, and the last line now reads:

13 passed, 0 failed, 8 skipped

Level 2 adds conformance-steps. It calls step.run twice with the same name, once with a name that contains : and \, and then runs two parallel branches. Each step returns a fixed value, so the suite can tell a replayed step from a step that ran again.

The handler needs a small step context. It holds the completed steps from the request, the current ID scope, a counter per step name, and the steps that ran in this call. Add use std::collections::HashMap; to the imports, then add this code:

fn conformance_steps(step: &mut Step) -> Value {
let o1 = step.run("plain", || json!({ "s": "plain#1" }));
let o2 = step.run("plain", || json!({ "s": "plain#2" }));
let o3 = step.run("a:b\\c", || json!({ "s": "escaped" }));
let branches = step.parallel("fan:out", 2, |step, branch| {
let mut last = step.run("leaf", || json!({ "s": format!("leaf-{branch}-0") }));
if branch == 0 {
last = step.run("leaf", || json!({ "s": "leaf-0-1" }));
}
last
});
json!([o1, o2, o3, branches[0], branches[1]])
}
struct Step {
memo: HashMap<String, Value>,
scope: String,
counters: HashMap<String, usize>,
delta: Vec<Value>,
}
impl Step {
fn new(req: &Value) -> Self {
let mut memo = HashMap::new();
for entry in req["steps"].as_array().into_iter().flatten() {
if entry["status"] == "completed" {
let id = entry["id"].as_str().unwrap_or("").to_string();
memo.insert(id, entry["output"].clone());
}
}
let scope = req["run_id"].as_str().unwrap_or("").to_string();
Step { memo, scope, counters: HashMap::new(), delta: Vec::new() }
}
fn next_id(&mut self, name: &str) -> String {
let index = self.counters.entry(name.to_string()).or_insert(0);
let id = format!("{}:{}:{}", self.scope, escape(name), index);
*index += 1;
id
}
fn run(&mut self, name: &str, body: impl FnOnce() -> Value) -> Value {
let id = self.next_id(name);
if let Some(output) = self.memo.get(&id) {
return output.clone();
}
let started_at = now_rfc3339();
let output = body();
self.delta.push(json!({
"id": id, "name": name, "type": "invoke", "status": "completed",
"started_at": started_at, "ended_at": now_rfc3339(), "output": output,
}));
output
}
fn parallel<T>(&mut self, name: &str, branches: usize, mut body: impl FnMut(&mut Step, usize) -> T) -> Vec<T> {
let parent = format!("{}:{}", self.scope, escape(name));
let mut results = Vec::new();
for branch in 0..branches {
let scope = std::mem::replace(&mut self.scope, format!("{parent}:{branch}"));
let counters = std::mem::take(&mut self.counters);
results.push(body(self, branch));
self.scope = scope;
self.counters = counters;
}
results
}
}
fn escape(part: &str) -> String {
part.replace('\\', "\\\\").replace(':', "\\:")
}
fn now_rfc3339() -> String {
humantime::format_rfc3339_seconds(SystemTime::now()).to_string()
}

Each rule of the step ID formula is one line here:

  • The ID is {scope}:{escape(name)}:{index}, and the index counts per name. The two plain steps get :plain:0 and :plain:1.
  • escape replaces the backslash before the colon. In the other order, the backslash that escapes a colon is escaped again.
  • A parallel branch gets the scope {parent}:{escape(name)}:{branch} and its own counters, which start at zero. std::mem::take gives each branch an empty map and puts the parent’s map back after the branch.
  • run returns a memoized output without calling the body, and it adds only the steps that ran to delta. The response steps array carries this delta, not the full history.

The branches run one after the other. The protocol cares about IDs and outputs, not about threads.

Now let handle build a Step and return its delta. Replace the rest of handle, from let response = match to the closing brace of the function, with:

let mut step = Step::new(&req);
let result = match req["function_id"].as_str().unwrap_or("") {
"conformance-echo" => json!({
"run_id": req["run_id"],
"function_id": req["function_id"],
"attempt": req["attempt"],
"data": req["event"]["data"],
}),
"conformance-fail" => {
let error = json!({ "message": req["event"]["data"]["message"], "retryable": false });
return (StatusCode::OK, Json(json!({ "status": "failed", "error": error })));
}
"conformance-steps" => conformance_steps(&mut step),
_ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))),
};
let response = json!({ "status": "completed", "steps": step.delta, "result": result });
(StatusCode::OK, Json(response))
}

Restart the handler and run level 2:

Terminal window
ironflow conformance push --url http://127.0.0.1:8099/ironflow --level 2

The last line of the report:

19 passed, 0 failed, 2 skipped

The if entry["status"] == "completed" line in Step::new matters more than it looks. My first version put every entry from steps into the memo. The suite caught it:

FAIL L2 steps.failed-memo-reruns
delta step IDs
want: run_conf_42hfgbz76rbn6wrf7cohyuwptt:plain:0
got: (none)
contract: https://docs.ironflow.run/reference/api/push-protocol/#completed-step-entries
18 passed, 1 failed, 2 skipped
Error: push conformance: one or more checks failed

The suite makes a new run ID for each check, so your IDs differ from these.

Today the engine puts only completed steps in steps. The protocol still says that only "completed" means memoized, and the suite holds you to that rule. A handler that skips the check works now, and it breaks on an engine that sends a failed entry: it never runs that step again, and the run continues with an output the step never produced.

Level 3 adds conformance-sleep. It runs a step, sleeps for an hour, and runs a second step. A push handler does not wait for an hour. It returns "status": "yielded" with the wake time, and the engine sends a new request when the time comes. That request marks the sleep step as completed in steps, so the memo check from level 2 is enough to resume.

Add Duration to the std::time import, then add the fixture, a Yield type and a sleep method on Step. Rust accepts a second impl Step block, so you can paste this as it is, or move sleep into the first block:

fn conformance_sleep(step: &mut Step) -> Result<Value, Yield> {
let before = step.run("before", || json!({ "s": "before" }));
step.sleep("nap", Duration::from_secs(3600))?;
let after = step.run("after", || json!({ "s": "after" }));
Ok(json!([before, after]))
}
struct Yield(Value);
impl Step {
fn sleep(&mut self, name: &str, duration: Duration) -> Result<(), Yield> {
let id = self.next_id(name);
if self.memo.contains_key(&id) {
return Ok(());
}
let until = humantime::format_rfc3339_seconds(SystemTime::now() + duration);
Err(Yield(json!({ "step_id": id, "type": "sleep", "until": until.to_string() })))
}
}

The ? operator stops the handler at the sleep. Now replace handle again, from let mut step = Step::new(&req); to the closing brace of the function. This version wraps each completed result in Ok, adds the new fixture, and turns a Yield into a yielded response:

let mut step = Step::new(&req);
let outcome = match req["function_id"].as_str().unwrap_or("") {
"conformance-echo" => Ok(json!({
"run_id": req["run_id"],
"function_id": req["function_id"],
"attempt": req["attempt"],
"data": req["event"]["data"],
})),
"conformance-fail" => {
let error = json!({ "message": req["event"]["data"]["message"], "retryable": false });
return (StatusCode::OK, Json(json!({ "status": "failed", "error": error })));
}
"conformance-steps" => Ok(conformance_steps(&mut step)),
"conformance-sleep" => conformance_sleep(&mut step),
_ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))),
};
let response = match outcome {
Ok(result) => json!({ "status": "completed", "steps": step.delta, "result": result }),
Err(Yield(y)) => json!({ "status": "yielded", "steps": step.delta, "yield": y }),
};
(StatusCode::OK, Json(response))
}

Restart the handler and run the whole suite. Level 3 is the default:

Terminal window
ironflow conformance push --url http://127.0.0.1:8099/ironflow
PASS L1 transport.method-not-allowed
PASS L1 transport.unknown-function
PASS L1 transport.echo
PASS L1 transport.unknown-fields
PASS L1 transport.fail-encoding
PASS L1 signature.valid
PASS L1 signature.missing
PASS L1 signature.tampered-body
PASS L1 signature.wrong-key
PASS L1 signature.skew-past
PASS L1 signature.skew-future
PASS L1 signature.unknown-scheme
PASS L1 signature.malformed
PASS L2 steps.first-run
PASS L2 steps.empty-array
PASS L2 steps.full-replay
PASS L2 steps.partial-replay
PASS L2 steps.failed-memo-reruns
PASS L2 steps.attempt-stable
PASS L3 yield.sleep
PASS L3 yield.resume
21 passed, 0 failed, 0 skipped

The push conformance page sets the bar: a language is supported for push mode when its handler passes every check at level 3. This handler does. Here is the complete file, with sleep moved into the first impl Step block. It is 169 lines.

The complete src/main.rs
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::{Json, Router, body::Bytes, http::HeaderMap, http::StatusCode, routing::post};
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{Value, json};
use sha2::Sha256;
static KEY: LazyLock<String> =
LazyLock::new(|| std::env::var("IRONFLOW_SIGNING_KEY").expect("set IRONFLOW_SIGNING_KEY"));
#[tokio::main]
async fn main() {
LazyLock::force(&KEY);
let app = Router::new().route("/ironflow", post(handle));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8099").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handle(headers: HeaderMap, body: Bytes) -> (StatusCode, Json<Value>) {
let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok());
if !verify(signature.unwrap_or(""), &body) {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" })));
}
let Ok(req) = serde_json::from_slice::<Value>(&body) else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "body is not JSON" })));
};
let mut step = Step::new(&req);
let outcome = match req["function_id"].as_str().unwrap_or("") {
"conformance-echo" => Ok(json!({
"run_id": req["run_id"],
"function_id": req["function_id"],
"attempt": req["attempt"],
"data": req["event"]["data"],
})),
"conformance-fail" => {
let error = json!({ "message": req["event"]["data"]["message"], "retryable": false });
return (StatusCode::OK, Json(json!({ "status": "failed", "error": error })));
}
"conformance-steps" => Ok(conformance_steps(&mut step)),
"conformance-sleep" => conformance_sleep(&mut step),
_ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))),
};
let response = match outcome {
Ok(result) => json!({ "status": "completed", "steps": step.delta, "result": result }),
Err(Yield(y)) => json!({ "status": "yielded", "steps": step.delta, "yield": y }),
};
(StatusCode::OK, Json(response))
}
fn conformance_steps(step: &mut Step) -> Value {
let o1 = step.run("plain", || json!({ "s": "plain#1" }));
let o2 = step.run("plain", || json!({ "s": "plain#2" }));
let o3 = step.run("a:b\\c", || json!({ "s": "escaped" }));
let branches = step.parallel("fan:out", 2, |step, branch| {
let mut last = step.run("leaf", || json!({ "s": format!("leaf-{branch}-0") }));
if branch == 0 {
last = step.run("leaf", || json!({ "s": "leaf-0-1" }));
}
last
});
json!([o1, o2, o3, branches[0], branches[1]])
}
fn conformance_sleep(step: &mut Step) -> Result<Value, Yield> {
let before = step.run("before", || json!({ "s": "before" }));
step.sleep("nap", Duration::from_secs(3600))?;
let after = step.run("after", || json!({ "s": "after" }));
Ok(json!([before, after]))
}
struct Yield(Value);
struct Step {
memo: HashMap<String, Value>,
scope: String,
counters: HashMap<String, usize>,
delta: Vec<Value>,
}
impl Step {
fn new(req: &Value) -> Self {
let mut memo = HashMap::new();
for entry in req["steps"].as_array().into_iter().flatten() {
if entry["status"] == "completed" {
let id = entry["id"].as_str().unwrap_or("").to_string();
memo.insert(id, entry["output"].clone());
}
}
let scope = req["run_id"].as_str().unwrap_or("").to_string();
Step { memo, scope, counters: HashMap::new(), delta: Vec::new() }
}
fn next_id(&mut self, name: &str) -> String {
let index = self.counters.entry(name.to_string()).or_insert(0);
let id = format!("{}:{}:{}", self.scope, escape(name), index);
*index += 1;
id
}
fn run(&mut self, name: &str, body: impl FnOnce() -> Value) -> Value {
let id = self.next_id(name);
if let Some(output) = self.memo.get(&id) {
return output.clone();
}
let started_at = now_rfc3339();
let output = body();
self.delta.push(json!({
"id": id, "name": name, "type": "invoke", "status": "completed",
"started_at": started_at, "ended_at": now_rfc3339(), "output": output,
}));
output
}
fn sleep(&mut self, name: &str, duration: Duration) -> Result<(), Yield> {
let id = self.next_id(name);
if self.memo.contains_key(&id) {
return Ok(());
}
let until = humantime::format_rfc3339_seconds(SystemTime::now() + duration);
Err(Yield(json!({ "step_id": id, "type": "sleep", "until": until.to_string() })))
}
fn parallel<T>(&mut self, name: &str, branches: usize, mut body: impl FnMut(&mut Step, usize) -> T) -> Vec<T> {
let parent = format!("{}:{}", self.scope, escape(name));
let mut results = Vec::new();
for branch in 0..branches {
let scope = std::mem::replace(&mut self.scope, format!("{parent}:{branch}"));
let counters = std::mem::take(&mut self.counters);
results.push(body(self, branch));
self.scope = scope;
self.counters = counters;
}
results
}
}
fn escape(part: &str) -> String {
part.replace('\\', "\\\\").replace(':', "\\:")
}
fn now_rfc3339() -> String {
humantime::format_rfc3339_seconds(SystemTime::now()).to_string()
}
fn verify(header: &str, body: &[u8]) -> bool {
let (mut t, mut v1) = (None, None);
for pair in header.split(',') {
match pair.split_once('=') {
Some(("t", value)) => t = Some(value),
Some(("v1", value)) => v1 = hex::decode(value).ok(),
_ => {}
}
}
let (Some(t), Some(v1)) = (t, v1) else { return false };
let Ok(sent) = t.parse::<i64>() else { return false };
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
if (now - sent).abs() > 300 {
return false;
}
let mut mac = Hmac::<Sha256>::new_from_slice(KEY.as_bytes()).unwrap();
mac.update(t.as_bytes());
mac.update(b".");
mac.update(body);
mac.verify_slice(&v1).is_ok()
}

The suite is a CLI with exit codes, so a CI job can run it against a test build of your service. Exit code 0 means no check failed, 1 means a check failed, and 3 means the endpoint did not answer. --json prints one result per check:

Terminal window
ironflow conformance push --url http://127.0.0.1:8099/ironflow --json
{
"results": [
{
"id": "transport.method-not-allowed",
"level": 1,
"outcome": "PASS",
"contract": "https://docs.ironflow.run/reference/api/push-protocol/#transport"
},
...
]
}

Run it on every change to the handler code. The step ID rules are easy to break in a refactor, and the engine does not reject a wrong ID. It runs the step again.

The suite checks the parts of the protocol that its fixtures use. The push conformance page lists what it does not check:

  • wait_for_event, invoke_function and invoke_function_async yields. A wait_for_event resume carries the matched event in resume.data, and this handler does not read resume.
  • The compensate: and publish: step-name prefixes. escape here escapes the whole name, so it would change the ID of a compensation step.
  • The capacity fencing headers, secrets, and trace headers.
  • started_at and ended_at on step results.
  • Constant-time comparison. The suite cannot see timing from outside the handler. verify_slice does it here.

This handler also has no step that can fail. A real step returns an error, and the handler must report a failed step result and decide if the error is retryable. The errors section of the protocol covers that encoding.

Two more limits come from push mode itself. The handler runs inside one HTTP request, so it suits short tasks. Pull mode, where a worker polls for jobs with no request timeout, ships only in the Go, Node and Python SDKs. And the suite checks only the engine-to-you direction. To emit events or start runs from Rust, generate a client from the OpenAPI spec. Use Ironflow from any language covers the generated client and the function registration call that points the engine at your endpoint.