Public delivery API

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, or Members and the caller holds a viewer token (see Members and viewer tokens).

Any field the content type marks as something other than Public is stripped from the payload. A Members field is kept for a viewer token.

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, X-Viewer-Token. 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 viewer header is listed so a cache never hands an anonymous copy to a caller who sent a token (see Members and viewer tokens). 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.

Each read also says its cache class, carries a weak ETag and the tags it was built from, and answers 304 when the caller already holds it. See Cache classes, validators and tags.

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)
POST /api/public/{type}/lookup one entry's named fields, by a reference plus an identifier (see public-lookup.md)

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.

Each link starts at the tenant's own site: the Url of its published site entry (site-settings.md). A tenant that sets none, or sets one that is not an absolute http or https URL without a query, links at Feeds:SiteUrl and then App:BaseUrl, which is what every tenant got before, so a deployment that only sets Feeds:SiteUrl serves the same feed it did. The {{links.public}} template placeholder builds the same address.

A site entry's Url takes precedence over Feeds:SiteUrl. A deployment that set both, with the site entry pointing somewhere else (a staging host, say), now links its feed and sitemap at the site entry's address. Check that the two agree before upgrading. The feed and the sitemap carry the site type's cache tag, so a purge for a change to the site entry reaches them.

A read by slug of a type that declares a structuredDataType also carries structuredData, the entry as schema.org JSON-LD built from its field roles. See structured-data.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. The count and the page come back from one query, so a list costs one round trip to Postgres. A page past the last one has no row to carry the count, and only that request counts separately.

Only what changed

changedSince takes an ISO 8601 instant and keeps the entries whose updatedAt is after it, for a build that fetches what changed since its last run:

GET /api/public/post?changedSince=2026-10-01T00:00:00Z&pageSize=100

It combines with filters, sorting and paging, and totalItems counts what it kept. It answers additions and edits only. An entry that was unpublished, archived, made non-Public or deleted is not in a published list, so changedSince cannot report it. A build learns of a removal from a Webhook workflow action on the Unpublished and Deleted triggers (webhooks.md), from content.unpublished on the event stream below while it is connected, or from a periodic full fetch whose ids it compares with what it holds. GET /api/changes carries no content topic, so it is not a way to learn of one.

A change to the type rather than to an entry does not move updatedAt. When a field goes from Public to Sensitive, every entry's delivered data loses that field and none of them comes back from changedSince. After a change to a type's fields, their sensitivity or its route template, fetch the type in full.

A value that is not a date is a 400. Leaving it out lists everything, as before.

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.

Counting entries by status

GET /api/contents/counts answers how many entries the caller may read, by status, so an overview card does not have to page the whole collection:

GET /api/contents/counts?contentType=article
{ "total": 154, "draft": 27, "published": 121, "archived": 3, "scheduled": 3, "uncountedTypes": [] }
  • Without contentType it counts every type with a stored definition that the caller may read; with one, only that type.
  • Each status is its own number and total is all four together. scheduled is the Scheduled status, entries waiting for their publish time.
  • Each type is counted under the same read rules the entries list pages with, so the numbers agree with what GET /api/contents would list. A type whose rules for the caller depend on the entry in a way that cannot be turned into a query (a condition on $status, or on a value that is not a string) is named in uncountedTypes and left out of the numbers, rather than loaded row by row. Only a type the caller has some read rule for is named.
  • It needs a signed-in caller, or an API key with content:read.

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.

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.

Members and viewer tokens

Members is a sensitivity level between Public and staff, on an entry and on a field. It is appended after Hidden, so no stored value changes number, and nothing is Members until someone sets it. Existing entries and fields stay Public, and a request without a viewer token is answered exactly as before.

A site with its own sign-in mints a token for one of its members with POST /api/viewer-tokens ({ "subject": "member-9", "lifetimeMinutes": 60 }), which needs the manage_public_delivery capability. Lifetime defaults to 60 minutes, at most 480. Each mint is written to the audit trail as viewer.token.minted (subject, tenant, expiry and the actor, never the token). If JWT:Key is not set the route answers 503. The token goes in the X-Viewer-Token header of GET /api/public/{type} and GET /api/public/{type}/{slug}.

  • An anonymous call never sees a Members entry (404 on a slug, absent from a page) or field. The feed, the sitemap, search, the lookup route, the event stream, the type description and the OpenAPI document are anonymous only: they never serve Members, with or without the header.
  • A viewer sees Public and Members entries, and Public and Members fields. Sensitive and Hidden stay staff-only. Staff read a Members value in the authoring API as they read a Public one.
  • The token is bound to the tenant that minted it. A token for tenant A is a 401 on tenant B.
  • A token that is expired, malformed or for another tenant is a 401, not an anonymous read, so an expired session shows as one instead of as a missing page.
  • A token cannot be revoked. The short lifetime is the control, so mint for as little time as the session needs. Rotating JWT:Key signs out every viewer at once, and every staff session with them.
  • The token is signed under its own :viewer audience. The bearer scheme rejects it, so it opens no authoring route and cannot mint another token.
  • A response served with a token is Cache-Control: private, no-store, Vary: Authorization, X-Tenant, X-Viewer-Token, with no cache tags, ETag or Last-Modified. It is never the same response an anonymous caller is given, and no shared cache keeps it.
  • A reference or file field on a Members entry resolves only to targets anonymous delivery serves. filter, sort and include work on Public fields only, for a viewer as for anyone.
  • A row condition (own record) is not evaluated for a viewer yet: Members means every verified viewer of the tenant.

