[ SCENARIOS ] Ironflow

What you can build with Ironflow.

Ten things you can build, all from one online store.

Diagrams first, short real code beside them. Read it top to bottom, or jump to the scenario closest to yours.

[ NW ] Meet Northwind

An online store with seven services and one runtime between them.

Northwind Market sells through a web store, a mobile app and partner marketplaces. Every scenario in this book is a moment in its day. Each chapter stands on its own. Chapters 1, 2 and 3 show three different ways to handle the same new order. A real store uses one of them, not all three.

CHANNELS ONE BINARY SERVICES Web store Mobile app Partner API Order trackingbrowser page Stripe · carrierswebhooks Ironflow Eventspub/sub Workflowsdurable steps Sagascompensation Waitsapprovals Schedulescron · sleep Webhooksintake Entity streamsappend-only Projectionsderived views History · replay · dashboard Local: embedded NATS + SQLite. Production: Postgres + NATS. Orders Inventory Payments Shipping Email Support agent (AI) Catalog importerPython worker 1 2 3 4 5 6 7 8 9 10

Numbered markers show where each scenario lives. Every chapter opens with this map, its part lit.

[ 01 ] Microservices over events

Services that never call each other.

An order comes in. Orders announces it once. Inventory, Email and Shipping each react on their own.

Architecture

Orders Inventory Email Shipping order.placed stock.reserved emitemit Blue pills are events. Ironflow carries them; no service calls another.
STEP 1

Orders announces the order and moves on.

import { createClient } from "@ironflow/node";

const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });

// Orders service: announce the order, then move on.
await client.emit(
  "order.placed",
  { orderId: "o-1042", items, total: 129.0 },
  { idempotencyKey: "order-o-1042" }
);
STEP 2

Inventory reacts with a function. The reservation is a durable step.

import { ironflow } from "@ironflow/node";

// Inventory service
export const reserveStock = ironflow.createFunction(
  { id: "reserve-stock", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    return step.run("reserve", () =>
      inventory.reserve(event.data.orderId, event.data.items)
    );
  }
);
STEP 3

Email reacts to the same event. Neither service knows the other exists.

// Email service: same event, its own function.
export const sendReceipt = ironflow.createFunction(
  { id: "send-receipt", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    await step.run("send-receipt", () =>
      mailer.receipt(event.data.orderId)
    );
  }
);
STEP 4

Inventory hands off to Shipping with a new event. The broker is inside Ironflow.

// Inventory, after the reservation: tell Shipping.
await step.run("announce", () =>
  client.emit("stock.reserved", { orderId }, {
    idempotencyKey: `stock-${orderId}`,
  })
);

// Shipping service
export const createShipment = ironflow.createFunction(
  { id: "create-shipment", triggers: [{ event: "stock.reserved" }] },
  async ({ event, step }) => {
    await step.run("label", () => carrier.createLabel(event.data.orderId));
  }
);

Also used for

FintechPayment, risk and notification services react to one transfer event.
LogisticsScan events fan out to tracking, billing and customer updates.
SaaS backendsSign-up, billing and provisioning stay decoupled.
[ 02 ] Workflows many services trigger

Three storefronts, one fulfillment run.

Orders arrive from the web store, the mobile app and partner marketplaces. All three start the same fulfillment run. Three teams follow its result.

Architecture

Web store Mobile app Partner API Warehouse Email Analytics order.placed order.fulfilled fulfil-order run 1 reserve-stockdone 2 charge-carddone 3 create-shipmentdone 4 announcedone Each finished step is stored. A restarted run picks up at the first unfinished one.
STEP 1

Every storefront makes the same call. None of them knows how fulfillment works.

// Web store, mobile app and partner API: the same call from each.
await client.emit(
  "order.placed",
  { orderId, channel: "mobile", items },
  { idempotencyKey: `order-${orderId}` }
);
STEP 2

One function owns the order. Each call is a durable step, so a crash never repeats a finished one.

export const fulfilOrder = ironflow.createFunction(
  { id: "fulfil-order", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    const { orderId } = event.data;
    await step.run("reserve-stock", () => inventory.reserve(orderId));
    await step.run("charge-card", () => payments.charge(orderId));
    const shipment = await step.run("create-shipment", () =>
      shipping.create(orderId)
    );
    // ...step 3 announces the result
  }
);
STEP 3

The last step announces the result. If a crash repeats the step, the idempotency key stops a second event.

