TL;DR
If your Rotor jobs reference process.env.STRIPE_KEY or process.env.SLACK_TOKEN in callback URLs, headers, or payloads, you can move those into Rotor's secrets vault and reference them as ${{ secrets.STRIPE_KEY }} instead — gaining a centralized audit trail, rotation API, and team-shared access without redeploying your app.
This guide covers secrets used by Rotor jobs at dispatch time (callback URLs, callback headers, job payloads). It does NOT cover secrets your app reads at boot. See What stays in Vercel/Railway for the full scope boundary.
Why this matters
- Audit trail — every secret access is recorded in
audit_eventwith actor and timestamp. Know who changed what and when, without digging through Vercel's team activity log. - Rotation without redeploy —
PATCH /v1/secrets/:namere-encrypts the value. Queued jobs pick up the new plaintext at next dispatch. No redeploy, no downtime. - Team-shared — secrets are workspace-scoped. Teammates with workspace access read the same values without sharing your Railway or Vercel login credentials.
What stays in Vercel/Railway
Rotor secrets are resolved at job dispatch time. Anything your application reads outside of a Rotor job should stay where it is:
- Database connection strings (your app needs them at boot)
- Supabase service role key and anon key (read by your backend at startup)
- Auth provider secrets (NextAuth, Clerk, etc.)
- Anything consumed outside a Rotor callback handler
If you're not sure, ask: "Does Rotor need this value to dispatch a job?" YES → vault it. NO → leave it in Vercel/Railway.
Architecture mapping
| Concept | Vercel / Railway env vars | Rotor secrets vault |
|---|---|---|
| Storage | Platform UI or .env file | Encrypted at rest, AES-256-GCM |
| Reference | process.env.MY_KEY | ${{ secrets.MY_KEY }} |
| Rotation | Edit in dashboard, redeploy | PATCH /v1/secrets/:name, no redeploy |
| Audit | None (or Vercel team log) | audit_event rows on every create / rotate / delete / access |
| Scope | Per project | Per workspace (team-shared) |
| Resolution | At app boot / request time | At job dispatch time (plaintext never in BullMQ or Postgres) |
Before / After
The common pattern: your callback handler reads process.env.NOTIFY_TOKEN to authenticate outbound requests. After migration, Rotor injects the token directly into the callback URL or header — your handler no longer needs the env var.
ROTOR_CALLBACK_SECRET stays in your env. That secret verifies inbound
requests from Rotor to your handler. Rotor can't inject it into itself. Only
secrets that Rotor sends outbound (in callback URLs, headers, or payloads)
belong in the vault.
// your-app/api/rotor-callback.ts
export async function POST(req: Request) {
const auth = req.headers.get("authorization");
// ROTOR_CALLBACK_SECRET stays in your env — it verifies inbound Rotor requests
if (auth !== `Bearer ${process.env.ROTOR_CALLBACK_SECRET}`) {
return new Response("unauthorized", { status: 401 });
}
const job = await req.json();
// NOTIFY_TOKEN used to authenticate outbound request — this moves to Rotor vault
await fetch(
`https://api.example.com/notify?token=${process.env.NOTIFY_TOKEN}`,
{ method: "POST", body: JSON.stringify(job) }
);
return new Response("ok");
}// 1. Store the secret once:
// POST /v1/secrets {"name":"NOTIFY_TOKEN","value":"abc123..."}
// 2. Configure your queue's callback URL to interpolate the secret:
// PATCH /v1/queues/notifications {
// "callback_url": "https://api.example.com/notify?token=${{ secrets.NOTIFY_TOKEN }}"
// }
// Rotor resolves ${{ secrets.NOTIFY_TOKEN }} at dispatch — plaintext never
// persists in Redis or Postgres.
// 3. Your callback handler no longer needs NOTIFY_TOKEN:
export async function POST(req: Request) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.ROTOR_CALLBACK_SECRET}`) {
return new Response("unauthorized", { status: 401 });
}
// The token is already in the URL Rotor called — handler doesn't need it.
return new Response("ok");
}Using secrets in callback headers
If your downstream API expects the token as a header rather than a query parameter:
curl -X PATCH https://api.rotor.sh/v1/queues/notifications \
-H "Authorization: Bearer $ROTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"callback_headers": {
"X-API-Key": "${{ secrets.NOTIFY_TOKEN }}"
}
}'Rotor resolves the template at dispatch and forwards the plaintext header value to your URL. The template string ${{ secrets.NOTIFY_TOKEN }} is what gets stored in Postgres — never the plaintext.
Migration steps
-
Create the secret — use the dashboard at
/dashboard/settings/secretsor the API:curl -X POST https://api.rotor.sh/v1/secrets \ -H "Authorization: Bearer $ROTOR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"NOTIFY_TOKEN","value":"your-token-here"}' # Response: {"name":"NOTIFY_TOKEN","value_hint":"your-t...","created_at":"..."} -
Update your queue's callback URL or
callback_headersto reference${{ secrets.YOUR_KEY }}:curl -X PATCH https://api.rotor.sh/v1/queues/notifications \ -H "Authorization: Bearer $ROTOR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"callback_url":"https://api.example.com/notify?token=${{ secrets.NOTIFY_TOKEN }}"}' -
Verify with a test job — enqueue a job and check that your downstream endpoint received the resolved token (not the template string) in the URL or header.
-
Remove the env var from Vercel/Railway — once you've confirmed delivery works end-to-end.
-
Redeploy if needed — only if the env var was previously read at app boot. For most callback-token use cases (env var only needed in outbound calls), no redeploy is required.
Run a 24-hour observation window before removing the env var. Confirm zero callback failures with the new vault-sourced value before deleting the original env var from Railway or Vercel. If Rotor's resolution fails, you can roll back instantly by re-adding the env var.
Bulk migration script
The script below reads env var names from your environment (or from .env), filters the ones you want to migrate, and POSTs each to /v1/secrets. Review the list before running — it only creates secrets you explicitly include.
#!/usr/bin/env node
// scripts/migrate-to-rotor-vault.ts
// Usage: ROTOR_API_KEY=xxx npx tsx scripts/migrate-to-rotor-vault.ts
// Add the env var names you want to migrate to KEYS_TO_MIGRATE below.
const ROTOR_API_URL = process.env.ROTOR_API_URL ?? "https://api.rotor.sh";
const ROTOR_API_KEY = process.env.ROTOR_API_KEY ?? "";
const KEYS_TO_MIGRATE = [
"NOTIFY_TOKEN",
"SLACK_BOT_TOKEN",
"STRIPE_WEBHOOK_SECRET",
// add more...
];
if (!ROTOR_API_KEY) {
console.error("ROTOR_API_KEY is required");
process.exit(1);
}
for (const name of KEYS_TO_MIGRATE) {
const value = process.env[name];
if (!value) {
console.warn(`[skip] ${name} — not set in current env`);
continue;
}
const res = await fetch(`${ROTOR_API_URL}/v1/secrets`, {
method: "POST",
headers: {
"Authorization": `Bearer ${ROTOR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name, value }),
});
if (res.status === 201) {
const data = (await res.json()) as { value_hint: string };
console.log(`[ok] ${name} → hint: ${data.value_hint}`);
} else if (res.status === 409) {
console.log(`[skip] ${name} — already exists in vault`);
} else {
const text = await res.text();
console.error(`[fail] ${name} — HTTP ${res.status}: ${text}`);
process.exit(1);
}
}
console.log("\nNext step: update your queue callback_url / callback_headers");
console.log(' PATCH /v1/queues/<queue> {"callback_headers":{"X-Token":"${{ secrets.YOUR_KEY }}"}}');The script never logs plaintext values — it only prints the value_hint returned by the API (first 8 characters + ...).
Reference values
| Field | Details |
|---|---|
| Secret name format | [A-Z][A-Z0-9_]* (uppercase, underscores, digits — no lowercase) |
| Surfaces supported | Callback URL, callback headers, job payload fields, workflow step inputs |
| Resolution timing | Dispatch-time — Rotor substitutes plaintext before sending; template string stored in Postgres/Redis |
| Audit trail | Every create / rotate / delete / access logged to audit_event with resource_type = 'secret' |
| Rotation | PATCH /v1/secrets/:name with {"value":"new-value"} — zero-downtime, no redeploy |
| Listing | GET /v1/secrets returns names + hints, never plaintext |
Audit verification
After migrating, confirm the audit trail is clean:
-- No plaintext values should appear in input/output blobs
SELECT event_type, occurred_at, actor_id
FROM audit_event
WHERE resource_type = 'secret'
ORDER BY occurred_at DESC
LIMIT 10;Expected: secret.created rows for each migrated key. Confirm the input and output columns do not contain the raw secret values.
Next steps
- Dashboard secrets page — create, rotate, and delete secrets in the UI.
- /v1/secrets API reference — full CRUD: create, list, rotate (
PATCH), delete. - Audit log API — query
audit_eventfor secret access history. - Start a Pro trial — 14 days, no credit card.