Docs / Developer tools / API keys
API keys
Keel ships team-scoped API keys so a buyer building a public API on top of it has issuance, storage, and revocation already handled. Keys are managed from /app/settings/api-keys and backed by the api_keys table (lib/db/schema/api-keys.ts), scoped to a team like everything else.
Keys are hashed, shown once
Creation (POST /api/teams/[teamId]/api-keys) generates the secret server-side, stores only its hash, and returns the raw key in the response body exactly once:
const secret = randomBytes(24).toString("base64url");
const rawKey = `keel_live_${secret}`;
const keyHash = createHash("sha256").update(rawKey).digest("hex");
const keyPrefix = rawKey.slice(0, 14); // e.g. "keel_live_ab12"
A lost key can't be recovered, only revoked
key_hash never round-trips back to the client after creation, so there is no way to display a key again, this is the same model Stripe and GitHub use. key_prefix (the first 14 chars) is stored in the clear purely so the UI can show a masked, distinguishable label. If a user loses a key, they revoke it and create a new one.
Permissions
Listing keys needs only billing:read, so any member can see what keys exist, but creating and revoking need api_keys:write, gated per-route rather than at the whole-resource level. Every route calls authorizeTeamAccess first, the same gate documented in Permissions.
Revoke is a soft delete
DELETE /api/teams/[teamId]/api-keys/[keyId] sets revoked_at rather than deleting the row, so a revoked key stays on record for audit. The list query filters on revoked_at IS NULL, so revoked keys drop out of the UI but not the table.
What you still need to build
This subsystem is issuance and lifecycle only. In v1 no route authenticates an incoming request against a key, there is no verification middleware yet. To actually accept keys on a public endpoint, hash the presented key with the same sha256 and look it up:
const hash = createHash("sha256").update(presentedKey).digest("hex");
const [key] = await db.select().from(apiKeys)
.where(and(eq(apiKeys.keyHash, hash), isNull(apiKeys.revokedAt)));
if (!key) return Response.json({ error: "Invalid API key" }, { status: 401 });
// key.teamId scopes the request; touch key.lastUsedAt here
last_used_at exists for exactly this, stamp it when a key authenticates a request.