The Oddlynew Content API
The Content API serves an organization's published documents to sites and services over plain HTTPS: https://api.oddlynew.com. Besides its reads it publishes content (see Lifecycle); writing and every other lifecycle step happen in Oddlynew itself, through the content MCP namespace. The API is designed to be fetched from your server on every page render: published reads are served from Cloudflare's edge cache, so repeat reads are fast and do not count against your organization's read allowance.
The machine-readable contract lives at https://api.oddlynew.com/v1/openapi.json. This page explains the model; the OpenAPI document is authoritative for exact shapes.
Authentication
Every request carries an organization API key as a bearer token:
curl -H "Authorization: Bearer $ODDLYNEW_API_KEY" \
-H "Idempotency-Key: $ODDLYNEW_IDEMPOTENCY_KEY" \
"https://api.oddlynew.com/v1/orgs/{orgId}/content"Every content and document read, and the publish write below, also carries a required caller-owned Idempotency-Key containing a UUIDv7. Mint one fresh value for each logical request, and reuse that value only when retrying the same request after an ambiguous response. Never reuse a key for a different URL, query, or operation.
Keys are created in the app under Developers > Content API. Each key's permissions decide what it can do: content:read serves published reads, content:preview permits draft reads, and documents:read serves typed document reads. A key that serves a site usually carries content:read and content:preview together. Keys are secrets: keep them server-side, never in a browser bundle.
Published content
GET /v1/orgs/{orgId}/contentlists published items, newest publication first, with opaque-cursor pagination and the filters below. The walk is not a snapshot: to rebuild an index, walk every page.GET /v1/orgs/{orgId}/content/search?q={term}is the same list, narrowed to items whose published title containsq.GET /v1/orgs/{orgId}/content/{slug}reads one published item.
Slugs are stored as leading-slash site paths and travel as one percent-encoded path segment: the post at /recipes/paella is read as
GET /v1/orgs/{orgId}/content/%2Frecipes%2FpaellaAn unencoded slash in the slug changes the route and answers 404.
Published responses answer from the post's pinned publication revision: the exact representation that was reviewed and published, regardless of edits made since. Editing a document never changes what the API serves until the author publishes again.
A post carries two axes and nothing else: its publication state (draft, scheduled, published) and its required-roles list. The published reads serve a post whose state is published and whose required-roles list is empty. A post with required roles is still a published post; it is simply not this API's audience, so the list omits it and its path answers 404, indistinguishable from an unknown route.
Every list, search, summaries, bodies and detail response carries mode. It is published for the reads above. Every list, search and summaries item, and the detail response, also carries status and scheduledFor; a bodies item does not. Without the draft header every item is published; the other values appear only in draft mode, described below.
Lifecycle
Everything this API serves is a post attached to a document. Writing happens in the content MCP namespace, and publishing also here; this is the whole lifecycle and what each step does to the reads above.
Attach.
attachContentputs an existing document on a path. The post starts as a draft: the published reads do not serve it, and draft mode does.Publish and unpublish.
publishpins the representation a reviewer read, and the path answers from that pin.POST /v1/orgs/{orgId}/content/{contentId}/publishruns the same publish here, with a key that may manage content: passliveRevisionfrom your own last edit's answer, and a document someone changed since answers409live_revision_movedand publishes nothing.unpublishreturns the post to draft and the path answers404again.Schedule.
schedulepublishes at a future time andcancelSchedulereturns the post to draft. A scheduled post is not public until its schedule fires.Trash and restore.
trashContenttakes the post off every public surface recoverably, andrestoreContentbrings it back as a draft. Neither touches the document.Delete the document. Deleting the document is the strongest retraction its author can express, so the post goes with it: the post reads as trashed and its path answers
404. If the document comes back the post does not; trash and restore of a post stay explicit content operations.
Publishing a post with no required roles is a one-way door. That post is now public to everyone, and unpublish, schedule and trash are all refused for it, because a path the public was given must not go quiet. Deleting the document is the way out, and it is deliberately the loudest one. A post that has always carried required roles was never public and moves freely between the states above.
A path a post ever served publicly stays reserved to that post, whichever way the post left. No other document can take that path, so a stale link answers 404 rather than someone else's content.
Filters and paging
The list and the search endpoint take the same filters, and they mean the same thing on both. Every filter composes with the others. A filter that matches nothing answers an empty page rather than an error.
| Parameter | Required | Meaning |
|---|---|---|
typeId | no | Narrows to one document type family. Any revision id of the type matches items published on every revision of that type, so a site pinned to one revision id keeps every post after the type is revised. An unknown or foreign id answers an empty page. |
tags | no | Repeat the parameter once per slug: ?tags=paella&tags=rice. An item matches when its published tags carry every slug given, so each extra slug narrows the page. At most 10 slugs. |
locale | no | Exact, case-sensitive match on the item's locale (de, de-AT). Items with no locale are returned only when locale is absent. |
featured | no | true narrows to items marked as featured, false to the rest. |
referenceField, referenceId | no | Both together, with typeId: items whose reference field referenceField, as their type declares it, holds the id referenceId, such as the courses that list one recipe. A field the type does not declare answers 400 invalid_reference. |
limit | no | 1 to 50, default 20. |
cursor | no | Opaque cursor from a previous page. |
GET /v1/orgs/{orgId}/content?tags=paella&tags=rice&locale=es&limit=10The document list matches the same lineage by default; there typeMatch=exact narrows it to the one revision named.
Tags are matched against the tags the response itself carries: the set frozen into the pinned publication revision. Adding, removing or renaming a tag on a published post changes neither the filter nor the served tags until the author publishes again. Slug values are compared exactly, so the slugs to filter by are the ones the response returns.
Search
GET /v1/orgs/{orgId}/content/search answers with exactly the body the list endpoint answers with (the same items, nextCursor, hasMore and assets), narrowed to items whose published title matches.
| Parameter | Required | Meaning |
|---|---|---|
q | yes | Search term, trimmed, 2 to 200 characters. Matched case-insensitively as a substring of the published title. % and _ are literal characters, not wildcards. |
mode | no | title when omitted, and the only mode available today. |
Every filter of the list, limit and cursor are accepted here exactly as on the list endpoint, with the same meanings, and compose with q.
GET /v1/orgs/{orgId}/content/search?q=paella&tags=rice&locale=es&limit=10A missing, empty or whitespace-only q answers 400, never an unfiltered page.
Results keep the list's ordering, newest publication first, rather than a relevance ranking, so paging a search behaves exactly like paging the list. Both the title matched and the title returned come from the pinned publication revision, so a title edited after publish is neither findable nor visible until the next publish.
mode=semantic is reserved for meaning-level search over document bodies. It is not available yet and answers 503 today. When it lands, the response shape stays the same, so a client written against title search will not have to change.
Pages in several languages
Each language of a page is its own document with its own path, and a translation names the document it was made from. The API never picks a language: your site knows the language from its own address and asks for that page's path. No request header changes the answer.
post.localeand each list item'slocaleare the language the page's document states, such asenorde-AT. When it states none, they are the language an earlier publish stored on the page, for one release, or null.The detail response lists every published copy of the page in
languages, itself included: each copy'slanguage,path, publishedversionand, for a translation, thesourceVersionof the source it was made from. Print them as hreflang alternate links and sitemap alternates. Each language page names itself as canonical; a copy in another language is an alternate, never the canonical. Pick your ownx-default.sourceDocumentIdnames the document the translations are made from, the page's own id when it is not a translation; the source itself can be unpublished. List items carry it too, so a sitemap walk groups the copies without reading each detail.A translation is behind when its
sourceVersionis lower than the source copy'sversion. Publishing stays per language: a new English text can be live while the German one is still being updated.
Collections and references
A document can point at other documents by their stable ids. The document type declares which metadata field holds the ids and which type family they are expected to belong to (references on the type, next to searchAttributes), and the field itself is an ordered array of ids in the type's metadata schema. A course, for example, is a document of a course type whose metadata.recipes lists recipe ids in the order the course teaches them. The recipe keeps its own title, image and body; the course stores only the reference.
Authoring happens in the documents MCP namespace. The course type is created once with createType; its definition shapes the field in metadataSchema and names the family in references (any revision id of the recipe type; every revision of that family qualifies):
{
"name": "Course",
"description": "An ordered set of recipes to cook in sequence",
"blocks": [],
"content": [{ "types": ["markdown"], "min": 0, "max": null }],
"metadataSchema": {
"type": "object",
"properties": {
"recipes": { "type": "array", "items": { "type": "string" }, "maxItems": 50 }
}
},
"references": [{ "field": "recipes", "targetTypeId": "{recipeTypeId}" }]
}A course is then a document of that type whose metadata carries the ids, written with patchDocumentEnvelope, which merges the fields you pass into the stored metadata, then attached and published through the content namespace like any other post:
{ "recipes": ["{recipeA}", "{recipeB}"] }Publishing the course freezes that list and its order with the rest of its metadata: a later edit to recipes shows in draft mode and reaches readers only when the course is published again. The referenced recipes are not frozen with it. Each one resolves on its own, at read time, to its latest published version, so republishing a recipe updates it in every course at once.
GET /v1/orgs/{orgId}/content/summaries reads the referenced items in one request:
GET /v1/orgs/{orgId}/content/summaries?ids={recipeA}&ids={recipeB}&typeId={recipeTypeId}| Parameter | Required | Meaning |
|---|---|---|
ids | yes | Repeat the parameter once per id, 1 to 50 per request. A repeated id is answered once, at its first position. |
typeId | no | The type family the ids are expected in, as on the list endpoint. A published item of another family answers wrong_type. |
The answer carries items, one per distinct id in the order you sent them, and the same assets map the list carries. Each item is either the summary or a reason there is none:
{
"mode": "published",
"items": [
{ "id": "{recipeA}", "outcome": "available", "item": { "id": "{recipeA}", "slug": "/recipes/paella", "title": "Paella", "...": "the list item shape" } },
{ "id": "{recipeB}", "outcome": "missing" },
{ "id": "{recipeC}", "outcome": "restricted" }
],
"assets": {}
}available carries exactly the list item (title, path, feature image, excerpt, tags, metadata, status, version). missing means no item this reader can be told about answers to the id: an unknown id, another organization's id, a deleted or trashed document, a post that was never published, was unpublished, or is scheduled and not yet due. restricted is a published post kept off the public surface by its required roles; holding a reference to it grants no access. wrong_type is a published post outside the lineage of the typeId you gave. When several apply, the first in that order wins.
To render a collection: read the collection's own item (detail by path, or the list narrowed to its type), take the ids from its metadata, deduplicate, call summaries with them, and walk your own id list to place each answer. Show a card from the summary and read the recipe's detail when you need its body. Nothing is expanded for you: summaries never follow references inside the items they return.
Media in metadata
A site often keeps one of its own files in a metadata field rather than in the body: a short silent loop for a card, its poster frame. Declare the field on the document type, beside the metadata schema, and every read resolves it like a feature image:
{
"metadataSchema": {
"type": "object",
"properties": {
"loopClip": { "type": ["string", "null"], "pattern": "^oddlynew-asset:" },
"loopPoster": { "type": ["string", "null"], "pattern": "^oddlynew-asset:" }
}
},
"metadataMediaFields": [
{ "field": "loopClip", "kind": "video" },
{ "field": "loopPoster", "kind": "image" }
]
}Upload the file, write its oddlynew-asset:{fileId} reference into the field, and the list, search, summaries and detail responses carry an entry for it in assets, keyed by the file id. The metadata itself keeps the reference as written: strip the oddlynew-asset: prefix, lower-case the id, and look it up in assets, whose keys are lower-case file ids, the same step a feature image takes. The kind image serves pictures with their sizes. The kind video serves video files (MP4 and WebM) and the kind audio serves audio files, both with a null width and height; a video in a field declared audio gets no entry. A file of another type, of another organization, or one that no longer exists is simply absent, and so is anything in a field the type does not declare. Each document resolves against the type version it was published with, so a document gains a declaration when it moves to the new type version and is published again. Outside addresses, such as a YouTube link, belong in ordinary fields and are never copied.
Body blocks by id
A card that shows something from each body, such as a recipe card's ingredient pictures, reads it for the whole grid in one request instead of one detail read per card:
GET /v1/orgs/{orgId}/content/bodies?ids={recipeA}&ids={recipeB}&blocks=recipeIngredients| Parameter | Required | Meaning |
|---|---|---|
ids | yes | As on summaries: repeat once per id, 1 to 50 per request, answered once per distinct id in the order you sent them. |
blocks | yes | The block types to serve, repeated once per type, 1 to 10. A type is a top-level block's _type. |
typeId | no | As on summaries: a published item of another type family answers wrong_type. |
Each item is available with typeId, revisionId and blocks, every top-level block of the asked types, whole and in body order (an empty list when the body has none), or missing, restricted or wrong_type exactly as on summaries. assets holds the images and audio those blocks reference, and only those:
{
"mode": "published",
"basis": "persisted",
"items": [
{ "id": "{recipeA}", "outcome": "available", "typeId": "{recipeTypeId}", "revisionId": "{revision}", "blocks": [{ "_type": "recipeIngredients", "_key": "...", "...": "the block as the detail serves it" }] },
{ "id": "{recipeB}", "outcome": "missing" }
],
"assets": {}
}Each body is the one the item's detail serves: its current publication. The answer is cached and purged like summaries, carries no ETag, and is metered like a list read, by its size. The served blocks of one request add up to at most 7.6 MB, what four whole bodies at the document size limit hold; a request over it answers 422 with bodies_too_large, and fewer ids or fewer block types per request serve it. In draft mode each body is the saved head of the item (basis: "persisted", revisionId: null): an edit shows once the editor has saved it, while the detail serves one document's live state.
Caching, ETags, and freshness
Published list, search, summaries and bodies responses are cacheable for 60 seconds, and may be served up to 15 seconds stale while a fresh copy is fetched. On production, a published detail response is cacheable for up to one day, and may be served for up to one further day while a fresh copy is fetched. Other environments keep the 60 second lifetime for detail too, because the asset URLs they serve expire.
None of those windows is the delay between publishing and readers seeing the new revision: every content change (a publish, an unpublish, a schedule firing, trash, restore, metadata, tags, authors) purges the organization's cached responses, so a publish is visible on the next read. The window is the fallback bound if a purge is ever missed. Independent of it, a change to what is published, including a removal, takes effect within about 75 seconds, however long the cached response may live.
What the longer detail window does extend is the parts a detail response looks up when it is read rather than freezing them at publish: an author's name and avatar, the newest live revision of the post's type (familyLatestTypeId), and asset metadata. On production those can lag by up to two days on a detail page. Everything the publication revision froze is exact.
Title search is the exception. Its database reads travel through a second cache with the same 60 second life and 15 second stale window, which no purge reaches. A search response filled from that cache after the purge lives its own 60 plus 15 seconds, so a publish or an unpublish can take up to 150 seconds to be reflected in search results. When you need the tighter bound for a specific post, read it through the detail endpoint.
Detail responses carry a strong ETag. Send it back as If-None-Match and an unchanged post answers 304 Not Modified with no body, the same ETag and the same cache headers. The ETag covers live-resolved parts too (author display data, asset URLs), so a 200 after a 304 streak means something real changed.
One rule matters when you cache on your side: cache the document data, never the asset URLs. On production the asset URLs in a response are immutable public capabilities. Other environments may serve URLs that expire. Store the document and re-read it when you need asset URLs, rather than persisting the URLs themselves.
None of this applies to a draft request. Draft-mode responses are Cache-Control: no-store, carry no ETag, and ignore If-None-Match.
Draft mode
Draft mode lets a site show its editors what the site would look like after publishing everything. It is switched on per request by a header.
A bearer key with the content:preview permission requests draft mode by sending X-Oddlynew-Draft: true on content list, search, summaries, bodies and detail reads. The gateway checks the permission on every request before an internal cache lookup and strips the header before routing. the response is no-store, has no ETag, and carries mode: "draft".
If the key lacks content:preview, a request with the header answers 403 with error: "insufficient_scope", the same shape as every other scope refusal. Without the header every key receives the published read. There is no silent fallback: a site that receives the 403 should show published content and say so, because a reviewer would otherwise read the published page under a draft banner.
In draft mode the list and search walk every active post a site would render after publishing everything: draft, scheduled and published items, role-gated posts included. The detail read admits exactly the same set, so an item a reviewer sees in the list always opens at its own path. Title, excerpt, feature image, tags and metadata are the live values. version.number is the persisted head's version number, or 0 while the document has no persisted version. Published items are ordered by last publish, all other items by last update. Search matches the live title.
In draft mode a detail cache fill reads the document's live editor state from its collaboration object: version.content, version.metadata, version.settings and version.checksum are what the editor saw at that fill, meta and tags are the live listing values, and the response carries basis: "live". The pin stays the persisted head: version.id, version.number, version.title and version.metadataDigest name the persisted head a publisher pins as expected.version, expected.title and expected.metadataDigest, so a publish freezes what has persisted, and the top-level title is the live title a page renders. When the document's live journal is under repair the detail falls back to the persisted head with basis: "persisted"; a document that refuses the read answers 404 and a failed read answers 503. For an item whose status is published, revisionId and the compared head fields are the publication's while the body and listing fields stay live. An autosave that lands between the live read and the pin read is re-read once; only a remaining mismatch answers 503, and the next read is consistent.
Every item carries one of four status values:
| Status | Meaning |
|---|---|
draft | Never published, or unpublished. Visible only in draft mode. |
scheduled | Publication is scheduled; scheduledFor says when. Visible only in draft mode. |
published | The persisted head equals what is published. |
published_with_newer_draft | Published, and an edit has persisted since the last publish. |
published_with_newer_draft means what a reader sees in draft mode differs from what the public sees. Any persisted change counts: a newer version of the body, a title-only edit, a change of document type, or a metadata-only edit, which the API detects by comparing metadata digests. Publishing again resolves it to published.
Successful GET draft reads share an internal platform cache for five seconds after a fill, separate from published content. The cache is shared only within one organization and the same preview capability, endpoint, type and query. It has no stale extension, so an ordinary draft edit can remain briefly behind a warm response. Only fills consume read capacity; hits do not. Keep draft mode to editors and never serve it to anonymous visitors. Poll draft previews in tens of seconds, for example every 30 seconds. Keep the loaded page visible when a refresh fails, and never trigger an immediate loader refetch in response to a 429.
Typed document reads
The same API also serves persisted documents of one type with their typed metadata: GET /v1/orgs/{orgId}/documents?typeId={typeId} and GET /v1/orgs/{orgId}/documents/{id}. These power structured use cases (directories, catalogs, knowledge exports) and follow the same authentication, caching, and refusal model. typeId is required on the list. Exact shapes live in the OpenAPI document.
To search those documents by words, by meaning or both, use GET /v1/orgs/{orgId}/documents/search with mode set to fts, exact, semantic or hybrid: The Oddlynew Documents API: Search documents. The content search below matches published titles only.
The document list is the exception to that caching model. Its database reads travel through a second cache with a 60 second life and a 15 second stale window, and the response then lives up to 60 seconds in the response cache. No purge reaches either cache, so a new, changed or removed document can take up to 135 seconds to be reflected in the list. When you need the tighter bound for a specific document, read it through the detail endpoint.
One refusal belongs to typed document reads and the key routes below. While a change to the organization is in progress, such as a membership change or the start of its deletion, a read or a key call answers 429 with code: "organization_transition_pending" and a Retry-After of one or two seconds; retry after it. An organization that is being deleted answers 403 to every read of this API, content reads included. A document list that is already cached may still answer for up to 60 seconds after such a change or a deletion begins, and a content read for up to 75 seconds after a deletion begins; a single document read and every uncached document list refuse at once.
Agent keys for scripts
An organization Admin's script can list, create, rotate and revoke agent keys without opening the app, so a key goes straight from the response into a secret store and nobody reads it. The script authenticates with the Admin's own personal key, created once under Your agents with read and write access to the organization. It needs the Admin role there, as the Shared agents page does.
GET /v1/orgs/{orgId}/keyslists the organization's agent and Site keys withcreatedAt,expiresAtandlastUsedAt(null when the key was never used), never a secret. A personal key with read access to the organization is enough for the list.POST /v1/orgs/{orgId}/agent-keyscreates an agent key:name,expiresInDays(1 to 60; a key created here always expires) and acreationRequestId(UUIDv7).POST /v1/orgs/{orgId}/keys/{keyId}/rotatereplaces a key's secret; the old secret keeps working for 24 hours, or until its own expiry when that comes sooner. The replacement keeps the old key's lifetime, for an agent key at most 60 days, so rotate it before it expires.DELETE /v1/orgs/{orgId}/keys/{keyId}revokes the key's member with every key it holds.
The secret is in the response body once and is never shown again. Mint one creationRequestId per attempt and reuse it only to retry that attempt: a retry after the attempt committed answers replayed without a secret. Then create a new key and revoke the replayed one. A 429 committed nothing: wait for its Retry-After and send the same attempt again.
REQUEST_ID=$(uuidv7) # any UUIDv7 generator; keep it for retries of this attempt
token=$(jq -n --arg id "$REQUEST_ID" \
'{name: "Release bot", expiresInDays: 60, creationRequestId: $id}' |
curl -sS -X POST -H "Authorization: Bearer $ODDLYNEW_ADMIN_KEY" \
-H "Content-Type: application/json" --data @- \
"https://api.oddlynew.com/v1/orgs/{orgId}/agent-keys" |
jq -r '.token // empty')
# A replayed attempt has no token: never overwrite the stored secret with nothing.
[ -n "$token" ] && printf '%s' "$token" | doppler secrets set RELEASE_BOT_KEY > /dev/nullBudget changes
A script can lower the monthly budget of a billing account at once, and ask for a higher one, but never raise it itself: a higher budget waits until an owner or admin of the account confirms it in the browser. The script authenticates with a person's personal key that has write access; the person needs the owner or admin role on the billing account.
POST /v1/orgs/{orgId}/billing-accounts/{billingAccountId}/budget-changesasks formonthlyBudgetUsd(whole dollars, ornullfor no budget stop). A lower budget answersappliedand is set already. A higher one answers the change with itsapprovalUrl; hand that link to the person.GET /v1/orgs/{orgId}/budget-changes/{operationId}reads whether the change was confirmed.
Confirmation policy proposals
A script can propose which writes on a connection run automatically and which wait for a person. A change that only adds holds (a class from automatic to confirm, a tool to a stricter policy that waits at least as much) applies at once and answers state: "completed". Any change that loosens something changes nothing until an organization admin confirms the exact change in the browser. The key's member needs the Admin role in the organization; a personal key also needs write access.
POST /v1/orgs/{orgId}/connections/{connectorId}/confirmation-policy-proposalsproposesclasses(per consequence class,confirmorautomatic),tools(on an MCP or API connection, the policy of single tools or operations) and areason. A change that waits answers with itsapprovalUrl: hand that link to the person. No proposal can set classcriticaltoautomaticor move a tool classed critical or blocked below critical; such a proposal is refused. Only an admin can let critical writes run automatically, on the connection's page.GET /v1/orgs/{orgId}/confirmation-policy-proposals/{operationId}reads whether it was confirmed.
Approvals
A script can read what the organization's agents asked an admin to approve on its connections: writes, new API connections, new keys and confirmation policy changes. It only reads; a person approves or declines in the browser. The key's member needs the Admin role in the organization, as deciding an approval does.
GET /v1/orgs/{orgId}/approvalslists them newest first, each with what was asked and on which connection, which client asked and for whom, how it ended, who decided and when. Passfilter(waiting,approved,declinedorexpired) for only those, and thenextCursorof a page ascursorfor the next.
Limits
Reads served from cache do no billable work for your organization and have no practical ceiling. What is bounded is uncached work, and different lanes are bounded at different scopes: published and draft cache fills draw on a pool shared by your whole organization, and site keys use the organization lane for typed document reads too. Every request, cached or not, additionally passes a high organization-wide ceiling that exists for abuse, not for pacing.
When knowledge capacity is exhausted the refusal uses 429 Too Many Requests, code: "knowledge_capacity_exhausted", and Cache-Control: no-store. If refill can make the same operation admissible, it also includes an honest integer Retry-After (the shortest wait that is never too short). If the operation itself is too large, or the bounded forecast cannot establish a reliable retry time, retryAt is null and Retry-After is omitted, the same contract the platform's anonymous endpoints document. The numeric ceilings are operational calibration, sized generously above what a site rendering pages produces, and may change without notice. Write clients against the refusal shape, not against observed numbers.
What a site costs
Four things decide what a public site spends here.
Search in title mode costs what a list read costs. It is one query narrowed by a term, so it is priced and paced like GET /content, not like a heavier retrieval. When semantic search lands it will be metered separately, on its own lane.
Cached responses cost nothing. Only cache fills reach the read pool. Published list, search, summaries and bodies responses are cached for 60 seconds unless invalidated earlier, and a published detail response for up to one day on production; successful authorized draft GET responses use a separate five-second cache. Equivalent reads within each window share a fill at the same cache location. After expiry, the next request fills again. These windows do not promise a fixed number of fills across locations, invalidations or evictions.
A site key draws on your organization's knowledge read allowance and has no per-person slice. All site keys in an organization share that allowance. Humans and agent keys keep their per-person buckets. A site key's capacity refusal names target_organization or shared_free_grant, never actor_within_target or credential_within_actor.
Caching on your side is what keeps a busy site inside a free plan. Build a static index at build time, or put a CDN in front of your site, and your traffic stops being proportional to your readers. Without that, every uncached reader is a cache fill.
Writing a well-behaved client
Fetch server-side and let the edge cache work for you. A site that reads on every render is the designed load pattern.
Use
If-None-Matchwhen you poll published detail.Send a fresh caller-owned UUIDv7
Idempotency-Keyfor each logical request. Reuse it only when retrying that same request.Honour
Retry-Afterwhen present and never retry before it. When it is absent andretryAtisnull, change the operation or let application policy decide when new state justifies another attempt. Do not automatically poll it unchanged.Percent-encode slugs into a single path segment, leading slash included.
Search with
modeomitted. Expect503frommode=semanticuntil that mode lands.Send
X-Oddlynew-Draft: trueonly while an editor is in draft mode, on every content read of that request, and answer those pages withCache-Control: private, no-store.Render draft content only when the response says
mode: "draft". On403 insufficient_scopeshow published content and tell the editor so; never fall back silently.