On a private site. Private mode (see site-settings.md) gates every /api/public/* route before the handler runs, for a visitor with a site-wide share key or a signed-in user who may read the site type. A viewer token is not a way in: on a private tenant a member needs the site pass and the token, and the response is private, no-store either way.

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 class and tags come from barakoCMS.Infrastructure.Caching.DeliveryCache: call DeliveryCache.Shared(HttpContext, DeliveryCacheClass.Short, scopes) beside the two lines, and add Options(x => x.WithMetadata(DeliveryCache.Validators)) in Configure for the ETag and the 304. 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.

Cache classes, validators and tags

Cache-Control keeps the value each route always sent, so a deployment that sets nothing caches exactly as long as before. Beside it every delivery read says, in X-Barako-Cache-Class, how long the read may be kept, which a renderer can tell apart from the CDN hint:

Class Means Sent by
short minutes /api/public/{type}, {type}/{slug}, {type}/search, {type}/semantic, {type}/feed.xml, sitemap.xml, types/{type}/description (all public, max-age=60), files/{id}/meta (public, max-age=300), redirects/resolve (no Cache-Control, held 5 minutes in the server's output cache)
long hours or days files/{id} (public, max-age=86400)
no-store never kept a slug read under a valid ?preview= token, JWT or share link key
swr may be served stale while it is fetched nothing by default

ETag and Last-Modified

A short or long read carries a weak ETag, W/"...", a hash of the tenant and the exact body sent. It is weak because a proxy that compresses the body changes its bytes but not its meaning (nginx turns a strong tag weak when it gzips anyway), and no delivery route serves byte ranges, the one case that needs a strong tag. Because the body is hashed after it is built, anything that changes what a caller is sent changes the tag: an edit, a resolved reference, a field made non-Public. Two tenants with the same body get different tags.

A file's tag is a hash of the stored record and how it is served, since its bytes never change, and is answered before the bytes are read.

Last-Modified is sent only where one timestamp covers everything in the body: a type description (the type's UpdatedAt) and a file (its upload time). Lists, search, the feed and the sitemap have none, because an entry leaving them moves no timestamp. A slug read has none either: a referenced entry or a file can change what it sends without moving the entry's UpdatedAt.

If-None-Match matching the ETag (weak comparison, * matches anything) answers 304 Not Modified with no body. If-Modified-Since is used only when If-None-Match is absent, as RFC 9110 says, and only on a read that sent Last-Modified; anywhere else it is ignored and the read answers in full.

Tags

Surrogate-Key (Fastly) and Cache-Tag (Cloudflare) carry the same space-separated value. Every tag starts with the tenant, so a purge by tag cannot cross tenants:

Tag On
t:<tenant> every tagged read
t:<tenant>:type:<type> reads of that type, and of any type it refers to or includes; the sitemap for each type it lists, best effort
t:<tenant>:entry:<id> every entry in the response, included references too
t:<tenant>:file:<id> a public file and its metadata (a resized copy is tagged by its original)
t:<tenant>:sitemap the sitemap
t:<tenant>:redirects a redirect lookup

Only values the response already shows go in a tag: a type name, a published entry's id, a public file's id. Anything outside letters, digits and -_.~ is percent-encoded, so a type name cannot pose as another tag. A no-store read carries no tag and no ETag.

A response carries at most 32 tags and 1024 bytes per header, which keeps both headers inside the 4 KB header buffer of a default nginx proxy. Past either bound the remaining tags are dropped in order, and X-Barako-Cache-Tags-Dropped says how many. The order is the tenant, the type read, each type it refers to or includes, then the entries, so entry tags are what falls off. That gives the purge rule: when an entry of type X is published, purge t:<tenant>:entry:<id> and t:<tenant>:type:X. The type tag reaches every list and search of X, and every read of another type that refers to or includes X, whether or not its entry tags survived.

The sitemap is the exception, because a tenant can have more types than the bound holds. Purge t:<tenant>:sitemap on every publish; its type tags are best effort and fall off past about thirty types. It carries no entry tags, since it holds up to 50,000 entries.

These headers are exposed to browsers through CORS. Nothing purges a CDN yet; that is #561.

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.