Reference
API reference
Everything you build in polimorf is reachable over a plain REST API: authenticate, scope an organization, and call the endpoints that your schemas generate. No SDK required.
Base URL and surfaces
The API is served at https://api.polimorf.com and splits into two surfaces, each with its own prefix:
| Surface | Prefix | Who calls it |
|---|---|---|
| Admin / build | /admin/v1/ | Developers and clients — the authenticated build console and dashboard |
| Public per-org | /api/v1/ | Your client's own website and integrations — the generated content API |
The version lives in the path (v1). The admin surface is where you and your clients manage an organization; the public surface is the read/write content API you expose to a client's own front end, governed per schema.
Authentication
Authentication is a bearer access token. Exchange credentials at POST /admin/v1/auth/login for a short-lived JSON Web Token, then send it on every request:
# Exchange credentials for a short-lived access token + refresh
curl -X POST https://api.polimorf.com/admin/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "you@agency.com", "password": "…"}'
# 200 — the access_token is bearer; the refresh is also set as an
# HttpOnly cookie for browsers. mfa_required is true when TOTP is on.
# {
# "user": { "id": "…", "email": "you@agency.com", … },
# "memberships": [ { "organization_id": "…", "slug": "acme", "role": "admin" } ],
# "access_token": "<jwt>",
# "refresh_token": "<opaque>",
# "mfa_required": false
# } Access tokens are short-lived — they expire about 15 minutes after issue. Rather than re-prompt for a password, rotate them at POST /admin/v1/auth/refresh. Browsers receive the refresh token as an HttpOnly, SameSite=strict cookie (polimorf_refresh) that JavaScript can never read; non-browser clients pass it in the request body and store it themselves.
# Rotate the access token. Browsers send the HttpOnly cookie +
# the X-Polimorf-Client sentinel; CLI/mobile send the token in the body.
curl -X POST https://api.polimorf.com/admin/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token": "<opaque>"}'
# 200 — { "access_token": "<jwt>", "refresh_token": "<rotated>" }Send the access token as a standard header on every authenticated call:
Authorization: Bearer <access_token>API keys for backend integrations
For server-to-server use, mint an API key in the build console (/admin/v1/api-keys). Keys are prefixed (pk_ for publishable, sk_ for secret), carry their own organization and scopes, and never expire until revoked. Pass a key as a bearer token, or in the dedicated x-api-key header. # An API key carries its own organization context — pass it as a
# bearer token, or in the dedicated header.
curl https://api.polimorf.com/api/v1/orders \
-H 'Authorization: Bearer pk_live_…'
# Equivalent, using the dedicated header:
curl https://api.polimorf.com/api/v1/orders \
-H 'x-api-key: pk_live_…'Scoping an organization
Every organization is walled off in its own database, so each request has to resolve to exactly one. With a user token, scope is implicit: your access token embeds your active organization as the oid claim, and the request is bound to it. With an API key, scope is baked into the key itself.
To act on a different organization you belong to, mint a fresh token for it rather than passing an identifier on the call — this keeps a leaked or wrong identifier from ever reaching another tenant's data:
# A user access token embeds your active organization as the
# "oid" claim — every request is scoped to it automatically.
# To act on a different organization you belong to, mint a fresh
# token for it:
curl -X POST https://api.polimorf.com/admin/v1/auth/switch-organization \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-d '{"organization_id": "8f1c…"}'
# 200 — { "access_token": "<jwt scoped to that org>", "refresh_token": "…" }The header is not an override
There is anX-Polimorf-Organization header (UUID or slug), but it is a development convenience: it is disabled in production unless explicitly enabled, and it refuses outright whenever any Authorization or x-api-key header is present. Credentialed requests are always scoped by the credential, never by a header — so a bad token plus a header can never cross organizations. Dynamic per-schema endpoints
You do not register routes by hand. The moment you define a data model in the schema designer, a full REST surface appears for it. A schema with the slug order gets endpoints on both surfaces:
| Method + path | Does |
|---|---|
| POST /admin/v1/content/order | Create an entry (build/admin surface) |
| GET /admin/v1/content/order | List entries, including drafts |
| GET /admin/v1/content/order/:id | Fetch one entry by id |
| PATCH /admin/v1/content/order/:id | Partial update |
| PUT /admin/v1/content/order/:id | Full replace |
| DELETE /admin/v1/content/order/:id | Delete (soft by default) |
| GET /api/v1/orders | Public list — pluralised, published-only by default |
| GET /api/v1/orders/:id | Public read by id |
| GET /api/v1/orders/by-slug/:slug | Public read by slug |
| POST /api/v1/orders/submit | Honeypot-guarded public form submission |
The admin surface addresses a schema by its slug (content/order); the public surface uses the pluralised form (orders). What the public surface exposes — read, write, whether an API key is required, the default status filter — is configured per schema, so a model can be fully private, read-only, or write-enabled for forms.
# Create an entry against a schema you defined (here, "order").
# The body is your field data; the response is the stored entry.
curl -X POST https://api.polimorf.com/admin/v1/content/order \
-H 'Authorization: Bearer <access_token>' \
-H 'Content-Type: application/json' \
-d '{
"reference": "PO-1042",
"total": { "amount": "249.00", "currency": "EUR" },
"status": "draft"
}'
# 201 Created
# {
# "id": "a1b2c3d4-…",
# "status": "draft",
# "data": { "reference": "PO-1042", "total": { … } },
# "created_at": "2026-06-21T09:14:02Z"
# } On read, you can expand referenced entries with ?expand=customer,line_items, trim each entry's payload with sparse fieldsets (?fields=reference,total), and request a specific ?locale=.
Pagination
List endpoints are cursor-paginated. Pass ?limit= (defaults to 50) to size a page; the response carries a next_cursor, which you feed straight back as ?cursor= to fetch the following page. A null next_cursor means you have reached the end. Every list response also includes a total count.
# List entries. Public per-org reads live under /api/v1/<plural>.
curl 'https://api.polimorf.com/api/v1/orders?limit=25&status=published' \
-H 'x-api-key: pk_live_…'
# 200
# {
# "items": [ { "id": "…", "status": "published", "data": { … } } ],
# "next_cursor": "eyJpZCI6…",
# "total": 134
# }
# Fetch the next page by feeding next_cursor straight back:
curl 'https://api.polimorf.com/api/v1/orders?limit=25&cursor=eyJpZCI6…' \
-H 'x-api-key: pk_live_…' Cursors are opaque — treat them as a token to round-trip, not a value to parse or construct. Filtering and sorting are driven by query parameters on the same endpoint (for example ?status=published), so a page is just a filtered list plus a cursor.
Error shape
Every error — validation, auth, not-found, rate limit, server fault — comes back as the same JSON envelope: a single error object with a stable, machine-readable code, a human message, and an optional details bag. Branch on code, never on the prose message.
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"details": {
"fields": {
"total.currency": ["Value is not a valid ISO-4217 code"]
}
}
}
}The codes you will meet most often, with their HTTP status:
| Code | Status | Means |
|---|---|---|
| validation_error | 422 | Payload failed validation; details.fields maps each field to its errors |
| unauthenticated | 401 | Missing, expired, or invalid credentials |
| permission_denied | 403 | Authenticated, but not allowed to do this |
| not_found | 404 | No such resource (also returned to hide existence) |
| conflict | 409 | Clashes with existing data (e.g. a duplicate) |
| organization_not_resolved | 409 | Authenticated, but no organization context resolved |
| rate_limited | 429 | Too many requests; back off and retry |
| internal_error | 500 | Unexpected server fault |
Rate limits
Requests are metered in a sliding per-minute window, bucketed by caller class. The default ceilings are:
| Caller | Bucketed by | Default / minute |
|---|---|---|
| Anonymous | Client IP | 60 |
| User session | User id | 600 |
| API key | Key id | 1200 |
Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. When you exceed a bucket you get a 429 with the rate_limited code plus a Retry-After header telling you how many seconds until the window resets. A public schema can be tuned with its own per-minute ceiling, so a client's content API can run tighter or looser than these defaults.
Limiting is fail-open
The limiter never takes the API down: if its backing cache is unreachable, requests pass through rather than being rejected.OpenAPI and next steps
The API is self-describing. A live OpenAPI schema is served at /openapi.json, with interactive documentation at /docs (Swagger UI) and /redoc (ReDoc). Both reflect the current build, so they are always in step with the version you are calling.
From here:
- Schema designer — define the models that generate these endpoints.
- Security & access — roles, scopes, and how organization isolation is enforced.
- Integrations — connect outside systems and receive inbound webhooks.