API keys
API keys authenticate integrations against the /v1 surface. They are managed under /v1/api-keys (api/src/routes/api-keys.ts) and verified in verifyApiKey (packages/adapters/src/clerk-auth.ts). They are checked against the application database, with no Clerk round-trip.
Managing keys
All three endpoints require the integrations.manage permission — minting a key is privilege-sensitive because a key can carry any scope.
| Method | Path | Permission | Notes |
|---|---|---|---|
POST | /v1/api-keys | integrations.manage | Body { name, scopes }; returns the plaintext key once (201). |
GET | /v1/api-keys | integrations.manage | Lists key summaries; no secrets. |
POST | /v1/api-keys/:id/revoke | integrations.manage | Revokes by public id. |
apiKeyRoutes.post('/api-keys', async (c) => {
if (!can(c.var.tenant, 'integrations.manage')) return c.json(forbidden('integrations.manage'), 403);
const parsed = createApiKeyBody.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: { code: 'invalid_request', message: 'invalid api key body' } }, 400);
const created = await new ApiKeyService(c.var.ports.db).create(c.var.tenant, parsed.data.name, parsed.data.scopes);
return c.json(created, 201); // plaintext key present here, and only here
});
The plaintext key is returned exactly once on creation — clients must store it immediately. It is never recoverable afterwards.
Storage & hashing
The key string is prefixed voc_sk_. The server stores only a SHA-256 hash. Verification hashes the incoming key and looks it up in identity.api_key:
const hash = await sha256Hex(apiKey);
const row = (await db`select public_id, client_account_id, revoked_at, scopes
from identity.api_key where key_hash = ${hash} limit 1`)[0];
if (!row) return { ok: false, reason: 'invalid' };
if (row.revoked_at) return { ok: false, reason: 'expired' };
await db`update identity.api_key set last_used_at = now(), request_count = request_count + 1
where public_id = ${row.public_id}`;
A revoked key (revoked_at set) fails verification. Each successful verification stamps last_used_at and increments request_count (usage logging).
Scopes and the permission model
A key carries a list of scopes. Its effective permissions are the deduped union of those scopes, resolved by permissionsForApiKeyScopes(scopes) in packages/core/src/permissions.ts. A scope entry is either a coarse preset tier or an individual permission key:
| Scope tier | Resolves to |
|---|---|
read | view + export permissions (dashboard.view, reports.view, responses.view, responses.export, verbatim.view, surveys.view, alerts.view, leads.view) |
write | all read permissions plus day-to-day mutations (responses.edit, verbatim.code, surveys.edit, surveys.distribute, alerts.manage, leads.manage, agents.manage, badges.manage) |
admin | the full permission catalogue (ALL_PERMISSIONS) |
A key may also list individual permission keys directly (e.g. responses.view) for a precise grant beyond the three tiers.
const scopes = Array.isArray(row.scopes) ? (row.scopes as string[]) : [];
const permissions = permissionsForApiKeyScopes(scopes);
return { ok: true, principal: {
userId: asId<UserId>('0'),
email: `apikey:${row.public_id}`, // marks this principal as an API key (used by the rate limiter)
clientAccountId: asId<ClientAccountId>(String(row.client_account_id)),
roles: ['integration'],
permissions,
} };
Least privilege is the default
This is the most important rule for integrators:
- Empty or unrecognised scopes grant NO permissions (deny). An unknown scope entry contributes nothing. A key with no scopes can authenticate but can call no permission-gated endpoint.
adminis the only scope that grants the full catalogue. A key that needs full access must carry the explicitadminscope.
RLS still isolates the tenant regardless of scope — a key can never reach another tenant's data. Scopes govern what the key may do within its own tenant; the tenant binding governs where.
Rate limiting
Because the key principal's email is apikey:<public_id>, the apiKeyRateLimit middleware meters it (default 600 req/min). Session traffic is exempt. See Rate limiting.