Skip to content

Use Ironflow from any language

Ironflow ships hand-written SDKs for Go and Node, and a generated client for Python. If your stack is C#, Java, Rust, Ruby, PHP or Elixir, you have two supported routes:

  • Call Ironflow — over HTTP. Two surfaces, and you will use both. Generate a client from the OpenAPI 3.1 spec for the REST routes, and send plain JSON POSTs for the ConnectRPC methods. Or shell out to the CLI.
  • Run functions in your language — your code is the workflow. Use push mode: the engine sends an HTTP request to your service, and your service answers.

This guide covers both. Read the SDK comparison for the tier model behind these choices.


If your service only needs to emit events or start functions, the ironflow CLI is already a working integration surface. It works from any language that can run a subprocess, and it needs no code generation.

Terminal window
# Emit an event. Exit code is 0 on success.
ironflow emit order.placed --data '{"orderId":"ord_123","total":4200}'
# Emit and wait for every triggered run to finish.
ironflow emit order.placed --data '{"orderId":"ord_123"}' --wait --timeout 60s
# Invoke one function directly and print JSON.
ironflow invoke process-order --data '{"orderId":"ord_123"}' --json

Set IRONFLOW_SERVER_URL and IRONFLOW_API_KEY in the environment, add --json, and parse stdout. For a service that emits a few events per request this is enough, and it stays correct when the API changes.

Use a generated client when you need typed models, connection reuse, or calls on a hot path where process startup cost matters.


You need a running server, an API key, and an environment.

Terminal window
# Create a tenant-scoped key. Save the value: it is shown once.
ironflow apikey create my-service --env env_default
# A platform-scoped key, only for cross-tenant operations.
ironflow apikey create my-platform-key --platform

Two key prefixes exist and they are not interchangeable:

PrefixScopeUse for
ifkey_One tenantNormal application traffic.
ifplatform_Cross-tenantPlatform administration only.

Every request needs the key as a bearer token:

Authorization: Bearer ifkey_...
X-Ironflow-Environment: env_default

Send this header on every request, set to the environment your key is scoped to.

The header is a declaration of intent, not a routing input. For a tenant ifkey_ the environment stored on the key decides what your call touches, always. The middleware only compares the header against it: a value naming a different environment is 403 environment does not match API key scope, and omitting it falls through to the key’s own environment rather than to env_default. The six /api/v1/secrets routes go further and reject a missing header with 400, so a client that never sends it cannot read or write a secret.

Set it once, to the same environment you passed to ironflow apikey create --env. Setting it to anything else breaks every request the key can make.


Terminal window
# Published copy. Tracks the latest release.
curl -o openapi.json https://docs.ironflow.run/openapi.json
# Your own server's copy. Requires your API key. Use this before you ship.
curl -H "Authorization: Bearer $IRONFLOW_API_KEY" \
-o openapi.json http://localhost:9123/api/v1/openapi.json

Start with the published copy. Regenerate against your server before you release, because the published copy is whatever the docs site last built. The spec’s info.version is a route-set hash such as route-set-a413b630d572609f, not a semantic version, so you cannot compare two copies by reading it. Diff the files.

The spec is OpenAPI 3.1. Its 3.1 surface is narrow: a few dozen nullable type unions (type: ["string","null"]) and a handful of anyOf: [$ref, null] unions. It uses no const, prefixItems, if/then/else or patternProperties. Any 3.1-aware generator handles it. Count the current populations rather than trusting a number written here — both move whenever routes do:

Terminal window
jq '[.. | objects | select(.type? | type == "array" and index("null"))] | length' openapi.json
jq '[.. | objects | select(has("anyOf"))] | length' openapi.json

Use whichever generator your team already knows. Three C#/.NET generators were tested against the real spec, and all three produce code that compiles:

GeneratorOutput sizeNullable anyOf204 responsesBearer auth
Kiota 1.3432k linesCorrectNo branchYou supply
openapi-generator 7.26120k linesCorrectIsNoContentGenerated
NSwag 14.726k linesBrokenThrowsYou supply
Terminal window
dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate \
--language CSharp \
--openapi ./openapi.json \
--output ./src/IronflowClient \
--class-name IronflowClient \
--namespace-name Ironflow.Client

npx @microsoft/kiota does not work. That npm package ships no executable. Use the dotnet global tool.

Kiota generates no authentication code by design. Supply an IAuthenticationProvider, and set the environment header in the same place:

using Microsoft.Kiota.Abstractions;
using Microsoft.Kiota.Abstractions.Authentication;
using Microsoft.Kiota.Http.HttpClientLibrary;
public sealed class IronflowAuthProvider : IAuthenticationProvider
{
private readonly string _apiKey;
private readonly string _environmentId;
public IronflowAuthProvider(string apiKey, string environmentId)
{
_apiKey = apiKey;
_environmentId = environmentId;
}
public Task AuthenticateRequestAsync(
RequestInformation request,
Dictionary<string, object>? additionalProperties = null,
CancellationToken cancellationToken = default)
{
request.Headers.Add("Authorization", $"Bearer {_apiKey}");
request.Headers.Add("X-Ironflow-Environment", _environmentId);
return Task.CompletedTask;
}
}

