Skip to Content
Integration KeysOverview

Integration Keys Contract

Status: Contract Frozen Date: 2026-02-06

Customer handoff

Integration keys are the primary auth surface for API customers under the assisted API-first launch posture. When a new customer is onboarded, an operator mints an integration key on the customer’s tenant and hands the one-time secret to the customer over a secure channel — the secret is returned exactly once, on creation, and is never retrievable again. If it is lost before handoff, the only remedy is to revoke the key and mint a new one.

What to hand the customer at onboarding:

  • The integration key secret (sk_int_...), delivered via a secure channel (password manager or encrypted email).
  • The developer quickstart and the Postman collection under docs/postman/.
  • The recommended workflow folder for their first run (TRADE feed, SHIP audit, or SIMA screening).

See runbooks/api-customer-onboarding.md for the full operator runbook (tenant creation, key issuance, first-call verification, rotation, revocation, and PREVIEW_BLOCKED handoff).

Lifecycle endpoints are Clerk-only and are not part of the public agent contract. The rotate (POST /api/integration-keys/:id/rotate), revoke (POST /api/integration-keys/:id/revoke), and admin-by-tenant mint (POST /api/admin/tenants/:id/integration-keys) routes require a Clerk session and are tenant-scoped via req.orgId. Customers do not call them; operators do, from the customer’s Clerk org context.

Auth Endpoints

  • POST /api/auth/token remains legacy fallback (x-api-key + x-org-id).
  • POST /api/auth/token/integration is integration-key auth (x-api-key only).

Auth Principal Contract

Three principal shapes are expected at request time:

  1. Interactive/admin principal (web/Clerk or internal JWT)
  • Used for integration key management endpoints.
  • Tenant context source: req.orgId.
  • Actor identity source: req.userId.
  1. Integration principal (integration key token)
  • Used for module upload/read endpoints.
  • Claims source: req.user with auth_type: "integration", ikid, scopes.
  • DB context source: req.integrationKey.
  • Tenant context source: req.integrationKey.tenantId (and req.orgId when set by auth middleware for compatibility).
  1. Legacy API-key principal (existing fallback)
  • Used by legacy routes while fallback remains enabled.
  • Tenant context source: req.orgId.
  • Actor identity source: req.userId.
  • No scope model (do not infer integration scopes for this principal).

Principal contract rule:

  • Use a single principal source per endpoint category.
  • Admin lifecycle endpoints should consistently use the same tenant/actor source throughout a handler (req.orgId + req.userId today).
  • Integration endpoints should rely on req.user claims + DB-backed req.integrationKey.

Permission contract rule:

  • Scope middleware must read from one normalized source: req.permissions.
  • Auth middleware is responsible for projecting principal-specific permissions into req.permissions.
    • Integration keys: req.permissions = req.user.scopes
    • Clerk/admin: req.permissions resolved from admin role policy
    • Legacy API key fallback: explicit policy decision (often ['*'] for backward compatibility)

Principal guard rule:

  • Sensitive endpoint groups must enforce allowed principal types, not only scopes.
  • Example: integration key lifecycle endpoints must be Clerk/admin only.
  • When denied, return 403 PRINCIPAL_DENIED with structured details.

Prisma Models

model IntegrationKey { id String @id @default(cuid()) keyPrefixHash String keyHash String @unique hashAlgo String @default("hmac-sha256") hashVersion Int @default(1) keyVersion Int @default(1) tenantId String name String scopes String[] defaultAdapter String? allowedAdapters String[] createdBy String createdAt DateTime @default(now()) lastUsedAt DateTime? revokedAt DateTime? expiresAt DateTime? events IntegrationKeyEvent[] @@schema("app") } model IntegrationKeyEvent { id String @id @default(cuid()) keyId String? key IntegrationKey? @relation(fields: [keyId], references: [id]) keyPrefixFingerprint String? event String metadata Json? createdAt DateTime @default(now()) @@schema("app") }

Security Rules

  • Never persist clear-text prefixes (keyPrefix).
  • Persist full keyPrefixHash (for example sha256(logicalPrefix) hex). Truncate only for UI/log display.
  • keyPrefixFingerprint is a display/log fingerprint only (truncated derivative).
  • Do not use apiKey.slice(...) for persisted event fingerprinting.

Example fingerprint:

