Projects, Spanlens keys & provider keys
A Spanlens organization contains one or more projects. Each project owns one or more Spanlens keys (sl_live_…), the value you put in your app's SPANLENS_API_KEY env. Each Spanlens key in turn owns its own pool of provider keys (the real OpenAI / Anthropic / Gemini credentials), so two Spanlens keys in the same project can carry different underlying credentials. Projects let you separate dev/staging/prod, per-service, or per-team, whatever scoping makes sense.
Why projects
Without projects, every request from every service you own shows up in one giant stream. That's fine for a solo side project; painful for anything beyond. Projects let you:
- Filter /requests by source service
- Compute cost per team for chargeback
- Apply different alert rules to prod vs staging
- Revoke one service's keys without affecting others
Why two key types
Spanlens keys are what your app ships with. Provider keys are what the LLM provider bills against. Splitting them gives you:
- Real provider keys never ship to clients. Frontend bundles, mobile apps, anywhere, they only ever see the revocable
sl_live_…. - Centralized rotation. Replace the OpenAI key on the dashboard (pencil icon on the provider key row) → next request flows through the new key. No redeploys, no env changes.
- Per-environment isolation without cross-env leakage. Issue two Spanlens keys (
prod,staging), give each its own OpenAI/Anthropic/Gemini credentials, and a leaked staging key can never touch prod billing.
How keys work
Spanlens key format
Keys are sl_live_ + 48 random hex chars. At creation time the plaintext is shown once in the dashboard, copy it immediately. On the server we compute SHA-256 over the key and store only the hash in api_keys.key_hash.
Incoming proxy requests present the key in whichever transport the upstream SDK uses (see Direct proxy for the full table). The authApiKey middleware extracts the key, hashes it, and looks it up. No plaintext comparison, no plaintext storage.
Provider key encryption
Provider keys (the real sk-… / sk-ant-… / AIza… values) are stored encrypted with AES-256-GCM. See Keys & encryption for the cryptographic details, fresh IV per key, authenticated, decrypted only in memory for the duration of one upstream fetch().
Per-key metadata tracked
name, human label (e.g. “prod-backend”)key_prefix, first 15 chars (Spanlens key only), shown in UI for IDlast_used_at, updated on every successful auth (throttled to one write per key per 5 minutes so the proxy hot path stays cheap)is_active, revoke flag; inactive keys return 401
Stale key surfacing
The dashboard classifies each active key by how long it has been idle and surfaces forgotten keys before they leak:
- 30–89 days idle→ neutral “Stale” badge on the key row in /projects.
- 90+ days idle→ accent “Consider revoking” badge, surfaced in two extra places so it's hard to miss:
- The sidebar Projects & Keys entry carries a red count of stale + revoke-tier keys.
- The dashboard Needs Attention strip shows a warning card with a sample key name and a deep link to /projects.
Keys that have never authenticated fall back to created_atas the idleness floor — a brand-new key isn't flagged for the first 30 days. Revoked keys (is_active=false) are excluded from staleness reporting entirely: once you've already disabled a key, nagging about it is noise.
Using it
Dashboard
Go to /projects. The flow is two steps:
- Issue a Spanlens key. On the project card click + New Spanlens key → enter a name → the dialog returns the
sl_live_…value once. Copy it immediately and put it in your app'sSPANLENS_API_KEYenv. - Attach provider keys. The new Spanlens key now appears as a section. Click + Add provider key on it → pick OpenAI / Anthropic / Gemini → paste your real provider credential. Repeat per provider you need.
- After you save a provider key the dialog flips to a success view showing the one-line integration snippet for that provider (
createOpenAI(),createAnthropic(),createGemini()). Drop it into your code, no CLI re-run needed.
Existing keys can be:
- Toggled active/inactive via the switch on the Spanlens key row. Inactive keys return 401 immediately, no cache to invalidate.
- Provider keys rotated via the pencil icon on each provider key row. The
sl_live_…stays the same; only the underlying credential changes. - Deleted with a 72-hour grace period. The trash icon on a Spanlens key or a provider key flips
is_active = falseright away so the proxy stops accepting it, then queues a hard delete that runs every six hours. While the row is in the queue you can restore it from Settings → Pending deletions. After the grace window the hard delete runs and the row is gone for good.
Restoring an accidental deletion
Misclicked the trash icon? Open Settings → Pending deletions. Every soft-deleted key, provider key, and prompt version shows up there with a timer; click Restoreto reactivate the row. After the timer expires the row is hard-deleted and restore is no longer possible — you'd have to issue a fresh key (the SHA-256 hash is irreversible).
Audit events are recorded both for the original delete request (api_key.delete / provider_key.delete) and for the restore (pending_deletion.restore), so the trail stays intact even when the action is reversed.
The page also surfaces a wizard hint: npx @spanlens/cli init can bootstrap an existing OpenAI/Anthropic/Gemini codebase by rewriting new OpenAI(...) → createOpenAI() in one pass. For newcode, you don't need the CLI, copy the snippet from the dashboard and you're done.
API
# ── Projects ──────────────────────────────────────────────────
GET /api/v1/projects
POST /api/v1/projects { "name": "backend-prod" }
DELETE /api/v1/projects/:id
# ── Spanlens keys (provider-agnostic, project-scoped) ─────────
GET /api/v1/api-keys?projectId=<uuid>
POST /api/v1/api-keys/issue { "name": "prod-backend",
"projectId": "<uuid>" }
# → { "id": "...", "key": "sl_live_..." } ← shown ONCE
PATCH /api/v1/api-keys/:id { "is_active": false } # toggle
DELETE /api/v1/api-keys/:id # is_active=false + 72h delete queue
# ── Provider keys (under a specific Spanlens key) ─────────────
GET /api/v1/provider-keys?apiKeyId=<spanlens-key-uuid>
POST /api/v1/provider-keys { "api_key_id": "<uuid>",
"provider": "openai",
"key": "sk-...",
"name": "prod-openai" }
PATCH /api/v1/provider-keys/:id { "key": "sk-rotated..." } # rotate
PATCH /api/v1/provider-keys/:id { "name": "renamed" } # rename
DELETE /api/v1/provider-keys/:id # is_active=false + 72h delete queue
# ── Pending deletions queue (restore within 72h) ──────────────
GET /api/v1/pending-deletions # active queue
GET /api/v1/pending-deletions/history # terminal rows (executed or cancelled)
POST /api/v1/pending-deletions/:id/restore # cancel + flip is_active back to truebashTagging requests with a project from client code
By default, a request's project is determined by which Spanlens key was used. One key = one project. If you want to override per-request, pass:
X-Spanlens-Project: my-project-slugbash… in the proxy request headers. The SDK also accepts a project option on createOpenAI() / createAnthropic() / createGemini().
Security design
- No recovery.Lose a plaintext Spanlens key → create a new one. We can't retrieve it from the SHA-256 hash. Same for the underlying provider key plaintext after registration.
- Revocation is instantaneous. Flipping
is_activeto false blocks all subsequent traffic. No key cache to invalidate. - Rate limits and quotas are enforced per-org.A leaked Spanlens key can be revoked without breaking your other keys, but while active it shares the org's quota. Rotate regularly for defense-in-depth.
- Provider keys are scoped to one Spanlens key.A stolen Spanlens key only unlocks the provider keys you registered under it, not the org's other Spanlens keys' provider keys. (UNIQUE INDEX
(api_key_id, provider) WHERE is_active = trueenforces 1 active per Spanlens key per provider.)
Limitations
- No per-key rate limits yet. Enterprise ask, on roadmap.
- No automatic rotation. Provider key rotation is manual (pencil icon or
PATCH /api/v1/provider-keys/:id). Scheduled rotation is deferred to Phase 5. - No IP allowlisting.Keys work from anywhere that presents them. Network-level allowlisting is an Enterprise request we're tracking.
- Spanlens key plaintext shown once.No “reveal existing key” escape hatch, by design. If CI lost the value, delete the key, create a new one, and update the secret in your CI settings.
Related: Keys & encryption (AES-256-GCM), Quick start, Direct proxy & auth transports, /projects dashboard.