Docs/API Reference

API Reference

Forme exposes two REST APIs. This page documents the conventions shared across both. For the full endpoint catalog, use the interactive OpenAPI docs linked below.


Two APIs

APIBase pathKey typePurpose
Management API/managementSecret Key (ce_secret_)Create, update, publish, delete content models, entries, assets, environments, locales
Delivery API/deliveryRead Key (ce_read_)Fetch published content (entries, assets, content models, locales)

Base URLs:

APIURL
Management APIhttps://management.forme.sh
Delivery APIhttps://delivery.forme.sh

Authentication

Both APIs use Bearer token authentication. Pass your API key in the Authorization header:

curl -s https://management.forme.sh/management/content-models \
  -H "Authorization: Bearer ce_secret_YOUR_KEY_HERE"
ScenarioResult
Valid Secret Key → Management API200 OK
Valid Read Key → Delivery API200 OK
Read Key → Management API401 Unauthorized
Secret Key → Delivery API401 Unauthorized
Missing or invalid key401 Unauthorized

Secret Keys and Read Keys are scoped to a specific workspace and environment.


Pagination

List endpoints return paginated results. Use limit and offset query parameters:

curl -s "https://delivery.forme.sh/delivery/entries?limit=10&offset=20" \
  -H "Authorization: Bearer $READ_KEY"

Management API response shape:

{
  "data": [{ "id": "...", "...": "..." }],
  "pagination": {
    "total": 42,
    "limit": 10,
    "offset": 20
  }
}

Delivery API response shape:

{
  "items": [{ "id": "...", "...": "..." }],
  "total": 42,
  "limit": 10,
  "offset": 20
}

Default limit: 20. Maximum limit: 100.

SDK note: The TypeScript SDK normalizes both response formats into a unified PaginatedResult<T> with { items, total, limit, offset }.

Unpaginated endpoints

Some Management API endpoints return all items without pagination (e.g., environments, locales, API keys):

{
  "data": [{ "id": "...", "...": "..." }]
}

Filtering

Entry list filters

Top-level filters (always available):

ParameterDescriptionExample
contentModelIdFilter by content model ID?contentModelId=<MODEL_ID>
statusFilter by status (Management API only)?status=published
localeResolve localized fields to a single locale?locale=de-DE

Field-level filters

Filter entries by field values using the fields.{apiId}[{op}]=value syntax. Requires contentModelId (the API needs the schema to validate operators and types).

# Equality (default operator — `eq` is implied)
GET /delivery/entries?contentModelId=<id>&fields.slug=my-post

# Numeric comparison
GET /delivery/entries?contentModelId=<id>&fields.viewCount[gte]=100

# Membership (comma-separated)
GET /delivery/entries?contentModelId=<id>&fields.category[in]=tech,news

# Array contains
GET /delivery/entries?contentModelId=<id>&fields.tags[in]=ai,ml

# Substring (case-insensitive, min 3 chars)
GET /delivery/entries?contentModelId=<id>&fields.title[contains]=launch

# System fields use the `sys.` prefix
GET /delivery/entries?contentModelId=<id>&sys.publishedAt[gte]=2026-01-01
GET /delivery/entries?contentModelId=<id>&sys.id[in]=<uuid1>,<uuid2>

Operators (which operators are valid depends on the field type):

OperatorDescriptionField types
eqEquals (default — can omit)shortText, longText, number, boolean, dateTime, asset, reference
neNot equalssame as eq
inValue is in listshortText, number, asset, reference; on array = "contains any"
ninValue is not in listshortText, number, asset, reference
gt / gteGreater than / greater or equalnumber, dateTime
lt / lteLess than / less or equalnumber, dateTime
existsField has a non-null valueall (boolean param: true / false)
containsSubstring (text), contains (array)shortText, longText, richText, array — text values need ≥3 chars
allArray contains all listed itemsarray

Localized fields require a locale query parameter (or a configured default locale). Filtering operates on the selected locale's value.

