TL;DR
Vercel Cron triggers HTTP GETs (or POSTs) on a schedule. Rotor schedules + HTTP callback mode replace it — with no 1-cron-per-day Hobby cap and no per-project Pro ceiling. Your existing /api/cron/* handlers work as-is; Rotor becomes the scheduler that calls them.
At Rotor Free ($0) you get unlimited schedules against a 10k/mo execution budget — vs. Vercel Hobby's hard 1 cron per day limit.
Pricing at a glance
| Plan | Price | Schedule limits | Notes |
|---|---|---|---|
| Vercel Hobby | $0 | 1 cron per day | Daily granularity only |
| Vercel Pro | $20/mo | Every minute | Per-project Vercel plan cost |
| Rotor Free | $0 | Unlimited schedules | 10k executions/mo total |
| Rotor Pro | $19/mo | Unlimited schedules | 100k executions/mo (covers most cron use cases) |
| Rotor Team | $99/mo | Unlimited schedules | 1M executions/mo |
Key differentiator: Rotor Free lets you run 10,000 scheduled executions per month across as many schedules as you want. Vercel Hobby caps you at 1 cron per day regardless of execution volume. If you're hitting the Hobby cron cap, Rotor Free is a drop-in replacement at the same price ($0).
Competitor pricing was verified against vercel.com/pricing on the publish date of this guide. Vercel frequently adjusts their cron allowances — re-read their pricing page before making a purchasing decision.
Architecture mapping
| Vercel Cron concept | Rotor equivalent | Migration notes |
|---|---|---|
vercel.json crons[] entry | POST /v1/schedules | One schedule per entry |
Handler route (e.g. /api/cron/cleanup) | Queue with callback_url | Same handler — just attach its URL to a Rotor queue |
CRON_SECRET bearer header | X-Rotor-Signature HMAC verification | Swap the auth check; handler body stays the same |
| Default UTC | Explicit timezone required | Rotor schedules MUST declare a timezone — see below |
| Per-project isolation | Per-workspace isolation | Works across any deploy target (Vercel, Railway, Fly, self-hosted) |
No handler-side changes required: Vercel Cron already hits an HTTP route on your app. Rotor does the same thing — you just change what authenticates the incoming request (HMAC instead of bearer).
Before / After
{
"crons": [
{
"path": "/api/cron/cleanup",
"schedule": "0 */6 * * *"
},
{
"path": "/api/cron/daily-digest",
"schedule": "0 12 * * *"
}
]
}# 1. Create one queue per cron, pointing at your existing handler URL
curl -X POST https://api.rotor.sh/v1/queues \
-H "Authorization: Bearer $ROTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"cron-cleanup"}'
curl -X PATCH https://api.rotor.sh/v1/queues/cron-cleanup \
-H "Authorization: Bearer $ROTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"callback_url": "https://your-app.vercel.app/api/cron/cleanup",
"rotate_callback_secret": true
}'
# Response returns callback_secret ONCE — save to env var ROTOR_CALLBACK_SECRET.
# 2. Attach a schedule
curl -X POST https://api.rotor.sh/v1/schedules \
-H "Authorization: Bearer $ROTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queue": "cron-cleanup",
"cron": "0 */6 * * *",
"timezone": "UTC"
}'Delete the crons array from vercel.json once the Rotor schedule is live. Your handler route stays exactly where it is.
Timezone — read this first
Rotor schedules REQUIRE an explicit timezone parameter (API-12). Vercel
Cron defaults to UTC silently — when you migrate, decide what timezone
you want.
- If your existing cron was "run at 12:00 every day" and you intended that in
UTC, set
"timezone": "UTC". - If you intended local business hours (e.g. "noon eastern"), set
"timezone": "America/New_York"— Rotor will handle DST.
Pick wrong and your reports will fire an hour off twice a year.
Supported timezone values: any IANA zone name (e.g. UTC, America/New_York, Europe/London, Asia/Tokyo). See the full list at IANA tz database.
Signature verification
Vercel Cron authenticates callbacks via the CRON_SECRET env var + bearer header:
// Before: Vercel Cron handler
export async function GET(req: Request) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("unauthorized", { status: 401 });
}
// ...do cron work
}Rotor authenticates via HMAC signature on the request body — stronger because it binds the signature to the exact payload, preventing replay with altered bodies. See the full Webhook Signature Verification reference for Node, Python, Go, and Ruby.
// After: Rotor callback 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 jobId = req.headers.get("x-rotor-job-id");
// ...do cron work (same as before); use jobId for idempotency if needed
return new Response("ok");
}
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);
}Request method changes: Vercel Cron sends GET. Rotor sends POST with a JSON body (empty {} for schedules with no payload). Update your route handler from GET to POST — or accept both if you want to leave Vercel Cron running in parallel during migration.
Auth migration cheatsheet
| Check | Vercel Cron | Rotor callback |
|---|---|---|
| Header read | authorization: Bearer $CRON_SECRET | x-rotor-signature: v1,<base64> |
| Secret source | process.env.CRON_SECRET | process.env.ROTOR_CALLBACK_SECRET (minted once via PATCH /v1/queues/:name) |
| Replay window | None | ±300s (checked against x-rotor-timestamp) |
| Idempotency | — | x-rotor-job-id — stable across retries |
| Request method | GET | POST |
Automated import
We ship a small script that reads your vercel.json, extracts the crons[] array, and emits the Rotor queue + schedule configuration as JSON-lines.
# Run it
npx tsx docs/scripts/import-from-vercel-cron.ts ./vercel.jsonExample input and output:
// vercel.json
{
"crons": [
{ "path": "/api/cron/cleanup", "schedule": "0 */6 * * *" },
{ "path": "/api/cron/daily-digest", "schedule": "0 12 * * *" }
]
}// stdout — one object per line
{"type":"queue","config":{"name":"cron-cleanup","callback_url":"https://YOUR_DOMAIN/api/cron/cleanup"}}
{"type":"schedule","config":{"queue":"cron-cleanup","cron":"0 */6 * * *","timezone":"UTC"}}
{"type":"queue","config":{"name":"cron-daily-digest","callback_url":"https://YOUR_DOMAIN/api/cron/daily-digest"}}
{"type":"schedule","config":{"queue":"cron-daily-digest","cron":"0 12 * * *","timezone":"UTC"}}
After running the script:
- Search-and-replace
YOUR_DOMAINwith your actual production host (e.g.your-app.vercel.app). - Replay the
type:queuelines asPOST /v1/queues+PATCH .../callback_url. - Replay the
type:schedulelines asPOST /v1/schedules. - Verify one scheduled execution fires and hits your handler, then remove the
cronsarray fromvercel.json.
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.