// Last step of fulfil-order: tell everyone who cares.
await step.run("announce", () =>
  client.emit(
    "order.fulfilled",
    { orderId, trackingId: shipment.trackingId },
    { idempotencyKey: `fulfilled-${orderId}` }
  )
);
STEP 4

Followers subscribe to the result. Adding a fourth one changes no existing code.

export const pickList = ironflow.createFunction(
  { id: "warehouse-pick-list", triggers: [{ event: "order.fulfilled" }] },
  async ({ event, step }) => {
    await step.run("print", () => warehouse.pick(event.data.orderId));
  }
);

// Email ("send-shipped-email") and Analytics ("record-sale")
// follow the same way, each with its own function id.

Also used for

Customer onboardingSign-up from web, sales and partners starts one provisioning run.Use case
Insurance claimsClaims from agents, apps and email follow one durable process.
[ 03 ] Saga with compensation

When step three fails, undo steps one and two.

Stock is reserved and the card is charged. Then the carrier rejects the address. The refund and the stock release run, in reverse order.

Architecture

FORWARD PATH reserve-stockdone charge-carddone book-carrierfailed UNDO PATH, IN REVERSE 2 release stock 1 refund card Each undo is registered right after its step and is itself durable.
STEP 1

Reserve the stock, then register its undo right away.

import { ironflow, NonRetryableError } from "@ironflow/node";

export const checkout = ironflow.createFunction(
  { id: "checkout", triggers: [{ event: "order.placed" }] },
  async ({ event, step }) => {
    const { orderId } = event.data;
    const hold = await step.run("reserve-stock", () =>
      inventory.reserve(orderId)
    );
    // Undo for reserve-stock. Runs only on a non-retryable failure.
    step.compensate("reserve-stock", () => inventory.release(hold.id));
    // ...
STEP 2

Charge the card, and register the refund the same way.

    const charge = await step.run("charge-card", () =>
      payments.charge(orderId)
    );
    step.compensate("charge-card", () => payments.refund(charge.id));
STEP 3

The carrier rejects the address. Retrying cannot help, so the step fails for good and the undos run in reverse.

    await step.run("book-carrier", async () => {
      const res = await carrier.book(orderId);
      // Retrying cannot fix a rejected address. Fail now.
      if (res.rejected)
        throw new NonRetryableError("carrier rejected the address");
      return res;
    });
    // The run fails: the refund runs first, then the stock release.
  }
);

Also used for

Bookings and reservationsFlight, hotel and car: if one fails, cancel the others.Use case
Money transfersDebit one account, credit another, reverse the debit if the credit fails.
[ 04 ] Webhook intake

Outside calls become events, once.

Stripe confirms a payment. A carrier reports a delivery. Each provider retries until it gets a 200, so the same call can arrive twice.

Architecture

Stripe Carrier serve({ webhooks }) 1 verify signature 2 transform 3 emit + key stripe.payment_intent.succeeded shipment.updated Orders Email /webhooks/stripe/webhooks/carrier Stripe redelivers, same key: no second run
STEP 1

Describe the Stripe source: check the signature, shape the event, and key it by Stripe's event id.

import { createWebhook } from "@ironflow/node";

export const stripeHook = createWebhook({
  id: "stripe",
  verify: (req) => {
    const sig = req.headers["stripe-signature"];
    return stripe.webhooks.constructEvent(req.body, sig, secret);
  },
  transform: (payload) => ({
    name: `stripe.${payload.type}`,
    data: payload.data.object,
    // Stripe resends with the same id. Same key, no second run.
    idempotencyKey: payload.id,
  }),
});
STEP 2

Hand the sources to the same handler that serves your functions. Each gets its own path.

import { serve } from "@ironflow/node/serve";

// POST /webhooks/stripe and /webhooks/carrier land here.
export const POST = serve({
  functions: [paymentSucceeded, shipmentUpdated],
  webhooks: [stripeHook, carrierHook],
  // Webhook events are emitted here. If unset, they are accepted and dropped.
  serverUrl: process.env.IRONFLOW_SERVER_URL,
});
STEP 3

From here it is an ordinary event. Orders marks the order paid.

export const paymentSucceeded = ironflow.createFunction(
  {
    id: "payment-succeeded",
    triggers: [{ event: "stripe.payment_intent.succeeded" }],
  },
  async ({ event, step }) => {
    await step.run("mark-paid", () =>
      orders.markPaid(event.data.metadata.orderId)
    );
  }
);
STEP 4

The carrier gets the same treatment, with its own event name and key.

