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

A File Upload Can Start a Workflow

Your import screen says “Upload complete.” The file is safely stored. Nobody has read the orders inside it yet.

You could have the browser make a second request to start processing. But the user might close the tab after the upload succeeds, or that second request might fail. Now you have a file sitting in an inbox and no job to read it.

Ironflow v0.41.0 gives files their own events. In this example, a signed URL accepts an order batch, ironflow.file.created starts a worker, and the worker reads the exact file version named by the event.

This walkthrough uses Ironflow v0.41.0, Node.js 24 or newer, and three terminals. Start the engine in the first terminal:

Terminal window
ironflow serve --dev

--dev runs locally without API-key authentication. In a second terminal, create a temporary TypeScript project:

Terminal window
mkdir upload-workflow
cd upload-workflow
npm init -y
npm pkg set type=module
npm install @ironflow/node@0.41.0 @ironflow/browser@0.41.0
npm install --save-dev tsx

A bucket holds the rules for its files. Save this as setup.ts and run it once:

import { createClient } from "@ironflow/node";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
await client.files().createBucket("order-imports", {
maxObjectBytes: 5 * 1024 * 1024,
allowedContentTypes: ["application/json"],
emitEvents: true,
allowSignedUrls: true,
});
Terminal window
npx tsx setup.ts

The two switches matter. allowSignedUrls lets your backend give a browser a short-lived upload URL. emitEvents makes a successful write produce an ironflow.file.created event. Both are off by default in the API.

Save this as worker.ts:

import {
createClient,
createFunction,
createWorker,
NonRetryableError,
} from "@ironflow/node";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
const inspectImport = createFunction(
{
id: "inspect-order-import",
triggers: [{
event: "ironflow.file.created",
expression: "data.bucket == 'order-imports'",
}],
},
async ({ event, step }) => {
const { bucket, path, etag } = event.data as {
bucket: string;
path: string;
etag: string;
};
return step.run("inspect-json", async () => {
const file = await client.files().bucket(bucket).get(path, { ifMatch: etag });
let orders: unknown;
try {
orders = JSON.parse(await file.text());
} catch {
throw new NonRetryableError("Import is not valid JSON");
}
if (!Array.isArray(orders)) {
throw new NonRetryableError("Import must contain an array of orders");
}
return { path, rows: orders.length, etag };
});
},
);
await createWorker({ functions: [inspectImport] }).start();

Run it in the second terminal. Leave it running. Wait for Registered function: inspect-order-import before sending a file. A function registered after the upload does not receive that earlier event.

Terminal window
npx tsx worker.ts

The event carries an ETag for the object that caused it. ifMatch pins the read to that version. If someone overwrites the path before the worker reads it, Ironflow returns 412 instead of handing the worker different bytes.

Open a third terminal in upload-workflow and save this as upload.ts:

import { createClient } from "@ironflow/node";
import { uploadToSignedUrl } from "@ironflow/browser";
import { writeFile } from "node:fs/promises";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
const path = `incoming/${crypto.randomUUID()}.json`;
const { url } = await client.files().bucket("order-imports").signUpload(path, {
ttlSeconds: 600,
maxBytes: 5 * 1024 * 1024,
contentType: "application/json",
createOnly: true,
});
const fileFromInput = new File(
[JSON.stringify([{ id: "ord-1" }, { id: "ord-2" }])],
"orders.json",
{ type: "application/json" },
);
const info = await uploadToSignedUrl(url, fileFromInput);
await writeFile("last-upload.json", JSON.stringify({ path, etag: info.etag }));
console.log({ path, etag: info.etag });
Terminal window
npx tsx upload.ts

To wait for the run and see its output, save this as inspect.ts:

import { createClient } from "@ironflow/node";
import { readFile } from "node:fs/promises";
const client = createClient({ apiKey: process.env.IRONFLOW_API_KEY });
const { path } = JSON.parse(await readFile("last-upload.json", "utf8")) as {
path: string;
};
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { runs } = await client.listRuns({ functionId: "inspect-order-import" });
const run = runs.find((candidate) =>
(candidate.input as { path?: string })?.path === path,
);
if (run?.status === "completed") {
console.log(run.output);
process.exit(0);
}
if (run?.status === "failed") throw new Error(JSON.stringify(run.error));
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error("Import run did not complete within 30 seconds");
Terminal window
npx tsx inspect.ts

The output includes rows: 2. last-upload.json holds only the path and ETag, so the inspection script checks this upload’s run. The upload script creates a File in Node so you can try the flow without building a web form. In an app, the backend signs the URL and returns it to the browser. The browser passes the file selected by the user to uploadToSignedUrl. The Ironflow API key stays on the backend; the signed URL is the browser’s temporary permission to upload to one path.

The upload writes a file record and its event in one database transaction. If the write fails, there is no created event. Once it commits, Ironflow dispatches the event to the registered function. createOnly makes a second upload to the same path fail with 412 instead of replacing the file. The signed token stays valid until it expires, so keep the URL out of logs and messages you share.

If dispatch is interrupted, a recovery sweep can try the event again. The engine deduplicates dispatch for the same event and function, but a worker can still retry a run or a step. If a step writes orders to another database and loses the response before Ironflow records its completion, that write may be attempted again. Give the destination a unique key based on the bucket, ETag, and each order’s ID. A durable step does not make an external write exactly once.

The files guide covers signed downloads, file limits, and event recovery. The file storage explanation describes how file metadata, bytes, and events are stored.