Playbook: Resend API Key Rotation
Service: Resend (https://resend.com) — transactional email
Secrets: RADEK_BRAND_RESEND_API_KEY, RESEND_API_KEY
SOPS files: secrets/monitoring.env.sops, secrets/brandpilot.env.sops
Rotation frequency: 90 days (or immediately on suspected exposure)
Last rotated: 2026-06-27
Next due: 2026-09-27
Token inventory
| Key name | SOPS file | Consumer |
|---|---|---|
RADEK_BRAND_RESEND_API_KEY | monitoring.env.sops | n8n workflows for radekkonarski-personal-brand email sending |
RESEND_API_KEY | brandpilot.env.sops | BrandPilot app — transactional emails (welcome, notifications) |
These may be the same Resend account but different API keys, or different keys with different scopes. Check the Resend dashboard for existing keys before rotating. Rotate each one separately.
Where stored
| Location | Key name | How to update |
|---|---|---|
secrets/monitoring.env.sops | RADEK_BRAND_RESEND_API_KEY | SOPS write pattern |
secrets/brandpilot.env.sops | RESEND_API_KEY | SOPS write pattern |
| Vercel env vars (BrandPilot project) | RESEND_API_KEY | vercel env rm RESEND_API_KEY production && vercel env add or dashboard |
Login credentials
- URL:
https://resend.com/api-keys - Email: radieu@gmail.com
- Auth: Google SSO or email/password
Automation status
Playwright-automatable — Resend API key management is accessible via web UI with no additional MFA.
Rotation steps
Option A — Playwright agent (preferred)
You are rotating Resend API keys: RADEK_BRAND_RESEND_API_KEY and RESEND_API_KEY.
IMPORTANT: Never display any secret value in your response. Reference key names only.
1. Use Playwright to navigate to https://resend.com/api-keys
- Sign in with Google using radieu@gmail.com
- For RADEK_BRAND_RESEND_API_KEY:
→ Find the key named "radek-brand" or similar → Delete it
→ Create API Key → Name: "radek-brand-<YYYY-MM-DD>" → Full Access or Sending Access
→ Copy value into $env:NEW_BRAND_KEY (never print)
- For RESEND_API_KEY:
→ Find the key named "brandpilot" or similar → Delete it
→ Create API Key → Name: "brandpilot-<YYYY-MM-DD>" → Full Access or Sending Access
→ Copy value into $env:NEW_BP_KEY (never print)
- Close the browser
2. Update secrets/monitoring.env.sops (RADEK_BRAND_RESEND_API_KEY):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$temp = "$PWD\secrets\monitoring-edit.env.sops"
$updated = $plain -replace "^RADEK_BRAND_RESEND_API_KEY=.*", "RADEK_BRAND_RESEND_API_KEY=$env:NEW_BRAND_KEY"
[System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
[System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
[System.IO.File]::Delete($temp)
3. Update secrets/brandpilot.env.sops (RESEND_API_KEY):
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops
$temp = "$PWD\secrets\brandpilot-edit.env.sops"
$updated = $plain -replace "^RESEND_API_KEY=.*", "RESEND_API_KEY=$env:NEW_BP_KEY"
[System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
[System.IO.File]::WriteAllText("$PWD\secrets\brandpilot.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
[System.IO.File]::Delete($temp)
$env:NEW_BRAND_KEY = ""; $env:NEW_BP_KEY = ""
4. Update Vercel env var for BrandPilot (RESEND_API_KEY):
Run secrets-sync.yml via GH Actions (triggers automatically on merge to dev/main),
OR update manually via Vercel dashboard:
https://vercel.com/radieu/brandpilot/settings/environment-variables
5. Commit both SOPS files in one PR:
git checkout -b fix/rotate-resend-api-keys origin/main
git add secrets/monitoring.env.sops secrets/brandpilot.env.sops
git commit -m "chore: rotate RESEND_API_KEY + RADEK_BRAND_RESEND_API_KEY (scheduled 90d)"
git push -u origin fix/rotate-resend-api-keys
gh pr create --base main --title "chore: rotate Resend API keys"
gh pr merge --merge --delete-branch
6. Append to docs/secrets-rotation-log.md:
| <YYYY-MM-DD> | RESEND_API_KEY + RADEK_BRAND_RESEND_API_KEY | scheduled 90d rotation | AI-agent | SOPS (monitoring + brandpilot) + Vercel |
Option B — Manual (fallback)
Step 1 — Create new keys in Resend dashboard
URL: https://resend.com/api-keys
→ Sign in → for each key:
Delete old → Add API Key → name + scope → copy value (shown once, starts with re_)
Step 2 — Store temporarily in .env.local then hand off to Claude:
Add to d:\code_2026\p24-infra\.env.local:
RADEK_BRAND_RESEND_API_KEY_NEW=<value>
RESEND_API_KEY_NEW=<value>
Tell Claude "Resend keys updated in .env.local" — Claude handles SOPS + PR.
Step 3 — Clear .env.local lines after PR merged
Remove _NEW lines from .env.local
Verification
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# 1. Check lengths in SOPS
$b = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
Select-String "^RADEK_BRAND_RESEND_API_KEY="
Write-Host "RADEK_BRAND_RESEND_API_KEY length: $($b.ToString().Split('=',2)[1].Length)" # expected: > 20
$r = sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops |
Select-String "^RESEND_API_KEY="
Write-Host "RESEND_API_KEY length: $($r.ToString().Split('=',2)[1].Length)" # expected: > 20
# 2. Quick API check
$env:K = $r.ToString().Split('=',2)[1]
$resp = Invoke-RestMethod "https://api.resend.com/domains" `
-Headers @{ Authorization = "Bearer $env:K" }
Write-Host "Resend domains: $($resp.data.Count)"
$env:K = ""; $env:SOPS_AGE_KEY_FILE = ""Escalation
| Symptom | Action |
|---|---|
| Resend login fails | Try email/password login if Google SSO fails |
| BrandPilot emails stop after rotation | Check Vercel env var was updated; redeploy if needed |
| n8n email workflows fail on bms-4 | secrets-sync.yml may not have run; check GH Actions or manually update bms-4 env |
re_ prefix missing from new key | Ensure “Full Access” scope is selected when creating |
Prevention
- Resend does not send expiry warnings for API keys.
- The
credential-rotation.ymlGH Actions workflow (Monday 06:00 UTC) checksnext_dueand opens ahuman-actionissue. - After rotation, update Last rotated and Next due at the top of this playbook.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="RESEND_API_KEY",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — Resend API key regenerated and SOPS brandpilot updated",
env="vps-i1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'RESEND_API_KEY', 'success', 'Scheduled rotation — Resend API key regenerated and SOPS brandpilot updated', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''