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.

Note

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_event with actor and timestamp. Know who changed what and when, without digging through Vercel's team activity log.
  • Rotation without redeployPATCH /v1/secrets/:name re-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

ConceptVercel / Railway env varsRotor secrets vault
StoragePlatform UI or .env fileEncrypted at rest, AES-256-GCM
Referenceprocess.env.MY_KEY${{ secrets.MY_KEY }}
RotationEdit in dashboard, redeployPATCH /v1/secrets/:name, no redeploy
AuditNone (or Vercel team log)audit_event rows on every create / rotate / delete / access
ScopePer projectPer workspace (team-shared)
ResolutionAt app boot / request timeAt 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.

Note

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");
}

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

  1. Create the secret — use the dashboard at /dashboard/settings/secrets or 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":"..."}
  2. Update your queue's callback URL or callback_headers to 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 }}"}'
  3. 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.

  4. Remove the env var from Vercel/Railway — once you've confirmed delivery works end-to-end.

  5. 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.

Warning

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

FieldDetails
Secret name format[A-Z][A-Z0-9_]* (uppercase, underscores, digits — no lowercase)
Surfaces supportedCallback URL, callback headers, job payload fields, workflow step inputs
Resolution timingDispatch-time — Rotor substitutes plaintext before sending; template string stored in Postgres/Redis
Audit trailEvery create / rotate / delete / access logged to audit_event with resource_type = 'secret'
RotationPATCH /v1/secrets/:name with {"value":"new-value"} — zero-downtime, no redeploy
ListingGET /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

  1. Dashboard secrets page — create, rotate, and delete secrets in the UI.
  2. /v1/secrets API reference — full CRUD: create, list, rotate (PATCH), delete.
  3. Audit log API — query audit_event for secret access history.
  4. Start a Pro trial — 14 days, no credit card.