function canonicalKeyPrefix(apiKey: string): string { // Parse `sk_int_<prefix>_<secret>` (or `sk_int_<prefix>`), stop at separator. const match = apiKey.match(/^sk_int_([a-z0-9]+)(?:_|$)/i) if (!match) return "invalid" return `sk_int_${match[1]}` } const keyPrefix = canonicalKeyPrefix(apiKey) const keyPrefixHash = hashString(keyPrefix) // full hash persisted in DB const keyPrefixFingerprint = keyPrefixHash.slice(0, 16) // logs/UI only

Do not hash arbitrary raw slices (for example apiKey.slice(0, 12)) as canonical fingerprint material.

Generation rule:

  • Generated integration keys must match expected key format.
  • Lifecycle handlers should fail closed if generated key prefix parsing fails (no raw-slice fallback).

KEY_HASH_SECRET Rotation Lookup Contract

During rotation, lookup must support both active secret generations. Do not rely on a single findUnique({ keyHash }) path.

Startup contract:

  • App startup must fail fast if KEY_HASH_SECRET is missing in environments where integration-key auth is enabled.
  • Runtime auth handlers should not silently degrade when hash secret config is absent.
function candidateKeyHashes(apiKey: string): string[] { const activeSecrets = [ process.env.KEY_HASH_SECRET, process.env.KEY_HASH_SECRET_NEW, ].filter((v): v is string => Boolean(v)) if (activeSecrets.length === 0) { throw new Error("KEY_HASH_SECRET is not configured") } return [...new Set(activeSecrets.map((secret) => hmacSha256(apiKey, secret)))] } const keyHashes = candidateKeyHashes(apiKey) const integrationKey = await prisma.integrationKey.findFirst({ where: { keyHash: { in: keyHashes } }, })

Rotation semantics:

  • Re-hash stored records to the new secret and bump hashVersion.
  • Keep dual-hash lookup enabled until all active keys are migrated.
  • Remove old secret and fallback lookup only after migration completion.

JWT Contract

JWT includes:

  • sub (tenant id)
  • auth_type: "integration"
  • ikid (IntegrationKey.id)
  • scopes

Server authorization source of truth:

  • Revocation/expiry checks from DB by ikid
  • Adapter authorization from DB (allowedAdapters)
  • Keep JWT minimal: avoid persisting allowed_adapters claims in the token to prevent claim/DB drift.
  • If adapter hints are needed for UX, return them out-of-band and still enforce from DB.

Key Lifecycle APIs

  • POST /api/integration-keys (one-time secret reveal)
  • GET /api/integration-keys (masked list)
  • POST /api/integration-keys/:id/revoke
  • POST /api/integration-keys/:id/rotate

Lifecycle responses should return safe identifiers only, e.g.:

  • keyPrefixFingerprint
  • keyVersion
  • metadata timestamps
  • Never return stored keyHash

Lifecycle persistence rule:

  • POST /api/integration-keys must persist expiresAt when supplied.
  • POST /api/integration-keys/:id/rotate must carry forward prior expiresAt unless explicitly overridden.

Handler consistency rule:

  • In a single lifecycle handler, do not mix principal sources for metadata writes.
  • Example: if tenantId comes from req.orgId, createdBy/revokedBy/rotatedBy should come from req.userId in that same handler.

Request Typing Additions

apps/api/src/types/express.d.ts must be extended for integration flow:

interface Request { orgId?: string userId?: string principal?: "clerk" | "integration" | "legacy" permissions?: string[] clerkOrgId?: string user?: { sub: string auth_type?: "integration" | "legacy" ikid?: string scopes?: string[] } integrationKey?: { id: string tenantId: string allowedAdapters: string[] defaultAdapter?: string | null revokedAt: Date | null expiresAt: Date | null } }

Principal guard helper:

function requirePrincipal(...allowed: Array<"clerk" | "integration" | "legacy">) { return (req: Request, res: Response, next: NextFunction) => { if (!req.principal || !allowed.includes(req.principal)) { return res.status(403).json({ code: "PRINCIPAL_DENIED", message: "Authentication principal not allowed for this endpoint", details: { required: allowed, actual: req.principal ?? "unknown" }, }) } next() } }

Scoped Route Wrapper (Test Registry Fix)

Use COLLECT_ROUTES guard when appending to test registry:

let SCOPED_ROUTES: Array<{ method: string; path: string; scopes: string[] }> = [] const COLLECT_ROUTES = process.env.NODE_ENV === "test" export function requireScopes(...required: string[]) { return (req: Request, res: Response, next: NextFunction) => { const granted = req.permissions ?? [] const allowed = required.every((scope) => granted.includes(scope) || granted.includes("*")) if (!allowed) { return res.status(403).json({ code: "SCOPE_DENIED", message: "Insufficient permissions", details: { required, provided: granted }, }) } next() } } export function scopedRoute( router: Router, method: "get" | "post" | "put" | "delete", path: string, scopes: string[], ...handlers: RequestHandler[] ) { if (COLLECT_ROUTES) { SCOPED_ROUTES.push({ method: method.toUpperCase(), path, scopes }) } router[method](path, requireScopes(...scopes), ...handlers) }