v1 API

HTTP API

Content API

Two endpoints read every collection in your workspace. Both return JSON, both return published entries only, and neither one writes anything.

Endpoints

GET /api/v1/{collection}

Depends on the collection's visibility: public, gated or private. See below.

GET /api/v1/{collection}/{id}

Same rule as the list endpoint.

{collection} is the collection’s slug. Slugs are stored with underscores (blog_post) and the API also accepts the dashed spelling blog-post, converting it for you, so a URL scheme that prefers hyphens does not force you to rename anything.

{id} is an entry’s UUID. Anything that is not a well-formed UUID returns 404 without a database lookup.

There are no content write endpoints

/api/v1 has no POST, PATCH or DELETE for content. Entries are written from the dashboard or through the MCP tools. The only write on this API is form submission.

Public, gated and private

A collection has one of 3 visibilities, set in the dashboard, and it decides what a read of /api/v1/{collection} needs:

VisibilityWhat a read needs
publicNothing. Anyone, no credentials.
gatedAn end-user account session Bearer token satisfying the collection's attached rule, or “any signed-in account” when no rule is attached, OR an API key holding entries:read, which bypasses the rule entirely. Requires the workspace's Identity beta.
privateAn API key holding entries:read.

A key holding entries:read reads a gated collection exactly like a private one: the visibility tier only changes what a caller with no key can do. That is deliberate: an API key already reads private content, so carving out a gated exception for keys would make key behavior depend on a visibility tier nothing else does.

StatusCodeWhen
401user_session_requiredA gated collection, no API key, and the bearer token is missing, malformed, expired, or belongs to a disabled account.
403access_rule_not_satisfiedA gated collection, a valid account session, but the collection’s attached rule evaluated false for that account. The response never describes what the rule tests for.

See Identity for account signup, login and session details, and Errors & rate limits for the full code list.

Published only, for everyone

The delivery API filters to _status = 'published' and there is no way to switch that off. There is no status query parameter; sending one has no effect. No API key of any scope changes the behavior, and total counts published entries only.

If you need to see drafts (a preview build, an agent verifying what it just wrote), use the MCP list_items and get_item tools, which return drafts and published entries alike.

This is a server-side API

These endpoints send no CORS headers

/api/v1/{collection}, the single-entry endpoint and /api/v1/media/{id}/meta return no Access-Control-Allow-Origin header and answer no OPTIONS preflight. A cross-origin fetch from a browser will be blocked by the browser, even for a public collection with no key involved.

“Public” here means no credential required, not “callable from any web page”.

Call these endpoints from somewhere that is not a browser:

  • A build step: a static site generator reading content at build time. This is the intended shape, and it is also the fastest and cheapest one.
  • Your own server: a route handler, a server component, an API route that fetches and forwards. Same-origin requests from your own front end to your own backend are unaffected.
  • Anything else without a browser: curl, a script, an agent.

Two things are reachable directly from a browser: the form endpoints, which implement CORS deliberately and are meant to be posted to from your visitors’ pages, and /api/v1/media/{id} used as an <img src>: loading an image is not a CORS-governed request. Reading the media /meta JSON from a browser is.

Pagination

The list endpoint takes two query parameters and returns four keys.

ParameterDefaultNotes
limit50Clamped to 1–200. A value above 200 is silently reduced, not rejected.
offset0Negative values are treated as 0.

A value that is not a number falls back to its default rather than erroring, so a malformed page link degrades to the first page instead of a 400.

json
{
  "data": [ /* entries */ ],
  "total": 128,
  "limit": 50,
  "offset": 0
}
KeyMeaning
dataThis page of entries, newest published first.
totalPublished entries across the full result set, not just this page.
limitThe limit actually applied, after clamping.
offsetThe offset actually applied.
There is no hasMore field, and no cursor

Compute it: offset + data.length < total. Paging is offset-based only.

javascript
async function fetchAll(collection) {
  const rows = []
  let offset = 0

  for (;;) {
    const res = await fetch(
      `https://acme.sedgemark.app/api/v1/${collection}?limit=200&offset=${offset}`
    )
    if (!res.ok) throw new Error(`Sedgemark responded ${res.status}`)

    const page = await res.json()
    rows.push(...page.data)
    offset += page.data.length

    // No `hasMore` field, and an empty page is the other stop condition.
    if (page.data.length === 0 || offset >= page.total) break
  }

  return rows
}

