Skip to content

Instantly share code, notes, and snippets.

@damln
Created April 13, 2026 22:27
Show Gist options
  • Select an option

  • Save damln/35de912fad37744899af1f4326de42c0 to your computer and use it in GitHub Desktop.

Select an option

Save damln/35de912fad37744899af1f4326de42c0 to your computer and use it in GitHub Desktop.
HTTP API Specs - v1
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.

API Design Specification

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.


Table of Contents


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.

Required files

File Format Purpose
openapi.yaml (or openapi.json) OpenAPI 3.1 Machine-readable API contract at the project root

Serving the spec

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 (or application/x-yaml if serving YAML)
  • In development, read from disk (openapi.yaml at the project root). In production releases, serve from priv/static/openapi.json or 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

What the spec must cover

Every endpoint described in this document must appear in the OpenAPI spec with:

  • Paths and operations -- every route with its HTTP method, summary, and operationId following the naming convention: listItems, getItem, createItem, updateItem, deleteItem, searchItems, bulkCreateItems
  • Request bodies -- full schema for POST/PUT/PATCH payloads, with required fields 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 (:id as UUID format)
  • Authentication -- securitySchemes defining 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, 500 with their schemas following RFC 9457
  • Enums -- field values that use fixed lists must use enum in the schema (matching /api/v1/enums output)
  • Examples -- at least one example or examples block per operation
  • Headers -- document ETag, If-Match, If-None-Match, Idempotency-Key, RateLimit, RateLimit-Policy, Retry-After, X-Request-Id, Deprecation, Sunset headers
  • Polymorphic payloads -- use discriminator where applicable
  • $ref reuse -- shared schemas, parameters, and response objects MUST use $ref for DRY specs

Structure conventions

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: []

Keeping the spec in sync

  • 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 spectral or openapi-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 with rswag, FastAPI with built-in OpenAPI), prefer generation over hand-maintained YAML -- but always review the output

Relation to other endpoints

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.


Workspace & Token Model

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_id is 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).


Token Format & Storage

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)

Token storage at rest

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_prefix is stored in plaintext for lookup: find the token row by prefix, then verify the full token against the hash

Token Authentication

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

Token Scopes

Each token carries a list of scopes that define exactly what it can do. Scopes follow the OAuth2 pattern: {resource}:{action}.

Scope format

{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

Available resources

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

Wildcard scope

  • all:read -- read access to every resource
  • all:write -- full access to every resource

Scope examples

// 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"]}

Scope rules

  • write implies read -- a token with companies:write can 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/people requires companies:read
  • Polymorphic link endpoints (e.g., /api/v1/observations/:id/links) require the parent resource's scope

Scope enforcement edges

  • Read-your-writes: a write-only scope implies read (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 write on the created resource and read on the referenced resource. If the referenced resource's scope is missing, return 422 with 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), return 404 Not Found (no information leakage)

Enforcement order

  1. Auth plug -- validates token, resolves workspace, loads scopes
  2. Scopes plug -- maps the request (HTTP method + path) to a required scope, checks if the token has it
  3. 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"]
}

Token creation

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"]
  }'

Token info response

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}
  }
}

Implementation notes

  • 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).

Token Revocation

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)

Versioning

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 v2 is introduced, v1 continues to work for a documented deprecation period
  • The version applies to the entire API -- do not version individual endpoints differently

Why URL-based versioning

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

Deprecation policy

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 Deprecation and Sunset headers (RFC 8594):
Deprecation: true
Sunset: Sat, 01 Mar 2028 00:00:00 GMT
  • The /api/v1/agents and OpenAPI spec document the deprecation timeline
  • Clients calling deprecated endpoints receive a Warning response header:
Warning: 299 - "API v1 is deprecated. Migrate to v2 by 2028-03-01."

Base URL & Conventions

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

JSON Columns (JSONB) -- Replace Semantics

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.


HTTPS Requirement

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-Security header on all responses (see CORS & Security Headers)

CORS & Security Headers

Security headers (all responses)

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

CORS headers (for browser clients)

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.


Request Correlation

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-Id is 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 traceparent header if present

