delivery api - BaryoDev/barakoCMS GitHub Wiki

Public delivery API

The anonymous read surface a website frontend talks to. No token, no cookie. It is separate from /api/contents, which is the authoring API and requires a bearer token.

Three things have to be true before an entry is delivered, and none of them can be turned off by a query parameter:

  • its content type has isPubliclyDeliverable: true,
  • the entry's status is Published, and
  • the entry's document sensitivity is Public.

Any field the content type marks as something other than Public is stripped from the payload.

A type that has not opted in and a type that does not exist both return 404. That is deliberate: answering differently would confirm which types exist.

Responses carry Cache-Control: public, max-age=60 and Vary: X-Tenant, because the tenant can be resolved from the X-Tenant header (see docs/multi-tenancy.md) and the payload is built entirely from that tenant's content. The one exception is a slug read served under a valid ?preview= token, which is no-store because it can return an unpublished entry. Vary tells a conforming cache to key on the header, but it is not a CDN setting on its own: see the caching section of docs/deploy-in-production.md for what the CDN itself has to be configured to do.

Preview tokens are minted through the API, not the admin. POST /api/preview returns a token for one entry in the resolved tenant. It is authenticated, and the caller also needs read on the entry being previewed, so minting a token is not a way around the permissions that guard reading it normally. There is no button for it in barakoCMS itself, so a front end that wants preview links calls that endpoint from its own code with a token that satisfies both. Deferred deliberately rather than overlooked (#306), and recorded here so nobody goes looking for a screen that does not exist.

The route is deprecated and its 200 says so in a Deprecation header. The token is the key of an entry share link that lasts 30 minutes: stored hashed, capped at 20 live per entry, deleted with its entry. A link made with POST /api/contents/{id}/share-links replaces it; that one is listed, audited and revocable, and its key is posted in a body rather than put in a URL. See "Links to one entry or one page" and "Preview tokens" in site-settings.md.

Routes

Method Route Returns
GET /api/public/{type} a page of published entries
GET /api/public/{type}/{slug} one entry by slug
GET /api/public/{type}/search?q= ranked matches, not a page
GET /api/public/{type}/feed.xml RSS
GET /api/public/sitemap.xml sitemap
GET /api/public/types/{type}/description the type's route template and its Public fields' roles
GET /api/public/events a server-sent event stream of changes (off by default, see below)

The feed takes an item's title, description and date from the fields the type gives the title, summary and date roles, and the feed and the sitemap build each link from the type's routeTemplate. A type that declares neither is read by field name and linked at Feeds:Paths:{type} or /{type}/{slug}, as before. See field-hints-and-roles.md.

A file field is answered as the public file it names: its id, url, fileName, contentType, size, alt and caption. A field naming any other file, or a file that is gone, is left out of the entry, and the entry is still delivered.

/{type}/{slug} needs the type to have a slug field: a field of type slug, or failing that a Public string or text field named slug. Without one the route is 404. A Sensitive or Hidden field named slug, or a token, is not served as a slug.

A slug is unique within its content type, so the route resolves to one entry. The authoring API enforces that on the way in: a create, an update or a rollback carrying a slug another entry of the type already holds is refused with 400. Every status counts, drafts and archived entries included, because a draft allowed to hold a taken slug only fails at the moment somebody publishes it, and the scheduler publishes on a timer with nobody to tell. Matching is case-insensitive, the same way this route matches. Reusing the slug of a retired entry means changing or clearing it on that entry first.

Content stored before that rule existed is not rewritten, so a database can still hold two entries on one slug. The route resolves those oldest first, with the entry id as the tiebreak, so the URL answers with the same entry on every request until the duplicate is cleaned up. It used to answer with whichever row the database returned first.

Pagination

GET /api/public/{type} takes page and pageSize.

Parameter Default Range
page 1 1-indexed. Anything below 1 is clamped to 1
pageSize 20 clamped to 1..100

The cap is 100 whatever the caller asks for, so a page size of 5000 returns 100 rather than an error. Every paginated endpoint in barakoCMS returns the same envelope:

{
  "items": [],
  "page": 1,
  "pageSize": 20,
  "totalItems": 137,
  "totalPages": 7,
  "hasNextPage": true,
  "hasPreviousPage": false
}

totalItems counts everything matching the query, filters included, not just the current page.

An entry looks like this:

{
  "id": "0198b0e9-...",
  "contentType": "blog-post",
  "slug": "hello-world",
  "data": { "title": "Hello world", "publishedAt": "2026-08-30" },
  "createdAt": "2026-08-30T02:11:04.113507Z",
  "updatedAt": "2026-08-30T02:11:04.113507Z"
}

data holds only the fields the type marks Public.

Filtering

filter[field][op]=value

Repeat the parameter for more than one filter. Each repeat is its own filter, including repeats of the same field, so ?filter[tag][eq]=a&filter[tag][eq]=b is two filters rather than one filter for the literal a,b.

Operators:

Op Meaning
eq equals
ne not equal
lt lte gt gte ordered comparison
contains case-insensitive substring
has the field holds a list with this value as one whole element
near within radiusKm of lat,lng, geopoint fields only; see The near filter

At most five filters per request. A sixth returns 400. The cap is there because arbitrary filter combinations against a JSONB column on an anonymous endpoint is a denial-of-service surface.

A filter value is at most 256 characters. A longer one returns 400 instead of being trimmed, because a trimmed value matches entries the caller did not ask for.

Only fields the type marks Public can be filtered. Naming any other field returns 400 rather than being ignored, because filtering on a field you cannot read is an oracle: the value never appears in a response, but which entries come back reveals it. A silently ignored filter is worse still, since the caller cannot tell "no filter applied" from "no matches".

A field name is matched without regard to case, in the request and in the stored entry. So when a type declares two fields that differ only by case and one of them is not Public, neither name can be filtered or sorted on: the lookup could land on the value the caller may not read. With both Public, the first one declared is used.

Comparison happens in jsonb using the field's declared type, so a numeric field compares numerically: filter[price][lt]=10 puts 9 below 10 instead of after it.

A choice field is matched by its stored value, exactly, never by its label: filter[EntryType][eq]=FUN. A choice that holds a list (multiple) takes eq and ne only, meaning the entry holds the value or does not; any other operator on it is 400. See Choice fields.

has takes a field that holds a list: an array, a choice with multiple, or a reference with multiple. It matches an entry whose list holds the value as one whole element, so filter[Tags][has]=abc-123 does not match a list holding abc-1234, where contains would, since contains searches the list's text. How an element is compared depends on the field:

  • An array element that is text, a number or true/false is compared by its text, exactly, case included: has=5 matches 5 and "5" and not 50, and has=true matches true. An element that is itself an object or a list never matches.
  • A reference id is compared without regard to case, so has finds an id however it was stored.
  • A choice value is compared exactly, as eq compares it.

Any other field is 400. A reference with multiple takes eq, ne and has, like a choice that holds a list, and eq and ne compare ids the same way has does. has is served by no index, for the reason given under What a filter costs.

Filters narrow what the published-and-public predicate already allows. No filter can widen it.

GET /api/public/blog-post?filter[category][eq]=engineering&filter[title][contains]=marten

What a filter costs

A filter finds its field by name inside the entry's data without regard to the key's casing, so no index serves it. The content type index narrows the scan to the entries of the type, and each of those is then compared. On a type with a hundred thousand entries a filtered request reads all of them. The five filter cap and the page size cap bound what one request asks for, not how many rows it reads.

The same filters on the entries list

GET /api/contents takes the same parameters when contentType is set:

GET /api/contents?contentType=lead&filter[Stage][eq]=won

The operators, the five filter cap and the 256 character cap are the same. What differs is who is asking:

  • A filter is accepted on a declared field the caller reads unmasked: a Public field, or a Sensitive or Hidden one their stored roles may see (by view_sensitive, view_hidden, or the field's own role list; see access-control.md). Any other field returns 400, in the same words as a field that does not exist, and the answer does not list the type's fields.
  • A filtered list leaves out an entry whose document sensitivity withholds its data from the caller. Unfiltered, that entry comes back blanked; matched by a filter, it would say what the blanked value is.
  • A filter without contentType, or on a type with no stored definition, returns 400.
  • sort is not read on this route. Order is sortOrder over the creation time, as before.

Sorting

sort=field      ascending
sort=-field     descending

Same allowlist as filtering: a field the type does not mark Public returns 400. A requested sort replaces the default (createdAt descending) rather than adding to it, and createdAt stays as the tiebreaker so a page boundary cannot move between two entries that compare equal.

Entries missing the sort field collect at the end in both directions.

sortBy was removed in 4.0. It was accepted here and honoured nowhere.

Locations and proximity

A field of type geopoint holds a coordinate pair:

{ "lat": 6.5031, "lng": 124.8469 }

Both keys are required, both must be JSON numbers, latitude within -90..90 and longitude within -180..180. A string like "6.5031,124.8469" is refused on write, because the proximity query casts the stored numbers and a string would not cast. The admin editor hint is geopoint.

The near filter

filter[field][near]=lat,lng,radiusKm

Everything within radiusKm of the centre. Only a geopoint field marked Public can be named, one near per request, and it counts against the five-filter cap. A malformed centre, a centre outside the valid range, a radius of zero or below, or a radius above the cap is 400 with the reason. The cap is Delivery:MaxRadiusKm, default 1000, and a request above it is refused rather than quietly narrowed.

The query runs in SQL over the stored JSONB in two stages: a bounding box on the latitude and longitude, then the haversine distance against the radius. It is applied in the same chain as every other filter, so the published, public and sensitivity rules apply unchanged: a Draft two kilometres from the centre is not returned.

Distances are great-circle on a sphere of mean radius 6371 km. That is right for "within 10 km" and for ordering a list. It is not geodesy: against the ellipsoid it is off by up to a third of a percent, and a survey or a legal boundary needs PostGIS, which this deliberately does not require.

Distance in the response

When a near filter is present each item carries distanceKm, kilometres from the centre to two decimals, the same number the rows were filtered and ordered by. Without a near filter the key is absent, not null.

sort=distance and sort=-distance order by it. Without a near filter, sort=distance is 400, unless the type has a Public field called Distance, which then sorts as any other field would.

curl "https://cms.example.com/api/public/store?filter[Location][near]=6.5031,124.8469,60&sort=distance" \
  -H "X-Tenant: default"
{
  "items": [
    { "id": "3f2c1a9e-6b0d-4f7a-9c2e-1d5b8a7e4c01", "data": { "Title": "Koronadal", "Location": { "lat": 6.5031, "lng": 124.8469 } }, "distanceKm": 0 },
    { "id": "8a1e4d2c-0b9f-4e3a-a7c6-2f4d9b8e1c02", "data": { "Title": "General Santos", "Location": { "lat": 6.1164, "lng": 125.1716 } }, "distanceKm": 56.01 }
  ],
  "totalItems": 2
}

Resolving references

include=author,category

Replaces reference ids with the referenced entries, in one batched load rather than one request per reference. At most five includes per request.

A named field must be of type reference and marked Public, or the request is 400. Each resolved entry goes through the same projection as the list itself, so published state, document sensitivity, type opt-in and the field allowlist all still apply. A target that does not survive that projection has its field removed rather than left as an id, which makes an unreadable target indistinguishable from no reference at all.

A reference with multiple holds a list of ids and resolves to a list of entries, in the order the ids are stored. An entry that does not survive the projection is left out of the list and not named. All the ids on a page are read in one query, and a page whose named fields hold more than 1000 distinct ids is 400, telling the caller to ask for a smaller pageSize or fewer fields. A single reference never reaches that: a full page of 100 entries with five includes holds 500.

Many-valued references

A reference field declared with "multiple": true holds a list of ids:

{ "name": "Speakers", "type": "reference", "referenceType": "speaker", "multiple": true }

An entry write refuses, with 400, a value that is not a list (send a list even of one), more than 100 ids, an id not written in lower case with hyphens (the form this API returns ids in), the same id twice, and any id that is not an entry of the declared type in the tenant. The error names the ids at fault, and does not name the type a wrong one is of. An empty list holds nothing, and a required field refuses it. The list is stored in the order sent. The same check runs on every write that validates an entry: create, update, collection push, bulk and bundle import, a transition that carries data and a rollback. A bundle import points listed ids of records in the bundle at the records as imported, as it does a single reference, and takes records that list each other: a list may name a record of the same bundle that is written after it. A single reference still needs its target written first.

Two writers do not run that check, and refuse a list reference instead: the UpdateField workflow action fails without retrying, since its value is one piece of text, and a collection sync refuses a mapping or rule onto one when it is saved (and skips the item, naming the field, if the field became a list after the sync was saved). A form cannot submit a reference at all, so a form on a type with a required list reference cannot be submitted.

A stored field cannot change between one id and a list: there is no endpoint that changes a field's type or multiple. A bundle import that writes a type over a stored one can, and entries stored before then keep their value until their next save, which then has to send the new shape. include reads what an entry holds rather than what the field declares, so either shape still resolves, and eq and has on a list field do not match an entry still holding a single id (and ne does).

Every delivery route other than the list with include (the slug route, search, preview, share links, the Pages routes and semantic search) serves a Public list reference as the ids it holds, ids of drafts and of entries that are not Public included, as a single reference has always been served. A caller who compares a list with and without include can therefore tell which listed ids it may not read. Making the field non-Public keeps the ids out of delivery entirely.

Search

GET /api/public/{type}/search?q=marten&limit=20

limit is clamped to 1..50, default 20. A q shorter than two characters returns an empty result rather than an error.

This is the one delivery endpoint that does not take the pagination envelope:

{ "results": [], "count": 0, "query": "marten" }

There is no stable ordering to page through and no total beyond the scan cap, so a caller asking for page 3 of a relevance ranking would get something that changes underneath them. count is how many of a bounded, ranked scan matched, not how many exist. Matching runs over public fields only, and a title or name hit outranks a body hit.

Search takes the list's filter[field][op]=value parameters, near included, under the same rules and caps:

GET /api/public/news/search?q=flood&filter[Category][eq]=advisory

The filter runs in the query, ahead of the scan cap and limit, so a search for one category returns that category's best matches even when better matches sit in another. A refused filter is 400 whatever q holds. sort is not read: results are ranked.

Errors

Status When
400 unknown filter field, unknown operator, malformed filter[...], more than 5 filters, a filter value over 256 characters, unknown or non-reference include, more than 5 includes, more than 1000 distinct ids to resolve on one page, has on a field that holds no list, unsortable field, malformed near centre or radius, near on a field that is not a geopoint, sort=distance without a near filter, an operator other than eq, ne or has on a choice or reference that holds a list
404 unknown type, type not marked publicly deliverable, no slug field, no published entry at that slug

A 400 carries the reason, including the fields that would have been accepted.

What a field value is, and is not

Delivery returns the value that was saved. Nothing here escapes, strips or rewrites it, including for the richtext and markdown types, which are both stored and returned as the string somebody typed. Whoever can edit an entry decides what a consumer renders.

So the consumer owns the escaping. Render markdown through something that drops raw HTML, and never put a richtext value into innerHTML without sanitising it first. This is why the shipped blueprints use markdown for every body field: the ordinary way to render markdown is already safe, and the only way to render richtext is not. The feed and the sitemap are the exceptions, and only because they build a document rather than hand you a field, so they escape what they put in it.

Change events

GET /api/public/events is a server-sent event stream of the tenant's content changes, for a frontend or a dashboard that wants to react to a publish without polling. Anonymous, like every other route here, and it answers to the same three rules: an entry is streamed only if its type has opted in, only while it is Published, and only with its Public fields. The payload is produced by the same projection the REST reads use, so a field GET /api/public/{type}/{slug} masks is masked here for the same reason: it is the same function. ContentEventStreamTests asserts that directly against a Sensitive field.

It is off by default. Delivery:Events:Enabled turns it on; while it is off the route is 404.

This route must never be cached. Unlike every other route here it sends Cache-Control: no-store, not max-age=60, because a stream has no single response to cache: buffering one connection's frames and replaying them to a later request is wrong in a single tenant, and a cross-tenant version of the same mistake is the class of bug #546 exists to close. It sends Vary: X-Tenant too, for consistency with the rest of this surface, though a store honouring no-store never needs it. A reverse proxy or CDN placed in front of this route has to be told two separate things: not to cache it at all (most already treat no-store correctly, but confirm rather than assume), and, independently, not to buffer the response before forwarding it. A proxy that buffers waits for the stream to end before sending anything downstream, which breaks Server-Sent Events outright, whether or not caching is involved. nginx's own setting for this is proxy_buffering off; on the route; check the equivalent for whatever sits in front of it.

GET /api/public/events
GET /api/public/events?type=post&type=page

type repeats to filter by content type. A type that has not opted in never matches, which says nothing about whether it exists. At most 20 types per connection.

Three event names, and every payload carries id, contentType and slug:

Event When Payload
content.published an entry became public: published, created as published, or its document sensitivity set back to Public the entry, in the shape GET /api/public/{type}/{slug} returns
content.updated a published entry changed: an edit, a lifecycle transition, or a field's sensitivity changed on its type the entry, same shape
content.unpublished a public entry stopped being public: moved to Draft or Archived, or its document sensitivity changed id, contentType, slug only

A draft save emits nothing. A draft moved to Archived emits nothing either, because it was never on anybody's site and an unpublish for it would hand out the slug of an entry the REST API answers 404 for. Erasing an entry (DELETE /api/contents/{id}/erase) emits nothing yet.

A comment line (: keepalive) is sent whenever nothing else has been for Delivery:Events:KeepAliveSeconds (15), so a proxy does not close an idle connection. An EventSource never dispatches a comment. The frames carry no event id (the id: line is empty) and there is no replay: a client that reconnects should re-read what it cares about, then resume listening.

const source = new EventSource("/api/public/events?type=post");
source.addEventListener("content.published", e => render(JSON.parse(e.data)));
source.addEventListener("content.updated", e => render(JSON.parse(e.data)));
source.addEventListener("content.unpublished", e => remove(JSON.parse(e.data).id));

Caps

An anonymous long-lived connection is a resource anybody on the internet can hold, which is why the stream is opt-in, and why it is capped:

  • Delivery:Events:MaxConnections (100) is the number of open streams across all tenants on one instance. The next connection gets 503 with Retry-After.
  • Delivery:Events:MaxConnectionsPerClient (5) is the number of open streams from one client address, so one caller cannot hold every slot under the instance cap and starve every other tenant on the instance. The next stream from that address gets 503 with Retry-After and a body that says the client is at its own limit, while another address still connects. The slot comes back when the stream closes. The address is the one the rate limiter partitions on: the socket peer, or the forwarded client when ForwardedHeaders names the proxy. Behind a proxy that is not named, every client shares the proxy's address and the cap counts them together. Zero turns the per-client cap off.
  • Each connection buffers 64 changes. A subscriber that stops reading has its oldest change dropped, and the drop is logged once per connection. Nothing a slow subscriber does holds memory for anybody else.

One instance, its own writes

The stream is fanned out in process, on the instance that committed the write. With several API instances behind a load balancer, each streams the writes it handled and nothing else: a subscriber connected to instance A does not see a publish that went through instance B. That is the known limitation until a shared bus exists between instances; a single-instance deployment sees everything. Content types running with EventSourcing:DocumentTypesAppend off write no events, so nothing about them is streamed, the same way nothing about them fires a workflow.

Serving public content from a module

A module that adds its own anonymous route resolves IPublicContentProjector from barakoCMS.Core.Interfaces and hands it a Content plus its ContentTypeDefinition. It gets back a PublicContentProjection, which serialises to the same JSON as /api/public/{type}/{slug}, or null when the entry must not be served at all.

var def = await session.Query<ContentTypeDefinition>()
    .FirstOrDefaultAsync(d => d.Name == type, ct);

if (!projector.IsDeliverable(def)) { await Send.NotFoundAsync(ct); return; }

var projected = projector.Project(entry, def);
if (projected is null) { await Send.NotFoundAsync(ct); return; }

HttpContext.Response.Headers.CacheControl = "public, max-age=60";
HttpContext.Response.Headers.Vary = "X-Tenant";

await Send.OkAsync(projected, ct);

Use it rather than filtering in the module. Four checks stand between a stored document and a public response (published status, document sensitivity, the type's delivery opt-in, and the field allowlist), each fails open in a different direction, and a second copy of them looks right until it delivers a draft. The core routes above call the same code, so there is one copy of the four and a module holds none of it.

The definition has to be the entry's own content type. Project checks it and returns null on a mismatch, because the schema is what names the Public fields, so another type's definition would apply another type's allowlist and the response would still read as authentic. Load the definition and the entry under the same type name, as the example does, and never take the type from one place and the entry from another.

Nothing in the interface queries. The module loads the entry and the definition from its own session, which keeps tenant scoping where it already is. SlugField is there because a module addressing entries by slug has to query the field delivery reads the slug back from. All three members take a null definition, so the result of a FirstOrDefaultAsync can go straight in.

Filters on a module route

A module route that lists or searches content takes the list's filters through IPublicContentFilterParser, from the same namespace. It reads the filter[...] keys of the query string, accepts only fields the type marks Public, and applies the same operators and caps.

var filter = filters.Parse(HttpContext.Request.Query, def);
if (filter.Error is not null)
{
    AddError(filter.Error);
    await Send.ErrorsAsync(400, ct);
    return;
}

var entries = await filter.Apply(session.Query<Content>()
        .Where(c => c.ContentType == type
                    && c.Status == ContentStatus.Published
                    && c.Sensitivity == SensitivityLevel.Public))
    .Take(50)
    .ToListAsync(ct);

Apply adds to the query it is given. It does not select Published or document-Public entries, so the module's query still has to, and each entry still goes through the projector. Apply throws when Error is set, so a refused filter cannot run as no filter. sort is not read.

The cache headers a module route owes its callers

The two lines above are the contract the core public routes set, and a module route has to set them itself: SetCache is internal to the delivery slice. The Cache-Control window is short on purpose, long enough for a CDN to absorb a burst and short enough that a publish shows up quickly.

Vary: X-Tenant is not optional in a multi-tenant deployment. TenantResolutionMiddleware reads that header before it reads Host, and the response is built entirely from the resolved tenant, so a shared cache keyed on the URL alone in front of a header-routed deployment will serve one tenant's public response to another. See docs/multi-tenancy.md for how the front end sets the header, and docs/deploy-in-production.md for the caches that honour Vary only when configured to.

Stability and deprecation

There is no version segment in the URL and none is planned. The delivery API, meaning every route under /api/public, follows the semantic version of the package that registers it instead. For the routes above that is the core package. A module that adds a route under the same prefix, such as /api/public/files/{id} from BarakoCMS.Files or /api/public/{type}/semantic from BarakoCMS.AI, follows that module's own version under the same rules.

What counts as breaking. A route going away or changing shape, a response field being removed or changing type, a filter or operator changing meaning, or a default changing (page size, sort order, cache headers, what an omitted parameter means). Any of those lands only in a major version.

What can land in a minor. A new field, a new filter or operator, a new route, a new optional parameter. A client that ignores what it does not know keeps working, so write clients that way.

How a break is announced. At least one minor release before the major that ships it, the change is recorded in CHANGELOG.md under a Delivery API lead inside the entry, and the field or behaviour is marked deprecated in this document. Until the major, the old behaviour keeps working unchanged. So a consumer reading the changelog for each minor sees every break coming with at least one release of notice, and a consumer who only reads this document sees it too.

Why no version segment. /api/v1/public and /api/v2/public means two code paths, two test suites and two sets of projection rules to keep in step, for a project of this size. The change that prompted the question, 3.20.0 making public delivery opt-in, would not have been helped by it: that was a breaking change shipped in a minor with no notice, and a v2 would have shipped the same break to anyone who moved to it. What was missing was a written rule about when a break may ship and how it is announced. This section is that rule.

Security is the one exception. A security fix ships in the next release whatever its number: a hole in authentication, authorization, integrity or availability as much as a data exposure, which is what 3.20.0 closed. It is still recorded under the same Delivery API lead and under ### Breaking, and it says what a consumer has to do.


Generated from docs/delivery-api.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.

⚠️ **GitHub.com Fallback** ⚠️