Keel

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:

ts
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:

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

Edit this page on GitHub →Last updated Jul 22, 2026