TL;DR

Inngest serves HTTP events; Rotor's HTTP callback mode (new in Phase 3) maps 1:1 to your existing handler code. Migration is a handler URL swap and a config copy — your Inngest handlers are already Rotor-compatible HTTP routes. No SDK rewrite, no worker container, no runtime change.

At 1M runs/mo, Rotor Team costs $99 flat; Inngest requires Enterprise pricing.

Note

Rotor is the right fit if you like Inngest's HTTP-function ergonomics but want flat per-workspace pricing instead of per-execution usage metering. It is not a replacement for multi-step Durable Execution (step.waitForEvent, step.invoke, cross-step observability). See What Rotor does NOT replace.

Pricing at a glance

PlanPriceExecutions / moRetentionNotes
Inngest Free$050k runsLow limits
Inngest Cloud$100/mo500k runsPer-execution meter
Inngest EnterpriseCustom1M+Required above Cloud limits
Rotor Free$010k7dUnlimited queues + schedules
Rotor Pro$19/mo100k30d14-day trial, no card
Rotor Team$99/mo1M90dFlat — no overages
Rotor EnterpriseCustomCustom365dManaged workers + SOC 2 + SSO
Tip

100k runs/mo: Inngest Cloud $100 vs Rotor Pro $19 → 5.3x cheaper. 1M runs/mo: Inngest requires Enterprise (unknown pricing); Rotor Team is $99 flat. Competitor pricing was verified against inngest.com/pricing on the publish date of this guide — re-check before making a purchasing decision.

Architecture mapping

Inngest conceptRotor equivalentMigration notes
inngest.createFunction({id, event}, handler)Queue + callback_urlPOST /v1/queues then PATCH the queue with your handler URL
inngest.send({name, data})POST /v1/queues/:name/jobs1:1 — same enqueue-from-API pattern
step.run("name", async () => ...)Idempotent handler + BullMQ attempts + backoffRotor has one handler per queue; split steps into separate queues if you need independent retry budgets
step.sleep("1h")Delayed enqueue (delay: 3600 on the job)Same outcome — job becomes eligible after delay
step.waitForEvent(...)Approval queue (Phase 2 — see docs)Partial match: approval queues pause until approved/rejected; not a general "wait for any event" primitive
createFunction({cron})POST /v1/schedulesCron syntax is the same; Rotor requires a timezone field
Dashboard event historyJob history archive (Phase 3)7–365 days retention by plan

Before / After

// inngest/functions.ts
import { inngest } from "./client";
 
export const welcomeEmail = inngest.createFunction(
  { id: "welcome-email" },
  { event: "user/created" },
  async ({ event, step }) => {
    await step.run("send-email", async () => {
      return sendEmail({
        to: event.data.email,
        template: "welcome",
      });
    });
  }
);
curl -X POST https://api.rotor.sh/v1/queues \
  -H "Authorization: Bearer $ROTOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"welcome-email"}'
 
# Attach your handler URL (paid plans only)
curl -X PATCH https://api.rotor.sh/v1/queues/welcome-email \
  -H "Authorization: Bearer $ROTOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://your-app.example.com/api/rotor/welcome-email",
    "rotate_callback_secret": true
  }'
# Response includes callback_secret once — save it to your env var.

Signature verification

Every callback from Rotor includes three headers you must verify:

HeaderPurpose
X-Rotor-Job-IdUnique job id — use as idempotency / dedup key
X-Rotor-AttemptRetry attempt counter (1 on first delivery)
X-Rotor-SignatureSvix-compatible HMAC-SHA256 signature of the body

The signature format is identical to the webhook signing format — see Webhook Signature Verification for Node, Python, Go, and Ruby examples. The only difference is the header names (x-rotor-* instead of rotor-webhook-*).

import { createHmac, timingSafeEqual } from "node:crypto";
 
function verifyRotorSignature(
  headers: Headers,
  body: string,
): boolean {
  const id = headers.get("x-rotor-job-id") ?? "";
  const ts = headers.get("x-rotor-timestamp") ?? "";
  const sig = headers.get("x-rotor-signature") ?? "";
  if (!id || !ts || !sig) return false;
 
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
 
  const expected = `v1,${createHmac("sha256", process.env.ROTOR_CALLBACK_SECRET!)
    .update(`${id}.${ts}.${body}`)
    .digest("base64")}`;
 
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && timingSafeEqual(a, b);
}