Concurrency Control (ETags)

All single-resource responses include an ETag header for optimistic concurrency control. This prevents lost updates when multiple clients modify the same resource concurrently.

How it works

  1. GET returns an ETag header:
HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6"
Content-Type: application/json

{"data": {"id": "uuid", "name": "Item A", ...}}
  1. PUT/PATCH sends If-Match with 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"}'
  1. 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."
}

Conditional GETs

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).

Rules

  • ETag is required on all single-resource GET, PUT, and PATCH responses
  • If-Match is required on PUT requests (full replace demands conflict detection)
  • If-Match is recommended on PATCH requests
  • If-Match header missing on PUT returns 428 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)

Implementation notes

  • Hash of updated_at timestamp at microsecond precision for ETag
  • ETags must change on every update, including updates that set the same values

Idempotency

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"}'

How it works

  1. Client generates a unique key (UUID recommended) and sends it with the POST
  2. Server stores the key + response on first execution
  3. If the same key is sent again (retry), server returns the stored response without re-executing
  4. Keys expire after 24 hours

Rules

  • Idempotency-Key is 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 422 with 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)

Response

On replay, the response includes a header indicating it was served from cache:

Idempotency-Replayed: true

Response Format

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 vs PATCH Semantics

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

PUT rules

  • 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-Match header is required (see Concurrency Control)
  • JSONB fields are replaced entirely (see JSONB Replace Semantics)

PATCH rules

  • 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-Match header is recommended

For scalar fields

# 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": {}}'

Pagination

All collection (index) endpoints return paginated results. Two strategies are available: offset (default) and cursor.

Offset pagination (default)

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:

  • page below 1 is treated as 1
  • per_page above the max is clamped to the max (500)
  • total_count reflects 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.

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: false means this is the last page
  • Invalid or expired cursors return 400
  • Cursor pagination does not support page or total_count (these are offset concepts)

When to use which

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

Implementation notes

  • 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)

Sorting

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 400 with 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)

Full-Text Search

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 flow matches records containing both "tech" and "flow"
  • Search works with pagination -- total_count reflects 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 tsvector with GIN indexes for performant full-text search
  • Create GIN indexes on all searchable columns
  • Minimum query length: 1 character (no blank searches)
  • Blank q= or q= with only whitespace is ignored (returns unfiltered results)

POST /search Escape Hatch

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 /search returns the same response format as GET (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-Key header is not needed (search is inherently idempotent)

Sparse Fieldsets

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 id field is always included, even if not requested
  • links are always included (HATEOAS cannot be opted out)
  • inserted_at and updated_at are always included
  • If fields is not provided, all fields are returned (default behavior)
  • Invalid field names return 400 with 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).


Filtering

Collection endpoints support composable filters via a structured operator grammar:

?filter[field][operator]=value

Operator set

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

Shorthand

When the operator is omitted, eq is assumed:

?filter[status]=active

is equivalent to:

?filter[status][eq]=active

Rules

  • Multiple filters are ANDed together
  • Filters compose with ?q= search, sorting, and pagination
  • Boolean filter values accept true / false as strings
  • Date filter values use ISO 8601 format: ?filter[inserted_at][gte]=2026-03-01T00:00:00Z
  • Unknown filter fields return 400 with 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 like operator maps to PostgreSQL ILIKE with wildcards: %value%
  • The null operator maps to IS NULL / IS NOT NULL

Sideloading (Include)

Use ?include= to embed related resources in the response, avoiding N+1 requests.

Format:

?include=relation1,relation2

Nested includes

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.

How it works

When ?include= is provided, the response gains two additions:

  1. relationships on each data entry -- lightweight ID references with type
  2. included at 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 ?include is NOT provided: no included key, no relationships on data entries
  • Each resource type documents its valid include values
  • Invalid include values return 400 with the offending value. Silent failures cause prod bugs
  • Maximum include depth: 3 levels (e.g., orders.items.product is allowed, orders.items.product.category is 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

HATEOAS Links

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:

  • self link 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 links include first, prev, next, last (null when not applicable)
  • A Link HTTP header (RFC 5988) is also set on collection responses for machine-readable pagination

Rate Limiting

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)