The spec declares no servers, so no generator knows your base URL. Set it yourself:

var adapter = new HttpClientRequestAdapter(
new IronflowAuthProvider(apiKey, "env_default"))
{
BaseUrl = "http://localhost:9123",
};
var client = new IronflowClient(adapter);
// Event reads are REST, so the generated client covers them.
var events = await client.Api.V1.Events.GetAsync(q => q.QueryParameters.Limit = 20);

Emitting is not on the generated client — see the section below.

Free-form JSON fields such as event.data are {} in the spec, because their shape is yours, not Ironflow’s. Kiota turns each one into a small *_data class that holds an AdditionalData dictionary. openapi-generator emits Object? and NSwag emits object for the same fields.

Four things the generator cannot give you. Handle each once, centrally.

GapWhat happensWhat to do
servers is absentNo base URL, or a wrong hardcoded one.Set the base URL explicitly.
X-Ironflow-Environment under-declaredThe secrets routes answer 400; a value that names another environment answers 403.Add it as a default header, set to the key’s own environment.
No enums on status and yield.typeBare strings for completed, failed, yielded, progress and the yield types.Write your own enum and parse at the boundary.
register and heartbeat accept POST and PUTTwo generated methods, no guidance. The Go SDK uses PUT, the Node SDK uses POST.Pick one and use it consistently. Both work.

The spec has no numbered 4xx or 5xx responses. Every operation declares one typed default response of the shape { "error", "code", "details" }. Handle errors from that shape and the HTTP status code, not from generated per-status types.

You can sanity check your copy of the spec with npx @redocly/cli lint openapi.json. It reports no schema errors. It does report a missing summary on every operation, which is why your generated client has no doc comments.

This is the largest gap and it is deliberate. cmd/sdk-gen drops every ConnectRPC route before it writes the spec, so api/openapi.json describes REST only — and ADR 0079 makes that the rule rather than an oversight. #1972 step 10 then removed 37 duplicate REST routes whose capability now lives on Connect alone, so a generated client no longer reaches any of this:

