Authentication & API Conventions
This file is the canonical reference for API-wide conventions: base URL, authentication, the standard error model and error-code table, pagination, rate limiting, IDs, and timestamps. Every other API document (workspaces, pages, blocks, databases, comments, files, search, webhooks) links back here instead of repeating these rules.
Base URL for all endpoints:
https://<host>/api/v1
In local development the API serves at http://localhost:4000/api/v1.
1. Authentication Overview
AI Software Factory is a multi-tenant platform: the workspace is the top-level tenant, and every tenant-scoped resource (pages, databases, files, comments, webhooks, …) is owned by exactly one workspace. All read/write endpoints operate within a workspace; the API infers it in this order:
- The path parameter
:workspaceId(workspace-scoped routes). - The
X-Factory-Workspaceheader (any workspace the caller belongs to). - For user-scoped routes, the caller's personal workspace.
Authentication is always resolved server-side. Three credential types are
accepted, all sent in the Authorization header:
| Credential | Header | Scope | Lifetime |
|---|---|---|---|
| JWT access token | Authorization: Bearer eyJhbGciOi… |
User session | 15 minutes |
| API key | Authorization: Bearer fk_live_… |
Workspace (per-key scopes: READ / WRITE / ADMIN) | Until revoked or expired |
| OAuth2 (for third-party apps) | Authorization: Bearer eyJhbGciOi… (app-issued JWT) |
App + user grant | 15 minutes (refreshable via OAuth2 refresh token) |
Every response to an authenticated request carries an X-Request-Id header
that matches the requestId field in error bodies. Use it when reporting
issues.
1.1 Access Token Format
Access tokens are signed JWTs (HS256 by default, RS256 in enterprise deployments). Payload:
{
"sub": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"workspaceId": "wks_01J9ZKP2B7F4H6J8K0L2M4N6P8",
"roles": ["MEMBER"],
"scopes": ["read", "write"],
"jti": "at_01J9ZKR1C8G5I7J9L1M3N5P7Q9",
"iat": 1735689600,
"exp": 1735690500
}
sub— the user id (usr_…).workspaceId— the workspace the token was minted for; requests are validated against it (or against theX-Factory-Workspaceheader if the caller is a member of that workspace too).exp— exactly 15 minutes afteriat.
1.2 Refresh Token Rotation
Refresh tokens are opaque 256-bit random strings (e.g.
rft_7f9c2e…41 chars), stored hashed (SHA-256) in the refresh_tokens
table, and rotated on every use: each refresh returns a new refresh token
and invalidates the one that was presented.
┌──────────┐ POST /auth/login ┌────────────┐
│ Client │ ──────────────────► │ API │
│ │ ◄────────────────── │ │
│ │ { accessToken, │ │
│ │ refreshToken: R1 }│ │
└──────────┘ └────────────┘
│ │
│ access token expires (15 min) │
▼ │
┌──────────┐ POST /auth/refresh ┌──────┴─────┐
│ Client │ { refreshToken: R1 }│ API │
│ │ ──────────────────► │ verify R1 │
│ │ ◄────────────────── │ rotate: │
│ │ { accessToken, │ R1 → R2 │
│ │ refreshToken: R2 }│ │
└──────────┘ └────────────┘
Rotation rules:
- Presenting a already-rotated or revoked token is treated as a token
reuse attack: the entire session (the whole rotation chain) is revoked and
the client must log in again. Response:
401 UNAUTHORIZEDwithcode: "REFRESH_TOKEN_REUSED". - Refresh tokens expire after 30 days of inactivity (the expiry is extended on each successful rotation).
- The
refreshendpoint always returns the session id of the session that owns the token so clients can correlate withGET /api/v1/sessions.
2. Auth Endpoints
2.1 POST /auth/register
Create a user account. A personal workspace is created automatically and an email-verification token is emailed (see §6 Email Verification).
- Auth: none (public).
- Rate limit: 5 / hour / IP.
Request:
{
"email": "ada@example.com",
"password": "c0rrect-Horse-battery9",
"name": "Ada Lovelace"
}
| Field | Type | Required | Notes |
|---|---|---|---|
email |
string (email) | yes | Lower-cased and unique. |
password |
string | yes | 8–128 chars; must satisfy the configured strength policy (upper, lower, digit). |
name |
string (1–100) | yes | Full name; displayName defaults to it. |
201 Created
{
"data": {
"user": {
"id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"email": "ada@example.com",
"name": "Ada Lovelace",
"displayName": "Ada Lovelace",
"avatarUrl": null,
"emailVerifiedAt": null,
"emailVerification": "PENDING",
"status": "ACTIVE",
"locale": "en-US",
"timezone": "UTC",
"createdAt": "2025-01-01T10:00:00.000Z"
},
"workspace": {
"id": "wks_01J9ZKP2B7F4H6J8K0L2M4N6P8",
"name": "Ada Lovelace's Workspace",
"slug": "ada-lovelace-7f3k9q",
"isPersonal": true,
"ownerId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"refreshToken": "rft_8f14e45fceea167a5a36dedd4bea2543…",
"session": {
"id": "ses_01J9ZKQ9D0G7I9K1L3M5N7P9Q1R3",
"expiresAt": "2025-01-30T10:00:00.000Z"
}
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Bad email, weak password, missing fields (see details). |
| 409 | CONFLICT |
Email already registered. |
| 429 | RATE_LIMITED |
Registration limit exceeded. |
Events emitted: user.registered, workspace.created (personal).
2.2 POST /auth/login
Exchange email + password for a token pair.
- Auth: none (public).
- Rate limit: 10 / 15 min / IP+email.
Request:
{
"email": "ada@example.com",
"password": "c0rrect-Horse-battery9"
}
200 OK
{
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"refreshToken": "rft_1c383cd30bfaee4c945754e37e86a074…",
"expiresIn": 900,
"session": {
"id": "ses_01J9ZKR3E2H8J0L2M4N6P8Q0R2S4",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)…",
"ipAddress": "203.0.113.7",
"expiresAt": "2025-01-30T10:00:00.000Z"
},
"user": {
"id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"email": "ada@example.com",
"emailVerified": true,
"status": "ACTIVE"
}
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing fields. |
| 401 | UNAUTHORIZED |
Unknown email or wrong password (message is deliberately identical for both). |
| 403 | FORBIDDEN |
Account suspended (details: [{reason: "account_suspended"}]). |
| 429 | RATE_LIMITED |
Too many attempts; details includes retryAfterSeconds. |
Events emitted: user.login (audit-logged: actor, IP, user agent).
2.3 POST /auth/refresh
Rotate the refresh token and receive a fresh access token.
- Auth: none (the refresh token itself is the credential).
Request:
{
"refreshToken": "rft_1c383cd30bfaee4c945754e37e86a074…"
}
200 OK
{
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs…",
"refreshToken": "rft_9d0b5a1e2f3c4d5e6f7a8b9c0d1e2f3a…",
"expiresIn": 900,
"session": { "id": "ses_01J9ZKR3E2H8J0L2M4N6P8Q0R2S4" }
}
}
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
Token unknown or expired. |
| 401 | REFRESH_TOKEN_REUSED |
Token already rotated → whole session revoked. |
| 403 | FORBIDDEN |
Session revoked or user suspended. |
Events emitted: auth.refresh (audit).
2.4 POST /auth/logout
Revoke the refresh token that issued the given access token (or the session named in the body, if provided). Subsequent refreshes with the rotated token fail. Idempotent.
- Auth: JWT (Bearer).
Request (both fields optional):
{
"sessionId": "ses_01J9ZKR3E2H8J0L2M4N6P8Q0R2S4"
}
204 No Content
Errors: 401 UNAUTHORIZED (expired/invalid access token).
Events emitted: user.logout (audit).
2.5 POST /auth/email-verification/request
Re-send the email verification link (or issue a new token for an unverified account).
- Auth: JWT (Bearer).
- Rate limit: 3 / hour / user.
Request:
{ "email": "ada@example.com" }
202 Accepted — always 202 with an empty envelope so the endpoint cannot
be used to enumerate accounts:
{ "data": { "sent": true } }
The verification email contains a link
https://<host>/verify-email?token=evt_… (single-use, 24 h).
Errors: 401 UNAUTHORIZED, 429 RATE_LIMITED.
2.6 POST /auth/email-verification/confirm
Confirm the email address with the token from the verification link.
- Auth: none (the token is the credential).
Request:
{ "token": "evt_5b1e0c3d9f2a4b6c8d0e1f2a3b4c5d6e" }
200 OK
{
"data": {
"user": {
"id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"email": "ada@example.com",
"emailVerification": "VERIFIED",
"emailVerifiedAt": "2025-01-01T10:05:00.000Z"
}
}
}
Errors: 400 VALIDATION_ERROR (malformed token), 410 GONE (token
expired), 409 CONFLICT (token already used — reported as already-verified).
3. Password Reset Flow
POST /auth/password-reset/request ──► email with 24 h single-use token
POST /auth/password-reset/confirm ──► new password, all sessions revoked
3.1 POST /auth/password-reset/request
- Auth: none (public).
- Rate limit: 5 / hour / email.
Request:
{ "email": "ada@example.com" }
202 Accepted — always succeeds (no account enumeration):
{ "data": { "sent": true } }
The email links to https://<host>/reset-password?token=pwrt_….
Errors: 400 VALIDATION_ERROR, 429 RATE_LIMITED.
3.2 POST /auth/password-reset/confirm
- Auth: none (the token is the credential).
Request:
{
"token": "pwrt_c9f2a7b4e1d83056f7a9b2c4d6e8f0a1",
"password": "n3w-C0rrect-Horse-battery"
}
200 OK
{ "data": { "reset": true } }
All active sessions and refresh tokens for the user are revoked; the client must log in again.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Weak password, malformed token. |
| 410 | GONE |
Token expired. |
| 409 | CONFLICT |
Token already used or revoked. |
Events emitted: user.password_reset (audit, all sessions revoked).
4. Current User
4.1 GET /users/me
- Auth: JWT (Bearer).
200 OK
{
"data": {
"user": {
"id": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"email": "ada@example.com",
"name": "Ada Lovelace",
"displayName": "Ada",
"avatarUrl": "https://<host>/files/fil_01J9ZKS…/avatar.png",
"emailVerification": "VERIFIED",
"emailVerifiedAt": "2025-01-01T10:05:00.000Z",
"status": "ACTIVE",
"locale": "en-US",
"timezone": "Europe/London",
"theme": "dark",
"lastLoginAt": "2025-01-02T08:12:00.000Z",
"createdAt": "2024-11-03T09:00:00.000Z"
},
"workspaces": [
{ "id": "wks_01J9ZKP2B7F4H6J8K0L2M4N6P8", "name": "Ada Lovelace's Workspace", "isPersonal": true, "role": "OWNER" },
{ "id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "name": "Analytical Engines Co.", "isPersonal": false, "role": "ADMIN" }
]
}
}
Errors: 401 UNAUTHORIZED.
4.2 PATCH /users/me
Update profile and preferences. Only the fields present in the body are changed.
- Auth: JWT (Bearer).
Request:
{
"name": "Ada K. Lovelace",
"displayName": "Ada",
"avatarUrl": "https://<host>/files/fil_01J9ZKS…/avatar.png",
"locale": "en-GB",
"timezone": "Europe/London",
"theme": "dark"
}
200 OK — returns the updated user object (same shape as GET /users/me
data.user).
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED,
409 CONFLICT (e.g. name length violation is VALIDATION_ERROR; a second
user with the same displayName in a workspace is CONFLICT with
details: [{field: "displayName"}]).
Events emitted: user.profile_updated.
5. Session Management
A session is a long-lived login (30 days of inactivity) that owns a refresh token rotation chain. Sessions can be listed and revoked — revoking a session revokes every refresh token in its chain.
5.1 GET /sessions
- Auth: JWT (Bearer).
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
cursor |
string | – | Opaque cursor from nextCursor. |
limit |
int (1–100) | 20 | Page size. |
200 OK
{
"data": [
{
"id": "ses_01J9ZKR3E2H8J0L2M4N6P8Q0R2S4",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/121",
"ipAddress": "203.0.113.7",
"status": "ACTIVE",
"isCurrent": true,
"lastActiveAt": "2025-01-02T09:55:00.000Z",
"createdAt": "2025-01-02T08:12:00.000Z",
"expiresAt": "2025-01-30T09:55:00.000Z"
},
{
"id": "ses_01J9ZKQ9D0G7I9K1L3M5N7P9Q1R3",
"userAgent": "factory-api-client/1.0 (curl/8.4.0)",
"ipAddress": "203.0.113.9",
"status": "ACTIVE",
"isCurrent": false,
"lastActiveAt": "2025-01-02T07:03:00.000Z",
"createdAt": "2025-01-01T10:00:00.000Z",
"expiresAt": "2025-01-29T07:03:00.000Z"
}
],
"nextCursor": "c2VzXzAxSlpaS1E5RDBHNUk5SzFMMy"
}
Errors: 401 UNAUTHORIZED.
5.2 DELETE /sessions/:sessionId
Revoke a session. The session id of the current session can be revoked too
(its refresh chain dies, but the access token lives until its 15-min expiry).
Idempotent: deleting an already-revoked session returns 204.
- Auth: JWT (Bearer).
Path params:
| Param | Type | Notes |
|---|---|---|
sessionId |
string | Session id (ses_…). |
204 No Content
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED |
Missing/invalid access token. |
| 404 | NOT_FOUND |
Session does not exist (or belongs to another user). |
Events emitted: session.revoked (audit, actor + target session recorded).
6. Email Verification
See §2.5 and §2.6. Summary of the flow:
register ──► email (token evt_…, 24 h, single use)
│
▼
confirm ──► user.emailVerification = VERIFIED
- An unverified account can still use the API, but sending mail on its behalf (invitations, notifications) is suppressed.
GET /users/meexposesemailVerificationandemailVerifiedAt.- Verification tokens are hashed at rest and single-use; confirming with an
already-used token returns
409 CONFLICT.
7. API Keys
API keys authenticate service-to-service calls on behalf of a workspace. They are created in workspace settings; the raw key is shown exactly once at creation (only a hash is stored server-side).
- Prefix:
fk_(factory key), e.g.fk_live_aB3dE5fG7hI9jK1lM3nO5pQ7rS9t. - Scopes: each key carries one or more of
READ,WRITE,ADMIN.ADMINimpliesWRITE,WRITEimpliesREAD. Read-only endpoints requireREAD; mutations requireWRITE; member/permission/api-key management requiresADMIN. - Acting as: API-key requests act as the workspace, not a user. The audit
log records
actorType: "API_KEY". Endpoints that require a user identity (e.g.PATCH /users/me, invitations as a specific user) return403 FORBIDDENwithdetails: [{reason: "requires_user_credentials"}]for API keys. - Rate limits: keys can carry a per-key
rateLimit(requests/minute); the global user/IP limits still apply on top.
Usage — identical to JWT usage, just a different token in the same header:
curl -s https://factory.example.com/api/v1/pages \
-H "Authorization: Bearer fk_live_aB3dE5fG7hI9jK1lM3nO5pQ7rS9t" \
-H "X-Factory-Workspace: wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1"
Key management (workspace-scoped, ADMIN scope or workspace Owner/Admin role):
7.1 POST /workspaces/:workspaceId/api-keys
- Auth: JWT (workspace Owner/Admin) — API keys cannot create API keys.
Request:
{
"name": "CI pipeline",
"scopes": ["READ", "WRITE"],
"rateLimit": 600,
"expiresAt": "2025-07-01T00:00:00.000Z"
}
201 Created — key appears only in this response:
{
"data": {
"id": "apk_01J9ZKS7F2H9J1L3M5N7P9Q1R3S5",
"name": "CI pipeline",
"key": "fk_live_aB3dE5fG7hI9jK1lM3nO5pQ7rS9tUvWxYz",
"prefix": "fk_live_aB3d",
"scopes": ["READ", "WRITE"],
"rateLimit": 600,
"expiresAt": "2025-07-01T00:00:00.000Z",
"createdById": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"createdAt": "2025-01-02T10:00:00.000Z"
}
}
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (duplicate name is not an error — names are unique per
workspace, so this returns CONFLICT).
Events emitted: api_key.created (audit, actor + key name; the raw key is
never written to the audit log).
7.2 GET /workspaces/:workspaceId/api-keys
- Auth: JWT (Owner/Admin).
Returns { data: [ {id, name, prefix, scopes, rateLimit, lastUsedAt, expiresAt, revokedAt, createdById, createdAt} ] } — never the raw key.
7.3 DELETE /workspaces/:workspaceId/api-keys/:apiKeyId
- Auth: JWT (Owner/Admin).
- 204 No Content — revokes immediately. Idempotent.
Events emitted: api_key.revoked (audit).
8. OAuth2 (Authorization Code)
Third-party applications can obtain access to a user's workspaces via OAuth2 authorization code flow with PKCE (mandatory for public clients).
Registered clients carry client_id, a redirect_uris allow-list, and a
grant type of authorization_code (+ refresh_token).
8.1 Flow
1. App redirects user:
https://<host>/oauth/authorize?
response_type=code
&client_id=cli_01J9ZKT…
&redirect_uri=https://app.example.com/callback
&scope=pages:read databases:write
&state=xyz123
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
2. User logs in (if needed) and approves.
Browser is redirected:
https://app.example.com/callback?code=oc_…&state=xyz123
3. App exchanges the code:
POST /api/v1/oauth/token
Authorization: Basic base64(client_id:client_secret) (confidential clients)
{ "grant_type": "authorization_code",
"code": "oc_…",
"redirect_uri": "https://app.example.com/callback",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
4. API responds:
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900,
"refresh_token": "rft_…", "scope": "pages:read databases:write" }
5. Later: refresh with grant_type=refresh_token (rotation, same rules as §1.2).
Scope catalog (the only values a request may grant; apps offer a subset):
pages:read, pages:write, databases:read, databases:write, files:read,
files:write — resource:action, where <resource>:read grants VIEW on that
resource and <resource>:write grants EDIT. There is deliberately no scope
that reaches FULL_ACCESS (owner/admin-only routes stay closed to OAuth
tokens; see C-13.7d in docs/tasks/phase-13-api.md).
8.2 Endpoints
| Endpoint | Purpose |
|---|---|
GET /oauth/authorize |
Browser redirect; renders consent screen (login first if anonymous). 200 HTML, or 302 to redirect_uri with code + state on approval, error=access_denied on decline. |
POST /oauth/token |
Token endpoint (JSON). Supports authorization_code and refresh_token grants. |
POST /oauth/revoke |
{"token": "…", "token_type_hint": "refresh_token"} → 200 (RFC 7009; always 200, even for unknown tokens). |
GET /oauth/clients/:clientId |
Introspection of the client's public metadata (name, logo, scopes offered). |
Token endpoint errors (JSON, same canonical envelope as other errors —
{ error: { code, message, details?, requestId } }):
| Status | code |
Meaning |
|---|---|---|
| 400 | INVALID_GRANT |
Code expired/used, wrong PKCE verifier, or a refresh token that is dead (rotated, revoked, or family-killed). |
| 400 | INVALID_CLIENT |
Client authentication failed — client_id mismatch, or a confidential client with a missing or wrong client_secret. |
| 400 | INVALID_SCOPE |
scope contains a value the client does not offer. |
Refresh rotation + reuse detection (same family model as §1.2, applied to
the OAuth token family): every grant_type=refresh_token issues a new
refresh token and invalidates the presented one. A presentation of an already
rotated (dead) refresh token is treated as a token-reuse attack: the whole
token family is revoked (the attacker and the victim's live token both die)
and oauth.token_revoked is audited with reason: "reuse_detected". Note
this is intentionally STRICTER than §1.2's login-session nuance (which
distinguishes a benign stale retry with a live successor); the OAuth contract
specifies plain family-kill on old-token reuse. See C-13.7e.
Issued JWTs contain client_id and the granted scope instead of a fixed
workspaceId (plus type:"oauth" and a jti used for per-token revocation);
each request must carry X-Factory-Workspace, validated against the user's
memberships and the scope grant. Scope → permission cap: any *:write
scope → EDIT, only *:read → VIEW, none → NONE (there is no scope that
reaches FULL_ACCESS, so owner/admin-only routes stay closed to OAuth tokens).
Events emitted: oauth.app_created, oauth.app_updated,
oauth.app_deleted, oauth.code_issued, oauth.token_issued,
oauth.token_revoked (audit).
8.3 App registration
A workspace's OWNER or ADMIN registers third-party OAuth apps through the
workspace-scoped Integration surface (stored as
integrations.config.oauthApp for provider: "oauth"). The client_secret
is returned exactly once (on create) and stored only as a SHA-256 hash;
it is never included in list/get responses. Public clients ("public": true)
have no secret and are required to use PKCE at authorize time.
| Endpoint | Purpose |
|---|---|
POST /workspaces/:workspaceId/oauth/apps |
Create an app. Body: { name, redirectUris[], scopes[], logoUrl?, public? } → 201 with { id, clientId, clientSecret?, name, public, redirectUris, scopes, createdAt } (clientSecret present only for confidential clients). |
GET /workspaces/:workspaceId/oauth/apps |
List the workspace's apps ({ data: [ … ] }, no secrets). |
PATCH /workspaces/:workspaceId/oauth/apps/:clientId |
Update name / logoUrl / redirectUris / scopes. |
DELETE /workspaces/:workspaceId/oauth/apps/:clientId |
Delete the app and drain all of its in-flight authorization codes and refresh tokens (families revoked, reason: "app_deleted"). → 200 { deleted: true, clientId }. |
All four are role-gated to OWNER/ADMIN (a MEMBER gets 403 { reason: "oauth_role_denied" } — and an OAuth token can never manage apps
since it cannot reach FULL_ACCESS; see C-13.7d). redirectUris entries must
be absolute http(s) URLs and are matched exactly at authorize time.
scopes are filtered to the six-scope catalog (pages:read|write,
databases:read|write, files:read|write); at least one valid scope is
required.
9. Standard Error Model (Canonical)
Every non-2xx response from the API uses the same envelope, produced by the
typed ApiError class:
{
"error": {
"code": "PERMISSION_DENIED",
"message": "You do not have EDIT access to this page.",
"details": [
{ "field": "permission", "issue": "requires EDIT level, you have VIEW" }
],
"requestId": "req_01J9ZKV2A8B4C6D8E0F2G4H6J8"
}
}
| Field | Type | Notes |
|---|---|---|
code |
string | Stable machine-readable code (table below). Never localize. |
message |
string | Human-readable, safe to surface in UIs. |
details |
array (optional) | Field-level or contextual info. For validation errors: one entry per failed constraint (field, issue, received). |
requestId |
string | Matches the X-Request-Id response header; include in support reports. |
9.1 Error Code Table (THE canonical table)
| Code | HTTP | Meaning | Typical details |
|---|---|---|---|
VALIDATION_ERROR |
400 | Failed request validation (Zod). | [{field, issue, received}] per constraint. |
UNAUTHORIZED |
401 | Missing, expired, or invalid credentials (JWT, refresh token, API key). | [{reason: "token_expired" | "token_malformed" | "refresh_token_reused" | "api_key_revoked" | "api_key_expired"}] |
AI_BUDGET_EXCEEDED |
402 | The workspace's monthly AI token budget is exhausted (AI endpoints only). 402 signals "payment/budget required" — distinct from RATE_LIMITED 429. |
[{reason: "monthly_token_budget_exceeded", used, limit}] |
FORBIDDEN |
403 | Authenticated, but not permitted (RBAC/resource level, scope, wrong credential type). | [{reason: "requires_EDIT" | "requires_user_credentials" | "api_key_scope_write" | "account_suspended"}] |
NOT_FOUND |
404 | Resource does not exist or is not visible to you (never leak existence across permission boundaries). | — |
CONFLICT |
409 | State conflict: duplicate unique value, stale version (optimistic concurrency), invitation already accepted, token already used. | [{field, issue: "already_exists" | "version_mismatch", expected, received}] |
PRECONDITION_FAILED |
412 | If-Match / version check failed on an update. |
[{expectedVersion, actualVersion}] |
GONE |
410 | Resource existed but is permanently gone (expired one-time token). | [{reason: "token_expired"}] |
UNPROCESSABLE_ENTITY |
422 | Syntactically valid but semantically invalid (e.g. filter tree references a deleted property, circular page nesting). | [{issue, resource}] |
FORMULA_TIMEOUT |
422 | A syntactically valid formula exceeded the evaluation budget: the AST evaluator's 100 000-op limit (EVAL_OP_LIMIT) or the 50 ms cooperative wall-clock backstop (EVAL_TIMEOUT_MS). Distinct from VALIDATION_ERROR/UNPROCESSABLE_ENTITY (syntax/semantics) — this is a resource-guard rejection (TASK-0134; the table is extensible by design). |
[{reason: "formula_budget", opLimit: 100000, timeoutMs: 50}] |
RATE_LIMITED |
429 | Too many requests (see §11 Rate Limiting). | [{retryAfterSeconds}] |
PAYLOAD_TOO_LARGE |
413 | Body exceeds the per-route limit (e.g. block batch > 1 MB). | [{maxBytes}] |
METHOD_NOT_ALLOWED |
405 | Known path, wrong verb. | [{allowedMethods: ["GET", "PATCH"]}] |
INTERNAL_ERROR |
500 | Unhandled server error. | — |
SERVICE_UNAVAILABLE |
503 | Dependency down (OpenSearch, object storage, Redis) or workspace in degraded state. | [{service: "opensearch"}] |
NOT_IMPLEMENTED |
501 | Endpoint not enabled in this deployment/plan. | [{feature}] |
Conventions:
- AI errors (Phase 12): the AI gateway normalizes all provider failures
into this table — provider auth/config failure and timeouts surface as
SERVICE_UNAVAILABLE(details[0]={service: "ai", reason: "ai" | "ai_timeout", retryable?}), provider rate limits asRATE_LIMITED— never as raw provider error bodies.AI_BUDGET_EXCEEDED(402) is a new code added in Phase 12 (recorded decision: the table is extensible by design; the frozen@factory/sharedpackage carries the code indetails.codeof the sharedAI_ERRORenvelope, which the API maps to this top-level code). - Existence is hidden across permission boundaries: a page you cannot see
returns
404 NOT_FOUND, never403 FORBIDDEN. - Optimistic concurrency:
PATCH/POSTon pages, blocks, and records accept an optionalIf-Match: "<version>"header (orversionbody field); a stale version yields409 CONFLICT/412 PRECONDITION_FAILEDwith the actual version so clients can rebase. - Idempotency: mutation endpoints honor
Idempotency-Key(echoed back in the response headerIdempotency-Replayed: truewhen a cached result is returned within 24 h).
9.2 Validation Error Example
POST /api/v1/pages with a bad body:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"details": [
{ "field": "title", "issue": "string too short (min 0, received NaN)" },
{ "field": "parentId", "issue": "invalid cuid" }
],
"requestId": "req_01J9ZKX5B0C2D4E6F8G0H2I4J6"
}
}
10. Pagination (Cursor-Based, All List Endpoints)
All list endpoints use the same cursor scheme.
Request:
| Param | Type | Default | Notes |
|---|---|---|---|
cursor |
string | – | Opaque base64 cursor from the previous page's nextCursor. Never parse it. |
limit |
int | 20 | Clamped per-endpoint (see each doc; max 100 for most, 200 for search). |
Response envelope (identical everywhere):
{
"data": [ "…" ],
"nextCursor": "c2VzXzAxSlpaS1E5RDBHNUk5SzFMMy"
}
nextCursor: null(or field omitted) means last page.- Cursors are stable for reads but not across writes: inserting rows can shift the result set; for live feeds, re-query from the start.
- Ordering within a page is deterministic and documented per endpoint
(usually
createdAt DESCor position order).
11. Rate Limiting
Rate limits are applied per (credential, IP) using a Redis sliding window.
All buckets below are enforced server-side; the exact defaults are pinned in
tests/security/rate-limits.test.ts (a change to a limit without a new
finding + justification fails CI).
| Bucket | Key | Default limit | Notes |
|---|---|---|---|
| Per credential / IP (API) | rl:api:<user|ip> |
100 req/min (RATE_LIMIT_API) |
The generic window for authenticated (user) and anonymous (IP) API calls. |
| Auth endpoints (login / register / reset) | rl:auth:<user|ip> |
10 req/min (RATE_LIMIT_AUTH) |
The tighter bucket for the credential endpoints. |
| Per API key | window per key | 600 req/min (RATE_LIMIT_API_KEY), or the key's own rateLimit if set |
The key's rateLimit always wins (set at key creation); applies on top of the per-credential window. |
| Incoming webhook (per webhook) | auto:webhook-rate:<webhookId> |
60 req/min (WEBHOOK_RATE_PER_MINUTE) |
New in TASK-0134. Enforced on POST /api/v1/webhooks/incoming/:webhookId before signature verification (rate-limited callers never reach signature work). 429 body carries details[0].retryAfterSeconds: 60. |
| Public form submission (per IP + form) | form:rl:{slug}:{ip} |
5 req/min (FORM_SUBMIT_LIMIT, 60 s window) |
New in TASK-0134. Enforced on POST /api/v1/public/forms/:slug/submit per (IP, form). 429 returns the Retry-After: 60 header + details[0].retryAfterSeconds: 60. |
Spec delta (recorded, AGENTS rule 29): the task spec's example for public-form is 20/min/IP+form; this release ships the stricter 5/min (a public form accepting more than a handful of submissions per minute per IP is an abuse signal). The incoming-webhook bucket matches the spec (60/min). Weakening either value requires a new finding + justification in
docs/security/audit-report.md.Deployment note:
RATE_LIMIT_API/RATE_LIMIT_AUTHare deployment tunables (the dev.envraises them for load testing; CI raises them so the other test suites don't trip them). The two new buckets are NOT env tunables — they are fixed platform limits (a public webhook/form endpoint must be bounded regardless of deployment), which is what makes them live-testable at their documented value in CI.
Headers are returned on every response:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1735690500 # unix seconds when the window resets
Retry-After: 32 # only present on 429 responses
Exceeding a bucket returns 429 RATE_LIMITED with
details: [{retryAfterSeconds: 32}] and the Retry-After header
(the incoming-webhook 429 uses WEBHOOK_RATE_LIMITED; the public-form 429
uses RATE_LIMITED + the Retry-After header).
Best practice: when you receive 429, stop, wait Retry-After seconds
(jitter ±10%), then resume with linear backoff up to 5 s.
12. IDs & Timestamps
- IDs are CUID-style strings with a kind prefix, stable and sortable
(time-ordered prefix):
usr_…,wks_…,ses_…,pg_…,blk_…,db_…,rpt_…(records),cmt_…,fil_…,webh_…,apk_…,tm_…(teams),inv_…,view_…,prop_…. - Timestamps are ISO-8601 UTC with millisecond precision:
2025-01-02T10:00:00.000Z. - Money/units are not applicable; numeric fields are JSON numbers.
- Soft-deleted resources (pages → trash, archived records) keep their ids
and remain addressable for restore; hard-deleted resources return
404 NOT_FOUND.
13. Quick Reference: curl
# Register
curl -s -X POST https://factory.example.com/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"c0rrect-Horse-battery9","name":"Ada Lovelace"}'
# Login
curl -s -X POST https://factory.example.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"c0rrect-Horse-battery9"}'
# Authenticated call (JWT)
curl -s https://factory.example.com/api/v1/users/me \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Refresh (rotation)
curl -s -X POST https://factory.example.com/api/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$REFRESH_TOKEN\"}"
# Authenticated call (API key)
curl -s https://factory.example.com/api/v1/pages \
-H "Authorization: Bearer fk_live_aB3dE5fG7hI9jK1lM3nO5pQ7rS9tUvWxYz" \
-H "X-Factory-Workspace: wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1"
# Revoke session
curl -s -X DELETE https://factory.example.com/api/v1/sessions/ses_01J9ZKQ9D0G7I9K1L3M5N7P9Q1R3 \
-H "Authorization: Bearer $ACCESS_TOKEN" -o /dev/null -w "%{http_code}\n"