-
Notifications
You must be signed in to change notification settings - Fork 6
HTTP surface
What a consumer talks to. Read from the 4.0.1 source on master.
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 a version 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 114 routes, plus one alias; the shipped modules add
50 more under the same /api prefix. Health probes are the exception and sit at the root: /health,
/health/live and /health/ready.
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.
Two things sit outside that split, both anonymous and neither under /api/public: the sign-in half
of /api/auth/* (see below), and 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). That is the
route a frontend uses to render a site header. 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, /api/auth/mfa/setup, /enable, /disable, /status
|
Authenticated. Ending your own session, and 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 |
/api/contents, /api/contents/{id}/*
|
Authoring API for entries: create, read, update, status, schedule, history, rollback, erase |
/api/content-types/* (alias /api/schemas) |
Type definitions, blueprints, field sensitivity, public delivery opt-in, SEO fields, projection rebuild |
/api/public/* |
The anonymous delivery API. See below |
/api/preview |
Mints a preview token for one tenant, type and slug |
/api/workflows/*, /api/workflow-runs/*, /api/webhook-deliveries
|
Automation: definitions, validate, dry run, run history, per-action retry, delivery log |
/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 is the one anonymous route in this group |
/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
|
Operations: audit log, job queue, installed modules, health and metrics, build and contract version |
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, and
/api/auth/{provider}/start plus /callback with /api/auth/providers and /api/me/profile
(ExternalAuth). A module route follows that module's own version, not the core's.
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. -
Path=/api/auth/refresh, so it is not attached to every other call.
The response body still carries the refresh token. That is deliberate and is not going away: the cookie is an addition for browsers, not a replacement.
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.
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, 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. 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. See docs/multi-tenancy.md.
X-Api-Contract-Version is on every response, including a 401. It is written by middleware
rather than by an endpoint, which is why an unauthenticated response carries it too
(ApiContract.cs).
It is 1 in 4.0.1. The same number is in apiContractVersion on GET /api/meta, which needs a
session, and that is exactly why the header exists: 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.
It is 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 it: 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.
Browsers need one more thing. Cross-origin, script sees only the seven CORS-safelisted response
headers unless the server says otherwise. barakoCMS exposes exactly two: ETag and
X-Api-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.
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. 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 the type to have a field of type slug, or one named slug, else 404 |
GET /api/public/{type}/search?q= |
A ranked, bounded scan. limit 1..50, default 20. 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 |
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, and per type Feeds:Paths:{type} (a template like /blog/{slug},
defaulting to /{type}/{slug}). With neither set, and no trustworthy request host, both answer 503
with a message naming the setting rather than building a link out of a header the caller wrote.
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. Three routes do not follow that pattern:
- a slug read under a valid
?preview=token isno-store, because it can return an unpublished entry, - the event stream is
no-storeon every outcome, -
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.
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
|
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/user-groups, /api/me/tenants, /api/jobs, /api/modules, /api/capabilities, /api/webhook-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 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. Three policies are tighter (ServiceCollectionExtensions.cs):
| 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
|
telemetry |
20 batches per minute per IP | The anonymous browser error ingest in Diagnostics and Pwa |
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.
Four 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 |
| 503 |
feed.xml and sitemap.xml with no site URL configured |
The message naming the setting, as text/plain; charset=utf-8
|
| 409 | An Idempotency-Key replay (IdempotencyFilter.cs) |
Request with this Idempotency-Key already processed. |
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/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, 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/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
|
| docs/device-trust.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
|
Releases
Start here
- Approval by configuration
- Configuring email
- Delivering a client project on barakoCMS
- Deploying barakoCMS on a VM
- Upgrading from 3.x to 4.0
Content
- Content type blueprints
- Public delivery API
- Event-sourced content types
- Image variants
- SEO fields
- URL redirects
Security and access
- Security and compliance posture
- Scanning uploads for malware
- Where the admin keeps your session, and why
Tenancy
Operations