Content API

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.

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.

$ 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 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.
$ 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:

{
  "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), preview and token (for previews), and filter[...].

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.

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

$ 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=<nextCursor>"

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

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

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

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

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

$ 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, so it answers with the content that release served rather than what is published now.

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

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.

{
  "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.

$ 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:

$ 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 | removeprojects 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.

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.