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
| API | Base path | Key type | Purpose |
|---|---|---|---|
| Management API | /management | Secret Key (ce_secret_) | Create, update, publish, delete content models, entries, assets, environments, locales |
| Delivery API | /delivery | Read Key (ce_read_) | Fetch published content (entries, assets, content models, locales) |
Base URLs:
| API | URL |
|---|---|
| Management API | https://management.forme.sh |
| Delivery API | https://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"
| Scenario | Result |
|---|---|
| Valid Secret Key → Management API | 200 OK |
| Valid Read Key → Delivery API | 200 OK |
| Read Key → Management API | 401 Unauthorized |
| Secret Key → Delivery API | 401 Unauthorized |
| Missing or invalid key | 401 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):
| Parameter | Description | Example |
|---|---|---|
contentModelId | Filter by content model ID | ?contentModelId=<MODEL_ID> |
status | Filter by status (Management API only) | ?status=published |
locale | Resolve 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):
| Operator | Description | Field types |
|---|---|---|
eq | Equals (default — can omit) | shortText, longText, number, boolean, dateTime, asset, reference |
ne | Not equals | same as eq |
in | Value is in list | shortText, number, asset, reference; on array = "contains any" |
nin | Value is not in list | shortText, number, asset, reference |
gt / gte | Greater than / greater or equal | number, dateTime |
lt / lte | Less than / less or equal | number, dateTime |
exists | Field has a non-null value | all (boolean param: true / false) |
contains | Substring (text), contains (array) | shortText, longText, richText, array — text values need ≥3 chars |
all | Array contains all listed items | array |
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 →
400withvalidFieldsarray - Unknown operator →
400withvalidOperatorsarray (specific to the field type) - Operator not supported for field type →
400withvalidOperators - 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
| Parameter | Description | Example |
|---|---|---|
status | Filter by status (Management API only) | ?status=draft |
locale | Resolve localized metadata to a single locale | ?locale=en-US |
Content model list filters
| Parameter | Description | Example |
|---|---|---|
apiId | Filter 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.Xkey → 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 forIf-Match) - Weak ETags (
W/"...") sent asIf-Matchare explicitly rejected with412 - The check is atomic against the underlying version counter — no SELECT-then-UPDATE race
If-Matchis 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:
- Requested locale (e.g.,
de-DE) - Fallback locale (configured per locale, e.g.,
en-US) - 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}
| Mode | Host | Notes |
|---|---|---|
| Default (GCS-direct) | storage.googleapis.com/{bucket} | No CDN, ~$0/mo |
| CDN mode | cdn.forme.build or cdn.forme.sh | Edge 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:
urlis a public URL. No auth needed. - Published + unsafe MIME (e.g., HTML, SVG):
urlis the auth-gated Delivery API path (/delivery/assets/{id}/file). Requires Bearer token. This prevents stored-XSS from uploaded HTML/script files. - Draft/unpublished:
urlis 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.urlin the TypeScript and Swift SDKs reflects this behavior automatically. When the server hasCDN_BASE_URLconfigured, 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
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 204 | Deleted (no content) |
| 400 | Bad request (validation error, malformed input) |
| 401 | Unauthorized (missing, invalid, or wrong-type API key) |
| 404 | Not found |
| 409 | Conflict (e.g., deleting environment with content) |
| 429 | Rate limited (see below) |
| 500 | Internal 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 ashortTextorlongTextfield on the entry's content model.tone— one offormal,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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing / malformed fieldName, tone, or locale; field not found on content model; field type not shortText/longText; input exceeds 16 384 chars |
| 400 | EMPTY_FIELD | The stored field is empty or (for a localized field) has no value for the requested locale |
| 400 | CONTEXT_TOO_LARGE | Provider rejected the prompt as too long for the model's context window |
| 400 | INVALID_OUTPUT | Model output failed post-validation (refusal, dominant preamble, em-dash, length over cap) — one retry already spent |
| 403 | CREDITS_EXHAUSTED | Monthly credit budget exhausted — resets at resetsAt. See AI Credit Usage endpoint above |
| 404 | NOT_FOUND | Entry does not exist, or belongs to a different tenant (RLS-hidden) |
| 409 | ALREADY_DECIDED | Approve/discard on an audit row that has already been decided |
| 429 | RATE_LIMITED | Per-workspace 60 req/min cap hit — retry after Retry-After seconds |
| 429 | PROVIDER_RATE_LIMITED | Upstream provider (Anthropic) is rate-limiting us — retry after Retry-After seconds |
| 502 | PROVIDER_ERROR | Upstream 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:
| API | Interactive docs URL |
|---|---|
| Management API | https://management.forme.sh/docs |
| Delivery API | https://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
- SDK Getting Started — use the TypeScript SDK instead of raw HTTP calls
- Core Concepts — understand the data model behind the API
- Quickstart — end-to-end tutorial