Entries are ordered by _published_at descending, with the entry id as a tiebreaker. That second key is what makes paging stable: publishing several entries at once stamps them with identical timestamps, and without a unique tiebreaker a LIMIT/OFFSET walk would silently skip and repeat rows.

The generated TypeScript client ships fetchAll and fetchAllPages helpers so you do not have to write the loop above at all. See TypeScript client.

Filtering

Narrow a collection with filter[{field_slug}]={value}. The parameter takes your own field slugs, so it reads the same way your content does:

url
https://acme.sedgemark.app/api/v1/blog_post?filter[category]=engineering

Repeat a parameter to match any of several values; name different fields to require all of them:

bash
# Repeat the parameter for "any of": this matches either category.
https://acme.sedgemark.app/api/v1/blog_post?filter[category]=engineering&filter[category]=design

# Different fields combine with AND: engineering posts that are also featured.
https://acme.sedgemark.app/api/v1/blog_post?filter[category]=engineering&filter[featured]=true

A list-valued field (a repeater, a gallery, a many-to-many relation) tests containment. Pass a single element, never the whole array: filter[tags]=release matches every entry whose tags list contains it.

What can be filtered

FilterableNot filterable
text, integer, decimal, boolean, date, datetime, enum, media, relationrich_text, json

Up to 10 fields per request, and 50 values per field.

A filter that cannot be applied is an error, never a silent pass

An unknown field slug, an unfilterable type, or a malformed value returns 400. It is never ignored. Ignoring one would return every entry, unfiltered, with a 200, which reads exactly like “everything matched” and is the kind of bug you find in production.

Filtering for an unset value is not supported: filter[category]= is a 400, not “is null”.

Every response echoes the filters it applied

A filtered list response carries a filters key describing what the server actually applied:

json
{
  "data": [ /* … */ ],
  "total": 3,
  "limit": 50,
  "offset": 0,
  "filters": { "category": ["engineering"] }
}

Check for it. A workspace older than this feature ignores unknown query parameters and answers 200 with the full, unfiltered result set, and the echo is the only way to tell that apart from a filter that genuinely matched everything. Both generated clients throw when they sent filters and the echo came back missing.

Sorting and search are still not available

There is no ?sort= and no full-text search, and the ordering cannot be changed. Read every entry and sort or search it locally, where it is faster and free; for a collection too large for a build, put a search index in front and keep it current with a webhook.

Looking an entry up by something other than its id

The single-entry endpoint takes a UUID and nothing else. If your URLs look like /blog/hello-world, filter the collection instead: ?filter[slug]=hello-world, and take the first result. The generated client exposes the same thing as a typed filter argument.

Caching

These endpoints send no Cache-Control, no ETag and no Last-Modified. Every request is served fresh, and there is no conditional request to make. This is deliberate: content that has just been published should be visible immediately.

Cache on your side if you need to: a build artifact is the usual answer, and it makes the question moot. The media endpoints are the exception and do set cache headers: see Media.

The shape of an entry

The single-entry endpoint returns the entry object directly, unwrapped. Your fields sit at the top level alongside the system columns:

json
{
  "id": "9f8c1a2b-4d5e-4f60-8a71-2c3d4e5f6071",
  "title": "Hello, world",
  "body": "<p>My first post.</p>",
  "cover_image": "0c4d9e11-7b2a-4c38-9de5-1f60a7b8c9d0",
  "tags": ["7e2b...", "b13c..."],
  "author": "3b1e7c40-88a2-4d19-9f5c-6a0b1d2e3f40",
  "_status": "published",
  "_published_at": "2026-08-04T15:04:05.000Z",
  "_created_by": "3b1e7c40-88a2-4d19-9f5c-6a0b1d2e3f40",
  "_created_at": "2026-08-04T14:58:11.000Z",
  "_updated_at": "2026-08-04T15:04:05.000Z"
}

System columns

Present on every entry of every collection, and not something you define.