export const carrierHook = createWebhook({
  id: "carrier",
  verify: (req) => carrier.verifySignature(req),
  transform: (payload) => ({
    name: "shipment.updated",
    data: { orderId: payload.reference, status: payload.status },
    idempotencyKey: payload.eventId,
  }),
});

Also used for

GitHubPush and pull request hooks start builds and reviews.Guide
TwilioDelivery receipts and inbound SMS become events.
ShopifyOrder and inventory hooks feed the same functions as your own store.
[ 05 ] Scheduled jobs

Later, and every night, without cron boxes.

A shopper fills a cart and leaves. A day later, Northwind sends one reminder. Every night at two, prices sync from suppliers.

Architecture

LATER: ONE REMINDER PER CART cart.updated cart.updated quiet for 24h per cart debounce load-cart send-reminder Email EVERY NIGHT AT 02:00 cron 0 2 * * * fetch-prices apply-prices No timer server. No worker held while waiting.
STEP 1

Debounce per cart. Every update restarts the 24-hour quiet period, so a busy shopper gets one reminder, not ten.

export const cartReminder = ironflow.createFunction(
  {
    id: "cart-reminder",
    triggers: [{ event: "cart.updated" }],
    // One run per cart, 24 hours after its last update.
    // The key is a path inside event.data, so "cartId", not "data.cartId".
    debounce: { periodMs: 24 * 60 * 60 * 1000, key: "cartId" },
  },
  async ({ event, step }) => {
    // ...step 2
  }
);
STEP 2

Check the cart is still open, then send. Both are durable steps.

  async ({ event, step }) => {
    const cart = await step.run("load-cart", () =>
      carts.get(event.data.cartId)
    );
    if (cart.status !== "open") return { skipped: true };

    await step.run("send-reminder", () => mailer.cartReminder(cart));
  }
STEP 3

A cron trigger replaces the cron box. A failed sync retries its steps and shows in the dashboard.

export const priceSync = ironflow.createFunction(
  {
    id: "nightly-price-sync",
    // Every day at 02:00 UTC.
    triggers: [{ event: "prices.tick", cron: "0 2 * * *" }],
  },
  async ({ step }) => {
    const prices = await step.run("fetch-prices", () => supplier.prices());
    await step.run("apply-prices", () => catalog.updatePrices(prices));
  }
);

Also used for

Billing runsInvoice every account on the first of the month.
ReportsBuild and email the daily summary.
Trial remindersNudge a user three days before the trial ends.
[ 06 ] Human approval

A run that waits days for a person.

A customer asks for a $740 refund. Anything over $500 needs a manager. The manager is in meetings until Thursday.

Architecture

refund.requested refund-approval run ask-manager manager-approvalwaiting, no worker held issue / decline Manager console Payments refund.decided No answer in 72h: the run fails and shows in the dashboard.
STEP 1

Small refunds go straight through. Large ones ask a manager.

export const refundApproval = ironflow.createFunction(
  { id: "refund-approval", triggers: [{ event: "refund.requested" }] },
  async ({ event, step }) => {
    const { refundId, amount } = event.data;
    if (amount <= 500) {
      return step.run("issue", () => payments.refund(refundId));
    }
    await step.run("ask-manager", () => approvals.request(refundId));
    // ...step 2
STEP 2

The run waits without holding a worker. It wakes only for a decision about this refund.

    // Matches refund.decided whose data.refundId equals this run's refundId.
    const decision = await step.waitForEvent("manager-approval", {
      event: "refund.decided",
      match: "data.refundId",
      // No answer in 72 hours: the run fails and shows in the dashboard.
      timeout: "72h",
    });
STEP 3

Thursday, the manager clicks Approve. The console emits one event.

// Manager console, on Approve or Decline.
await client.emit("refund.decided", {
  refundId: "r-881",
  approved: true,
  by: "manager-17",
});
STEP 4

The run resumes where it stopped and acts on the decision.

    if (decision.data.approved) {
      await step.run("issue", () => payments.refund(refundId));
    } else {
      await step.run("decline", () => mailer.refundDeclined(refundId));
    }
  }
);

Also used for

Loan approvalsAn underwriter signs off before funds move.
Expense claimsA manager approves anything over the limit.
Content moderationA reviewer clears flagged posts before they publish.
[ 07 ] Durable AI support agent

An agent that refunds once, even after a crash.

A customer writes: "My blender arrived broken." The support agent looks up the order, refunds it and emails the customer. Halfway through, its process is killed by a deploy.

Architecture

