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.
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
| Plan | Price | Executions / mo | Retention | Notes |
|---|---|---|---|---|
| Inngest Free | $0 | 50k runs | — | Low limits |
| Inngest Cloud | $100/mo | 500k runs | — | Per-execution meter |
| Inngest Enterprise | Custom | 1M+ | — | Required above Cloud limits |
| Rotor Free | $0 | 10k | 7d | Unlimited queues + schedules |
| Rotor Pro | $19/mo | 100k | 30d | 14-day trial, no card |
| Rotor Team | $99/mo | 1M | 90d | Flat — no overages |
| Rotor Enterprise | Custom | Custom | 365d | Managed workers + SOC 2 + SSO |
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 concept | Rotor equivalent | Migration notes |
|---|---|---|
inngest.createFunction({id, event}, handler) | Queue + callback_url | POST /v1/queues then PATCH the queue with your handler URL |
inngest.send({name, data}) | POST /v1/queues/:name/jobs | 1:1 — same enqueue-from-API pattern |
step.run("name", async () => ...) | Idempotent handler + BullMQ attempts + backoff | Rotor 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/schedules | Cron syntax is the same; Rotor requires a timezone field |
| Dashboard event history | Job 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",
});
});
}
);// app/api/rotor/welcome-email/route.ts (your existing handler)
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const body = await req.text();
if (!verifyRotorSignature(req.headers, body)) {
return new Response("unauthorized", { status: 401 });
}
const job = JSON.parse(body);
const jobId = req.headers.get("x-rotor-job-id")!;
// Idempotent send keyed on the Rotor job id
await sendEmailOnce(jobId, {
to: job.payload.email,
template: "welcome",
});
return new Response("ok");
}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.# Was: inngest.send({ name: "user/created", data: { email, id } })
curl -X POST https://api.rotor.sh/v1/queues/welcome-email/jobs \
-H "Authorization: Bearer $ROTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"payload": {"email":"[email protected]","id":"u_1"}}'Signature verification
Every callback from Rotor includes three headers you must verify:
| Header | Purpose |
|---|---|
X-Rotor-Job-Id | Unique job id — use as idempotency / dedup key |
X-Rotor-Attempt | Retry attempt counter (1 on first delivery) |
X-Rotor-Signature | Svix-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);
}import hmac, hashlib, base64, time
def verify_rotor_signature(headers, body, secret):
jid = headers.get("x-rotor-job-id", "")
ts = headers.get("x-rotor-timestamp", "")
sig = headers.get("x-rotor-signature", "")
if not (jid and ts and sig): return False
if abs(time.time() - int(ts)) > 300: return False
digest = hmac.new(secret.encode(), f"{jid}.{ts}.{body}".encode(), hashlib.sha256).digest()
expected = f"v1,{base64.b64encode(digest).decode()}"
return hmac.compare_digest(expected, sig)import (
"crypto/hmac"; "crypto/sha256"; "encoding/base64"
"fmt"; "math"; "strconv"; "time"
)
func VerifyRotorSignature(headers map[string]string, body, secret string) bool {
jid, ts, sig := headers["x-rotor-job-id"], headers["x-rotor-timestamp"], headers["x-rotor-signature"]
if jid == "" || ts == "" || sig == "" { return false }
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-tsInt)) > 300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%s.%s.%s", jid, ts, body)))
expected := fmt.Sprintf("v1,%s", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
return hmac.Equal([]byte(expected), []byte(sig))
}Retry semantics
Rotor uses BullMQ-native retry counts + exponential backoff. Where Inngest retries individual steps, Rotor retries the entire handler invocation.
| Behavior | Inngest | Rotor |
|---|---|---|
| Default attempts | 4 per step | 3 per job (configurable per queue) |
| Backoff | Exponential (step-level) | Exponential (job-level) |
| On exhaustion | Marked failed; visible in dashboard | Moved to workspace DLQ — retry via POST /v1/queues/:name/retry-all |
| Partial progress | Each step independently retried | Handler re-runs from the top — must be idempotent |
| Delivery semantics | At-least-once | At-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
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/.tsxfiles - Greps for
inngest.createFunction({ id: ..., event: ... })andcron: ...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
- Start a Pro trial — 14 days, no credit card.
- Install the SDK:
pnpm add @rotorsh/sdk— see the Node.js quickstart. - Join the Anthropic Discord —
#rotorchannel 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;
}
);import { createFunction } from '@rotorsh/sdk';
import { WorkflowFailedError, WorkflowTimeoutError } from '@rotor/sdk/errors';
export const orchestrator = createFunction(
{ id: 'orchestrator', trigger: { event: 'campaign.requested' }, retries: 2 },
async ({ event, step }) => {
// Rotor: timeout is REQUIRED — TypeScript build fails without it (max 24h)
const { result } = await step.invoke<{ score: number }>('enrich-contact', {
workflow: { id: 'contact-enricher' }, // target by workflow id string, not function ref
data: { contactId: event.data.contactId },
timeout: '5m', // REQUIRED
});
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
});// Rotor: ts is Unix timestamp in milliseconds (not ISO string or Date object)
await step.sendEvent('spawn-sub-agent', {
name: 'agent/sub-agent.spawn',
data: { contactId: '123' },
ts: new Date('2026-05-01T09:00:00Z').getTime(), // convert Date to Unix ms
});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');
}
);// Rotor: step.waitForSignal — pause/resume with typed return value + timeout
import { SignalTimeoutError } from '@rotor/sdk/errors';
export const approvalWorkflow = createFunction(
{ id: 'approval-workflow', trigger: { event: 'approval.requested' }, retries: 2 },
async ({ event, step, runId }) => {
const { approved, reason } = await step.waitForSignal<{ approved: boolean; reason?: string }>(
'wait-for-approval',
{
signal: `approval-${runId}`, // workspace-unique; POST to /v1/signals/:id/complete
timeout: '48h',
}
).catch((err) => {
if (err.name === 'SignalTimeoutError') return { approved: false, reason: 'Timed out' };
throw err;
});
return approved ? { status: 'approved' } : { status: 'rejected', reason };
}
);API differences table
| Aspect | Inngest | Rotor Phase 10+ |
|---|---|---|
step.invoke — timeout param | Optional; omitting blocks forever (no cap) | REQUIRED; TypeScript build error if missing; max 24h |
step.invoke — target reference | Function reference (function: myFn) | Workflow ID string (workflow: { id: 'my-workflow' }) |
step.invoke — return shape | Bare result R | { result: R; runId: string } — runId included for observability |
| Parent-side timeout cancels child | N/A (no timeout concept) | NO — child runs independently; parent step throws WorkflowTimeoutError |
| Child cancellation surfaces to parent | YES — WorkflowCancelledError | YES — WorkflowCancelledError (same behavior) |
step.sendEvent — future scheduling | ts: Date or ISO string | ts: number — Unix timestamp in milliseconds |
step.waitForSignal | Does not exist | YES — signal is workspace-scoped unique ID; resume via POST /v1/signals/:id/complete |
| Signal resume auth | N/A | rt_ws_ API key scoped to the workspace owning the signal |
| Cross-workspace signal resume | N/A | Returns 404 (not 401) — existence is opaque across workspace boundaries |
| Concurrency keys | Shipped (concurrency: { key, limit }) | Shipped — set concurrencyKey: string on enqueue; see Concurrency Keys |
cancelOn config | Shipped | E2 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 hook | partial | ✓ |
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}`,
});await rotor.workflow.trigger(
"stripe/charge.succeeded",
{ chargeId: charge.id },
{ idempotencyKey: `stripe-${event.id}` },
);What you get:
- Same
idempotencyKeytwice → 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 */ },
);const sendReceipt = workflow({
id: "send-receipt",
trigger: { event: "order/placed" },
onFailure: async ({ ctx, error }) => {
// Same Slack alert. ctx.runId, ctx.functionId, ctx.attempt available.
await slack.send({
channel: "#alerts",
text: `Receipt failed: ${error.message} (run ${ctx.runId})`,
});
},
steps: async ({ step }) => { /* ... */ },
});Locked semantics (matching Inngest where applicable):
onSuccessfires only on FINAL completion (after retries succeed). A workflow that fails twice then succeeds →onSuccessfires once.onFailurefires only after retries are exhausted. Per-attempt failures do NOT fireonFailure.onCancelfires when the run is cancelled mid-execution (running | sleeping | waiting→cancelled). 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 atGET /v1/runs/:idunderhookErrors[]— but do NOT change the run's terminal status. A workflow that succeeded with a failingonSuccessstill showsstatus: completed. - Hooks have a 30s soft timeout. Hooks running longer trigger an audit event (
workflow.hook.slow) but are NOT killed — handle long work viastep.sendEventfrom insidestepsinstead.
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 });
});
}
);import { createFunction } from '@rotorsh/sdk';
export const welcomeEmail = createFunction(
{
id: 'welcome-email',
trigger: { event: 'user.created' }, // dot-notation, not slash
retries: 3, // explicit retry count
},
async ({ event, step }) => {
const profile = await step.run('fetch-profile', async () => {
return fetchProfile(event.data.userId);
});
await step.sleep('wait-1-day', '1d'); // identical
await step.run('send-email', async () => {
await sendEmail({ to: profile.email });
});
}
);Key differences
| Feature | Inngest | Rotor |
|---|---|---|
| Event name format | user/created (slash) | user.created (dot) |
| Serve adapter | serve() (conflicts with Node.js) | serveWorkflow() (no naming conflict) |
| Pricing | $100/mo for 500k steps | $19/mo Pro for 100k jobs (5.3x cheaper) |
| State storage | Inngest cloud | Your Postgres (you own the data) |
| Open source | Server is closed | rotor-core is MIT |
| Step graph | Inngest 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' });