This quickstart is a strict, opinionated path. Every command works on macOS and Linux. Windows users: run from Git Bash or WSL.
Total wall-clock: ~20 minutes if you're new; under 10 minutes once you're warmed up.
Prerequisites
You need three things before starting:
- Node 18+ on your
PATH. Verify withnode --version. - A Rotor workspace API key starting with
rt_ws_. Sign up at rotor.sh/signup — your key is shown once after checkout. Treat it like a database password. - A server you can run on a public URL for step 4 (callback handler). Localhost works during development if you tunnel via ngrok / cloudflared / Tailscale Funnel.
Install the CLI
npm i -g @rotorsh/cli
rotor --version # confirm 0.1.x printsLogin
rotor login
# Paste your rt_ws_* key when prompted. Stored at ~/.rotor/config.json (mode 0600).You can also export the key as ROTOR_API_KEY in your shell — rotor commands prefer the env var over the saved config when both are present.
Create a queue
rotor queues create welcome-emails \
--concurrency 5 \
--retry-attempts 3The queue is now live and accepting jobs. Confirm with rotor queues list.
Create a callback handler
This is where most of the time goes. Rotor delivers each job as an HMAC-signed POST to the URL you configure on the queue. Your handler verifies the signature, runs your job logic, and returns a 2xx on success.
The simplest production-quality handler — Hono on Node, using verifyRotorSignature from the SDK:
npm i hono @rotorsh/sdk// server.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { verifyRotorSignature } from "@rotorsh/sdk";
const app = new Hono();
app.post("/rotor-callback", async (c) => {
const body = await c.req.text();
const ok = verifyRotorSignature(
body,
c.req.header("x-rotor-signature"),
process.env.ROTOR_CALLBACK_SECRET!,
{
id: c.req.header("x-rotor-job-id")!,
timestamp: Number(c.req.header("x-rotor-timestamp")),
maxAgeSeconds: 300,
},
);
if (!ok) return c.text("invalid signature", 401);
// Your job logic — keep it idempotent (BullMQ delivers at-least-once)
const { contactId, campaignId } = JSON.parse(body);
console.log(`Sending welcome email: contact=${contactId} campaign=${campaignId}`);
return c.json({ status: "ok" });
});
serve({ fetch: app.fetch, port: 3000 });
console.log("listening on :3000");# Run it
ROTOR_CALLBACK_SECRET=__placeholder__ npx tsx server.tsYou'll set the real secret in the next step. For local development, expose :3000 to the public internet via your tunnel of choice — cloudflared tunnel --url http://localhost:3000 is one option.
Idempotent handlers. BullMQ delivers jobs at-least-once. Use the x-rotor-job-id header as a dedup key (e.g., upsert on (workspace, jobId) in your DB) so a redelivery after a worker crash doesn't double-send.
Wire callback URL + secret into the queue
rotor queues update welcome-emails \
--callback-url https://your-tunnel.example.com/rotor-callback \
--rotate-callback-secret
# Output: New callback secret: <a long string starting with rt_cb_>
# Copy this — it's shown once.Set the printed secret as ROTOR_CALLBACK_SECRET in your server's environment and restart your handler.
Create a cron schedule
rotor schedules create \
--cron "*/2 * * * *" \
--timezone "UTC" \
--queue welcome-emails
# Fires every 2 minutes. Adjust to "0 9 * * 1-5" for 9am weekdays.Watch the first cron tick land
# Wait up to 2 minutes for the first cron tick
rotor schedules list
# Look for `last_fired_at` advancing within the last 2 minutes.
# Or watch live:
rotor status --watchWithin 2 minutes you should see your welcome-emails queue's depth advance and your callback handler's logs print the contact/campaign payload.