ticket.opened support-agent run llm.completerecorded lookup-orderrecorded issue-refundrecorded llm.completecrash here email-customernot yet run AFTER RESTART replayed from history, not called again runs for real, from here The model, the tools and the prompt stay yours. Ironflow records what already happened.
STEP 1

Tools are plain functions with a typed input. The refund passes a key to the payment provider too.

import { agent, defineTool } from "@ironflow/node/agent";
import { z } from "zod";
type Msg = { role: string; content: unknown };
const lookupOrder = defineTool({
  name: "lookup-order",
  input: z.object({ orderId: z.string() }),
  handler: async ({ orderId }) => orders.get(orderId),
});

const issueRefund = defineTool({
  name: "issue-refund",
  input: z.object({ orderId: z.string(), amount: z.number() }),
  // The provider key covers a crash in the middle of the call itself.
  handler: async (a) => payments.refund(a, { idempotencyKey: a.orderId }),
});
STEP 2

The agent is a function. Each model reply is recorded, so a resumed run does not pay for it twice.

export const supportAgent = agent(
  {
    id: "support-agent",
    triggers: [{ event: "ticket.opened" }],
    tools: [lookupOrder, issueRefund, emailCustomer],
  },
  async ({ event, llm, tool }) => {
    const messages: Msg[] = [{ role: "user", content: event.data.text }];
    while (true) {
      const reply = await llm.complete({
        messages,
        call: () => model.chat(messages),
      });
      if (!reply.toolCalls?.length) return reply.content;
      // ...step 3 runs the tools the model asked for
STEP 3

The refund is a durable step. Once it completes, a resumed run reuses its result and does not call the refund again.

      // Keep the model's turn, so it sees the calls it already made.
      messages.push({ role: "assistant", content: reply.content });
      for (const call of reply.toolCalls) {
        // Each tool call is a durable step, keyed and validated.
        const result = await tool(call.name, call.input);
        // model.chat maps this to your provider's tool-result format.
        messages.push({ role: "tool", content: { call, result } });
      }
    }
  }
);

Also used for

Support resolutionInvestigate, get approval, wait for the customer's reply.Use case
Document processingExtract, classify and review documents through worker restarts.Use case
Coding and ops agentsLong tool chains that must not repeat a deploy or a write.
[ 08 ] Event-sourced state

Stock levels you can rebuild from history.

Two checkouts grab the last blender at the same moment. Only one wins. Later, finance asks why the count changed on Tuesday.

Architecture

Scanner Checkout ENTITY STREAM sku-4417 v1received 40 v2sold 3 ...... v13sold 1 second checkout, expected v12: rejected, retry stock-levels projection sku-4417: 0 sku-2210: 18 pure reducer over the stream rebuild: replay every event from v1
STEP 1

Every change is an event appended to the SKU's stream, stating the version it read.

import { createClient } from "@ironflow/node";

const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
// Warehouse scanner: 40 blenders arrived.
// A new stream has no info yet, so its version starts at 0.
const info = await client.streams.getInfo("sku-4417");
await client.streams.append(
  "sku-4417",
  {
    name: "stock.received",
    entityType: "sku",
    data: { sku: "sku-4417", qty: 40 },
  },
  { expectedVersion: info?.version ?? 0 }
);
STEP 2

Two writers, one version. The loser's append is rejected with nothing applied. It reads again and checks the stock again, so the last blender sells once.

import { ContendedError } from "@ironflow/node";
async function sell(sku: string, qty: number, tries = 3): Promise<void> {
  const { events } = await client.streams.read(sku);
  const version = events.at(-1)?.entityVersion ?? 0;
  // countStock folds the events the same way as the projection in step 3.
  if (countStock(events) < qty) throw new Error(`${sku} is sold out`);
  const event = { name: "stock.sold", entityType: "sku", data: { sku, qty } };
  try {
    await client.streams.append(sku, event, { expectedVersion: version });
  } catch (err) {
    // Someone wrote first and nothing was applied. Read and check again.
    if (!(err instanceof ContendedError) || tries === 1) throw err;
    return sell(sku, qty, tries - 1);
  }
}
STEP 3

A projection folds the events into current counts. Same history in, same counts out.

import { createProjection } from "@ironflow/node";

type Move = { sku: string; qty: number };

export const stockLevels = createProjection({
  name: "stock-levels",
  events: ["stock.received", "stock.sold"],
  initialState: (): Record<string, number> => ({}),
  handler: (levels, event) => {
    const { sku, qty } = event.data as Move;
    const delta = event.name === "stock.received" ? qty : -qty;
    return { ...levels, [sku]: (levels[sku] ?? 0) + delta };
  },
});
STEP 4

