- Other Languages
- Use Ironflow from any language
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.
First: you may not need a client
Section titled “First: you may not need a client”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.
# 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"}' --jsonSet 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.
Prerequisites
Section titled “Prerequisites”You need a running server, an API key, and an environment.
# 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 --platformTwo key prefixes exist and they are not interchangeable:
| Prefix | Scope | Use for |
|---|---|---|
ifkey_ | One tenant | Normal application traffic. |
ifplatform_ | Cross-tenant | Platform administration only. |
Every request needs the key as a bearer token:
Authorization: Bearer ifkey_...Set the environment header
Section titled “Set the environment header”X-Ironflow-Environment: env_defaultSend 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.
Generate a client
Section titled “Generate a client”Get the spec
Section titled “Get the spec”# 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.jsonStart 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.
Choose a generator
Section titled “Choose a generator”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:
jq '[.. | objects | select(.type? | type == "array" and index("null"))] | length' openapi.jsonjq '[.. | objects | select(has("anyOf"))] | length' openapi.jsonUse whichever generator your team already knows. Three C#/.NET generators were tested against the real spec, and all three produce code that compiles:
| Generator | Output size | Nullable anyOf | 204 responses | Bearer auth |
|---|---|---|---|---|
| Kiota 1.34 | 32k lines | Correct | No branch | You supply |
| openapi-generator 7.26 | 120k lines | Correct | IsNoContent | Generated |
| NSwag 14.7 | 26k lines | Broken | Throws | You supply |
Worked example: Kiota
Section titled “Worked example: Kiota”dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate \ --language CSharp \ --openapi ./openapi.json \ --output ./src/IronflowClient \ --class-name IronflowClient \ --namespace-name Ironflow.Clientnpx @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.
Known gaps in generated clients
Section titled “Known gaps in generated clients”Four things the generator cannot give you. Handle each once, centrally.
| Gap | What happens | What to do |
|---|---|---|
servers is absent | No base URL, or a wrong hardcoded one. | Set the base URL explicitly. |
X-Ironflow-Environment under-declared | The 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.type | Bare 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 PUT | Two 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.
What the generated client does NOT reach
Section titled “What the generated client does NOT reach”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:
| Capability | Call this instead |
|---|---|
| Emit an event | POST /ironflow.v1.IronflowService/Emit |
| List / get functions, invoke a function | IronflowService/ListFunctions, GetFunction, InvokeFunction |
| List / get runs, get run steps | IronflowService/ListRuns, GetRun, GetRunSteps |
| Cancel or resume a run, patch a step | IronflowService/CancelRun, ResumeRun, PatchStep |
| Run audit trail | AuditService/GetAuditTrail |
| Entity streams (read, append, snapshot, history) | EntityStreamService/* |
| Projections (read, status, wait, rebuild, pause, resume) | ProjectionService/* |
| Event schema registry | EventSchemaService/* |
| Publish to a topic | PubSubService/Publish |
| Read-only SQL | QueryService/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.
Register your function
Section titled “Register your function”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:
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.
Run functions with push mode
Section titled “Run functions with push mode”In push mode the engine sends an HTTP request to your service, and your service answers with the result. You implement one endpoint.
The full wire contract is on the Push protocol reference page. Build it in two levels.
Level 1: a stateless function
Section titled “Level 1: a stateless function”The smallest correct implementation. No durable steps, nothing to compute.
- Accept
POST. Return405for anything else. - Read the raw body bytes and verify
X-Ironflow-Signaturebefore parsing. - Parse the body as a
PushRequest. Readevent.data. - Do the work.
- Return HTTP
200with{"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.
Level 2: durable steps
Section titled “Level 2: durable steps”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:
indexis a per-name counter, from0, in call order. Two steps namedcharge-cardget0and1.- Escape the backslash before the colon.
compensate:andpublish: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:
- Acknowledge before you execute. When an assignment carries a non-empty
lease_token,PUT .../jobs/{jobId}/ackfirst with{run_id, execution_seq, lease_token}. A409means another worker superseded you: drop the job without executing it. - Echo the fence on every mutating call. Each
PUT .../jobs/{jobId}must carryexecution_seqandlease_token. Omit both whenlease_tokenis empty. - 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 returns204. - POST or PUT.
registerandheartbeataccept 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.
Related
Section titled “Related”- Push protocol — the full wire contract.
- SDK comparison — the tier model.
- REST API — prose reference for every endpoint.
- ironflow emit and ironflow invoke — the CLI route.