---
title: Migrating from Inngest
description: Move an Inngest TypeScript SDK v3 or v4 app to the Workflow SDK by replacing createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, Hooks, and start()/getRun().
type: guide
summary: Translate an Inngest app into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

<Callout type="info">
Install the Workflow SDK migration skill:

```bash
npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
```
</Callout>

## Why migrate to the Workflow SDK

- Streaming is built in. Durable progress writes go to named streams via `getWritable()`. There is no separate realtime publish channel or WebSocket layer to operate.
- Infrastructure and orchestration live in a single deployment. Workflows run where the app runs. There is no separate Inngest Dev Server or event bus to operate.
- TypeScript-first DX. Steps are named async functions marked with `"use step"`. No inline closures tied to a framework-specific lifecycle.
- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` integration for durable AI agents, and a Claude skill for generating workflows.

Inngest code samples in this guide use the v4 two-argument `createFunction({ id, triggers }, handler)` shape. If the app is still on Inngest v3, treat the three-argument form `createFunction({ id }, { event: '...' }, handler)` as equivalent — the `triggers` option in v4 replaces the second argument in v3.

## What changes when you leave Inngest

Inngest defines functions with `inngest.createFunction()`, registers them through a `serve()` handler, and breaks work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The platform routes events, schedules steps, and applies retries.

The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no function registry, event dispatch layer, or SDK client. Durable replay, automatic retries, and step-level persistence are built into the runtime.

Migration collapses the SDK abstraction into plain async functions. Business logic stays the same.

<Callout type="info">
Inngest's event-bus model is loosely coupled — publishers don't know consumers. `start()` requires the caller to import the workflow function directly, giving stronger type safety but tighter coupling. For event-bus-like fan-out, wrap `start()` in a shared publisher module.
</Callout>

## Concept mapping

| Inngest | Workflow SDK | Migration note |
| --- | --- | --- |
| `inngest.createFunction()` | `"use workflow"` function started with `start()` | No wrapper needed. |
| `step.run()` | `"use step"` function | Standalone async function with Node.js access. |
| `step.sleep()` / `step.sleepUntil()` | `sleep()` | `sleep('5m')` for a duration; `sleep(date)` for sleep-until. |
| `step.waitForEvent()` | `createHook()` or `createWebhook()` | Hooks for typed signals, webhooks for HTTP. |
| `step.invoke()` | `start()` plus a `"use step"` wrapper around `getRun()` | Spawn a child run, pass `runId` forward, and collect from a step when needed. |
| `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. |
| Retry configuration (`retries`) | `RetryableError`, `FatalError`, `maxRetries` | Retry logic lives at the step level. |
| `step.sendEvent()` | `start()` from the workflow or app boundary | Fan out explicitly, not through an event bus. |
| Realtime / `step.realtime.publish()` | `getWritable()` / `getWritable({ namespace })` | Named streams are the canonical way for clients to read workflow status. No database or `getRun()` polling required. |

## Translate your first workflow

Start with the shell of a function. Inngest wraps it in `createFunction`; Workflow SDK marks it with a directive.

{/* @skip-typecheck: Inngest SDK types not available */}
```typescript title="inngest/functions/order.ts"
export const processOrder = inngest.createFunction(
  {
    id: 'process-order',
    triggers: { event: 'order/created' },
  },
  async ({ event, step }) => {
    return { orderId: event.data.orderId, status: 'completed' };
  }
);
```

```typescript title="workflow/workflows/order.ts"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  return { orderId, status: 'completed' };
}
```

**What changed:** the factory + event binding collapses into a plain exported function with a `"use workflow"` directive.

### Add a step

`step.run()` closures become named `"use step"` functions.

```typescript title="workflow/workflows/order.ts"
async function loadOrder(orderId: string) {
  'use step'; // [!code highlight]
  const res = await fetch(`https://example.com/api/orders/${orderId}`);
  return res.json() as Promise<{ id: string }>;
}
```

Call it from the workflow like any async function: `const order = await loadOrder(orderId)`. Additional side effects (`reserveInventory`, `chargePayment`) follow the same shape.

### Start the run

Inngest dispatches via `inngest.send({ name: 'order/created', data: { orderId } })`. Workflow SDK launches directly:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

const run = await start(processOrder, [orderId]); // [!code highlight]
```

No event bus, no registry. `start()` returns a handle immediately.

## Wait for an external signal

`step.waitForEvent()` becomes `createHook()` plus `await`.

```typescript title="workflow/workflows/refund.ts (Inngest)"
const approval = await step.waitForEvent('wait-for-approval', {
  event: 'refund/approved',
  match: 'data.refundId',
  timeout: '7d',
});
```

{/* @skip-typecheck: snippet without imports */}
```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
using approval = createHook<{ approved: boolean }>({ // [!code highlight]
  token: `refund:${refundId}:approval`,
});
const payload = await approval;
```

**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly, with no event schema.

### Resume from an API route

Inngest resumes with `inngest.send({ name: 'refund/approved', data: { refundId, approved } })`. The SDK equivalent is `resumeHook`:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
import { resumeHook } from 'workflow/api';

export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) {
  const { refundId } = await params;
  const { approved } = (await request.json()) as { approved: boolean };
  await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight]
  return Response.json({ ok: true });
}
```

### Add a timeout and branch on the payload

Inngest's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`:

{/* @skip-typecheck: continuation snippet */}
```typescript title="workflow/workflows/refund.ts"
const result = await Promise.race([
  approval.then((p) => ({ type: 'decision' as const, approved: p.approved })),
  sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight]
]);

if (result.type === 'timeout') return { refundId, status: 'timed-out' };
if (!result.approved) return { refundId, status: 'rejected' };
return { refundId, status: 'approved' };
```

