The Oddlynew Documents API
The Documents API creates and reads an organization's documents over plain HTTPS: https://api.oddlynew.com. A script, a migration or your own server puts material into Oddlynew and reads it back, without a person in the loop. Serving published pages to a website is the Content API; this page is about the documents themselves.
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.
One promise governs the create: your document is saved when the response returns. Search catches up in the background, usually within minutes; large imports take longer, and the response tells you where the document stands.
Keys
Every request carries a key as a bearer token. Which key you use decides what the request may do:
| Key | Made in the app under | Creates | Reads |
|---|---|---|---|
| Site key, Reader | Developers > Content API | no | yes |
| Site key, Submitter | Developers > Content API | documents of the types chosen for the key | no |
| Shared agent key | Developers > Shared agents | yes, as a Writer | yes |
| Personal key | Your agents | yes, with Read and write | yes |
A Submitter carries no other permission, so an importer that also reads its documents back uses a second key. A Submitter may only name one of the organization's own types it was given, in any revision of that type; any other typeId, or none, answers 404 type_not_found, the same answer as a type that does not exist. A personal key acts as you: it does only what your role allows, in the organizations you chose for it.
An organization the key may not act in answers 403 with error: "insufficient_scope", and a missing or unusable key answers 401 with error: "invalid_token". Keys are secrets: keep them server-side, never in a browser bundle.
Create a document
POST /v1/orgs/{orgId}/documents with a JSON body:
curl -X POST \
-H "Authorization: Bearer $ODDLYNEW_API_KEY" \
-H "Content-Type: application/json" \
--data @document.json \
"https://api.oddlynew.com/v1/orgs/{orgId}/documents"| Field | Required | Meaning |
|---|---|---|
creationId | yes | A UUIDv7 you mint once per document, before the first attempt. It is the replay key. |
markdoc | yes | The body as Markdoc. Plain Markdown is valid Markdoc. |
title | no | 1 to 512 characters. Without it the title comes from a title in the body's frontmatter, else its first heading, else its first line, else Untitled. |
typeId | no | The document type. Without it the document is a plain Markdown document. |
metadata | no | The fields the type defines, as a JSON object. |
language | no | The language of the text, such as en or de-AT. It is never guessed from the text. |
translationOf | no | The id of the document this one translates; needs language. |
sourceVersion | no | With translationOf: the version of the source the text was made from. The source's current version when omitted. |
resolveInternalLinks | no | Turns [[wikilinks]] and relative links whose title matches exactly one existing document of the organization into references. A link to a document created later stays text, so create link targets first. |
writeClass | no | bulk when omitted, the lane for imports. interactive is for the one document a person is waiting for. |
{
"creationId": "{creationId}",
"markdoc": "# Paella\n\nA rice dish from Valencia.",
"typeId": "{typeId}",
"metadata": { "source": "legacy-cms", "sourceId": "4821" }
}A new document answers 201:
{
"creationId": "{creationId}",
"documentId": "{documentId}",
"created": true,
"currentVersion": 1,
"title": "Paella",
"typeId": "{typeId}",
"index": { "state": "indexing" }
}The response may also carry warnings (the body was saved, but something in it probably did not survive a conversion, such as literal escape sequences) and sizeAdvisory (the document is longer than its type suggests). Both are hints; the create succeeded.
Retrying safely
The create takes no Idempotency-Key header. creationId is its replay key. After a timeout, a dropped connection or any answer you could not read, send the same request again with the same creationId: the existing document comes back with 200 and created: false, and nothing is created twice.
A replay returns the document as it stands; it never applies a changed body. The same creationId with a different title, type, metadata or language answers 409 creation_id_conflict with the documentId and the fields in mismatches. Without title, the title is worked out from the body on every attempt, so a retry whose first heading changed is such a conflict too. A creationId used where this key cannot replay it answers 409 creation_id_unavailable. Mint one creationId per source item and store it beside the source, so a second run over the same source creates nothing new.
Where the document stands
index on the receipt says how far the document is on its way into search:
| State | Meaning |
|---|---|
persisted | Saved, with no indexing work outstanding. |
scheduled | Indexing is queued; scheduledAt says when it starts. |
indexing | Indexing is due now, running or about to start. |
searchable | The document is in search. |
failed | Indexing failed with a code; retryAt says when the next attempt runs, if one is planned. |
Whatever the state, the document is saved at currentVersion and can be read at once. Import first and check afterwards: list by indexState rather than polling each document.
Read documents
GET /v1/orgs/{orgId}/documents?typeId={typeId}
GET /v1/orgs/{orgId}/documents/{documentId}?include=indexThe list needs typeId and pages with cursor and limit (1 to 50). It matches every revision of the type unless typeMatch=exact narrows it to the one named, and indexState narrows it to one of the states above. A list is served through caches and can miss a change of the last few minutes; a single document is always read fresh.
A single document comes back with its metadata and its body as typed blocks, not as Markdoc. It is the last saved version: an edit still open in an editor shows once it is saved. include=index adds the index state.
Every read 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. It names the request for your own bookkeeping; the platform never replays an earlier answer under it.
Search documents
GET /v1/orgs/{orgId}/documents/search?query={query}&mode=ftsIt is the search the search tool of the MCP endpoint runs, through the same function, so the rows and their order are the tool's. mode is required: fts searches the search index and can lag a recent write; exact matches the saved text as typed and needs at least three letters or digits in a row, or answers 400 query_too_short; semantic searches by meaning, for passages whose meaning is close to the query even in other words; hybrid runs fts and semantic side by side and merges their results, as the tool does by default. semantic and hybrid lag a recent write as fts does. A semantic search that finds nothing while some of the content it covers is not ready for it yet answers 409 semantic_search_incomplete instead of an empty page, with coverage partial or none; search with fts, exact or hybrid meanwhile. A document kept in several languages comes back once; preferLanguage answers with its copy in that language, and languageCopies=all returns every copy. Each search is metered once per Idempotency-Key, and is never served from a cache.
Editing
GET /v1/orgs/{orgId}/documents/{documentId}/live
POST /v1/orgs/{orgId}/documents/{documentId}/editsAn edit works on the live state, not on the saved version. Read the document live first: the answer carries a writeBasis. Send it unchanged with an ordered batch of block edits, the ops of the editDocument tool of the MCP endpoint. The batch applies whole or not at all, and the answer carries the writeBasis for the next edit. Editing needs a key that may change documents: a shared agent key, or a personal key with write.
An edit applies at most once. 409 stale_live_revision means the document changed after your read, possibly by this very edit sent earlier: read it live again and check whether your change is there before you send it again. 422 edit_invalid refuses the batch as written, and detail says what to change. Both routes take the same Idempotency-Key as every read.
The same API also creates document types, reads a document's live state, versions and rescues, edits a document's blocks, and reads Lenses; the OpenAPI document describes those routes. It does not delete documents.
Refusals
Every refusal is JSON with an error code; branch on it, never on the message.
| Status | error | What to do |
|---|---|---|
400 | invalid_request, invalid_cursor, invalid_language, query_too_short | Fix the request. |
401 | invalid_token | Check the key. |
403 | insufficient_scope | Use a key of this organization that may make this call. |
403 | organization_read_only | Retrying soon does not help; message says why and what lifts it. |
404 | not_found, type_not_found | Use an id this key can see. |
409 | creation_id_conflict, creation_id_unavailable, agents_md_already_exists, document_size_limit | Never retry unchanged. |
409 | semantic_search_incomplete | Search with another mode now; the same semantic search answers once new content is ready, usually within minutes. |
422 | markdoc_parse_failed, type_incompatible, metadata_invalid, translation_refused | Fix the content; the body names the problem, never the text you sent. |
429 | organization_transition_pending | Retry after Retry-After, one or two seconds. |
429 | bulk_slot_denied, knowledge_capacity_exhausted | See below. |
429 | rate_limited | Retry after Retry-After and lower your parallelism. |
503 | bulk_slot_unavailable, service_unavailable | Retry with backoff; a create with the same creationId, which returns the document if an earlier attempt saved it. |
A capacity refusal that carries a retryAt also sends Retry-After; wait that long, then send the same request again. knowledge_capacity_exhausted has three meanings:
With
documentCapin the body, a document limit is reached: the organization's own (scope: "organization"), or the one a free organization shares with the other free organizations of its account (scope: "free_account"). Waiting does not help: delete documents that are no longer needed, or upgrade the plan.With
reason: "operation_too_large", the request is larger than one request may be. Only a smaller request will pass.With
reason: "insufficient_capacity", capacity refills. WhenretryAtisnullno reliable time is known: retry the same request later, with backoff.
Depending on the plan, bulk creates can count against an allowance of new documents for each 30 days. Past it, a bulk create answers 429 bulk_slot_denied with reason: "document_allowance_exhausted" and a retryAt; wait until then or send writeClass: "interactive". A bulk_slot_denied without a reason is a capacity refusal of the bulk lane: with a retryAt, wait for it; with retryAt: null, message, when present, says whether the document is too large for one create or no reliable time is known.
The free organizations of a new account have no bulk lane until the account is verified, so their creates normally run as interactive whatever writeClass says. A bulk create that still reaches the bulk lane there answers bulk_slot_denied with reason: "verification_required": send it with writeClass: "interactive", or verify the account.
Writing a well-behaved importer
Mint one
creationIdper source item before the first attempt and store it with the source.Retry
503and network errors with backoff, always with the samecreationId.Never retry
409or422unchanged, nor the document limit oroperation_too_large.Honour
Retry-Afterwhenever it is present, and never retry before it.Keep parallelism bounded and steady: a create saves, versions and schedules indexing, so throughput comes from several requests at once, not from one fast request.
Send a fresh UUIDv7
Idempotency-Keyon every read, reused only for a retry of that read.Keep keys server-side and revoke an import key when the import is done.
The ceilings in front of the API exist for abuse, not for pacing, and may change without notice; write your client against the refusal shape, as the platform's refusal contract describes it.