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/tokenremains legacy fallback (x-api-key+x-org-id).POST /api/auth/token/integrationis integration-key auth (x-api-keyonly).
Auth Principal Contract
Three principal shapes are expected at request time:
- Interactive/admin principal (web/Clerk or internal JWT)
- Used for integration key management endpoints.
- Tenant context source:
req.orgId. - Actor identity source:
req.userId.
- Integration principal (integration key token)
- Used for module upload/read endpoints.
- Claims source:
req.userwithauth_type: "integration",ikid,scopes. - DB context source:
req.integrationKey. - Tenant context source:
req.integrationKey.tenantId(andreq.orgIdwhen set by auth middleware for compatibility).
- 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.userIdtoday). - Integration endpoints should rely on
req.userclaims + DB-backedreq.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.permissionsresolved from admin role policy - Legacy API key fallback: explicit policy decision (often
['*']for backward compatibility)
- Integration keys:
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_DENIEDwith 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 examplesha256(logicalPrefix)hex). Truncate only for UI/log display. keyPrefixFingerprintis 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 onlyDo 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_SECRETis 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_adaptersclaims 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/revokePOST /api/integration-keys/:id/rotate
Lifecycle responses should return safe identifiers only, e.g.:
keyPrefixFingerprintkeyVersion- metadata timestamps
- Never return stored
keyHash
Lifecycle persistence rule:
POST /api/integration-keysmust persistexpiresAtwhen supplied.POST /api/integration-keys/:id/rotatemust carry forward priorexpiresAtunless explicitly overridden.
Handler consistency rule:
- In a single lifecycle handler, do not mix principal sources for metadata writes.
- Example: if
tenantIdcomes fromreq.orgId,createdBy/revokedBy/rotatedByshould come fromreq.userIdin 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)
}