Errors are explicit, with helpful diagnostic fields:

  • Unknown field → 400 with validFields array
  • Unknown operator → 400 with validOperators array (specific to the field type)
  • Operator not supported for field type → 400 with validOperators
  • Value can't be coerced (e.g., gte=not-a-number) → 400
  • [contains] value shorter than 3 chars → 400 (DoS guard)
  • Multiple values for a single-value operator (?fields.slug=a&fields.slug=b) → 400 ("Use [in]")

Asset list filters

ParameterDescriptionExample
statusFilter by status (Management API only)?status=draft
localeResolve localized metadata to a single locale?locale=en-US

Content model list filters

ParameterDescriptionExample
apiIdFilter by API identifier?apiId=BlogPost

Partial updates (PATCH)

PATCH /management/entries/:id and PATCH /management/assets/:id use shallow-merge semantics — omitted keys are preserved, null clears, and localized fields merge at the locale key (so patching en-US doesn't wipe de-DE).

# Update one field, leave the rest alone
curl -X PATCH /management/entries/<ID> \
  -H "Content-Type: application/merge-patch+json" \
  -d '{"fields": {"slug": "new-slug"}}'

# Clear a field (set to SQL NULL)
curl -X PATCH /management/entries/<ID> \
  -d '{"fields": {"description": null}}'

# Localized: patch en-US, preserve de-DE
curl -X PATCH /management/entries/<ID> \
  -d '{"fields": {"title": {"en-US": "New title"}}}'

# Replace the whole locale map (wipes unlisted locales) with ?locale=*
curl -X PATCH "/management/entries/<ID>?locale=*" \
  -d '{"fields": {"title": {"en-US": "Only EN now"}}}'

Rules:

  • Omit a fields.X key → preserve the existing value
  • Send "X": null → clear the field (SQL NULL, or remove the locale entry on a localized field)
  • Send "X": value → replace
  • Localized fields merge at locale key by default; pass ?locale=* for full-map replacement
  • Arrays replace wholesale (no positional ops in v1)
  • Validation runs on the merged result — clearing a required field returns 400

Optimistic concurrency (ETag / If-Match)

Every PATCH (and PUT) response includes a strong ETag header. Echo it back as If-Match on the next write to detect stale updates:

# 1. PATCH and capture ETag
ETAG=$(curl -i -X PATCH /management/entries/<ID> \
  -d '{"fields": {"slug": "v1"}}' \
  | grep -i '^etag:' | awk '{print $2}' | tr -d '\r')

# 2. Subsequent write with If-Match
curl -X PATCH /management/entries/<ID> \
  -H "If-Match: $ETAG" \
  -d '{"fields": {"slug": "v2"}}'

# If someone else patched the entry between your reads:
# → 412 Precondition Failed, with the current ETag in the response header
#   so you can refresh and retry without an extra GET

Notes:

  • ETags are strong ("5" — RFC 7232 §3.1 requires strong comparison for If-Match)
  • Weak ETags (W/"...") sent as If-Match are explicitly rejected with 412
  • The check is atomic against the underlying version counter — no SELECT-then-UPDATE race
  • If-Match is optional in v1; a v2 release will require it and the SDK will auto-send

Locale resolution

Pass the locale query parameter to resolve localized fields to a single value:

Without ?locale= — localized fields return the full locale map:

{
  "fields": {
    "title": { "en-US": "Hello", "de-DE": "Hallo" },
    "slug": "hello-world"
  }
}

With ?locale=de-DE — localized fields are resolved:

{
  "sys": { "locale": "de-DE" },
  "fields": {
    "title": "Hallo",
    "slug": "hello-world"
  }
}

The sys.locale field indicates which locale was used for resolution.

Fallback chain

If the requested locale has no content for a field, Forme falls back:

  1. Requested locale (e.g., de-DE)
  2. Fallback locale (configured per locale, e.g., en-US)
  3. Default locale

Include resolution (Delivery API)

The Delivery API supports resolving linked entries and assets in a single request:

curl -s "https://delivery.forme.sh/delivery/entries?include=1" \
  -H "Authorization: Bearer $READ_KEY"

When ?include=1 is present, the response includes an includes object:

{
  "items": [{ "...": "..." }],
  "total": 10,
  "limit": 20,
  "offset": 0,
  "includes": {
    "entries": [{ "id": "referenced-entry-id", "...": "..." }],
    "assets": [{ "id": "referenced-asset-id", "...": "..." }]
  }
}

Public asset URLs

Published assets with safe MIME types (image/* excluding image/svg+xml, video/*, audio/*, font/*, application/pdf) are served via public URLs — no authentication required, usable directly in <img> tags, og:image meta, RSS feeds, and native mobile apps. SVG files stay behind the authenticated endpoint because they can contain executable scripts.

URL format:

The path is the stable contract; the host depends on deployment mode:

{host}/{assetId}/v{publishedVersion}/{filename}
ModeHostNotes
Default (GCS-direct)storage.googleapis.com/{bucket}No CDN, ~$0/mo
CDN modecdn.forme.build or cdn.forme.shEdge caching, CSP headers

Example (default mode):

https://storage.googleapis.com/forme-assets-cdn/550e8400-.../v3/hero-sunset.jpg

The URL contains no tenant identifiers — only the asset UUID (unguessable), the published version number (changes on each publish), and the original filename (for SEO). Treat asset.url from the Delivery API as opaque — do not hardcode the host.

Behavior:

  • Published + safe MIME: url is a public URL. No auth needed.
  • Published + unsafe MIME (e.g., HTML, SVG): url is the auth-gated Delivery API path (/delivery/assets/{id}/file). Requires Bearer token. This prevents stored-XSS from uploaded HTML/script files.
  • Draft/unpublished: url is the auth-gated path.
  • After re-publish: version number increments → new URL. Old URL expires from browser caches within 24h.
  • After unpublish: object is deleted. URL returns 404 after cache expiry (≤24h).

SDK note: asset.url in the TypeScript and Swift SDKs reflects this behavior automatically. When the server has CDN_BASE_URL configured, safe-MIME published assets return the CDN URL; everything else returns the relative path. No client-side logic needed.


Error responses

All error responses follow a consistent format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Field 'title' is required",
    "details": { "...": "..." }
  }
}

Common HTTP status codes

StatusMeaning
200Success
201Created
204Deleted (no content)
400Bad request (validation error, malformed input)
401Unauthorized (missing, invalid, or wrong-type API key)
404Not found
409Conflict (e.g., deleting environment with content)
429Rate limited (see below)
500Internal server error

Rate limiting

Both APIs enforce rate limits to protect the platform. When you exceed the limit, the API returns:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

Wait for the duration specified in the Retry-After header before retrying.

Intelligent Actions have an additional per-workspace cap of 60 requests per minute across all Cloud Run instances (backed by a Postgres counter). A workspace that exhausts the window gets 429 Too Many Requests with Retry-After until the next rolling window starts.

Monthly credit limits

Each workspace has a monthly credit budget. Credits are consumed by Intelligent Actions based on field type (1 credit for shortText, 3 for longText). Alpha workspaces (Starter tier) receive 1,000 credits per month.

When the budget is exhausted, all Intelligent Action endpoints return 403 CREDITS_EXHAUSTED. Credits reset on the 1st of each calendar month (UTC).

See Intelligent Actions — AI Credit Usage for the usage query endpoint.


Intelligent Actions

The Management API exposes tone-aware AI rewriting as first-class endpoints under the actions namespace on entries. Same surface serves the Admin UI, the TypeScript SDK, curl, and the future MCP server (CON-64). Costs are Forme-paid on your behalf; the per-workspace rate limit above contains abuse.

Privacy model. The server reads the field value from the stored entry (never from the request body), sends only that field + a deterministic system prompt to the provider, and writes an audit row to ai_action_audit retained for 90 days. No tokens, no cross-tenant content, no raw input/output leaks into observability events.

Rewrite a field

POST /management/entries/{entryId}/actions/rewrite
Authorization: Bearer ce_secret_…
Content-Type: application/json

{
  "fieldName": "title",
  "tone": "formal",
  "locale": "en-US"
}
  • fieldName — apiId of a shortText or longText field on the entry's content model.
  • tone — one of formal, casual, friendly, technical.
  • locale — optional BCP-47 code. Required for localized fields; otherwise used as an output-language hint.

Response (200):

{
  "outputValue": "Our new feature delivers significant performance improvements.",
  "auditId": "b2c9…",
  "model": "claude-haiku-4-5",
  "provider": "anthropic",
  "tokensIn": 120,
  "tokensOut": 35,
  "latencyMs": 1250,
  "retried": false
}

The rewrite is suggested by default — it is NOT applied to the stored entry. Pass the returned auditId to /approve (to record that the editor accepted the suggestion) or /discard (to record that the editor rejected it). If the editor accepts, your client is responsible for patching the entry with outputValue — the rewrite endpoint itself is pure.

Approve a suggestion

POST /management/entries/{entryId}/actions/{auditId}/approve
Authorization: Bearer ce_secret_…

Returns 200 { ok: true, auditId, approvalStatus: "approved", decidedAt }. A row that has already been decided returns 409 ALREADY_DECIDED so you can surface the correct message to the editor.

Discard a suggestion

POST /management/entries/{entryId}/actions/{auditId}/discard

Same semantics as approve; the audit row transitions to discarded.

AI Credit Usage

Query the workspace's monthly Intelligent credit budget:

GET /management/workspace/ai-usage
Authorization: Bearer ce_secret_…

Response (200):

{
  "creditsUsed": 34,
  "creditsLimit": 1000,
  "resetsAt": "2026-05-01T00:00:00.000Z",
  "breakdown": {
    "rewrite": 34
  }
}
  • creditsUsed — total credits consumed in the current billing month.
  • creditsLimit — the workspace's monthly cap (1,000 for Starter/Alpha).
  • resetsAt — ISO 8601 timestamp for the start of the next billing month (UTC).
  • breakdown — credits grouped by action type.

Error codes

StatusCodeWhen
400VALIDATION_ERRORMissing / malformed fieldName, tone, or locale; field not found on content model; field type not shortText/longText; input exceeds 16 384 chars
400EMPTY_FIELDThe stored field is empty or (for a localized field) has no value for the requested locale
400CONTEXT_TOO_LARGEProvider rejected the prompt as too long for the model's context window
400INVALID_OUTPUTModel output failed post-validation (refusal, dominant preamble, em-dash, length over cap) — one retry already spent
403CREDITS_EXHAUSTEDMonthly credit budget exhausted — resets at resetsAt. See AI Credit Usage endpoint above
404NOT_FOUNDEntry does not exist, or belongs to a different tenant (RLS-hidden)
409ALREADY_DECIDEDApprove/discard on an audit row that has already been decided
429RATE_LIMITEDPer-workspace 60 req/min cap hit — retry after Retry-After seconds
429PROVIDER_RATE_LIMITEDUpstream provider (Anthropic) is rate-limiting us — retry after Retry-After seconds
502PROVIDER_ERRORUpstream provider returned a 5xx or was unreachable

Not available

Intelligent Rewrite is currently the only shipping action. Alt text generation, translation, summarization, and SEO metadata are tracked under CON-71 and will appear on the same entries.actions.* surface.


Health check

Both APIs expose a health endpoint (no authentication required):

curl -s https://management.forme.sh/health
curl -s https://delivery.forme.sh/health
# Both return: {"status":"ok"}

Interactive OpenAPI docs

Both APIs auto-generate interactive documentation from their route schemas. Access them at:

APIInteractive docs URL
Management APIhttps://management.forme.sh/docs
Delivery APIhttps://delivery.forme.sh/docs

These provide a complete endpoint catalog with request/response schemas, parameter documentation, and a "Try it out" feature for testing requests directly in the browser.


Next steps