Skip to content

HTTP surface

Arnel Robles edited this page Sep 11, 2026 · 1 revision

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.

Shape of the surface

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.

Core route groups

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.

Authentication

Login

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

Refresh and the cookie

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.
  • Secure everywhere except a Development host. It deliberately does not follow Request.IsHttps, because behind a TLS-terminating proxy that is not forwarding headers an https request arrives as http and the cookie would ship without Secure on exactly the deployment that needs it. The consequence for you: a Production API served over plain HTTP cannot keep a browser session.
  • SameSite=Lax, not None. None requires Secure and 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.

API keys

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.

The 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.

The contract version

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.

The public delivery API

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:

  1. the content type has isPubliclyDeliverable: true,
  2. the entry's status is Published, and
  3. 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 is no-store, because it can return an unpublished entry,
  • the event stream is no-store on every outcome,
  • GET /api/public/redirects/resolve sets no Cache-Control and no Vary at all. It caches server side instead, through ASP.NET output caching: 5 minutes, varied by the path query 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 second Vary: X-Tenant rule above, because it sends neither header.

Stability policy

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.md under 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.

Pagination

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.

Rate limits

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.

Errors

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.

The four plain-text exceptions

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.
  • ETag and If-Match on content. GET /api/contents/{id} returns a strong, quoted ETag (no W/ prefix) carrying the document version, and PUT takes it back as If-Match for optimistic concurrency. An event-sourced content type does not return one, because its write path has its own expected-version check and an ETag there would promise a precondition nothing honours. Parsing tolerates a W/ prefix and one layer of quotes.
  • Idempotency-Key on writes. A global pre-processor keyed only on the HTTP method, so POST, PUT and PATCH honour it on every route, anonymous ones included; GET and DELETE ignore it. A replay is not the first response played back. It is 409 Conflict with 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.

Where to read more

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

Clone this wiki locally