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
Depends on the collection's visibility: public, gated or private. See below.
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.
/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:
| Visibility | What a read needs |
|---|---|
| public | Nothing. Anyone, no credentials. |
| gated | An 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. |
| private | An 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.
| Status | Code | When |
|---|---|---|
| 401 | user_session_required | A gated collection, no API key, and the bearer token is missing, malformed, expired, or belongs to a disabled account. |
| 403 | access_rule_not_satisfied | A 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
/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.
| Parameter | Default | Notes |
|---|---|---|
| limit | 50 | Clamped to 1–200. A value above 200 is silently reduced, not rejected. |
| offset | 0 | Negative 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.
{
"data": [ /* entries */ ],
"total": 128,
"limit": 50,
"offset": 0
} | Key | Meaning |
|---|---|
| data | This page of entries, newest published first. |
| total | Published entries across the full result set, not just this page. |
| limit | The limit actually applied, after clamping. |
| offset | The offset actually applied. |
Compute it: offset + data.length < total. Paging is offset-based only.
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:
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:
# 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
| Filterable | Not filterable |
|---|---|
text, integer, decimal, boolean, date, datetime, enum, media, relation | rich_text, json |
Up to 10 fields per request, and 50 values per field.
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:
{
"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:
{
"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.
| Column | Type | Notes |
|---|---|---|
| id | string | UUID. 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_at | string | null | ISO 8601. Stamped automatically when an entry is published and cleared when it returns to draft, never set by hand. |
| _created_by | string | null | UUID of the dashboard user who created the entry, when there was one. |
| _created_at | string | ISO 8601. |
| _updated_at | string | ISO 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 type | JSON | Notes |
|---|---|---|
| text | string | Single-line text. |
| rich_text | string | An HTML fragment, already sanitized: see below. |
| integer | number | 32-bit signed. |
| decimal | number | Stored with arbitrary precision, but returned as a JSON number (a 64-bit float). Do not store money in one and expect exact arithmetic back. |
| boolean | boolean | |
| date | string | YYYY-MM-DD. |
| datetime | string | ISO 8601 timestamp. |
| media | string | null | A media asset id, never a URL. See Media. |
| json | object | array | Stored and returned verbatim. |
| enum | string | One of the values configured on the field. |
| relation | string | 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:
{
"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": []
}
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
nullwhen nothing is related. -
A many-to-many relation field is
always an array of ids:
[]when empty, nevernull. 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:
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:
// rich_text values are already sanitized. Render them as HTML.
<article dangerouslySetInnerHTML={{ __html: post.body }} /> <article set:html={post.body} />
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
| Status | Body | When |
|---|---|---|
| 200 | The page or the entry | Success. |
| 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.