# Reading published content > Query published entries over plain HTTP with filters, ordering, cursor pagination, field projection, reference includes, and rendered HTML. The Content API serves the published side of a project: only entries whose change set has been published are visible, always at their latest published revision. It is a plain HTTP surface — no SDK required — rooted at `https://api.myna.sh/v1`. Drafts never leak here; to read in-flight work, use [previews](/concepts/previews). ## Routes | Method | Path | Returns | | --- | --- | --- | | `GET` | `/v1/projects/:project/collections` | Public collections of the project (key, display name, kind, path template) | | `GET` | `/v1/projects/:project/collections/:collection/entries` | Paginated list of published entries | | `GET` | `/v1/projects/:project/collections/:collection/entries/:slug` | One published entry by slug | | `GET` | `/v1/projects/:project/collections/:collection/singleton` | The single published entry of a singleton collection | | `GET` | `/v1/projects/:project/collections/:collection/entries/:slug/references` | Published entries that reference this one | `:project` accepts the project id or slug. `:collection` is the collection key. ```bash $ curl https://api.myna.sh/v1/projects/my-site/collections/posts/entries $ curl https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world $ curl https://api.myna.sh/v1/projects/my-site/collections/settings/singleton $ curl https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world/references ``` ## Access control - **Public collections** need no credentials, as long as the project's public API is enabled. Anonymous requests see only collections with `visibility: "public"`. - **Private collections** require a bearer [API key](/getting-started/authentication) carrying the `content:read` scope. Without it the request fails with `AUTHENTICATION_REQUIRED`. - If a project's public API is disabled, unauthenticated requests receive `NOT_FOUND` — the project is indistinguishable from a nonexistent one. ```bash $ curl -H "Authorization: Bearer myna_sk_..." \ https://api.myna.sh/v1/projects/my-site/collections/internal-notes/entries ``` Note: `GET /v1/projects/:project/collections` is dual-mode. Callers with `schema:read` (dashboard, CLI, management keys) get the full management view including private collections and schemas; everyone else gets the public list. ## Response envelope Single entries return `{ "data": ... }`; lists return `{ "data": [...], "pagination": { "nextCursor": ... } }`. Every entry carries a `meta` block identifying exactly which revision you are reading: ```json { "data": { "id": "ent_01j9...", "collection": "posts", "slug": "hello-world", "fields": { "title": "Hello world", "body": "First post." }, "meta": { "revision": "rev_01j9...", "publishedAt": "2026-07-01T12:00:00.000Z" } } } ``` ## List query vocabulary Unknown query parameters are rejected with `VALIDATION_FAILED` — they are never silently ignored. The full accepted set is `limit`, `cursor`, `order`, `fields`, `include`, `representation`, `locale`, `q`, `at` (for [reading at a release](#reading-at-a-release)), `preview` and `token` (for [previews](/concepts/previews)), and `filter[...]`. ### Search `q=` runs a full-text search across every text value in an entry, at any depth — titles, markdown bodies, object and list fields, block fields. There is nothing to configure: the index covers the document, not a chosen set of fields. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?q=webhook+signatures" ``` The query language is PostgreSQL's `websearch_to_tsquery`: bare words are ANDed, `"quoted phrases"` match in order, and a leading `-` excludes. Search composes with filters, ordering, and pagination, and applies to previewed reads too — a draft is a revision like any other, so it is searchable before it is published. ### Locale resolution On projects with [localization](/schema/defining-schemas) enabled, `?locale=pt-BR` resolves every `localized: true` field to that locale's value, falling back to the project's default locale (or `null` when neither exists). Without `locale`, localized fields return their full `{ [locale]: value }` objects. An unknown locale is rejected with `VALIDATION_FAILED`. Localized fields are filterable and sortable. They are compared in the locale you asked for, falling back to the default locale exactly as reads do — so `?locale=pt-BR&order=title` sorts by the Portuguese title. Without `locale`, comparisons use the project's default locale. ### Pagination | Parameter | Meaning | | --- | --- | | `limit` | Page size, clamped to `1–100`. Default `25`. Must be an integer. | | `cursor` | Opaque cursor from the previous page's `pagination.nextCursor`. | `nextCursor` is `null` on the last page. Cursors are opaque — pass them back verbatim, never construct them. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?limit=10" $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?limit=10&cursor=" ``` ### Ordering `order=field` sorts ascending, `order=-field` descending; ties break deterministically on entry id. Only scalar fields are sortable: `text`, `number`, `boolean`, `date`, `datetime`, and `slug`. Sorting on any other field fails with `VALIDATION_FAILED`. Entries that do not carry the ordering field sort last, in both directions. Without `order`, entries follow the collection's [editorial arrangement](/management-api/editorial-operations): entries with an assigned position come first, in that order, followed by any unarranged entries newest-first. A collection that has never been arranged is therefore newest-first throughout. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?order=-publishedAt" ``` ### Filtering Filters apply to the same scalar field types as ordering. `filter[field]=value` is an equality test; the operator form is `filter[field][op]=value`. Exactly these operators are implemented: | Operator | Form | Meaning | | --- | --- | --- | | (eq) | `filter[status]=live` | Equality (default when no operator is given) | | `ne` | `filter[status][ne]=draft` | Not equal | | `gt` | `filter[views][gt]=100` | Greater than | | `gte` | `filter[publishedAt][gte]=2026-01-01` | Greater than or equal | | `lt` | `filter[views][lt]=100` | Less than | | `lte` | `filter[publishedAt][lte]=2026-06-30` | Less than or equal | | `in` | `filter[category][in]=news,updates` | Membership in a comma-separated list | | `exists` | `filter[cover][exists]=true` | Field is present and non-null (`true`) or absent/null (`false`) | Values are coerced by field type: `number` fields via numeric coercion, `boolean` fields where `true` means true. Filtering on an unknown or non-scalar field, or using an unknown operator, fails with `VALIDATION_FAILED` — see [error codes](/management-api/errors). ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?filter\[category\]=news&filter\[publishedAt\]\[gte\]=2026-01-01" ``` ### Field projection `fields` restricts the returned `fields` object to a comma-separated allowlist. Projection is applied last, after rendering and includes. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?fields=title,slug" ``` ### Including references `include=field1,field2` resolves reference fields inline, one level deep (referenced entries come back with their raw field values — their own references are not expanded). Only published referenced entries are resolved. A single-reference field becomes `{ "id", "slug", "fields" }` or `null`; a multi-reference field becomes an array of the same shape. An embedded entry is subject to **its own** collection's visibility, not the visibility of the collection you requested. If a public collection references a private one, an anonymous caller sees `null` (or an array with that entry omitted) where a caller holding `content:read` sees the resolved entry. Because the same URL can therefore answer differently depending on credentials, an authorized `?include=` response is returned as `Cache-Control: private`. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world?include=author" ``` ### Rendered representation By default markdown fields are returned as source. With `representation=rendered`, every markdown field is replaced by sanitized HTML — safe to inject directly into a page. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world?representation=rendered" ``` ## Reading at a release `?at=` pins a read to a numbered [release](/concepts/releases), so it answers with the content that release served rather than what is published now. ```bash $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?at=release-42" $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?at=42" $ curl "https://api.myna.sh/v1/projects/my-site/collections/posts/entries?at=2026-03-01T00:00:00Z" ``` Three spellings, one meaning. A timestamp resolves to the newest release published at or before it; the release number is the canonical form, and it is what a [lockfile](/concepts/pinned-content) stores. An instant before the project's first release is valid and returns nothing, because nothing had shipped. A release number the project does not have is refused, and the error names the newest one. It works on every content read — lists, entries by slug, singletons, and backlinks — and applies to `?include=`d entries too, so a page built at release 42 cannot embed a reference resolved at release 60. All the ordinary query vocabulary still applies: filters, `?q=`, ordering, and pagination are evaluated against the pinned set, in the database, at the same cost as a live read. An entry is addressed by the slug it carried at that release. If `posts/pricing` was renamed to `posts/plans` in release 50, then at `?at=42` the entry answers to `pricing` and reports `"slug": "pricing"` — which is what makes a build of release 42 emit URLs that resolve at release 42. Two things stay live by design: - **Visibility.** A collection made private since is private now. `?at=` reproduces content, not access decisions. - **Editorial ordering.** `entries.rank` is arrangement rather than content, carries no revision, and reaches readers immediately — so a pinned list is ordered by the current arrangement. See [change sets](/concepts/change-sets) for why ordering is deliberately outside the revisioned path. `?at=` reconstructs from the release history, so it sees exactly what releases published. It cannot be combined with `?preview=`: one reads a release, the other reads a draft. Pinned responses are `Cache-Control: public, max-age=31536000, immutable`. A release does not change and neither do the revisions it names, so a CDN can hold one indefinitely — which is what makes a build that re-reads the same pinned content on every CI run nearly free. ## Backlinks `GET /v1/projects/:project/collections/:collection/entries/:slug/references` answers "what links here": the published entries in public collections whose current published revision references this one. ```json { "data": { "referencedBy": [ { "id": "ent_01j9...", "collection": "posts", "slug": "a-later-post" } ] } } ``` It is the inverse of a reference field, which a site would otherwise have to store in both directions and keep consistent. Use it for "other posts in this series", "pages that mention this author", and similar. The result is capped at 200 entries. ## Caching with ETags Entry list and entry-by-slug responses carry an `ETag` header derived from the response body. Send it back as `If-None-Match`; when the content has not changed, the API answers `304 Not Modified` with an empty body. ```bash $ curl -i "https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world" # etag: "wq9Y0kD..." $ curl -i -H 'If-None-Match: "wq9Y0kD..."' \ "https://api.myna.sh/v1/projects/my-site/collections/posts/entries/hello-world" # HTTP/1.1 304 Not Modified ``` ## CORS Browser access is allowed only from origins configured on the project. Allowed origins receive `Access-Control-Allow-Origin`; requests from other origins get no CORS headers, so the browser discards an otherwise successful response. In non-production environments, `localhost` origins are additionally allowed for development. Origins are compared in canonical `scheme://host[:port]` form, so `https://example.com/` and `https://example.com` are the same entry — a trailing slash will not silently break matching. Paths are ignored: configuring `https://example.com/blog` allows the whole `https://example.com` origin. Reading with a preview token (`GET /v1/previews/resolve`) applies the same per-project origin policy, so a browser preview client works from any origin the project allows. An unknown or expired token answers with readable problem details rather than an opaque CORS failure — as does an unknown project, so a mistyped slug says so instead of failing silently. Preflights (any read that sends an `Authorization` header, such as a private-collection read with an API key) are answered from the same per-project origin list. A disallowed origin gets a preflight response with no `Access-Control-Allow-Origin`, which the browser blocks. ### Diagnosing a blocked read A blocked cross-origin read is invisible from the browser — the response is discarded before your code sees it — so ask the server instead: ```bash $ myna cors check --origin http://localhost:5173 --collection posts ✓ Project my-site exists. ✓ Public API is enabled. ✓ Collection "posts" is public. ✗ http://localhost:5173 is not an allowed origin. Configured: https://my-site.com Allow it with: myna projects origins add http://localhost:5173 ``` It exits non-zero when a browser would be blocked, so it works in CI. The same answer is available at `GET /v1/projects/:project/cors-check?origin=…&collection=…`. Manage the list incrementally with `myna projects origins list | add | remove` — `projects update --origins` replaces the whole list, so adding one site by hand risks dropping another. ## Caching List, entry, and singleton reads return a strong `ETag` with `Cache-Control: public, max-age=0, must-revalidate`. Send the ETag back as `If-None-Match` and an unchanged resource answers `304 Not Modified` with no body, so browsers and CDNs keep a cached copy and spend only a conditional request to confirm it. A read whose body depends on the caller's credentials is marked `private` instead, so a shared cache does not hand one caller's answer to the next. That applies to reads of a private collection, and to authorized `?include=` reads, which can embed entries an anonymous caller would not see. Ordinary published reads stay `public` even when made with an API key, because the key does not change the response. Preview reads (`?preview=`) are `no-store` and carry `X-Robots-Tag: noindex, nofollow`. ## Client behavior The SDK sends one request for identical concurrent reads, giving each caller its own readable response. Only in-flight requests are shared; a later read goes to the network again. HTTP failures and transport failures have separate retry budgets. In the browser, transport failures are not retried by default — a rejected `fetch` there is most often a blocked CORS response or a disallowed origin, neither of which succeeds on a retry. Servers retry once. Both are configurable via `retry: { maxRetries, maxNetworkRetries }`. ## Limits Public reads are rate-limited per IP and metered per organization as `public_api_requests`. Soft limits vary by plan — see [plans](/platform/plans). Filtering, search, ordering, and pagination are evaluated by the database, so a large collection costs the same per page as a small one. Previewed reads are the exception: composing published content with a change set's staged state requires holding both, so a previewed list composes at most 5000 published entries, taking them from the front of the default ordering. No published read has such a limit. The full surface, including schemas for every response, is described in the OpenAPI 3.1 document at [/openapi.json](/openapi.json).