Retry semantics

Rotor uses BullMQ-native retry counts + exponential backoff. Where Inngest retries individual steps, Rotor retries the entire handler invocation.

BehaviorInngestRotor
Default attempts4 per step3 per job (configurable per queue)
BackoffExponential (step-level)Exponential (job-level)
On exhaustionMarked failed; visible in dashboardMoved to workspace DLQ — retry via POST /v1/queues/:name/retry-all
Partial progressEach step independently retriedHandler re-runs from the top — must be idempotent
Delivery semanticsAt-least-onceAt-least-once

Configure per-queue retry policy at queue creation time:

{
  "name": "welcome-email",
  "defaultJobOptions": {
    "attempts": 5,
    "backoff": { "type": "exponential", "delay": 2000 }
  }
}

Idempotency — read this

Warning

At-least-once delivery means your handler MUST be idempotent. Use X-Rotor-Job-Id as a dedup key on every side-effect. The header is stable across all retry attempts of the same job.

// Wrong — will send multiple emails on retry
await sendEmail(event.data);
 
// Right — idempotent via unique DB constraint on job_id
await db.outreach.upsert({
  where: { rotorJobId: jobId },
  create: { rotorJobId: jobId, sentAt: new Date(), ... },
  update: {}, // no-op if already sent
});
if (!alreadySent) await sendEmail(event.data);

This is the same rule Inngest imposes — it's just that Rotor makes it explicit.

Automated import

We ship a small script that statically analyzes your Inngest function definitions and emits the equivalent Rotor configuration as JSON-lines, one object per queue/schedule. Pipe the output to jq or save it and replay with curl / rotor queue create.

# Download the script (no install needed)
npx tsx https://rotor.sh/docs/scripts/import-from-inngest.ts ./inngest/functions.ts
# or run locally:
npx tsx docs/scripts/import-from-inngest.ts ./inngest/functions.ts
 
# Example output:
# {"type":"queue","config":{"name":"welcome-email","notes":"event: user/created"}}
# {"type":"schedule","config":{"queue":"nightly-digest","cron":"0 2 * * *","timezone":"UTC"}}

The script:

  • Walks the directory you point it at looking for .ts / .js / .tsx files
  • Greps for inngest.createFunction({ id: ..., event: ... }) and cron: ... shapes
  • Emits { type: "queue" | "schedule", config: {...} } per line
  • Prints warnings (to stderr) for shapes it couldn't parse — you handle those manually

The output is diagnostic — we never modify your code. Paste the queue JSONs into a bootstrap script or run them through curl to provision Rotor.

What Rotor does NOT replace

Be honest with yourself about which Inngest features you actually use:

  • Multi-step observability (step-level timing, step-level retries, branching). Rotor treats each handler as one atomic invocation — if you need step-level visibility, Rotor is not the right fit.
  • step.waitForEvent(...) — general "wait for any event" coordination. Rotor's approval queues solve the "human-in-the-loop pause" subset, but not arbitrary event coordination.
  • step.invoke(otherFunction) — cross-function fan-out with result composition. In Rotor you do this by enqueueing jobs to downstream queues and correlating on your own.
  • Durable, pausable function state across days/weeks — that's Temporal's domain.

If you are using Inngest primarily as "a queue with good ergonomics + webhook handlers + cron" → Rotor is a perfect fit and you'll save money. If you've built multi-step durable workflows with event-based coordination → evaluate Temporal or stay on Inngest.

Next steps

  1. Start a Pro trial — 14 days, no credit card.
  2. Install the SDK: pnpm add @rotorsh/sdk — see the Node.js quickstart.
  3. Join the Anthropic Discord#rotor channel for migration help.

Migrating step.invoke / step.sendEvent / step.waitForSignal

Rotor ships native equivalents for all three Inngest durable-execution primitives. The APIs are intentionally similar — most migrations are a rename and a timeout addition.

step.invoke

import { inngest } from './inngest';
 