Response headers (IETF draft standard)

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).

When exceeded -- 429 Too Many Requests

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.

Failure mode

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.

Exempt endpoints

  • GET /health/live -- never rate-limited
  • GET /health/ready -- never rate-limited
  • GET /api/v1/token -- does not count against the limit
  • GET /api/v1/agents -- public endpoint, rate-limited by IP instead of token (60 req/min)

Error Handling

All errors follow RFC 9457 (Problem Details for HTTP APIs) with Content-Type: application/problem+json.

Error response structure

{
  "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)

Standard problem types

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

Validation error format (422)

{
  "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:

  • type is always a URI -- stable for programmatic matching
  • title is always a human-readable string (same for all instances of the problem)
  • detail is specific to this occurrence
  • errors is 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.)
  • 404 is returned for resources that exist but belong to a different workspace (same as not found -- no information leakage)

Bulk Operations

For creating, updating, or deleting multiple resources in a single request.

Bulk create

POST /api/v1/items/bulk
{
  "items": [
    {"name": "Item A", "status": "active"},
    {"name": "Item B", "status": "draft"},
    {"name": "Item C", "status": "active"}
  ]
}

Bulk update

PATCH /api/v1/items/bulk
{
  "items": [
    {"id": "uuid-1", "status": "archived"},
    {"id": "uuid-2", "status": "archived"}
  ]
}

Bulk delete

DELETE /api/v1/items/bulk
{
  "ids": ["uuid-1", "uuid-2", "uuid-3"]
}

Partial success (207 Multi-Status)

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
  }
}

Rules

  • Maximum 100 items per bulk request
  • Each item is validated independently
  • If ALL items succeed: 201 Created (bulk create) or 200 OK (bulk update/delete)
  • If ALL items fail: 422 with the errors
  • If SOME succeed and SOME fail: 207 Multi-Status with per-item results
  • Bulk operations are atomic by default (all-or-nothing). If partial success is supported, document it explicitly per endpoint
  • Idempotency-Key is supported on bulk create

Async / Long-Running Operations

For operations that take longer than a few seconds (data exports, bulk imports, report generation).

Triggering an async job

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"
    }
  }
}

Polling for status

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"
    }
  }
}

Job statuses

Status Description
pending Job is queued
running Job is in progress
completed Job finished successfully
failed Job failed (check error field)

Rules

  • 202 Accepted always includes a Location header 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)

Webhooks & Events

APIs can notify external systems of changes via webhooks.

Webhook registration

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"
  }'

Event format

{
  "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"
}

Delivery

  • Events are delivered via POST to the registered URL
  • Request includes X-Webhook-Signature header: 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

Event types

Follow the {resource}.{action} convention:

Event Trigger
{resource}.created Resource created
{resource}.updated Resource updated
{resource}.deleted Resource soft-deleted

Rules

  • 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_at for ordering
  • Webhook endpoints must respond within 10 seconds

File Uploads

Upload method

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"

Constraints

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

Rules

  • 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

Money & Decimal Values

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.99 becomes 1999
  • Always pair amount with currency -- never store amounts without a currency
  • The field name always ends in _cents to 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"}

Soft Deletes

DELETE sets deleted_at timestamp, returns 204 No Content. Deleted records are excluded from all queries by default.

Viewing deleted records

# 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"

Restoring deleted records

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.

Unique constraints and soft deletes

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.


Enums Endpoint

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) and description (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 /enums endpoint 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)

Agents Endpoint

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.
...

Why this exists alongside OpenAPI

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.md and 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)

Token Info Endpoint

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

Nested Read-Only Endpoints

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, DELETE on these nested paths (manage links through dedicated endpoints)
  • Returns 404 if 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/items

Rules:

  • 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

Health Check

Separate endpoints for liveness and readiness checks, following the Kubernetes convention.

Liveness -- GET /health/live

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.

Readiness -- GET /health/ready

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/v1 prefix -- top-level routes
  • No rate limiting
  • GET /health redirects to GET /health/live for backwards compatibility
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment