API Keys

Dashboard: /overview/api-keys. Internal API: /api/internal/api-keys.

Endpoints (internal)

Method Purpose
GET List non-revoked keys for session's org
POST Create key — returns full key once
DELETE ?id=key_... Soft revoke via revoked_at

Create

POST /api/internal/api-keys
{
  "name": "Server",
  "environment": "live",
  "expiresInDays": 90,
  "rateLimitPerMinute": 1200
}
Field Notes
name required, user-visible label
environment live or test
expiresInDays optional, 1–3650. If set, expiresAt is stored; validation rejects expired keys.
rateLimitPerMinute optional, 1–100000. Per-key sliding-window limit checked before the org/plan bucket. Omit to inherit the plan default.
allowedCidrs optional array of up to 20 CIDR ranges (IPv4 or IPv6). Requests from outside these ranges fail with 403 IP_NOT_ALLOWED.
scopes optional, at least one of SCOPES (src/lib/api/scopes.ts). Omit for a full-access key (scopes IS NULL).
tenantId optional ten_ id of an active tenant in the org. The key can act only as that tenant. An archived or suspended tenant is refused with 422 (its key would fail every request), and the dashboard picker lists only active tenants. POST /v1/keys applies the same rule to its tenant_id (422 VALIDATION_ERROR, checked before the creation limit or the plan cap is touched).
allowedDomainIds optional array of up to 50 dom_ ids the org owns. The key sends only from those domains.

In the dashboard

The Create API key modal has a collapsed Restrictions section that sets all four: full access or a chosen scope list (with a Send only preset), allowed IPs (one per line), a tenant picker, and sending-domain checkboxes. The tenant and domain pickers list the first 200 rows and hide when the org has none. Entries are checked in the browser against the same pattern and caps the route uses (src/lib/api/key-restriction-form.ts) before anything is posted.

The key list shows each restriction as a pill under the key's name (2 scopes, IP 203.0.113.0/24, Tenant Shop A, From mail.acme.test), with the full list on hover. A key with no pills has full access from any IP.

Response (201):

{
  "id": "key_...",
  "key": "sok_live_...",
  "name": "Server",
  "environment": "live",
  "rate_limit_per_minute": 1200,
  "expires_at": "2026-07-20T00:00:00.000Z"
}

IP allowlist

When allowedCidrs is set, withApiAuth compares the request's client IP (from X-Forwarded-For or X-Real-IP) against each CIDR. Any match allows the request through; no match returns:

HTTP 403
{ "error": { "type": "authentication_error", "code": "IP_NOT_ALLOWED", "message": "Request IP is not in this key's allowlist" } }

IPv4 and IPv6 are both supported. Bare IPs are treated as /32 (IPv4) or /128 (IPv6). 0.0.0.0/0 effectively disables the check.

Audit: api_key.created logged.

Storage model

  • Full key displayed once to user, never persisted.
  • key_hash = sha256(full key) — lookup index.
  • last_four = last 4 chars of the nanoid portion — shown in UI.
  • key_prefix = sok_live_ or sok_test_.
  • expires_at enforced in validateApiKey() via OR (expires_at IS NULL, expires_at > now()).

Revoke

DELETE /api/internal/api-keys?id=key_... — sets revoked_at = now(). Revoked keys fail the isNull(revoked_at) filter and return 401 on use. Audit: api_key.revoked.

Test vs live

See ../api/authentication.md. Test keys bypass AWS calls and usage limits. A test key cannot create keys (POST /v1/keys answers 403 ENVIRONMENT_ESCALATION to it, of either mode): every key created spends the org's key-creation limit and a slot of the plan's key cap, both shared with live. It can still list, change and revoke test keys.