Run it in a pull worker. Changed the reducer, or need to explain Tuesday? Rebuild it from history.

import { createWorker } from "@ironflow/node";

// Inventory's pull worker runs the projection beside its functions.
// A Node worker needs at least one function to register.
// start() keeps running; do not await it.
createWorker({
  functions: [reserveStock],
  projections: [stockLevels],
}).start();

// Changed the reducer? Replay the whole history into it:
//   $ ironflow projection rebuild stock-levels

Also used for

ReconciliationMatch transactions against a history you can replay.Use case
Account balancesDeposits and withdrawals as events; the balance is derived.
Wallets and pointsLoyalty points that never go missing, and an answer to "why".
[ 09 ] Long-running pipeline

A three-hour import that survives a restart.

A supplier drops a feed of 200,000 products. The data team's Python worker imports it, 1,000 at a time. At page 88, the machine reboots.

Architecture

catalog.uploaded Ironflow run state, saved step results Python worker import-catalog, pull mode polls for jobssaves each page page-0 done page-1 done ... page-87 done page-88 reboot resumes here After the restart, saved pages 0 to 87 are skipped. Page 88 runs again, so its upsert must be safe to repeat.
STEP 1

The same durable steps, in Python. Counting the pages is the first one.

from ironflow.worker import function

@function(id="import-catalog", triggers=[{"event": "catalog.uploaded"}])
async def import_catalog(ctx):
    feed = ctx.event.data["feedUrl"]
    pages = await ctx.step.run("count-pages", lambda: supplier.page_count(feed))
    # ...step 2 imports page by page
STEP 2

One durable step per page. After a crash, the run resumes after the last page it saved. Each upsert is idempotent.

    for n in range(pages):
        # A page that ran just before a crash can run again, so the
        # upsert must be safe to repeat.
        await ctx.step.run(
            f"page-{n}",
            lambda: catalog.upsert(supplier.page(feed, n)),
        )
    return {"pages": pages}
STEP 3

Pull mode: the worker polls Ironflow over HTTP, so a job can run for hours.

from ironflow.worker import Worker

worker = Worker(
    functions=[import_catalog],
    server_url="http://localhost:9123",
    max_concurrent_jobs=4,
)
worker.run()  # blocks; SIGINT or SIGTERM drains

Also used for

Data imports and syncRecover a failed batch without starting over.Use case
Video transcodingHour-long jobs on machines that come and go.
ML batchesFeature builds and scoring runs that pick up where they stopped.
[ 10 ] Live order tracking

A tracking page that updates itself.

A customer keeps the tracking page open. Packed, shipped, out for delivery: each step appears as it happens, with no refresh and no polling.

Architecture

Shipping order.packed order.shipped order.out_for_delivery Ironflow events:order.* Order o-1042 Packed Shipped Out for delivery filter The filter narrows what the page receives. It is not access control.
STEP 1

Shipping emits an event at each stage. It does not know the page exists.

// Shipping service, as the parcel moves.
const orderId = "o-1042";
await client.emit("order.packed", { orderId, status: "packed" });
await client.emit("order.shipped", { orderId, status: "shipped" });
await client.emit("order.out_for_delivery", {
  orderId,
  status: "out_for_delivery",
});
STEP 2

The page connects with a short-lived token from your backend, never an API key.

import { ironflow } from "@ironflow/browser";

ironflow.configure({
  serverUrl: "https://ironflow.northwind.example",
  // Short-lived, issued by your backend for this session.
  auth: { token: session.ironflowToken },
});
STEP 3

Subscribe to order events, filtered to this order. Each one moves the timeline.

await ironflow.subscribe<{ status: string }>("events:order.*", {
  // Narrows what this page receives. It is not access control.
  // Status events for this order only. JSON.stringify escapes the id.
  filter: `"status" in data && data.orderId == ${JSON.stringify(orderId)}`,
  onEvent: (event) => timeline.advance(event.data.status),
});

Also used for

Orders and returnsShow a return's progress the moment the warehouse scans it.Use case
Ops consolesLive run status for the team on call.
Live dashboardsCounters and feeds that update as events land.
[ → ] Next steps

Run Northwind's runtime on your laptop.

Start it

$ ironflow serve
# dashboard at http://localhost:9123

One binary with embedded NATS and SQLite.

Read the docs

Use cases walk through more applications. Getting started takes about five minutes.

See the platform

The platform overview explains each capability these scenarios use.

Two deployment shapes: locally, one binary; in production, Postgres plus external NATS.