| name | custom-http-rest-api |
|---|---|
| description | Language-agnostic REST API design spec (.json, .ex, .rb, .py). Covers OpenAPI 3.1 contract, token auth, versioning, pagination (offset + cursor), sorting, filtering (operator grammar), sparse fieldsets, sideloading, HATEOAS, rate limiting, error codes (RFC 9457), concurrency (ETags), idempotency, bulk operations, async jobs, webhooks, CORS, security headers, and JSONB replace semantics. |
A language-agnostic specification for building consistent, production-grade JSON REST APIs. All APIs following this spec share the same authentication model, response format, pagination, error handling, and query conventions.
Note: All payload examples in this document are illustrative. Replace resource names, fields, and values with those specific to your application.
- Workspace & Token Model
- Token Format & Storage
- Token Authentication
- Token Scopes
- Token Revocation
- Versioning
- Base URL & Conventions
- HTTPS Requirement
- CORS & Security Headers
- Request Correlation
- Concurrency Control (ETags)
- Idempotency
- Response Format
- PUT vs PATCH Semantics
- Pagination
- Sorting
- Full-Text Search
- POST /search Escape Hatch
- Sparse Fieldsets
- Filtering
- Sideloading (Include)
- HATEOAS Links
- Rate Limiting
- Error Handling
- Bulk Operations
- Async / Long-Running Operations
- Webhooks & Events
- File Uploads
- Money & Decimal Values
- Soft Deletes
- Enums Endpoint
- Agents Endpoint
- Token Info Endpoint
- Nested Read-Only Endpoints
- Health Check
- OpenAPI 3.1 Specification
Every API built with this spec MUST have an OpenAPI 3.1 document. This is the single source of truth for the API contract -- it drives client generation, validation, documentation portals, and automated testing.
| File | Format | Purpose |
|---|---|---|
openapi.yaml (or openapi.json) |
OpenAPI 3.1 | Machine-readable API contract at the project root |
The spec MUST be served by the application itself at a public, unauthenticated endpoint:
GET /api/v1/openapi.json
Rules:
- No authentication required -- the spec is public so tooling and agents can discover it
- Content-Type:
application/json(orapplication/x-yamlif serving YAML) - In development, read from disk (
openapi.yamlat the project root). In production releases, serve frompriv/static/openapi.jsonor equivalent (bundled at build time) - The response MUST match the actual API behavior -- if the spec says a field is required, the server enforces it
Every endpoint described in this document must appear in the OpenAPI spec with:
- Paths and operations -- every route with its HTTP method, summary, and
operationIdfollowing the naming convention:listItems,getItem,createItem,updateItem,deleteItem,searchItems,bulkCreateItems - Request bodies -- full schema for POST/PUT/PATCH payloads, with
requiredfields marked. Document which fields are required vs optional, allowed fields, and how relationships are handled (foreign key IDs) - Response schemas -- the
{"data": ...}envelope, pagination object, error envelope (RFC 9457), and per-resource shapes - Parameters -- query parameters (
page,per_page,cursor,sort,q,filter[*],fields,include,with_count), path parameters (:idas UUID format) - Authentication --
securitySchemesdefining the Bearer token, applied globally with per-endpoint overrides for public routes (/health/live,/health/ready,/api/v1/agents,/api/v1/openapi.json) - Error responses --
400,401,403,404,409,412,422,429,500with their schemas following RFC 9457 - Enums -- field values that use fixed lists must use
enumin the schema (matching/api/v1/enumsoutput) - Examples -- at least one
exampleorexamplesblock per operation - Headers -- document
ETag,If-Match,If-None-Match,Idempotency-Key,RateLimit,RateLimit-Policy,Retry-After,X-Request-Id,Deprecation,Sunsetheaders - Polymorphic payloads -- use
discriminatorwhere applicable - $ref reuse -- shared schemas, parameters, and response objects MUST use
$reffor DRY specs
openapi: "3.1.0"
info:
title: "MyApp API"
version: "1.0.0"
description: "Short description of the API."
servers:
- url: /api/v1
description: "API v1"
paths:
/items:
get:
operationId: listItems
summary: "List all items"
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Sort"
- $ref: "#/components/parameters/SearchQuery"
- $ref: "#/components/parameters/WithCount"
responses:
"200":
description: "Paginated list of items"
headers:
RateLimit:
$ref: "#/components/headers/RateLimit"
RateLimit-Policy:
$ref: "#/components/headers/RateLimitPolicy"
content:
application/json:
schema:
$ref: "#/components/schemas/ItemListResponse"
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
headers:
RateLimit:
description: "Current rate limit state (IETF draft)"
schema:
type: string
RateLimitPolicy:
description: "Rate limit policy description"
schema:
type: string
ETag:
description: "Entity tag for conditional requests"
schema:
type: string
parameters:
Page:
name: page
in: query
schema:
type: integer
default: 1
minimum: 1
PerPage:
name: per_page
in: query
schema:
type: integer
default: 100
minimum: 1
maximum: 500
Cursor:
name: cursor
in: query
description: "Opaque cursor for cursor-based pagination"
schema:
type: string
Sort:
name: sort
in: query
description: "Comma-separated fields. Prefix with - for descending."
schema:
type: string
example: "-inserted_at,name"
SearchQuery:
name: q
in: query
schema:
type: string
WithCount:
name: with_count
in: query
description: "Set to false to skip total_count computation"
schema:
type: boolean
default: true
schemas:
OffsetPagination:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total_count: { type: integer, nullable: true }
total_pages: { type: integer, nullable: true }
CursorPagination:
type: object
properties:
per_page: { type: integer }
has_more: { type: boolean }
next_cursor: { type: string, nullable: true }
prev_cursor: { type: string, nullable: true }
ErrorResponse:
type: object
description: "RFC 9457 Problem Details"
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string, format: uri }
errors: { type: object }
security:
- BearerAuth: []- The OpenAPI spec is a first-class artifact -- it lives in version control alongside the code and is updated in the same PR that changes the API
- CI should validate the spec on every push (use a linter like
spectraloropenapi-generator validate) - If a new endpoint, field, or enum value is added without updating the spec, the PR is incomplete
- For frameworks that can generate specs from code annotations (e.g., Phoenix with
open_api_spex, Rails withrswag, FastAPI with built-in OpenAPI), prefer generation over hand-maintained YAML -- but always review the output
| Endpoint | Format | Audience |
|---|---|---|
GET /api/v1/openapi.json |
OpenAPI 3.1 JSON | Machines, code generators, SDK builders, CI validators |
GET /api/v1/agents |
Plain text / Markdown | AI agents and LLMs (compact, narrative) |
GET /api/v1/enums |
JSON | UI builders, form validators (live enum values) |
All three must describe the same API. The OpenAPI spec is the canonical reference; the agents endpoint is a human-readable summary; the enums endpoint provides runtime values.
Every API is multi-tenant via workspaces.
- A user belongs to many workspaces
- A workspace has many API tokens
- Each token is scoped to exactly one workspace
- The
workspace_idis resolved server-side from the token -- clients never send it
User --< WorkspaceUser >-- Workspace --< Token
Tokens are the sole authentication mechanism for the API. A workspace can have multiple tokens with different scopes (e.g., a read-only token for dashboards, a scoped token for a bot that only writes observations).
Tokens follow a structured, human-readable naming convention:
{app}_{env}_{hash}
| Segment | Description | Example Values |
|---|---|---|
{app} |
Application prefix (unique per API) | crm, inv, cms |
{env} |
Environment where the token was generated | prod, dev, test |
{hash} |
Cryptographically secure random string (64 hex chars minimum) | a1b2c3d4e5f6... |
Entropy target: minimum 256 bits (64 hex characters).
Examples:
crm_prod_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
crm_dev_1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef901234ab
inv_prod_f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3
Why this format:
- Prefix makes tokens identifiable at a glance (which app, which env)
- The hash portion provides cryptographic security
- Easy to grep in logs:
grep "crm_prod_"finds all production tokens - Permissions are NOT embedded in the token string -- they live in the database as scopes (avoids leaking permission info in logs/headers)
Tokens MUST be hashed before storage. Never store raw tokens in the database.
| Column | Content | Purpose |
|---|---|---|
token_prefix |
First 12 characters (e.g., crm_prod_a1b2) |
Lookup and identification in UI |
token_hash |
argon2id or bcrypt hash of the full token | Verification |
Rules:
- On creation, return the raw token exactly once in the response. After that, only the prefix is visible
- Use constant-time comparison when verifying tokens (e.g.,
Plug.Crypto.secure_compare/2,hmac.compare_digest(),crypto.timingSafeEqual()) to prevent timing attacks - Use argon2id (preferred) or bcrypt for hashing -- never SHA-256 or plain hashing
- The
token_prefixis stored in plaintext for lookup: find the token row by prefix, then verify the full token against the hash
All /api/v1/* endpoints (except explicitly public ones) require a Bearer token:
Authorization: Bearer {app}_{env}_{hash}
Enforcement rules:
- Missing or invalid token returns
401 - Revoked or expired token returns
401 - Token determines the workspace -- all queries are automatically scoped
- Scope violations return
403
Each token carries a list of scopes that define exactly what it can do. Scopes follow the OAuth2 pattern: {resource}:{action}.
{resource}:{action}
| Action | Meaning | Allowed HTTP Methods |
|---|---|---|
read |
Read-only access to the resource | GET, HEAD, OPTIONS |
write |
Full access (implies read) |
GET, POST, PUT, PATCH, DELETE |
Each API defines its own resource list matching its endpoint names. Example:
| Resource | Endpoints covered |
|---|---|
companies |
/api/v1/companies, /api/v1/companies/:id, nested sub-resources |
people |
/api/v1/people, /api/v1/people/:id, nested sub-resources |
opportunities |
/api/v1/opportunities, /api/v1/opportunities/:id |
observations |
/api/v1/observations, /api/v1/observations/:id |
actions |
/api/v1/actions, /api/v1/actions/:id |
files |
/api/v1/files, /api/v1/files/:id |
pipeline_stages |
/api/v1/pipeline-stages |
all:read-- read access to every resourceall:write-- full access to every resource
// Full access to everything
{"scopes": ["all:write"]}
// Read-only dashboard token
{"scopes": ["all:read"]}
// Research bot: reads companies/people, writes observations
{"scopes": ["companies:read", "people:read", "observations:write"]}
// Integration: manages companies and people only
{"scopes": ["companies:write", "people:write"]}
// Reporting: read-only on deals
{"scopes": ["opportunities:read", "pipeline_stages:read"]}writeimpliesread-- a token withcompanies:writecan also GET companies- Scopes are stored as a JSON array on the token record:
["companies:read", "observations:write"] - A token with an empty scopes array
[]has no access (zero-trust default) - The valid scopes list is discoverable via
GET /api/v1/enums - Nested sub-resources inherit the parent's scope:
/api/v1/companies/:id/peoplerequirescompanies:read - Polymorphic link endpoints (e.g.,
/api/v1/observations/:id/links) require the parent resource's scope
- Read-your-writes: a
write-only scope impliesread(see table above), so a token that just created a resource can GET it - Cross-resource writes: creating a resource that references another (e.g., an observation linking to a company) requires
writeon the created resource andreadon the referenced resource. If the referenced resource's scope is missing, return422with details - 403 vs 404: if a resource exists but the token lacks the scope to access it, return
403 Forbidden. If the resource belongs to a different workspace (cross-tenant), return404 Not Found(no information leakage)
- Auth plug -- validates token, resolves workspace, loads scopes
- Scopes plug -- maps the request (HTTP method + path) to a required scope, checks if the token has it
- If the required scope is missing ->
403 Forbidden
{
"type": "https://api.example.com/problems/insufficient-scope",
"title": "Insufficient Scope",
"status": 403,
"detail": "Token does not have the required scope.",
"required_scope": "observations:write",
"token_scopes": ["companies:read", "people:read"]
}When creating a token, scopes are required:
curl -X POST /ui/workspaces/my-workspace/tokens \
-H "Content-Type: application/json" \
-d '{
"name": "research-bot",
"scopes": ["companies:read", "people:read", "observations:write"]
}'GET /api/v1/token includes scopes:
{
"data": {
"workspace": {"id": "uuid", "slug": "acme-corp"},
"scopes": ["companies:read", "people:read", "observations:write"],
"rate_limit": {"limit": 240, "remaining": 115, "reset_at": 1710590460}
}
}- Scope-to-route mapping should be derived from the router, not hardcoded per controller. Map the first path segment after
/api/v1/to the resource name. - Store scopes as a JSONB array column on the tokens table.
- Valid scopes should be generated dynamically from the list of API resources (same as enums -- the router or a module attribute is the source of truth).
Tokens can be revoked via the API or the UI.
Endpoint: DELETE /api/v1/tokens/:id
Authentication: Required. The token performing the revocation must have workspace admin privileges or be the token itself (self-revocation).
curl -X DELETE /api/v1/tokens/token-uuid \
-H "Authorization: Bearer $ADMIN_TOKEN"Response: 204 No Content
Rules:
- Revoked tokens immediately stop working for all subsequent requests
- Revocation is permanent -- revoked tokens cannot be re-enabled (create a new one instead)
- A token can revoke itself (self-revocation)
- The last admin token in a workspace cannot be revoked (prevents lockout)
All API endpoints are prefixed with a version number:
/api/v1/resources
/api/v1/resources/:id
Rules:
- Always include the version prefix:
/api/v1/ - Bump the major version (
v2) only for breaking changes:- Removing a field from the response
- Changing the type of an existing field
- Renaming an endpoint
- Changing required fields on create/update
- Non-breaking additions (new optional fields, new endpoints) do NOT require a version bump
- When
v2is introduced,v1continues to work for a documented deprecation period - The version applies to the entire API -- do not version individual endpoints differently
URL-based versioning (/api/v1/) was chosen over header-based (Accept: application/vnd.myapp.v2+json) for these reasons:
- Simpler to use in browsers, curl, and documentation
- Easier to route at the load balancer / reverse proxy level
- More discoverable -- the version is visible in every URL
- Avoids content negotiation complexity
When a new major version ships:
- The previous version is supported for 12 months after the new version reaches GA
- Responses from deprecated versions include
DeprecationandSunsetheaders (RFC 8594):
Deprecation: true
Sunset: Sat, 01 Mar 2028 00:00:00 GMT
- The
/api/v1/agentsand OpenAPI spec document the deprecation timeline - Clients calling deprecated endpoints receive a
Warningresponse header:
Warning: 299 - "API v1 is deprecated. Migrate to v2 by 2028-03-01."
| Convention | Value |
|---|---|
| Base path | /api/v1 |
| Content-Type | application/json (except file uploads: multipart/form-data) |
| IDs | UUIDs (v4), lowercase, always strings |
| Timestamps | UTC, ISO 8601 format (2026-03-15T10:00:00Z). Always store and transmit in UTC. Client-side display in user-local timezones is the client's responsibility. Zone-aware filters (e.g., filter[inserted_at][gte]) accept UTC only |
| Timestamp fields | Every resource includes inserted_at and updated_at |
| Null handling | Absent optional fields are returned as null, never omitted |
| Empty arrays | Returned as [], never null |
| Boolean params | Accept true / false as strings in query params. 1/0, yes/no are rejected with 400 |
Fields stored as JSONB (maps or arrays -- e.g., emails, phones, addresses, metadata) use replace semantics on update, not merge. Sending a value on PUT/PATCH overwrites the entire field.
Lost-update risk: Because JSONB fields use replace semantics, clients MUST read-then-write when partially updating these fields. This creates a race condition window. Use If-Match / ETag headers (see Concurrency Control) to detect conflicts. Without ETags, concurrent updates to the same JSONB field will silently lose data.
Semantics of special values on PATCH:
| Value sent | Meaning |
|---|---|
{"key": {}} |
Replace the field with an empty map |
{"key": null} |
Set the field to SQL NULL |
| Key absent | Field is not touched (PATCH only) |
This means: to add an item to an array field, you must send all existing items plus the new one. To remove a key from a map field, send the map without that key.
Example -- adding an email to a contact that already has one:
# Current state: {"emails": [{"email": "old@example.com", "label": "work"}]}
# WRONG -- this removes the existing email:
curl -X PATCH /api/v1/contacts/:id \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"a1b2c3\"" \
-d '{"emails": [{"email": "new@example.com", "label": "personal"}]}'
# Result: {"emails": [{"email": "new@example.com", "label": "personal"}]}
# CORRECT -- send all existing items plus the new one:
curl -X PATCH /api/v1/contacts/:id \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"a1b2c3\"" \
-d '{"emails": [
{"email": "old@example.com", "label": "work"},
{"email": "new@example.com", "label": "personal"}
]}'This applies to all JSONB fields across all resources. Clients should always read-then-write with ETags when partially updating these fields.
All API traffic MUST use HTTPS (TLS 1.2+). This is non-negotiable.
- Servers MUST redirect HTTP to HTTPS or refuse HTTP connections entirely
- Bearer tokens sent over plain HTTP are considered compromised
- Include
Strict-Transport-Securityheader on all responses (see CORS & Security Headers)
Every response MUST include these headers:
| Header | Value |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none' |
Referrer-Policy |
strict-origin-when-cross-origin |
If the API serves browser clients, configure CORS:
| Header | Value |
|---|---|
Access-Control-Allow-Origin |
Explicit origin(s), never * for authenticated APIs |
Access-Control-Allow-Methods |
GET, POST, PUT, PATCH, DELETE, OPTIONS |
Access-Control-Allow-Headers |
Authorization, Content-Type, If-Match, If-None-Match, Idempotency-Key, X-Request-Id |
Access-Control-Expose-Headers |
ETag, RateLimit, RateLimit-Policy, Retry-After, X-Request-Id, Link, Deprecation, Sunset |
Access-Control-Max-Age |
86400 |
Handle OPTIONS preflight requests by returning 204 No Content with the CORS headers.
Every request SHOULD include a X-Request-Id header for distributed tracing. If the client does not provide one, the server MUST generate a UUID and include it in the response.
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
Rules:
- The
X-Request-Idis echoed back in every response - It is included in all server-side logs for this request
- It is propagated to downstream services
- For W3C Trace Context compatibility, also support
traceparentheader if present
All single-resource responses include an ETag header for optimistic concurrency control. This prevents lost updates when multiple clients modify the same resource concurrently.
- GET returns an
ETagheader:
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6"
Content-Type: application/json
{"data": {"id": "uuid", "name": "Item A", ...}}
- PUT/PATCH sends
If-Matchwith the known ETag:
curl -X PATCH /api/v1/items/:id \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"a1b2c3d4e5f6\"" \
-d '{"name": "Item A Updated"}'- If the resource changed since the ETag was issued ->
412 Precondition Failed:
{
"type": "https://api.example.com/problems/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "The resource has been modified since your last read. Re-fetch and retry."
}Clients can use If-None-Match for cache validation:
curl /api/v1/items/:id \
-H "Authorization: Bearer $TOKEN" \
-H "If-None-Match: \"a1b2c3d4e5f6\""If the resource has not changed -> 304 Not Modified (empty body, saves bandwidth).
ETagis required on all single-resource GET, PUT, and PATCH responsesIf-Matchis required on PUT requests (full replace demands conflict detection)If-Matchis recommended on PATCH requestsIf-Matchheader missing on PUT returns428 Precondition Required- ETag value is an opaque string (typically a hash of
updated_at+id, or a version counter) - Collection endpoints do NOT return ETags (use cursor pagination instead for consistency)
- Hash of
updated_attimestamp at microsecond precision forETag - ETags must change on every update, including updates that set the same values
POST requests (creates) support an Idempotency-Key header to make retries safe. This follows the Stripe convention.
curl -X POST /api/v1/items \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: client-generated-uuid-here" \
-d '{"name": "New Item"}'- Client generates a unique key (UUID recommended) and sends it with the POST
- Server stores the key + response on first execution
- If the same key is sent again (retry), server returns the stored response without re-executing
- Keys expire after 24 hours
Idempotency-Keyis optional but strongly recommended for all POST requests- Key must be unique per workspace (same key in different workspaces are independent)
- Sending the same key with a different request body returns
422with an error explaining the mismatch - GET, PUT, PATCH, DELETE are naturally idempotent and do not use this header
- The server stores: key, status code, response headers, response body
- Keys are scoped to the workspace (derived from the token)
On replay, the response includes a header indicating it was served from cache:
Idempotency-Replayed: true
All responses are wrapped in a {"data": ...} envelope.
Single resource:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Example Item",
"status": "active",
"metadata": {},
"inserted_at": "2026-03-15T10:00:00Z",
"updated_at": "2026-03-15T10:00:00Z",
"links": {
"self": "/api/v1/items/550e8400-e29b-41d4-a716-446655440000"
}
}
}Collection (offset pagination):
{
"data": [
{"id": "uuid-1", "name": "Item A", "links": {"self": "/api/v1/items/uuid-1"}},
{"id": "uuid-2", "name": "Item B", "links": {"self": "/api/v1/items/uuid-2"}}
],
"pagination": {
"page": 1,
"per_page": 100,
"total_count": 42,
"total_pages": 1
},
"links": {
"self": "/api/v1/items?page=1&per_page=100",
"first": "/api/v1/items?page=1&per_page=100",
"last": "/api/v1/items?page=1&per_page=100",
"next": null,
"prev": null
}
}Collection (cursor pagination):
{
"data": ["..."],
"pagination": {
"per_page": 100,
"has_more": true,
"next_cursor": "eyJpZCI6InV1aWQtMTAwIn0=",
"prev_cursor": null
},
"links": {
"self": "/api/v1/items?per_page=100",
"next": "/api/v1/items?cursor=eyJpZCI6InV1aWQtMTAwIn0=&per_page=100",
"prev": null
}
}HTTP status codes for success:
| Operation | Status | Body |
|---|---|---|
GET (single) |
200 OK |
{"data": {...}} |
GET (collection) |
200 OK |
{"data": [...], "pagination": {...}} |
POST (create) |
201 Created |
{"data": {...}} |
PUT (full replace) |
200 OK |
{"data": {...}} |
PATCH (partial) |
200 OK |
{"data": {...}} |
DELETE (soft) |
204 No Content |
Empty body |
POST (async job) |
202 Accepted |
{"data": {"job_id": "...", ...}} |
Pagination Link headers (RFC 5988):
All collection responses also include a Link header for machine-readable pagination:
Link: </api/v1/items?page=2&per_page=100>; rel="next", </api/v1/items?page=5&per_page=100>; rel="last"
PUT and PATCH have distinct, well-defined behaviors. Do not conflate them.
| Method | Semantics | Required fields | Missing fields |
|---|---|---|---|
PUT |
Full resource replace | All required fields must be present | Set to null/default |
PATCH |
Partial update (merge) | Only the fields you want to change | Left unchanged |
- The request body represents the complete desired state of the resource
- Any field not included in the body is reset to its default value (or null)
If-Matchheader is required (see Concurrency Control)- JSONB fields are replaced entirely (see JSONB Replace Semantics)
- Only fields present in the request body are updated
- Absent fields are left untouched
- To explicitly set a field to null, include
"field": null - To clear a JSONB field, send
"field": null(sets to SQL NULL) or"field": {}(sets to empty map) If-Matchheader is recommended
# PATCH: only updates name, all other fields unchanged
curl -X PATCH /api/v1/items/:id \
-H "Content-Type: application/json" \
-d '{"name": "New Name"}'
# PUT: replaces the entire resource; status and category reset to defaults
curl -X PUT /api/v1/items/:id \
-H "Content-Type: application/json" \
-H "If-Match: \"etag-value\"" \
-d '{"name": "New Name", "status": "active", "category": "saas", "metadata": {}}'All collection (index) endpoints return paginated results. Two strategies are available: offset (default) and cursor.
Best for: small-to-medium datasets, UI pages with page numbers, datasets under ~100k rows.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
page |
integer | 1 |
-- | Page number (1-indexed) |
per_page |
integer | 100 |
500 |
Records per page |
with_count |
boolean | true |
-- | Set to false to skip total_count / total_pages computation |
Response:
{
"data": ["..."],
"pagination": {
"page": 2,
"per_page": 25,
"total_count": 142,
"total_pages": 6
},
"links": {
"self": "/api/v1/items?page=2&per_page=25",
"first": "/api/v1/items?page=1&per_page=25",
"prev": "/api/v1/items?page=1&per_page=25",
"next": "/api/v1/items?page=3&per_page=25",
"last": "/api/v1/items?page=6&per_page=25"
}
}When with_count=false:
{
"data": ["..."],
"pagination": {
"page": 2,
"per_page": 25,
"total_count": null,
"total_pages": null
}
}Rules:
pagebelow 1 is treated as 1per_pageabove the max is clamped to the max (500)total_countreflects the count after all filters and search are applied- An empty result returns
{"data": [], "pagination": {"page": 1, "per_page": 100, "total_count": 0, "total_pages": 0}}
Performance warning: Offset pagination degrades at scale. OFFSET N is O(N) in PostgreSQL -- page 1000 of a large table scans 100k rows. COUNT(*) is expensive on large tables. For datasets exceeding ~100k rows, use cursor pagination.
Best for: large datasets, infinite scroll, real-time feeds, data export, any dataset where offset would be slow or where results may shift during iteration.
Query parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
cursor |
string | -- | -- | Opaque cursor from a previous response |
per_page |
integer | 100 |
500 |
Records per page |
Response:
{
"data": ["..."],
"pagination": {
"per_page": 100,
"has_more": true,
"next_cursor": "eyJpZCI6InV1aWQtMTAwIn0=",
"prev_cursor": "eyJpZCI6InV1aWQtMSJ9"
},
"links": {
"self": "/api/v1/items?per_page=100",
"next": "/api/v1/items?cursor=eyJpZCI6InV1aWQtMTAwIn0=&per_page=100",
"prev": "/api/v1/items?cursor=eyJpZCI6InV1aWQtMSJ9&per_page=100&direction=prev"
}
}Rules:
- Cursors are opaque, base64-encoded strings. Clients must not parse or construct them
- Cursors encode the sort key + ID for stable ordering
has_more: falsemeans this is the last page- Invalid or expired cursors return
400 - Cursor pagination does not support
pageortotal_count(these are offset concepts)
| Use case | Strategy |
|---|---|
| Admin dashboards, page 1-10 | Offset |
| Data export, sync | Cursor |
| Infinite scroll UI | Cursor |
| Dataset > 100k rows | Cursor |
| "Jump to page N" required | Offset |
- Index the columns used for cursor pagination (
id,inserted_at, sort fields) - Cursor value: base64-encode the last row's sort key + ID, e.g.,
base64({"inserted_at":"2026-03-15T10:00:00Z","id":"uuid"}) - Use
WHERE (sort_col, id) > (cursor_sort_val, cursor_id)for forward pagination (keyset pagination)
Collection endpoints support sorting via the sort query parameter.
Format:
?sort=field1,-field2
- Fields are comma-separated
- Prefix with
-for descending order - No prefix means ascending
- Multiple fields create a compound sort (first field is primary, second is tiebreaker, etc.)
Examples:
# Sort by name ascending
curl "/api/v1/items?sort=name" -H "Authorization: Bearer $TOKEN"
# Sort by newest first, then name ascending as tiebreaker
curl "/api/v1/items?sort=-inserted_at,name" -H "Authorization: Bearer $TOKEN"
# Sort by amount descending
curl "/api/v1/items?sort=-amount" -H "Authorization: Bearer $TOKEN"Rules:
- Each resource documents which fields are sortable
- Invalid sort fields return
400with the offending field name - Default sort is
-inserted_at(newest first) unless the resource specifies otherwise - Maximum 3 sort fields per request
- Sorting composes with filters, search, and pagination
- For cursor pagination, the sort fields become part of the cursor
Implementation notes:
- Create indexes for commonly sorted fields
- Compound sorts need compound indexes for performance
- JSONB fields are not sortable (use extracted/computed columns instead)
Collection endpoints support full-text search via the q query parameter.
| Param | Type | Description |
|---|---|---|
q |
string | Search terms (multiple words are ANDed together) |
Behavior:
- Search is case-insensitive
- Multiple words are ANDed:
q=tech flowmatches records containing both "tech" and "flow" - Search works with pagination --
total_countreflects the filtered count - Search works with filters -- they compose together
- Each resource documents which fields are searchable
Example:
# Search items
curl "/api/v1/items?q=enterprise" -H "Authorization: Bearer $TOKEN"
# Search + pagination + filter + sort
curl "/api/v1/items?q=enterprise&page=1&per_page=25&filter[status][eq]=active&sort=-inserted_at" \
-H "Authorization: Bearer $TOKEN"Implementation notes:
- Recommended: PostgreSQL
tsvectorwith GIN indexes for performant full-text search - Create GIN indexes on all searchable columns
- Minimum query length: 1 character (no blank searches)
- Blank
q=orq=with only whitespace is ignored (returns unfiltered results)
When filter combinations exceed URL length limits (~2 KB), use POST /search as an alternative to GET with query parameters.
curl -X POST /api/v1/items/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"q": "enterprise",
"filter": {
"status": {"eq": "active"},
"amount": {"gte": 100, "lte": 5000},
"category": {"in": ["saas", "fintech"]}
},
"sort": ["-inserted_at", "name"],
"page": 1,
"per_page": 25
}'Rules:
POST /searchreturns the same response format asGET(data + pagination + links)- It is NOT a create operation -- it is a safe, read-only query
- Supports the same filters, sort, pagination, sparse fields, and includes as GET
- The
Idempotency-Keyheader is not needed (search is inherently idempotent)
Use the fields query parameter to include only specific fields in the response payload. This reduces bandwidth and is useful for list views, mobile clients, or bulk exports.
Format:
?fields={resource}.{field1},{resource}.{field2}
Examples:
# Only return name and email for contacts
curl "/api/v1/contacts?fields=contacts.name,contacts.email" \
-H "Authorization: Bearer $TOKEN"
# Sparse fields on a single resource
curl "/api/v1/contacts/:UUID?fields=contacts.name,contacts.email,contacts.phone" \
-H "Authorization: Bearer $TOKEN"
# Sparse fields combined with include
curl "/api/v1/contacts/:UUID?fields=contacts.name,companies.name&include=companies" \
-H "Authorization: Bearer $TOKEN"Rules:
- The
idfield is always included, even if not requested linksare always included (HATEOAS cannot be opted out)inserted_atandupdated_atare always included- If
fieldsis not provided, all fields are returned (default behavior) - Invalid field names return
400with the offending field name in the error details. Silent failures cause prod bugs - The resource prefix (e.g.,
contacts.) is required to disambiguate when sideloading - When combined with
?include=, sparse fields apply to included resources too
Response with sparse fields:
{
"data": {
"id": "uuid-1",
"name": "Jane Doe",
"email": "jane@example.com",
"inserted_at": "2026-03-15T10:00:00Z",
"updated_at": "2026-03-15T10:00:00Z",
"links": {
"self": "/api/v1/contacts/uuid-1"
}
}
}Fields not requested are omitted entirely from the response (not returned as null).
Collection endpoints support composable filters via a structured operator grammar:
?filter[field][operator]=value
| Operator | Description | Example |
|---|---|---|
eq |
Equals | ?filter[status][eq]=active |
neq |
Not equals | ?filter[status][neq]=archived |
gt |
Greater than | ?filter[amount][gt]=100 |
gte |
Greater than or equal | ?filter[amount][gte]=100 |
lt |
Less than | ?filter[amount][lt]=5000 |
lte |
Less than or equal | ?filter[amount][lte]=5000 |
in |
Matches any value in comma-separated list | ?filter[status][in]=active,pending |
nin |
Matches none of the values | ?filter[status][nin]=archived,deleted |
like |
Case-insensitive substring match (ILIKE) | ?filter[email][like]=@gmail.com |
null |
Field is null / not null | ?filter[deleted_at][null]=true |
When the operator is omitted, eq is assumed:
?filter[status]=active
is equivalent to:
?filter[status][eq]=active
- Multiple filters are ANDed together
- Filters compose with
?q=search, sorting, and pagination - Boolean filter values accept
true/falseas strings - Date filter values use ISO 8601 format:
?filter[inserted_at][gte]=2026-03-01T00:00:00Z - Unknown filter fields return
400with the offending field name. Silent failures cause prod bugs - Each resource documents which fields are filterable and which operators each field supports
Examples:
# Active items with amount between 100 and 5000
curl "/api/v1/items?filter[status][eq]=active&filter[amount][gte]=100&filter[amount][lte]=5000" \
-H "Authorization: Bearer $TOKEN"
# Items in specific categories
curl "/api/v1/items?filter[category][in]=saas,fintech" \
-H "Authorization: Bearer $TOKEN"
# Items with email containing @gmail.com
curl "/api/v1/items?filter[email][like]=@gmail.com" \
-H "Authorization: Bearer $TOKEN"
# Combine everything
curl "/api/v1/items?filter[status][eq]=active&filter[amount][gte]=100&q=enterprise&sort=-amount&page=2&per_page=25" \
-H "Authorization: Bearer $TOKEN"Implementation notes:
- Create database indexes for commonly filtered fields
- JSONB fields can be filtered using extracted paths:
?filter[metadata.source][eq]=linkedin - The
likeoperator maps to PostgreSQLILIKEwith wildcards:%value% - The
nulloperator maps toIS NULL/IS NOT NULL
Use ?include= to embed related resources in the response, avoiding N+1 requests.
Format:
?include=relation1,relation2
Dot notation is supported for nested relationships, up to a maximum depth of 3:
?include=items.product,customer
This includes the order's items AND each item's product.
When ?include= is provided, the response gains two additions:
relationshipson each data entry -- lightweight ID references with typeincludedat the top level -- full serialized resources, deduplicated
Example:
curl "/api/v1/orders/:UUID?include=items.product,customer" \
-H "Authorization: Bearer $TOKEN"{
"data": {
"id": "order-uuid",
"status": "shipped",
"relationships": {
"items": [
{"type": "item", "id": "item-uuid-1"},
{"type": "item", "id": "item-uuid-2"}
],
"customer": {"type": "customer", "id": "customer-uuid"}
},
"links": {
"self": "/api/v1/orders/order-uuid"
}
},
"included": [
{
"type": "item",
"id": "item-uuid-1",
"name": "Widget A",
"relationships": {
"product": {"type": "product", "id": "product-uuid-1"}
},
"links": {"self": "/api/v1/items/item-uuid-1"}
},
{
"type": "product",
"id": "product-uuid-1",
"name": "Widget Product",
"links": {"self": "/api/v1/products/product-uuid-1"}
},
{
"type": "item",
"id": "item-uuid-2",
"name": "Widget B",
"links": {"self": "/api/v1/items/item-uuid-2"}
},
{
"type": "customer",
"id": "customer-uuid",
"name": "Acme Corp",
"links": {"self": "/api/v1/customers/customer-uuid"}
}
]
}Rules:
- When
?includeis NOT provided: noincludedkey, norelationshipson data entries - Each resource type documents its valid include values
- Invalid include values return
400with the offending value. Silent failures cause prod bugs - Maximum include depth: 3 levels (e.g.,
orders.items.productis allowed,orders.items.product.categoryis not) - Included resources are deduplicated by type + id
- Included resources contain their own
links - Works on both collection and single-resource endpoints
- Combines with
?fields=for sparse included resources
Every resource includes a links map with URLs to itself and related endpoints. Links are always present -- no query parameter needed.
Single resource:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Example Item",
"links": {
"self": "/api/v1/items/550e8400-e29b-41d4-a716-446655440000",
"comments": "/api/v1/items/550e8400-e29b-41d4-a716-446655440000/comments",
"attachments": "/api/v1/items/550e8400-e29b-41d4-a716-446655440000/attachments"
}
}Collection responses also include top-level links for pagination (RFC 5988):
{
"data": ["..."],
"pagination": {"..."},
"links": {
"self": "/api/v1/items?page=1&per_page=100",
"first": "/api/v1/items?page=1&per_page=100",
"prev": null,
"next": "/api/v1/items?page=2&per_page=100",
"last": "/api/v1/items?page=5&per_page=100"
}
}Rules:
selflink is always present on every resource- Links are relative paths (no host/scheme) -- the client prepends the base URL
- Each resource type documents its available links
- Links cannot be opted out via
?fields= - Collection
linksincludefirst,prev,next,last(null when not applicable) - A
LinkHTTP header (RFC 5988) is also set on collection responses for machine-readable pagination
Rate limiting is per-token using a token bucket algorithm.
Defaults:
| Setting | Value |
|---|---|
| Algorithm | Token bucket (allows short bursts, smooth over time) |
| Default limit | 240 requests/minute per token |
| Burst | Up to 40 requests in a single second (bucket refills steadily) |
| Overridable | Yes, per token (e.g., premium tokens can have higher limits) |
Every response includes rate limit headers:
| Header | Description | Example |
|---|---|---|
RateLimit |
Current rate limit state | limit=240, remaining=115, reset=42 |
RateLimit-Policy |
Policy description | 240;w=60 |
These follow the IETF RateLimit / RateLimit-Policy draft headers (replacing the older x-ratelimit-* convention).
HTTP/1.1 429 Too Many Requests
Retry-After: 42
RateLimit: limit=240, remaining=0, reset=42
RateLimit-Policy: 240;w=60
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/rate-limit-exceeded",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the rate limit of 240 requests per minute. Retry after 42 seconds."
}The standard Retry-After header (in seconds) tells the client when to retry. Clients MUST respect it.
If the rate limiter backend (e.g., Redis) is unavailable, the API fails open -- requests are allowed through without rate limiting. This prevents the rate limiter from becoming a single point of failure. Monitor rate limiter health separately.
GET /health/live-- never rate-limitedGET /health/ready-- never rate-limitedGET /api/v1/token-- does not count against the limitGET /api/v1/agents-- public endpoint, rate-limited by IP instead of token (60 req/min)
All errors follow RFC 9457 (Problem Details for HTTP APIs) with Content-Type: application/problem+json.
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/api/v1/items",
"errors": {
"name": ["cant_be_blank"],
"email": ["invalid_format", "already_taken"]
}
}| Field | Type | Required | Description |
|---|---|---|---|
type |
URI | Yes | A URI reference identifying the problem type (stable, bookmarkable) |
title |
string | Yes | Short human-readable summary (same for all instances of this type) |
status |
int | Yes | HTTP status code |
detail |
string | Yes | Human-readable explanation specific to this occurrence |
instance |
URI | No | URI reference identifying the specific occurrence (request path) |
errors |
object | No | Field-level validation errors (only for 422) |
| HTTP Status | type suffix |
title |
When |
|---|---|---|---|
400 |
/problems/bad-request |
Bad Request | Malformed JSON, unrecognized query parameters, invalid filter fields |
401 |
/problems/invalid-token |
Invalid Token | Missing, revoked, or expired Bearer token |
403 |
/problems/insufficient-scope |
Insufficient Scope | Token does not have the required scope for this resource/action |
404 |
/problems/not-found |
Not Found | Resource doesn't exist or belongs to another workspace |
409 |
/problems/conflict |
Conflict | Resource state conflict (e.g., duplicate idempotency key with different body) |
412 |
/problems/precondition-failed |
Precondition Failed | ETag mismatch on conditional update |
422 |
/problems/validation-failed |
Validation Failed | Invalid input fields (create/update) |
422 |
/problems/invalid-params |
Invalid Parameters | Missing required parameters |
422 |
/problems/upload-failed |
Upload Failed | File storage error |
428 |
/problems/precondition-required |
Precondition Required | PUT request missing If-Match header |
429 |
/problems/rate-limit-exceeded |
Rate Limit Exceeded | Too many requests |
500 |
/problems/internal-error |
Internal Error | Unexpected server error |
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "2 fields have validation errors.",
"errors": {
"name": ["cant_be_blank"],
"email": ["invalid_format", "already_taken"]
}
}Standard validation error codes:
| Code | Meaning |
|---|---|
cant_be_blank |
Required field is missing or empty |
already_taken |
Value must be unique but already exists |
invalid_format |
Value doesn't match the expected format |
too_short |
Value is below minimum length |
too_long |
Value exceeds maximum length |
not_a_number |
Expected a numeric value |
not_an_integer |
Expected an integer, got a float or string |
greater_than |
Value must be greater than a threshold |
less_than |
Value must be less than a threshold |
inclusion |
Value is not in the allowed set |
exclusion |
Value is in the forbidden set |
invalid_uuid |
Value is not a valid UUID |
invalid_date |
Value is not a valid ISO 8601 date |
not_found |
Referenced foreign key does not exist |
Apps may define additional codes following the same snake_case convention. The full list of codes for a given API should be discoverable via GET /api/v1/enums under an error_codes key.
Rules:
typeis always a URI -- stable for programmatic matchingtitleis always a human-readable string (same for all instances of the problem)detailis specific to this occurrenceerrorsis present only for validation errors -- a map of field name to array of error codes (snake_case, no spaces)- Errors never leak internal implementation details (stack traces, SQL, etc.)
404is returned for resources that exist but belong to a different workspace (same as not found -- no information leakage)
For creating, updating, or deleting multiple resources in a single request.
POST /api/v1/items/bulk
{
"items": [
{"name": "Item A", "status": "active"},
{"name": "Item B", "status": "draft"},
{"name": "Item C", "status": "active"}
]
}PATCH /api/v1/items/bulk
{
"items": [
{"id": "uuid-1", "status": "archived"},
{"id": "uuid-2", "status": "archived"}
]
}DELETE /api/v1/items/bulk
{
"ids": ["uuid-1", "uuid-2", "uuid-3"]
}Bulk operations may partially succeed. When some items succeed and some fail, return 207 Multi-Status:
{
"data": [
{"id": "uuid-1", "status": "created", "data": {"id": "new-uuid-1", "name": "Item A"}},
{"id": "uuid-2", "status": "created", "data": {"id": "new-uuid-2", "name": "Item B"}},
{"id": null, "status": "failed", "error": {
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation Failed",
"detail": "name cant_be_blank"
}}
],
"summary": {
"total": 3,
"succeeded": 2,
"failed": 1
}
}- Maximum 100 items per bulk request
- Each item is validated independently
- If ALL items succeed:
201 Created(bulk create) or200 OK(bulk update/delete) - If ALL items fail:
422with the errors - If SOME succeed and SOME fail:
207 Multi-Statuswith per-item results - Bulk operations are atomic by default (all-or-nothing). If partial success is supported, document it explicitly per endpoint
Idempotency-Keyis supported on bulk create
For operations that take longer than a few seconds (data exports, bulk imports, report generation).
curl -X POST /api/v1/exports \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"format": "csv", "filter": {"status": {"eq": "active"}}}'Response: 202 Accepted
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/job-uuid-123
{
"data": {
"job_id": "job-uuid-123",
"status": "pending",
"created_at": "2026-03-15T10:00:00Z",
"links": {
"self": "/api/v1/jobs/job-uuid-123"
}
}
}curl /api/v1/jobs/job-uuid-123 \
-H "Authorization: Bearer $TOKEN"{
"data": {
"job_id": "job-uuid-123",
"status": "completed",
"progress": 100,
"created_at": "2026-03-15T10:00:00Z",
"completed_at": "2026-03-15T10:02:30Z",
"result": {
"download_url": "/api/v1/exports/job-uuid-123/download",
"expires_at": "2026-03-16T10:02:30Z"
},
"links": {
"self": "/api/v1/jobs/job-uuid-123"
}
}
}| Status | Description |
|---|---|
pending |
Job is queued |
running |
Job is in progress |
completed |
Job finished successfully |
failed |
Job failed (check error field) |
202 Acceptedalways includes aLocationheader pointing to the job status endpoint- Jobs are scoped to the workspace (same as all other resources)
- Failed jobs include an error in RFC 9457 format
- Job results (e.g., download URLs) expire after a documented TTL (default 24 hours)
APIs can notify external systems of changes via webhooks.
curl -X POST /api/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhook",
"events": ["item.created", "item.updated", "item.deleted"],
"secret": "whsec_client-provided-secret"
}'{
"id": "evt-uuid",
"type": "item.created",
"created_at": "2026-03-15T10:00:00Z",
"data": {
"id": "item-uuid",
"name": "New Item",
"status": "active"
},
"workspace_id": "workspace-uuid"
}- Events are delivered via POST to the registered URL
- Request includes
X-Webhook-Signatureheader: HMAC-SHA256 of the body using the webhook secret - Delivery is retried with exponential backoff (1min, 5min, 30min, 2hr, 24hr) on non-2xx responses
- After 5 failed attempts, the webhook is marked as
failing(not disabled -- manual intervention required) - The event payload is signed, not encrypted -- use HTTPS endpoints only
Follow the {resource}.{action} convention:
| Event | Trigger |
|---|---|
{resource}.created |
Resource created |
{resource}.updated |
Resource updated |
{resource}.deleted |
Resource soft-deleted |
- Webhooks are scoped to the workspace
- A workspace can have multiple webhooks with different event subscriptions
- Events are delivered at least once (clients must handle duplicates via
id) - Event order is not guaranteed -- use
created_atfor ordering - Webhook endpoints must respond within 10 seconds
Files are uploaded via multipart/form-data:
curl -X POST /api/v1/files \
-H "Authorization: Bearer $TOKEN" \
-F "file=@document.pdf" \
-F "resource_type=item" \
-F "resource_id=item-uuid"For large files (> 10 MB), use presigned URLs:
# Step 1: Request a presigned upload URL
curl -X POST /api/v1/files/presign \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"filename": "large-video.mp4", "content_type": "video/mp4", "size_bytes": 524288000}'
# Response:
# {"data": {"upload_url": "https://storage.example.com/presigned...", "file_id": "file-uuid", "expires_at": "..."}}
# Step 2: Upload directly to storage
curl -X PUT "https://storage.example.com/presigned..." \
-H "Content-Type: video/mp4" \
--data-binary @large-video.mp4
# Step 3: Confirm the upload
curl -X POST /api/v1/files/file-uuid/confirm \
-H "Authorization: Bearer $TOKEN"| Constraint | Value |
|---|---|
| Max file size (direct) | 10 MB |
| Max file size (presigned) | 500 MB |
| Allowed MIME types | Configured per resource (documented in OpenAPI spec) |
| Checksum | Optional Content-MD5 header for integrity verification |
- Files are always associated with a resource (
resource_type+resource_id) - File metadata is returned in the standard
{"data": {...}}envelope - Deleting a file is a soft delete (same as other resources)
- File download URLs may be signed and time-limited
All monetary amounts are represented as integer cents (minor currency units) to avoid floating-point precision errors.
| Field | Type | Description | Example |
|---|---|---|---|
amount_cents |
integer | Amount in minor currency units | 1999 (= $19.99) |
currency |
string | ISO 4217 currency code | "USD" |
Rules:
- Never use floats for money.
19.99becomes1999 - Always pair amount with currency -- never store amounts without a currency
- The field name always ends in
_centsto make the unit explicit - Arithmetic and rounding happen server-side
- Display formatting is the client's responsibility
Example:
{
"data": {
"id": "order-uuid",
"total_cents": 15999,
"currency": "USD",
"line_items": [
{"name": "Widget A", "unit_price_cents": 999, "quantity": 10},
{"name": "Shipping", "unit_price_cents": 5999, "quantity": 1}
]
}
}For non-monetary decimals (e.g., percentages, rates), use string representation to preserve precision:
{"tax_rate": "0.0875"}DELETE sets deleted_at timestamp, returns 204 No Content. Deleted records are excluded from all queries by default.
# Include soft-deleted records in results
curl "/api/v1/items?filter[deleted_at][null]=false" \
-H "Authorization: Bearer $TOKEN"
# Only show deleted records
curl "/api/v1/items?filter[deleted_at][null]=false&filter[status][eq]=deleted" \
-H "Authorization: Bearer $TOKEN"curl -X POST /api/v1/items/:id/restore \
-H "Authorization: Bearer $TOKEN"Response: 200 OK with the restored resource. deleted_at is set back to null.
Soft-deleted records can collide with unique constraints (e.g., a deleted user with email "foo@bar.com" blocks a new user with the same email).
Solution: Use partial unique indexes that exclude soft-deleted rows:
CREATE UNIQUE INDEX idx_items_name_unique
ON items (name, workspace_id)
WHERE deleted_at IS NULL;This allows re-creating records with the same unique fields after deletion.
Returns all possible values for fields that use fixed lists (enums). Useful for building UIs, validating input, and understanding the data model without reading docs.
Endpoint: GET /api/v1/enums
Authentication: Required (Bearer token)
Caching: Clients should not cache for more than 5 seconds. The response includes:
Cache-Control: public, max-age=5
ETag: "enums-hash-abc123"
Response:
{
"data": {
"items": {
"status": {
"values": ["draft", "active", "archived"],
"description": "Current lifecycle status of the item"
},
"priority": {
"values": ["low", "medium", "high", "critical"],
"description": "Urgency level"
},
"category": {
"values": ["saas", "fintech", "e-commerce", "healthcare", "other"],
"description": "Industry classification"
}
},
"orders": {
"status": {
"values": ["pending", "confirmed", "shipped", "delivered", "cancelled"],
"description": "Order fulfillment status"
},
"currency": {
"values": ["USD", "EUR", "GBP", "CAD", "AUD"],
"description": "ISO 4217 currency code"
}
}
}
}Structure:
- Top-level keys are resource names (plural, matching endpoint names)
- Each resource contains its enum fields as keys
- Each field has
values(array of valid strings) anddescription(human-readable) - The endpoint reflects the current state of the application -- values may change between deploys
Implementation:
- Enum values MUST be read dynamically from the schema/model layer at runtime (e.g.,
Observation.valid_kinds(),Action.valid_kinds()), NOT hardcoded in the controller - Each schema that defines an enum exposes it via a public function (e.g.,
def valid_kinds, do: @valid_kinds) - The controller imports and calls these functions -- if a developer adds a new enum value to the schema, the
/enumsendpoint reflects it automatically with zero extra work
Rules:
- Values are always in sync with the codebase because they are sourced from the schema modules
- Values are returned in their canonical order (display order, not alphabetical)
Returns a plain-text or markdown description of the entire API, designed for AI agents and LLMs that need to understand the API without prior knowledge. This follows the emerging llms.txt convention for making APIs discoverable by language models.
Endpoint: GET /api/v1/agents
Authentication: None required (public endpoint)
Rate limiting: By IP address (not token), default 60 requests/minute
Response:
- **Content-Type: **
text/markdown - Body: A single string containing the full API documentation in a format optimized for LLM consumption
Example:
curl /api/v1/agents# MyApp API
## Authentication
All endpoints require a Bearer token...
## Endpoints
### GET /api/v1/items
List all items. Supports pagination (?page=, ?per_page=, ?cursor=), search (?q=), sort (?sort=-inserted_at), and filters (?filter[status][eq]=active).
### POST /api/v1/items
Create an item. Required fields: name (string). Optional: status, category, metadata.
...
The OpenAPI spec is machine-readable but verbose -- an LLM reading a full OpenAPI spec wastes context tokens on JSON Schema boilerplate. The agents endpoint provides a compact, narrative summary that fits in an LLM's context window. It covers the same endpoints but in prose form, with examples and common patterns highlighted. Think of it as the "quick start guide" vs. the "full reference."
Implementation:
- The endpoint MUST read and serve an existing documentation file (e.g.,
API.md) from disk, NOT return a hardcoded string - This ensures the documentation stays in sync with the codebase -- developers or agents update
API.mdand the endpoint reflects it immediately
Rules:
- No authentication required -- this endpoint is intentionally public so agents can self-discover the API
- The content should be concise but complete -- an agent reading this response should be able to use every endpoint
- Include authentication instructions, all endpoints with their fields, and example payloads
- Response should be under 50KB to fit comfortably in LLM context windows (approximately 12k tokens)
Introspect the current token to see its workspace, permission level, and rate limit status.
Endpoint: GET /api/v1/token
Authentication: Required
Does not count against rate limit.
curl /api/v1/token -H "Authorization: Bearer $TOKEN"{
"data": {
"workspace": {
"id": "workspace-uuid",
"slug": "acme-corp"
},
"scopes": ["companies:read", "people:read", "observations:write"],
"rate_limit": {
"limit": 240,
"remaining": 115,
"reset_at": 1710590460
}
}
}Use cases:
- Verify a token is valid before starting a batch operation
- Check remaining rate limit budget
- Confirm which workspace a token belongs to
- Inspect which scopes the token has
For navigating relationships between resources, provide read-only nested endpoints.
Pattern:
GET /api/v1/{parent_resource}/:parent_id/{child_resource}
Behavior:
- Returns a collection of the child resources linked to the parent
- Read-only -- no
POST,PUT,DELETEon these nested paths (manage links through dedicated endpoints) - Returns
404if the parent resource does not exist - Returns
{"data": []}if the parent exists but has no linked records - Supports pagination, search, sorting, and filters (same as top-level collection endpoints)
Examples:
# List all comments for an item
GET /api/v1/items/:item_id/comments
# List all items for a category
GET /api/v1/categories/:category_id/itemsRules:
- Nest at most one level deep:
/parents/:id/children-- never/parents/:id/children/:id/grandchildren - For deeper traversal, use
?include=sideloading on the child resource - Each nested endpoint is documented alongside its parent resource
Separate endpoints for liveness and readiness checks, following the Kubernetes convention.
Returns 200 if the application process is running. No dependency checks.
curl /health/live{"status": "ok"}Purpose: Tells the orchestrator the process is alive. If this fails, restart the container.
Returns 200 if the application can serve traffic (database connected, migrations applied, etc.).
curl /health/ready{"status": "ok"}Returns 503 Service Unavailable if any critical dependency is down:
{"status": "degraded", "checks": {"database": "ok", "redis": "unavailable"}}Purpose: Tells the load balancer whether to route traffic to this instance. A failing readiness check removes the instance from the pool but does NOT restart it.
Rules for both:
- No authentication required
- Not behind the
/api/v1prefix -- top-level routes - No rate limiting
GET /healthredirects toGET /health/livefor backwards compatibility