export const orchestrator = inngest.createFunction(
  { id: 'orchestrator' },
  { event: 'campaign/requested' },
  async ({ event, step }) => {
    // Inngest: timeout is optional — omitting it blocks forever (billing bug)
    const result = await step.invoke('enrich-contact', {
      function: contactEnricher,
      data: { contactId: event.data.contactId },
    });
    return result;
  }
);

step.sendEvent

// Inngest: step.sendEvent uses 'at' (ISO string) for future scheduling
await step.sendEvent('spawn-sub-agent', {
  name: 'agent/sub-agent.spawn',
  data: { contactId: '123' },
  ts: new Date('2026-05-01T09:00:00Z'), // Date object or ISO string
});

step.waitForSignal

Inngest does not have a direct step.waitForSignal equivalent. The closest Inngest pattern uses cancelOn matchers to respond to specific events — a fundamentally different model (event-bus broadcast vs. single-run named signal).

// Inngest: no step.waitForSignal — closest pattern is cancelOn event matcher
// This CANCELS the run (not a pause/resume pattern)
export const withCancelOn = inngest.createFunction(
  {
    id: 'approval-workflow',
    cancelOn: [{ event: 'approval/rejected', match: 'data.runId' }],
  },
  { event: 'approval/requested' },
  async ({ event, step }) => {
    // If 'approval/rejected' fires with matching runId, the whole run is cancelled
    // No way to "wait for approval and branch on it" without external state
    await step.sleep('wait', '48h');
  }
);

API differences table

AspectInngestRotor Phase 10+
step.invoketimeout paramOptional; omitting blocks forever (no cap)REQUIRED; TypeScript build error if missing; max 24h
step.invoke — target referenceFunction reference (function: myFn)Workflow ID string (workflow: { id: 'my-workflow' })
step.invoke — return shapeBare result R{ result: R; runId: string }runId included for observability
Parent-side timeout cancels childN/A (no timeout concept)NO — child runs independently; parent step throws WorkflowTimeoutError
Child cancellation surfaces to parentYES — WorkflowCancelledErrorYES — WorkflowCancelledError (same behavior)
step.sendEvent — future schedulingts: Date or ISO stringts: number — Unix timestamp in milliseconds
step.waitForSignalDoes not existYES — signal is workspace-scoped unique ID; resume via POST /v1/signals/:id/complete
Signal resume authN/Art_ws_ API key scoped to the workspace owning the signal
Cross-workspace signal resumeN/AReturns 404 (not 401) — existence is opaque across workspace boundaries
Concurrency keysShipped (concurrency: { key, limit })Shipped — set concurrencyKey: string on enqueue; see Concurrency Keys
cancelOn configShippedE2 STUB DELETED in Phase 10 — Wave 2 reintroduces with real enforcement
Idempotent triggers✓ (idempotency field on inngest.send)✓ (idempotencyKey on rotor.workflow.trigger)
onFailure hook
onSuccess hook
onCancel hookpartial
onStartAttempt / onWait / onResume / catchError— (deferred)

Key migration callouts

1. Required timeout on step.invoke

Add timeout to every step.invoke call. Omitting it is a TypeScript compile error — the build will fail and point at the missing field. Start with "5m" and increase only when you have a concrete SLA.

2. Parent-side timeout does not cancel the child

When WorkflowTimeoutError fires on the parent, the child workflow keeps running independently. This matches "I'm done waiting, but don't waste the work" semantics. If you want to cancel the child on timeout, you must call the Rotor cancel API manually using the runId captured before the timeout.

3. step.waitForSignal is a new mental model — not cancelOn

Inngest's cancelOn terminates the run on a matching event. Rotor's step.waitForSignal pauses the run and resumes it with the signal payload as the step result. You can branch on the result, catch SignalTimeoutError for fallback logic, or rethrow — the run stays in your control.

For more detail: step.invoke deep dive · step.sendEvent deep dive · step.waitForSignal deep dive


Idempotency keys

Inngest's inngest.send({ name, data, idempotency }) returns the same event ID for duplicate calls within a configured window.

Rotor's equivalent: rotor.workflow.trigger(name, data, { idempotencyKey }). Same call site, same intent, same dedup behavior — the key is scoped per (workspace, function) so the same Stripe webhook ID won't dedup against itself across two unrelated workflows.

