API Design

How Draftbase's Content API Works

A delivery API call hits auth, rate limits, a Mongo query, and a cache header. Here's the real request path, end to end.

SA
Samer Alsayegh
Founder
Published
5 min read
Flat illustration of a request flowing through sequential gate icons toward a document
Key takeaways

A delivery API call passes through API-key auth, dual-window rate limits, a status-filtered Mongo query, and auth-aware caching before returning fields. The one place we'd change: the revisions lookup for edited-since-published entries runs as a variable-cost second query.

A GET /delivery/entries request hits an API key check, two rate limiters, a Mongo query, and a cache header. In that order, before a single field reaches you. This post walks the actual path, end to end. It includes the one decision we'd probably make differently if we started over.

The Request Path

Every delivery request starts at requireApiKey("delivery"). It checks the key against the org it belongs to, and rejects anything scoped wrong before a query ever runs. Past that, two rate limiters apply in sequence. Five requests per second, one thousand per minute, both keyed by org id, not by IP. That matters for a leaked key. Whoever has it can't burn another org's budget just because they share a network path. A legitimate org's own limit only kicks in against its own traffic.

Query parsing happens next, and it's stricter than it looks. after (cursor pagination) and skip (offset pagination) are mutually exclusive; passing both is a 400, not a silent pick-one. Field filters (fields.<key>=value, or fields.<key>[in]=a,b for an OR match) aren't fixed schema properties. They fall through a Zod catchall and get parsed out separately in deliveryEntries.ts. You can't know a content model's field names at the framework level.

What the Query Actually Touches

The Mongo query behind a list call filters on three things: orgId, envId, and status: { $in: ["published", "updated"] }. Draft and archived entries never reach delivery, full stop, regardless of what filters a caller passes. That status list is worth pausing on. "Updated" is the interesting case. It means an entry was published once, then edited again without republishing. Delivery still has to return the last published version, not the live edit sitting in the entry document. We handle that by storing a published-tagged snapshot in a separate revisions collection. We swap it in for any entry whose status is "updated," after the main query runs. It's a second read, not a free one, but it's the only way to keep "published" meaning what it says.

Search works two ways. Plain search runs a Mongo $text index query, ranked by relevance. mode=semantic skips the text index. It compares embeddings by similarity instead, matching by meaning rather than keyword overlap. The tradeoff: semantic search has no cursor pagination. after is silently ignored in that mode. A similarity ranking doesn't have a stable sort order a cursor can walk.

Caching, and the One Rule Nobody Should Break

Every delivery response gets a Cache-Control header. But not the same one for every caller. An API-key request gets public, max-age=<N>, stale-while-revalidate=<5N>, cacheable by a shared CDN. A session-authenticated request gets no-store, always. That's someone hitting the endpoint from our own docs "try it" console, cookies attached. That distinction exists for a reason. A cookie-scoped response cached publicly would leak one user's data. The next visitor at the same CDN edge would see it. Getting that check backwards, even once, is the kind of bug you don't get a second chance to catch quietly.

Fetching a Single Entry

GET /delivery/entries/:id follows the same auth and rate-limit path. But the query itself is simpler: one document, matched by id, org, environment, and the same published-or-updated status filter. Pass locales=true, and the response gains a localizations array. Every sibling entry that shares the same groupId. Or the entry's own id, for one with no group yet. Minus the entry itself. That's the whole mechanism behind a language switcher. No separate translation table, no join across a locale-mapping collection. Just entries that share a group id, filtered the same way the base entry was.

include (0 to 5, on both the list and single-entry routes) controls how deep reference and media fields get resolved inline. At include=0, a reference field is just an id string. At include=1, that id gets swapped for the referenced entry's own fields. Point a reference field at another entry that itself has a reference field. include=2 resolves that one too. One level per hop. Five is a hard ceiling on the route's schema. The MCP server's own tools default to it too, resolving the full five levels on every list_entries and get_entry call.

Why Any of This Is a Blog Post

None of this is exotic. Auth, then rate limits, then a query, then a cache header is the shape of most read APIs. What's worth naming is where the shape breaks from the obvious version. Status filtering has to know about a "published" tag buried in a separate collection. Cache headers branch on auth method instead of applying uniformly. Reference resolution recurses per hop instead of resolving flat. Those are the details that don't show up in an OpenAPI spec, only in the code that implements it.

What We'd Change

The revisions lookup for "updated" entries runs as a second query. It's gated behind an if (updatedIds.length) check, so it's skipped entirely when nothing in a page needs it. That's a reasonable shortcut for most lists, but it means the true cost of a delivery call isn't fixed. It depends on how many "updated" entries happen to land in that page. A page of ten freshly published entries costs one query. A page of ten entries mid-edit costs two. We haven't hit a case where that variance matters in practice. But it's the kind of detail that only shows up in a slow-query log, not in a schema diagram. That's exactly why this post exists. The delivery API is simple to call, and not quite as simple underneath.

Ship content that's built to be found

Draftbase generates schema, structured data, and a fast MDX editor for every post.

Frequently asked questions

What does the delivery API return?

Only entries that are live or edited-since-live, in one org and one space. Drafts never show up.

How does caching work?

A key-based request gets a cached, shared answer. A cookie-based request never gets cached, so one user's data can't leak to another.

What happens if a live entry gets edited?

The API keeps showing the last live version, not the new edit. A saved copy holds that old version.

How deep can linked entries resolve?

Up to five steps deep, set by one option. Each step swaps a linked id for that entry's own fields.

Can search match on meaning, not just words?

Yes. One search mode compares meaning through embeddings, though it drops cursor paging.

SA
Samer Alsayegh
Founder at Draftbase

Samer is a software engineer and entrepreneur, founder of Draftbase and Ezi Home Services, building technology that simplifies home services. Passionate about software, APIs, automation, and creating products that solve real-world problems.

apiengineeringdelivery-api

Related posts

Draftbase is a headless CMS built for React devs.