ColumnTypeNotes
idstringUUID. Stable for the life of the entry.
_status'draft' | 'published'Always "published" on this API: the field is there so the value is explicit rather than assumed.
_published_atstring | nullISO 8601. Stamped automatically when an entry is published and cleared when it returns to draft, never set by hand.
_created_bystring | nullUUID of the dashboard user who created the entry, when there was one.
_created_atstringISO 8601.
_updated_atstringISO 8601.

Field types

11 types are available when you model a collection. This is how each one arrives in JSON. Any of them except relation can also be a repeater, which wraps the value in an array.

Field typeJSONNotes
textstringSingle-line text.
rich_textstringAn HTML fragment, already sanitized: see below.
integernumber32-bit signed.
decimalnumberStored with arbitrary precision, but returned as a JSON number (a 64-bit float). Do not store money in one and expect exact arithmetic back.
booleanboolean
datestringYYYY-MM-DD.
datetimestringISO 8601 timestamp.
mediastring | nullA media asset id, never a URL. See Media.
jsonobject | arrayStored and returned verbatim.
enumstringOne of the values configured on the field.
relationstring | null | string[]See Relations.

A field that has never been given a value comes back as null. Adding a field to a collection that already has entries does not backfill them.

Repeaters: a field that holds a list

A field configured with multiple holds an ordered list of its type’s values rather than one: several dates on a datetime, a handful of strings on a text, many assets on a media. Every type except relation can be one: a many-to-many relation already means “holds many”.

In JSON the value is simply the array form of the same type, and the order is the order the editor set:

json
{
  "id": "9f8c1a2b-4d5e-4f60-8a71-2c3d4e5f6071",
  "title": "Spring tour",
  "tags": ["live", "acoustic"],
  "show_times": ["2026-03-01T19:00:00.000Z", "2026-03-02T19:00:00.000Z"],
  "gallery": ["6b1e…", "9c4a…"],
  "notes": []
}
An empty repeater is [], not null

A repeater that has never been filled comes back as an empty array rather than null, so you can map over it without a guard. That differs from a single-valued field, which is null until it is set.

Filtering a repeater tests containment: pass one element, not the list. See Filtering.

Relations

  • A one-to-many relation field is the related entry’s id as a string, or null when nothing is related.
  • A many-to-many relation field is always an array of ids: [] when empty, never null . Code defensively guarding for null there is guarding against a case that does not occur.

Relations are not expanded. You get ids, and fetching the related entries is a second request against their own collection. That keeps a response’s size predictable and keeps a cycle of relations from becoming an unbounded payload.

Rich text is sanitized on write

A rich_text value is sanitized once, when it is saved, against a fixed allowlist. It is never re-sanitized on read, and Sedgemark never returns an unsanitized value from one of these fields:

text
allowed tags:       p, h2, h3, h4, strong, em, s, code, pre, blockquote, ul, ol, li, a, br, hr
allowed attributes: a[href, target, rel]

Anything outside that list was stripped before the entry was stored (silently, not as an error). So the value you receive is safe to render as HTML with no further processing:

tsx
// rich_text values are already sanitized. Render them as HTML.
<article dangerouslySetInnerHTML={{ __html: post.body }} />
astro
<article set:html={post.body} />
This is the only field type that carries HTML

Every other field type is a plain scalar. If you are interpolating a text or enum value into markup, escape it exactly as you would any other string: the guarantee above is specific to rich_text.

Responses you should expect

StatusBodyWhen
200The page or the entrySuccess.
401{ "error": "Unauthorized" }A private collection with no key, or with one that is not valid. A gated collection answers user_session_required instead. See Visibility above.
404{ "error": "Not found" }No collection with that slug, or no published entry with that id.
404{ "error": "Unknown tenant" }The hostname names no workspace. The path is fine; the host is wrong.
429{ "error": "Too many requests" }Over the shared per-IP rate limit: see Errors & rate limits.

A collection your workspace’s current plan no longer covers also returns a plain 404, identical to a slug that never existed. Nothing has been deleted, and the dashboard tells you which resources are affected, but this endpoint is public, so it will not broadcast your billing state to anyone who can guess a slug.