CapabilityCall this instead
Emit an eventPOST /ironflow.v1.IronflowService/Emit
List / get functions, invoke a functionIronflowService/ListFunctions, GetFunction, InvokeFunction
List / get runs, get run stepsIronflowService/ListRuns, GetRun, GetRunSteps
Cancel or resume a run, patch a stepIronflowService/CancelRun, ResumeRun, PatchStep
Run audit trailAuditService/GetAuditTrail
Entity streams (read, append, snapshot, history)EntityStreamService/*
Projections (read, status, wait, rebuild, pause, resume)ProjectionService/*
Event schema registryEventSchemaService/*
Publish to a topicPubSubService/Publish
Read-only SQLQueryService/ExecuteSQL

What the generated client does still reach: event reads (GET /api/v1/events, /events/{id}, /events/names), config, secrets, KV, workers, API keys, users, environments, projects, orgs, tenants, roles, policies and policy templates, the platform console routes, capacity, circuit breakers, the outbox dead-letter queue, debounce entries, cluster token rotation, webhooks, the flow map, GET /api/v1/audit, GET /api/v1/runs/{id}/streams, GET /api/v1/projections/{name}/partitions and DELETE /api/v1/projections/{name}. jq -r '.paths | keys[]' openapi.json is the authoritative list.

Every Connect method is plain proto3-camelCase JSON over POST, with the same Authorization and X-Ironflow-Environment headers — the RegisterFunction example below is the pattern for all of them. Note the encoding rules: fields are camelCase, 64-bit integers are decimal strings, empty fields are omitted rather than sent as ""/0/[], and errors are a JSON body {"code": "...", "message": "..."} rather than a numbered HTTP status. See the REST API reference.


Every function must be registered before the engine will route anything to it. Like the capabilities in the table above, registration is a ConnectRPC method that code generation does not give you. There is no POST /api/v1/functions.

Send it as plain JSON over HTTP:

Terminal window
curl -X POST http://localhost:9123/ironflow.v1.IronflowService/RegisterFunction \
-H "Authorization: Bearer $IRONFLOW_API_KEY" \
-H "X-Ironflow-Environment: env_default" \
-H "Content-Type: application/json" \
-d '{
"id": "process-order",
"name": "Process Order",
"description": "Charges the card and books the shipment",
"triggers": [{ "event": "order.placed" }],
"preferredMode": "EXECUTION_MODE_PUSH",
"endpointUrl": "https://my-service.internal/ironflow",
"timeoutMs": 30000,
"retry": { "maxAttempts": 3, "initialDelayMs": 1000, "backoffFactor": 2.0 }
}'

Field names are proto3 JSON, which means camelCase: preferredMode, endpointUrl, timeoutMs, maxAttempts, cancelOn. The REST endpoints use snake_case. The two conventions do not mix.

preferredMode is EXECUTION_MODE_PUSH or EXECUTION_MODE_PULL. A trigger is { "event": "..." }, optionally with "expression" for a filter, or { "cron": "0 9 * * *" } for a schedule.

endpointUrl is set only through this call. Registration is idempotent: send it again on every deploy to update the endpoint or the triggers.


In push mode the engine sends an HTTP request to your service, and your service answers with the result. You implement one endpoint.

Eventorder.placedEnginecreates runYour endpointC# / Java / Rust / Rubyverify HMAC, replay memo, runPOST PushRequest200 PushResponsestatus: completed | yielded | failedyielded → engine re-sends later

The full wire contract is on the Push protocol reference page. Build it in two levels.

The smallest correct implementation. No durable steps, nothing to compute.

  1. Accept POST. Return 405 for anything else.
  2. Read the raw body bytes and verify X-Ironflow-Signature before parsing.
  3. Parse the body as a PushRequest. Read event.data.
  4. Do the work.
  5. Return HTTP 200 with {"status":"completed","steps":[],"result": ...}.

PushRequest, PushResponse and StepResult are your types. No generator produces them, because push mode is the engine calling you. Copy the field names from the Push protocol page.

app.MapPost("/ironflow", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
var rawBody = await reader.ReadToEndAsync();
if (!IronflowSignature.Verify(rawBody, req.Headers["X-Ironflow-Signature"], signingKey))
return Results.Unauthorized();
var push = JsonSerializer.Deserialize<PushRequest>(rawBody)!;
var order = push.Event.Data.Deserialize<OrderPlaced>()!;
var chargeId = await payments.ChargeAsync(order, push.RunId);
return Results.Json(new PushResponse
{
Status = "completed",
Steps = Array.Empty<StepResult>(),
Result = new { chargeId },
});
});

Retries re-run everything at this level. That is usually fine, because the engine sends Idempotency-Key — the run ID, stable across every retry — so you can pass it to downstream services and let them reject the duplicate.

Return 200 with "status":"failed" for a business failure. Reserve 5xx for your own service being broken; the engine retries those under the function’s retry policy.

Add durable steps when a run has several side effects and you do not want a retry to repeat the ones that already succeeded.

Memoization is client-side replay. The engine sends every previously completed step in steps. You run your handler from the top and skip the steps you find there. There is no round trip per step.

var memo = push.Steps
.Where(s => s.Status == "completed")
.ToDictionary(s => s.Id);
var counters = new Dictionary<string, int>();
var executed = new List<StepResult>();
async Task<T> StepAsync<T>(string name, Func<Task<T>> body)
{
var index = counters.TryGetValue(name, out var n) ? n : 0;
counters[name] = index + 1;
var stepId = $"{push.RunId}:{EscapeStepIdPart(name)}:{index}";
if (memo.TryGetValue(stepId, out var done))
return done.Output.Deserialize<T>()!;
var output = await body();
executed.Add(new StepResult
{
Id = stepId, Name = name, Type = "run", Status = "completed", Output = output,
});
return output;
}

The step ID is the memoization key on both sides of the wire. Get it wrong and the engine sends back a memo your handler cannot find, so the step runs a second time — a duplicate charge, a duplicate publish.

static string EscapeStepIdPart(string part)
{
foreach (var ns in new[] { "compensate:", "publish:" })
if (part.StartsWith(ns, StringComparison.Ordinal))
return ns + Replace(part[ns.Length..]);
return Replace(part);
// Backslash first. The reverse order double-escapes.
static string Replace(string s) => s.Replace(@"\", @"\\").Replace(":", @"\:");
}

Three rules that decide correctness:

  1. index is a per-name counter, from 0, in call order. Two steps named charge-card get 0 and 1.
  2. Escape the backslash before the colon.
  3. compensate: and publish: are structure, not user input. Escape only what follows them.

Your handler must be deterministic: the same step names in the same order on every replay, or the counters drift and every ID after the drift is wrong.

Read the Push protocol page for parallel branch scoping, yields (sleep, waitForEvent, invoke), and the resume contract.


Pull mode is not supported outside Go and Node

Section titled “Pull mode is not supported outside Go and Node”

Your generated client will contain workers_register, workers_list_jobs, workers_update_jobs and friends. They are fully typed in the spec, so they look ready to use. They are not part of the supported surface for a generated client, and the rules that make them safe are not in the spec.

If you go there anyway, these are the four traps:

  1. Acknowledge before you execute. When an assignment carries a non-empty lease_token, PUT .../jobs/{jobId}/ack first with {run_id, execution_seq, lease_token}. A 409 means another worker superseded you: drop the job without executing it.
  2. Echo the fence on every mutating call. Each PUT .../jobs/{jobId} must carry execution_seq and lease_token. Omit both when lease_token is empty.
  3. Two response shapes. The poll returns {"jobs":[...]} on the capacity path and a bare single-assignment object on the legacy path. Handle both. An idle poll returns 204.
  4. POST or PUT. register and heartbeat accept both, and the two official SDKs disagree about which to send.

The memo field is called completed_steps here, not steps. Everything else about step IDs and replay is identical to push mode.