await inngest.send({
  name: "stripe/charge.succeeded",
  data: { chargeId: charge.id },
  idempotency: `stripe-${event.id}`,
});

What you get:

  • Same idempotencyKey twice → second call returns { duplicate: true, runIds: [...] } with the same run ID.
  • Different idempotencyKey (or none) → fresh run.
  • Workspace at quota → 402 still wins. A duplicate webhook from a workspace at the Hobby cap gets the 402 response, not a free duplicate-hit pass. (Per pricing.md.)

Differences from Inngest:

  • Rotor's idempotency window is "forever" — the unique constraint is on a Postgres column, not a 24h Redis cache. Storage is bounded only by row retention (purged at the same rates as workflow_runs).
  • The dedup happens at the workflow level, not the global event-bus level. Same key under two different workflows fires two distinct runs (intended — they have different side effects).

Lifecycle hooks

Inngest exposes per-function onFailure (and a half-dozen others). Rotor ships three: onSuccess, onFailure, onCancel.

inngest.createFunction(
  { id: "send-receipt", name: "Send Receipt" },
  { event: "order/placed" },
  async ({ event, step }) => { /* ... */ },
);
inngest.createFunction(
  { id: "send-receipt-failure-handler" },
  { event: "inngest/function.failed", if: "event.data.function_id == 'send-receipt'" },
  async ({ event }) => { /* send Slack alert */ },
);

Locked semantics (matching Inngest where applicable):

  • onSuccess fires only on FINAL completion (after retries succeed). A workflow that fails twice then succeeds → onSuccess fires once.
  • onFailure fires only after retries are exhausted. Per-attempt failures do NOT fire onFailure.
  • onCancel fires when the run is cancelled mid-execution (running | sleeping | waitingcancelled). It does NOT fire if the run was queued (pending) and never started — same as Inngest.
  • Hooks are fire-and-forget. Errors thrown inside a hook are logged to your audit_event channel (kind=workflow.hook.errored) and visible at GET /v1/runs/:id under hookErrors[] — but do NOT change the run's terminal status. A workflow that succeeded with a failing onSuccess still shows status: completed.
  • Hooks have a 30s soft timeout. Hooks running longer trigger an audit event (workflow.hook.slow) but are NOT killed — handle long work via step.sendEvent from inside steps instead.

Hooks Rotor does NOT ship (yet): onStartAttempt, onWait, onResume, onComplete, catchError. Trigger.dev has these. Rotor doesn't, by design — three hooks cover 90% of buyer needs and we won't bloat the surface to mimic competitor feature counts. If you have a specific need, open an issue.


Side-by-side: Inngest vs Rotor createFunction

import { inngest } from './inngest';
 
export const welcomeEmail = inngest.createFunction(
  { id: 'welcome-email' },
  { event: 'user/created' },
  async ({ event, step }) => {
    const profile = await step.run('fetch-profile', async () => {
      return fetchProfile(event.data.userId);
    });
 
    await step.sleep('wait-1-day', '1d');
 
    await step.run('send-email', async () => {
      await sendEmail({ to: profile.email });
    });
  }
);

Key differences

FeatureInngestRotor
Event name formatuser/created (slash)user.created (dot)
Serve adapterserve() (conflicts with Node.js)serveWorkflow() (no naming conflict)
Pricing$100/mo for 500k steps$19/mo Pro for 100k jobs (5.3x cheaper)
State storageInngest cloudYour Postgres (you own the data)
Open sourceServer is closedrotor-core is MIT
Step graphInngest Cloud UI/dashboard/runs/:id on your domain

Migrating serve()

// Inngest
import { serve } from 'inngest/hono';
app.use('/api/inngest', serve({ client: inngest, functions: [myFn] }));
 
// Rotor — same pattern, different import
import { serveWorkflow } from '@rotorsh/sdk';
serveWorkflow(app, { functions: [myFn], signingKey: process.env.ROTOR_SIGNING_KEY! });

Migrating event publishing

// Inngest
await inngest.send({ name: 'user/created', data: { userId: '123' } });
 
// Rotor
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
await rotor.send('user.created', { userId: '123' });