HTTP surface - BaryoDev/barakoCMS GitHub Wiki
What a consumer talks to. Written from the 4.0.1 source and checked against the v4.6.0 tag: the route counts, the route tables, both contract versions, the rate limits and the status codes on this page come from that tag.
barakoCMS is headless, so HTTP is the whole product. There are no MVC controllers: every route is a
FastEndpoints REPR endpoint under barakoCMS/Features/<Area>/<Action>/, one class per route, and the
Endpoint, Request and Response classes are internal. Nothing outside this repository compiles
against them. The JSON they produce is the contract instead, and it has versions of its own.
Start here: Home. For configuration keys, see Configuration. For what changed in this major, see barakoCMS 4.0.
Everything lives under /api. The core declares 144 routes, plus one alias; the shipped modules add
60 more under the same /api prefix. Health probes are the exception and sit at the root: /health,
/health/live, /health/ready, and /health/build, which reports the commit the image was built
from.
The counts are lines matching ^\s*(Get|Post|Put|Patch|Delete)\( in the .cs files under
barakoCMS/ (core) and under the BarakoCMS.* module folders, leaving out BarakoCMS.Tests,
BarakoCMS.Testing and BarakoCMS.Templates; the alias is the second argument of
Get("/api/content-types", "/api/schemas").
Two surfaces, and the difference matters more than anything else on this page:
-
/api/public/*is anonymous delivery. No token, published entries only, public fields only, cacheable. - Everything else is authenticated authoring and administration. Bearer token or API key, permission-filtered per caller, returns drafts.
A website frontend uses the first. A console, an importer or a build step uses the second.
Four things sit outside that split, all anonymous and none under /api/public:
- the sign-in half of
/api/auth/*(see below), -
GET /api/tenants/by-host/{host}, which returns the handle of the tenant a host belongs to (Features/Tenants/ByHostEndpoint.cs), -
GET /api/tenants/{handle}/public, which returns one tenant's handle (the tenant slug), name, logo URL, about text, location, location URL, social handle, email and contact URL with no token (Features/Tenants/Endpoints.cs). Since 4.6.0 everything but the handle and name is read from the tenant's publishedsiteentry, not from the tenant record, andlogoUrl,locationUrlandcontactUrlcome back empty unless the value is an absolutehttporhttpsaddress. That is the route a frontend uses to render a site header, -
GET /api/tenants/tls-ask?domain={host}, theaskendpoint for Caddy's on-demand TLS. It answers 200 for a domain an active tenant holds and 404 for anything else (Features/Tenants/TlsAskEndpoint.cs).
Everything else under /api/tenants needs a token.
| Prefix | What it is for |
|---|---|
/api/auth/login, /refresh, /mfa/verify, /otp/request, /otp/verify, /register, /register/verify
|
Anonymous, rate limited. Sign in, refresh, the MFA and OTP challenge steps, registration |
/api/auth/logout |
Anonymous since 4.5.0, so the refresh cookie alone can sign out once the access token has expired. With neither a valid bearer nor the cookie it answers 401 |
/api/auth/mfa/setup, /enable, /disable, /status
|
Authenticated. Managing your own second factor. Calling /api/auth/mfa/setup without a token is a 401 |
/api/me/* |
The signed-in account: change own password, list own tenants, switch tenant. POST /api/me/switch takes { "tenant": "<handle>" }, with club kept as an alias |
/api/contents, /api/contents/{id}/*
|
Authoring API for entries: create, read, read by slug, update, status, schedule, history, rollback, erase |
/api/contents/{id}/share-links |
Share links to one entry or page: create, list, revoke. Needs update on that entry. Opened anonymously through POST /api/public/site/share-links/open
|
/api/content-types/* (alias /api/schemas) |
Type definitions, blueprints, adding a field, a choice field's options, a money field's currency, field presentation (editor, section, role), field sensitivity, public delivery opt-in, the route template, uniqueness rules and their duplicates, SEO fields, projection rebuild |
/api/collection-syncs/* |
Collection syncs: list, read, create, edit, delete, and run one now. manage_collection_syncs. A run that overlaps another run filling the same content type can answer 409 with Retry-After
|
POST /api/collections/{type}/push |
A source pushes entries into a collection, upserted by slug. A JWT or an API key, checked against the content permissions of the type |
/api/site/share-links/* |
Share links for a site held back from the public: create, list, revoke. Needs update on the site type. The anonymous routes are POST /api/public/site/share-links/redeem and /open
|
/api/public/* |
The anonymous delivery API. See below |
/api/preview |
Deprecated in 4.6.0, and its answers carry a Deprecation header. Mints a 30 minute preview token for one entry, which is now the key of an entry share link. Use /api/contents/{id}/share-links instead |
/api/workflows/*, /api/workflow-runs/*, /api/webhook-deliveries, /api/connector-deliveries
|
Automation: definitions, switching one on or off, delete, validate, dry run, run history, per-action retry, cancelling a run, the webhook delivery log, and the log of requests sent through connectors |
/api/connectors/* |
Outbound HTTP targets a workflow action can call, plus a test call |
/api/queries/*, /api/requests/*
|
Saved queries and saved requests, each with a preview or dry run |
/api/users, /api/user-groups/*, /api/roles/*, /api/capabilities
|
Identity and authorization administration |
/api/tenants, /api/tenants/members/*
|
Tenants and their memberships. GET /api/tenants/{handle}/public, GET /api/tenants/by-host/{host} and GET /api/tenants/tls-ask are the anonymous routes in this group. Since 4.6.0 the tenant writes refuse the seven profile fields with a 400 naming the site field to set instead |
/api/api-keys |
Machine credentials. Create, list, revoke |
/api/settings, /api/settings/email
|
Instance settings, including an email send test |
/api/redirects/* |
URL redirects: list, create, delete, and bulk import. The anonymous resolve route is /api/public/redirects/resolve, not under this prefix |
/api/audit, /api/jobs, /api/modules, /api/monitoring/*, /api/meta, /api/meta/describe
|
Operations: audit log, job queue, installed modules, health and metrics, build and contract versions, and what this instance accepts (field types and their rules, editor hints and roles, and for a caller allowed to see them the capabilities, workflow actions and running modules) |
Module routes follow the same pattern in their own prefixes: /api/files/* with
/api/public/files/{id} and /api/public/files/{id}/meta (Files, the second one returning the
file name, content type, size, public URL, alt text and caption so an <img> can carry the words an
editor wrote), /api/feature-flags/*, /api/devices/* (DeviceTrust),
/api/analytics/* (Umami), /api/accounting/*, /api/client-errors/* (Diagnostics),
/api/email-events and /api/webhooks/resend, /api/import/*, /api/portability/*, /api/pwa/*,
/api/ai/index/{type} with /api/public/{type}/semantic, /api/forms and /api/forms/{contentType}
with /api/public/forms/{slug} and /api/public/forms/{slug}/email-code (Forms), /api/pages/tree
with /api/public/pages/navigation and /api/public/pages/resolve (Pages), and
/api/auth/{provider}/start plus /callback for Google, GitHub, Facebook and LinkedIn,
/api/auth/oidc/{name}/start plus /callback for any OpenID Connect provider configured under
Oidc:Providers:{name}, with /api/auth/providers and /api/me/profile (ExternalAuth). A sign-in
callback redirects to {App:BaseUrl}/auth/social with the tokens in the URL fragment.
A module route follows that module's own version, not the core's, and neither core contract number
covers it. A module states its own number as HttpContractVersion, listed per module as
httpContractVersion in GET /api/meta/describe. At 4.6.0 Files declares 2, Forms, AI and Pages
declare 1, and the rest leave it at the default of 0, meaning unstated.
Swagger is a separate surface and is off unless Swagger:Enabled is true (it defaults on in
Development only). GET /api/meta reports swaggerEnabled so a client can offer the link without
probing for a 404.
POST /api/auth/login, anonymous, rate limited to 5 attempts per 15 minutes per IP by the auth
policy. Body is { "username": ..., "password": ... }, both required.
The 200 response is one shape carrying three different outcomes, so read the flags before reading the token (Features/Auth/Login/Models.cs):
| Field | When it is set |
|---|---|
token |
Signed in. A JWT, good for 15 minutes |
expiry |
When that token expires |
refreshToken |
Signed in. 7 days, rotated on use |
refreshTokenExpiry |
When that refresh token expires |
requiresMfa, mfaChallengeToken, message
|
Password was right, the account has a second factor. No tokens issued. Collect a TOTP or recovery code and call POST /api/auth/mfa/verify with the challenge token |
requiresDeviceApproval, email, message
|
Password was right, the device is not approved. No tokens issued. An OTP was emailed; call POST /api/auth/otp/verify
|
So a client that reads token without checking requiresMfa and requiresDeviceApproval sees an
empty string and reports the wrong failure. Both of those come back 200. The other statuses:
| Status | When |
|---|---|
| 401 | Unknown user, wrong password, or a user with no access to the resolved tenant. All three return the same body on purpose |
| 423 | Account locked. Five failed attempts lock it for 15 minutes, and the message names the minutes left |
| 503 | Device approval needed but the code could not be emailed |
POST /api/auth/refresh, anonymous, same auth rate limit. It reads the token from the body first,
then falls back to a cookie, so both browser and non-browser callers work
(Features/Auth/Refresh/Endpoint.cs).
Login also sets the refresh token as a cookie named barako_refresh
(RefreshTokenCookie.cs):
-
HttpOnly, so page script cannot read it. -
Secureeverywhere except a Development host. It deliberately does not followRequest.IsHttps, because behind a TLS-terminating proxy that is not forwarding headers an https request arrives as http and the cookie would ship withoutSecureon exactly the deployment that needs it. The consequence for you: a Production API served over plain HTTP cannot keep a browser session. -
SameSite=Lax, notNone.NonerequiresSecureand so requires https, which local stacks do not have. A cross-origin deployment either serves both halves from one origin or uses the token in the body. - Set twice, once with
Path=/api/auth/refreshand once withPath=/api/auth/logout, so it reaches the two routes that read it and no other call.
Sign-in still returns the refresh token in the body, and so does a refresh that was sent the token
in the body. A refresh sent only the cookie answers with the new token in the cookie and an empty
refreshToken in the body (since 4.5.0): that caller is a browser, and a body is something page
script can read. The server stores only a SHA-256 hash of each refresh token.
Refresh rotates the token. Replaying an already-rotated token is treated as a compromised family and revokes every active token for that user, so a client must not race two refreshes. Membership is re-checked on every refresh, so a revoked membership stops working at the next refresh rather than when the refresh token expires.
POST /api/me/switch does not issue a second session (since 4.5.0). It returns an access token for
the target tenant that expires when the presented one would have, revokes the presented one, and
returns an empty refreshToken. Keep the refresh token from sign-in: a refresh mints for the
X-Tenant it is sent with.
Authorization: Bearer bcms_.... The bcms_ prefix is what routes the request to the API key
scheme instead of JWT validation. A key is minted by POST /api/api-keys and the secret is returned
once; only its SHA-256 hash is stored.
Scopes (Models/ApiKey.cs):
content:read, content:write, content:destructive, contenttype:read, contenttype:write, and
* for all of them. At least one is required at create time and an unknown name is a 400.
API keys are confined to the content surface. /api/contents takes the content:* scopes and
/api/content-types (with its /api/schemas alias) takes the contenttype:* scopes, read for
GET and write for POST, PUT, PATCH and DELETE. Erasing an entry and rolling one back need
content:destructive, and POST /api/collections/{type}/push takes content:write. A key that
names content types reaches only the push route for those types. /api/contents/{id}/share-links is
closed to keys. Every other path answers 403 with a plain-text body, so a leaked key cannot reach
users, roles, tenants or the keys themselves
(ApiKeyScopeProcessor.cs).
A key carries its tenant, so a tenant-scoped key reads and writes the right partition with no
X-Tenant header.
X-Tenant names the tenant, lowercased. Resolution order is the header, then a registered custom
domain, then the host's leading subdomain (www, app, api and admin are skipped as infra
names), then the default tenant
(TenantResolutionMiddleware.cs).
The header is client-supplied and anonymous callers can set it freely, which is how path-based routing works. It is not an escalation, because an authenticated request is still checked: a token is only accepted on the tenant it was minted for, login refuses to mint a token for a tenant the user has no access to, and refresh re-checks membership. A caller can only select a tenant it is already authorized for, or public data. A token used on another tenant gets a 403 with a plain-text body.
With Tenancy:Mode set to Multi (the default is Single), a request that names no tenant, an
unregistered slug or an inactive tenant gets a plain-text 404, one body for all three. Health,
/metrics, GET /api/meta, /api/auth/*, GET /api/tenants/tls-ask and the two tenant lookups
still answer without a tenant
(TenantlessRoutes.cs).
See docs/multi-tenancy.md.
There are two numbers since 4.6.0, one per core surface, and both are on every response, including a 401. They are written by middleware rather than by an endpoint, which is why an unauthenticated response carries them too (ApiContract.cs):
| Header |
GET /api/meta field |
Covers | Value in 4.6.0 |
|---|---|---|---|
X-Api-Contract-Version |
apiContractVersion |
The admin surface: every core route that is not a delivery route | 6 |
X-Delivery-Contract-Version |
deliveryContractVersion |
The delivery surface: the core routes under /api/public/ and the two anonymous tenant lookups |
6 |
GET /api/meta needs a session, and that is exactly why the headers exist: a client has to be able
to decide whether it can drive this API before anyone signs in, and again mid-session when a rolling
upgrade moves it underneath them.
A change to an admin route alone moves only the admin number. A change to something every core route
shares, such as the error body, moves both. A module route moves neither (see the module routes
above). The delivery number starts at 6 because, until it existed, X-Api-Contract-Version stood for
delivery too. An API from before 4.6.0 sends no delivery header, so a consumer that finds it absent
reads X-Api-Contract-Version in its place.
History, read from ApiContract.cs at each tag:
| Release | X-Api-Contract-Version |
X-Delivery-Contract-Version |
|---|---|---|
| 4.0.0, 4.0.1 | 1 | not sent |
| 4.1.0 | 3 | not sent |
| 4.2.0 to 4.4.1 | 4 | not sent |
| 4.5.0 | 5 | not sent |
| 4.6.0 | 6 | 6 |
4.5.0 moved it for connector address changes needing their secrets again, stricter upload type
checks, the portability import running the create checks, global roles changed only by a platform
administrator, and tenant switching no longer renewing the session. 4.6.0 moved it for enforced
validationRules, newly read field and transition members, the tenant profile moving to the site
entry, the overlapping collection sync 409, the stricter filter[...] handling and the slug rule on
delivery. See CHANGELOG.md for each.
They are independent of the package version. barakoCMS can go 4.0 to 4.1 without a single HTTP field
changing, and a break to the HTTP surface does not have to wait for a package major. Reading
version instead of apiContractVersion means breaking on every patch that does not touch HTTP.
What moves a number: removing a response field, renaming one, changing a field's type, changing a status code, or tightening request validation so a request that used to be accepted is rejected. Adding an optional field, to a request or a response, does not move it.
What to do when it moves: refuse to run, and say so. The API does not declare a minimum supported client version, because the client is the side that breaks. So the client carries the range it works with and stops outside it. That is what barakoBrew does: it pins a supported range and stops rather than half-working, which is the useful behaviour, because a console that keeps going past a contract break fails later, somewhere unrelated, with a symptom that does not name the cause. A renderer reading delivery does the same with the delivery number.
Browsers need one more thing. Cross-origin, script sees only the seven CORS-safelisted response
headers unless the server says otherwise. barakoCMS exposes exactly three: ETag,
X-Api-Contract-Version and X-Delivery-Contract-Version. Anything else you set today would be
invisible to fetch with no error and no warning, which is how ETag went unread for a release.
That includes the Deprecation header on POST /api/preview and Retry-After.
Anonymous, published-only, slug-addressable, cacheable reads for a website frontend. It is not
/api/contents with the auth removed: it is a separate projection with its own rules, written so it
cannot leak regardless of how the authoring side is configured
(Features/Public/Endpoints.cs).
Three things must all be true before an entry is delivered, and no query parameter can turn any of them off:
- the content type has
isPubliclyDeliverable: true, - the entry's status is
Published, and - the entry's document sensitivity is
Public.
Any field the type does not mark Public is stripped, and so is a token field whatever its
sensitivity. The projection is an allowlist, not a denylist, so an orphaned key left by a renamed
field is dropped rather than leaked.
Delivery is opt-in per content type. A type that has not opted in and a type that does not exist
both answer 404, so the API does not confirm which types exist. The opt-in is flipped by
PUT /api/content-types/{name}/public-delivery, gated on manage_public_delivery.
| Route | Returns |
|---|---|
GET /api/public/{type} |
A page of published entries. Filters, near, sort, include
|
GET /api/public/{type}/{slug} |
One entry. Needs a field of type slug, or else a Public string or text field named slug, or it is 404 |
GET /api/public/{type}/search?q= |
A ranked, bounded scan. limit 1..50, default 20. Takes the same filters as the list. Not paginated |
GET /api/public/{type}/feed.xml |
RSS 2.0, newest 50. The literal feed.xml segment wins over {slug}
|
GET /api/public/sitemap.xml |
Sitemap over every deliverable type, capped at 50,000 entries |
GET /api/public/events |
Server-sent stream of content changes. Off unless Delivery:Events:Enabled, and 404 while off |
GET /api/public/redirects/resolve?path= |
One indexed lookup returning fromPath, toPath and a 301 or 302 status. 404 when nothing moved |
POST /api/public/site/share-links/redeem |
Redeems a site share link for a site held back from the public |
POST /api/public/site/share-links/open |
What a share link key opens, and for an entry or page link the entry itself. The key goes in the body. 200 or 404, nothing else |
Filters are filter[field][op]=value. At most 5 per request, near counted among them, and a value
longer than 256 characters is a 400. Since 4.6.0 a filter the API cannot apply (a field the type does
not declare, an unknown operator, a malformed key, a sixth filter) is a 400 where search used to
ignore it. On the list, include resolves at most 1,000 referenced entries per request; past that
it is a 400 asking for a smaller page or fewer include fields.
The feed and the sitemap need a site URL, since a headless CMS cannot know your frontend's URLs. Set
Feeds:SiteUrl or App:BaseUrl. Links follow the type's route template
(PUT /api/content-types/{name}/route-template) when it has one, else Feeds:Paths:{type} (a
template like /blog/{slug}), else /{type}/{slug}. With no site URL, and no trustworthy request
host, both answer 503 rather than building a link out of a header the caller wrote. The body is a
fixed plain-text message, and the setting to fix goes to the log, not the response.
Responses carry Cache-Control: public, max-age=60 and Vary: X-Tenant. The tenant can come from
that header, and a shared cache keyed on URL alone would serve one tenant's content to another.
Vary only helps if the CDN is configured to honour it. These routes do not follow that pattern:
- a slug read under a valid
?preview=token isno-store, because it can return an unpublished entry. Since 4.6.0 that token is the key of an entry share link, - the event stream is
no-storeon every outcome, - both share link routes are
no-store, -
GET /api/public/redirects/resolvesets noCache-Controland noVaryat all. It caches server side instead, through ASP.NET output caching: 5 minutes, varied by thepathquery value and by the resolved tenant slug (ResolveEndpoint.cs). Only a hit is cached; a 404 still reaches Postgres every time. Do not configure a shared cache for it on the 60 secondVary: X-Tenantrule above, because it sends neither header.
It is written down, in the Stability and deprecation section of docs/delivery-api.md. The short version:
- No version segment in the URL, and none planned. Delivery follows the semantic version of the package that registers the route, so the core for the routes above and the module's own version for a module route.
- A route going away or changing shape, a removed or retyped field, a filter or operator changing meaning, or a changed default (page size, sort order, cache headers, what an omitted parameter means) lands only in a major.
- A new field, filter, operator, route or optional parameter can land in a minor. Write your client to ignore what it does not recognise.
- A break is announced at least one minor before the major that ships it, in
CHANGELOG.mdunder a Delivery API lead and marked deprecated in that doc. - A security fix is the exception and ships in the next release whatever its number.
X-Delivery-Contract-Version is the machine-readable side of the same surface: it moves on the same
kinds of change, so a renderer can check it on every response.
Every list endpoint is bounded, takes page and pageSize, and caps pageSize at 100. The default
pageSize is not the same everywhere, because there are two base classes
(Models/PaginationModels.cs):
| Base class | Default pageSize
|
Routes |
|---|---|---|
PaginatedRequest |
20 |
/api/contents, /api/public/{type}, /api/users, /api/roles, /api/audit, /api/redirects, /api/site/share-links, /api/contents/{id}/share-links, /api/content-types/{name}/uniqueness/{rule}/duplicates
|
ListRequest |
100 | The administrative lists: /api/content-types (and its /api/schemas alias), /api/tenants, /api/tenants/members, /api/tenants/members/roles, /api/api-keys, /api/settings, /api/queries, /api/requests, /api/workflows, /api/workflow-runs, /api/connectors, /api/collection-syncs, /api/user-groups, /api/me/tenants, /api/jobs, /api/modules, /api/capabilities, /api/webhook-deliveries, /api/connector-deliveries, /api/contents/{id}/history
|
ListRequest starts at the cap deliberately. Those lists used to return a bare unbounded array, so
any default below 100 would have truncated a caller that was already reading the whole table. What
that means for you: omit pageSize on one of those routes and you get 100 rows, not 20. Send it
explicitly when the size matters.
| Parameter | Bound |
|---|---|
page |
1-indexed, default 1. Anything below 1 is clamped to 1 |
pageSize |
Clamped to 1..100. Default 20 or 100, per the table above |
The cap is 100 whatever you ask for, so pageSize=5000 returns 100 rather than an error. Do not
plan a client around fetching everything in one call.
The envelope is the same everywhere:
{
"items": [],
"page": 1,
"pageSize": 20,
"totalItems": 137,
"totalPages": 7,
"hasNextPage": true,
"hasPreviousPage": false
}The two share link lists add one field to it, maxExpiryDays, the longest expiry create accepts.
The pageSize in the envelope is the size actually used, so it is how a client confirms which
default it got. The sample above is a PaginatedRequest route; the same call against
/api/content-types comes back with "pageSize": 100.
totalItems counts everything matching the query, filters included, not just the page. sortBy was
removed in 4.0: it was accepted on every paginated endpoint and honoured by none, and on
/api/public/{type} it was silently skipped while ?sort= is a real parameter. sortOrder is still
there and is honoured only by the endpoints that document a sort column.
The one delivery endpoint that does not take this envelope is search, which returns
{ "results": [], "count": 0, "query": "..." }. There is no stable ordering to page through a
relevance ranking, so there is no page parameter.
The global limit is 100 requests per minute per IP. The policies below are tighter, except the
renderer bucket, which is looser
(RateLimitSetup.cs).
These are the defaults. The Global, Auth, Batch (the telemetry policy), Registration,
Renderer and SiteShare windows are configurable under RateLimiting:*; logout and tls-ask
are fixed. The Forms limits sit under Modules:Forms, and the OIDC one under Oidc:RateLimit; see
Configuration.
| Policy | Limit | Routes |
|---|---|---|
auth |
5 per 15 minutes per IP |
/api/auth/login, /refresh, /mfa/verify, /mfa/disable, /otp/request, /otp/verify
|
registration |
5 per hour per IP |
/api/auth/register, /api/auth/register/verify
|
logout |
30 per minute per IP, fixed | /api/auth/logout |
telemetry |
20 batches per minute per IP | The anonymous browser error ingest in Diagnostics and Pwa |
site-share |
10 per minute per tenant and visitor |
POST /api/public/site/share-links/redeem and /open
|
tls-ask |
60 per minute per bucket of asked names, fixed, in place of the global limit | GET /api/tenants/tls-ask |
delivery |
Off until RateLimiting:Delivery:PermitLimit is set, then per IP |
GET /api/public/{type}, /{slug}, /search, /feed.xml, /api/public/sitemap.xml. A request carrying the renderer key is left to the renderer bucket |
forms |
5 per 10 minutes per IP, across every form, unless Modules:Forms:PerForm:{slug} gives one form its own |
POST /api/public/forms/{slug} (Forms) |
forms-email-code |
5 per 10 minutes per IP, across every form |
POST /api/public/forms/{slug}/email-code (Forms) |
external-auth-oidc |
20 per 5 minutes per IP |
/api/auth/oidc/{name}/start and /callback (ExternalAuth) |
| renderer | 1000 per minute, one shared bucket, in place of the global limit | Any request whose X-Barako-Renderer-Key header matches RateLimiting:Renderer:Key. Off unless that key is set |
Two more limits are off until configured. RateLimiting:ApiKey counts every request an API key
authenticated against that key, whatever address it comes from. RateLimiting:Policies:{name}
defines a policy with PermitLimit, WindowSeconds, QueueLimit and a Partition of Ip, User
or ApiKey, which a module or host route names with RequireRateLimiting("{name}"). A route that
names a policy nobody defined stops the host at startup.
So registration is four times tighter than the rest of /api/auth/*. Plan bulk sign-up testing
around 5 per hour, not 5 per 15 minutes. /api/auth/mfa/setup, /enable and /status carry no
policy of their own and sit on the global 100 per minute.
A rejected request is 429 with a plain-text body, not ProblemDetails. Handle that status.
Nearly every error the core returns is RFC7807 ProblemDetails, configured globally through
FastEndpoints. Four shapes used to ship from one API (ProblemDetails, a hand-rolled {message} with the field
errors flattened into one string, a hand-rolled {errors: [...]}, and bodyless) and POST /api/content-types managed two of them from a single endpoint depending on which check failed. They
were consolidated in 4.0.
The part a client reads is the errors array, and each entry carries name and reason:
{
"errors": [
{ "name": "slug", "reason": "Slug is required." }
]
}reason is the field to read. A client reading message or errors[].message off a 400 gets
nothing, which is the bug the admin shipped for a release: it rendered every validation failure,
"Invalid credentials" included, as [object Object]. The rest of the body is the standard
ProblemDetails membership, and an entry can also carry a code and a severity.
Duplicate entries for one field are kept deliberately, so a type with three bad fields reports all three rather than making you post again twice.
These paths answer with a bare text body, so a client that parses every error as ProblemDetails throws on them. Branch on the status before parsing:
| Status | Path | Body |
|---|---|---|
| 429 | Any rate-limited route | Too many requests. Please try again later. |
| 403 | An API key used outside the content surface (ApiKeyScopeProcessor.cs) | The scope message |
| 403 | A route gated on a capability the caller lacks (CapabilityGateProcessor.cs) | This caller is missing the '<capability>' capability. |
| 403 | A token used on a tenant other than the one it was minted for (TenantAccessMiddleware.cs) | This session is not valid for this tenant. |
| 404 | No registered, active tenant while Tenancy:Mode is Multi
|
This deployment serves registered tenants only. |
| 503 |
feed.xml, sitemap.xml or any other link-building route with no base URL configured |
This endpoint is not available on this deployment., as text/plain; charset=utf-8
|
| 409 | An Idempotency-Key replay (IdempotencyFilter.cs) |
Request with this Idempotency-Key already processed. |
The DeviceTrust module adds one more: a device-bound token sent from a device that is not approved
gets a 401 with This device is not approved.
Other things a client needs to know about the wire format:
- Enums cross the wire as names, not numbers. Reading is tolerant and still accepts a number, so an older client keeps working. Storage is unchanged, which is why this was a serializer change rather than a migration.
-
ETagandIf-Matchon content.GET /api/contents/{id}returns a strong, quotedETag(noW/prefix) carrying the document version, andPUTtakes it back asIf-Matchfor optimistic concurrency. An event-sourced content type does not return one, because its write path has its own expected-version check and anETagthere would promise a precondition nothing honours. Parsing tolerates aW/prefix and one layer of quotes. -
Idempotency-Keyon writes. A global pre-processor keyed only on the HTTP method, soPOST,PUTandPATCHhonour it on every route, anonymous ones included;GETandDELETEignore it. A replay is not the first response played back. It is409 Conflictwith a fixed plain-text message and nothing from the original call, so if your client needs the created id after a network timeout, keep it on your side: the 409 does not carry it. Only a request that succeeded keeps its key, so a failed write stays retryable. Generate one value per logical write: see docs/idempotency.md.
| Doc | Covers |
|---|---|
| docs/delivery-api.md | The full delivery API: filters and operators, near, include, search, the event stream, preview tokens, the stability policy |
| docs/site-settings.md | The site entry the tenant profile is read from, share links to a site, an entry or a page, and preview tokens |
| docs/field-hints-and-roles.md | Field editor hints, sections and roles, and the route template the feed and sitemap link from |
| docs/uniqueness-rules.md | Uniqueness rules on a content type and the duplicates route |
| docs/access-control.md | Per-role CRUD, row-level scope, field and document sensitivity, and the capability each route group is gated on |
| docs/multi-tenancy.md | Tenant resolution, Tenancy:Mode, and what X-Tenant does and does not let a caller reach |
| docs/session-and-token-storage.md | Where a client should keep the access and refresh tokens |
| docs/idempotency.md | The inbound Idempotency-Key contract |
| docs/webhooks.md | Outbound deliveries, signing, and the delivery log behind GET /api/webhook-deliveries
|
| docs/connectors.md | Connectors, their credentials, and the requests sent through them |
| docs/collection-syncs.md | Collections filled from outside, and running a sync |
| docs/workflow-runs.md | Run history and per-action retry |
| docs/seo-fields.md | The seo block on a delivered entry and how it resolves |
| docs/url-redirects.md |
/api/redirects and the anonymous resolve route |
| docs/event-sourced-content-types.md | Why some types behave differently on write and carry no ETag
|
| BarakoCMS.DeviceTrust/README.md | The device approval branch of login |
| docs/background-jobs.md | The queue behind GET /api/jobs
|
| docs/deploy-in-production.md | What a CDN in front of the delivery API has to be configured to do |
| CLAUDE.md section 6 | The rule itself: what counts as a breaking change to each surface, and why the types stay internal
|