<Callout type="info">
Event matching disappears. A hook's token encodes the routing (for example, `refund:${refundId}:approval`), and the caller supplies that token to `resumeHook()`.
</Callout>

## Spawn a child workflow

`step.invoke()` splits into spawn and collect. In v5, call `start()` directly from the workflow to spawn the child. Wrap `getRun()` and `returnValue` access in a `"use step"` function when you need to collect the result. Returning the `Run` object from `start()` lets observability deep-link into the child run.

You can return either the full `Run` object (enables deep-linking) or just `run.runId` (simpler).

{/* @skip-typecheck: snippet without imports */}
```typescript title="workflow/workflows/parent.ts"
async function collectResult(runId: string) {
  'use step';
  const run = getRun(runId);
  return await run.returnValue; // [!code highlight]
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const child = await start(childWorkflow, [item]); // [!code highlight]
  return await collectResult(child.runId);
}
```

## What you stop operating

Dropping the Inngest SDK removes several moving parts:

- **No SDK client or serve handler.** Workflow files carry directive annotations. No registry, no serve endpoint.
- **No event bus.** `start()` launches workflows directly from API routes, server actions, or other entry points. No event schemas or dispatch layer.
- **No inline step closures.** Steps are named async functions. They type-check and test like any other TypeScript function.
- **No separate streaming transport.** `getWritable()` delivers progress to clients without WebSockets or SSE glue.
- **No idle workers.** Workflows suspended on `sleep()` or a hook consume no compute until resumed.

## Step-by-step first migration

Pick one Inngest function and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.

### Step 1: Install the Workflow SDK

Install the SDK. Framework integrations (`workflow/next`, `workflow/nitro`, `workflow/nuxt`, etc.) are subpath exports of the same `workflow` package — no additional install needed.

```bash
pnpm add workflow
```

### Step 2: Convert `createFunction` to a `"use workflow"` export

Replace the factory call with a plain async export. Move the handler body up. The event-binding argument goes away.

```ts title="workflows/order.ts"
// Before (Inngest)
// export const processOrder = inngest.createFunction(
//   { id: "process-order", triggers: { event: "order/created" } },
//   async ({ event, step }) => { ... }
// );

// After (Workflow SDK)
export async function processOrder(orderId: string) {
  "use workflow"; // [!code highlight]
  // ...
}
```

### Step 3: Convert `step.run` callbacks into named step functions

Each inline callback becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`.

```ts
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`https://example.com/api/orders/${id}`).then((r) => r.json());
}
```

Configure per-step retry counts by assigning `maxRetries` as a function property:

```ts
async function callApi(endpoint: string) {
  "use step";
  const response = await fetch(endpoint);
  return response.json();
}
callApi.maxRetries = 5;
```

See [Errors and retries](/docs/foundations/errors-and-retries) for full retry docs.

### Step 4: Replace `waitForEvent`, `sleep`, and `invoke`

- `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`.
- `step.sleep(...)` → `sleep("5m")` from `workflow`.
- `step.invoke(child, { data })` → call `start(child, [data])` from the workflow, and optionally read its return value from a step with `getRun(run.runId).returnValue`.

### Step 5: Start runs from the app

Delete the `serve()` handler and event dispatch. Launch runs directly from an API route:

```ts title="app/api/orders/route.ts"
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";

export async function POST(req: Request) {
  const { orderId } = await req.json();
  const run = await start(processOrder, [orderId]);
  return Response.json({ runId: run.runId });
}
```

### Step 6: Retire the Inngest infrastructure

Remove the `inngest` client, the `serve()` route, event schemas, and the Inngest Dev Server from the app. Verify the run in `npx workflow web` before shipping.

## Features without a 1:1 equivalent

- **Cron / scheduled functions (`triggers: { cron: '...' }`).** The SDK has no built-in scheduler. Trigger runs from Vercel Cron or a system cron calling `start()` from an API route.
- **Concurrency, throttling, rate limiting, debounce, singleton, priority, and `batchEvents`.** These function-level settings have no direct analog. Enforce limits inside steps (semaphores, external rate-limiter service) or debounce at the publisher before calling `start()`.
- **`EventSchemas` / typed events.** The event-bus indirection goes away; publishers import the workflow function directly, giving the same type safety through a different mechanism.
- **Event-bus fan-out by name match.** `inngest.send()` that triggered multiple functions by event name must be replaced by explicit `start()` calls for each target workflow.

## Quick-start checklist

- Replace `inngest.createFunction()` with a `"use workflow"` function; launch it with `start()`.
- Convert each `step.run()` callback into a named `"use step"` function.
- Swap `step.sleep()` / `step.sleepUntil()` for `sleep()` from `workflow`.
- Swap `step.waitForEvent()` for `createHook()` (internal) or `createWebhook()` (HTTP).
- Model `waitForEvent` timeouts as `Promise.race()` between the hook and `sleep()`.
- Replace `step.invoke()` with direct `start()` calls and a `"use step"` wrapper around `getRun()` when collecting results.
- Replace `step.sendEvent()` fan-out with explicit `start()` calls.
- Remove the Inngest client, `serve()` handler, and event definitions.
- Push retry configuration down to step boundaries via `maxRetries`, `RetryableError`, and `FatalError`.
- Use `getStepMetadata().stepId` as the idempotency key for external side effects.
- Replace `step.realtime.publish()` with `getWritable()`.
- Deploy and verify end-to-end with the built-in observability UI.

---

*Verified against `workflow@5.0.0-beta.1` and Inngest TypeScript SDK v4 on 2026-04-16.*
