# 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](#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`](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:

```bash
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}/content` lists 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 contains `q`.
- `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

```text
GET /v1/orgs/{orgId}/content/%2Frecipes%2Fpaella
```

An 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.** `attachContent` puts 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.** `publish` pins the representation a reviewer read, and the path answers from that pin. `POST /v1/orgs/{orgId}/content/{contentId}/publish` runs the same publish here, with a key that may manage content: pass `liveRevision` from your own last edit's answer, and a document someone changed since answers `409` `live_revision_moved` and publishes nothing. `unpublish` returns the post to draft and the path answers `404` again.
- **Schedule.** `schedule` publishes at a future time and `cancelSchedule` returns the post to draft. A scheduled post is not public until its schedule fires.
- **Trash and restore.** `trashContent` takes the post off every public surface recoverably, and `restoreContent` brings 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. |

```text
GET /v1/orgs/{orgId}/content?tags=paella&tags=rice&locale=es&limit=10
```

The 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`.

```text
GET /v1/orgs/{orgId}/content/search?q=paella&tags=rice&locale=es&limit=10
```

A 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.locale` and each list item's `locale` are the language the page's document states, such as `en` or `de-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's `language`, `path`, published `version` and, for a translation, the `sourceVersion` of 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 own `x-default`.
- `sourceDocumentId` names 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 `sourceVersion` is lower than the source copy's `version`. 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):

```json
{
  "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:

```json
{ "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:

```text
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:

```json
{
  "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:

```json
{
  "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:

```text
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:

```json
{
  "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`: {% docref blockKey="rqUpJ-fbC3i" documentId="01a1837b-2a17-751a-8d3f-af9b046cf929" %}The Oddlynew Documents API: Search documents{% /docref %}. 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}/keys` lists the organization's agent and Site keys with `createdAt`, `expiresAt` and `lastUsedAt` (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-keys` creates an agent key: `name`, `expiresInDays` (1 to 60; a key created here always expires) and a `creationRequestId` (UUIDv7).
- `POST /v1/orgs/{orgId}/keys/{keyId}/rotate` replaces 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.

```bash
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/null
```

## Budget 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-changes` asks for `monthlyBudgetUsd` (whole dollars, or `null` for no budget stop). A lower budget answers `applied` and is set already. A higher one answers the change with its `approvalUrl`; 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-proposals` proposes `classes` (per consequence class, `confirm` or `automatic`), `tools` (on an MCP or API connection, the policy of single tools or operations) and a `reason`. A change that waits answers with its `approvalUrl`: hand that link to the person. No proposal can set class `critical` to `automatic` or 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}/approvals` lists 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. Pass `filter` (`waiting`, `approved`, `declined` or `expired`) for only those, and the `nextCursor` of a page as `cursor` for 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](https://oddlynew.com/rate-limits.md). 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-Match` when you poll published detail.
- Send a fresh caller-owned UUIDv7 `Idempotency-Key` for each logical request. Reuse it only when retrying that same request.
- Honour `Retry-After` when present and never retry before it. When it is absent and `retryAt` is `null`, 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 `mode` omitted. Expect `503` from `mode=semantic` until that mode lands.
- Send `X-Oddlynew-Draft: true` only while an editor is in draft mode, on every content read of that request, and answer those pages with `Cache-Control: private, no-store`.
- Render draft content only when the response says `mode: "draft"`. On `403 insufficient_scope` show published content and tell the editor so; never fall back silently.