API Documentation
Drive the full content lifecycle programmatically — discover a brand, pick or create a topic, generate, poll, and read the draft. Every call is scoped to the organization that owns your API key.
Quick Start
The RoboWrite Public API is a JSON REST API. Authenticate with an organization API key, then verify it works:
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.roboad.ai/api/public/v1/ping"
Integrating with a coding agent?
We publish a complete, agent-ready version of these docs at robowrite.ai/llms-full.txt. Paste it into Claude, Cursor, or Copilot and it has everything needed to integrate — conventions, the full endpoint reference, request/response shapes, and a copy-paste end-to-end example.
Authentication
Every request requires your organization API key as a Bearer token. Create and manage keys in your RoboWrite dashboard under Settings → API keys. Each key is bound to a single organization; all reads and writes are scoped to it — there is no cross-tenant access. A missing or malformed header returns 401.
Base URL
The surface is path-versioned. Breaking changes ship under a new version prefix.
RoboWrite's Public API is served from api.roboad.ai, the shared RoboAd platform host: RoboAd and RoboWrite are the same platform, and there is no robowrite.ai API hostname to call instead.
Clients & User-Agent
api.roboad.ai sits behind Cloudflare bot protection, so non-browser clients must send a normal, non-empty User-Agent header. Bare default agents such as Python's urllib are blocked at the edge: you get a 403 with an empty body (Cloudflare error 1010) before the request reaches the API — so there is no { detail } envelope to read. curl, httpx, and requests send an acceptable UA by default; if you build raw urllib requests, set one explicitly.
Conventions
Pagination
List endpoints return a uniform envelope: { items, total, limit, offset, has_more }. Control with ?limit= (1–200, default 50) and ?offset=; total is the full filtered count. A few endpoints return their own documented shape (e.g. keyword inventory, topic scores, brief sections).
Errors
Failures return the documented status code with a { "detail": "…" } body. Common codes: 401 (bad key), 404 (not found or not owned by your org), 409 (wrong state), 422 (invalid input — unknown body keys are rejected), 429 (rate limited), 503 (retry). The one exception with no JSON body is the Cloudflare 403 above.
Idempotency
Spend-sensitive write endpoints accept an Idempotency-Key header; a retry with the same key replays the original response (with Idempotency-Replayed: true) instead of repeating the operation. It is required on POST /content/items and optional on POST /brands/{brand_id}/topics, POST /content/generate, and POST /content/items/{item_id}/generate. Other writes (e.g. POST /briefs) do not dedupe — a dropped-response retry can create a duplicate. A same-key retry replays the original result, so changing the body under the same key does not start a new operation — use a fresh key when changing generation options.
Generate content from a topic
The fastest path to a generation. The body is exactly { "topic_id": "<uuid>" } — it does not accept direction or citation_mode (those live on the item path, POST /content/items/{item_id}/generate). Send an Idempotency-Key.
POST /content/generate
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Idempotency-Key: <opaque-unique-key>
{ "topic_id": "<uuid>" }The call returns 202 Accepted with a job to poll:
HTTP/1.1 202 Accepted
{
"job_id": "…",
"workflow_id": "…",
"status": "running"
}POST /content/generate only runs for autopilot-eligible topics; ineligible ones return 409. Eligible: auto_brief, light_review, and topics with no decision band yet (a topic you just created). Ineligible: expert_review, reject_or_hold. Filter on the eligible_for_autopilot flag from GET /topics?brand_id=… rather than firing blindly. A freshly created topic (POST /brands/{brand_id}/topics) has no band yet, so it is always eligible.Poll the job
Poll GET /jobs/{job_id} until the status stops polling. content_item_id and content_version_id arrive on the polled job (not on the 202), populated by the time the run reaches a terminal or review state.
In-flight — keep polling
pendingqueuedrunningThe run is still working.
Terminal — stop
completedcompleted_with_errorsfailedcancelledThe run finished; read the item (or read error).
Needs human review — stop
awaiting_reviewA draft was produced but routed to the dashboard review inbox (e.g. the brand has no connected CMS). The draft is ready — fetch it via GET /content/{content_item_id}.
GET /jobs/{job_id}
{
"job_id": "…",
"status": "awaiting_review",
"content_item_id": "…",
"content_version_id": "…",
"error": null
}Read the content
Fetch the item with a page of its versions. versions is a pagination envelope, not a bare array — read versions.items (newest first, ?limit= 1–200 default 50, ?offset=). For a single version use ?latest_only=true or ?version=N. Add ?include=scoring for quality scores, ?include=sources for the research sources cited in each version, and ?include=assertion_provenance for the per-assertion provenance receipt (combine with a comma). They are opt-in and additive — without include they are null; guard on presence, not on null vs [].
GET /content/{content_item_id}?include=scoring,sources,assertion_provenance,compliance
{
"id": "…",
"title": "…",
"status": "draft",
"versions": {
"items": [
{
"version_number": 1,
"markdown_body": "# …",
"word_count": 1240,
"sources": [
{ "marker": 1, "source_name": "…", "source_url": "https://…" }
]
}
],
"total": 1, "limit": 50, "offset": 0,
"has_more": false, "next_cursor": null
}
}Endpoint reference
The full v1 surface. Every collection endpoint is org-scoped, filterable where noted, and paginated.
Discovery
- GET
/pingLiveness check - GET
/brandsList brands you own - GET
/brands/{brand_id}/projectionRead source ownership, current revision and recoverable rollback receipt; website_url is required - POST
/brands/{brand_id}/projection/previewPreview source-controlled brand changes without saving - POST
/brands/{brand_id}/projection/applyApply owned brand fields only if the expected revision still matches - POST
/brands/{brand_id}/projection/rollbackRestore a receipt's previous values only if no later edit occurred - GET
/brands/{brand_id}/keywordsKeyword inventory + market data (custom shape) - GET · POST
/propertiesList or create publishing properties
Topics
- GET
/topicsList topics — ?brand_id= is required - POST
/brands/{brand_id}/topicsCreate a topic from your own input (eligible immediately) - GET
/topics/{topic_id}/scoresCached opportunity scores (bare array)
Briefs & content
- GET · POST
/briefsCreate or list briefs - GET · PATCH
/briefs/{brief_id}Get or edit a brief (edits are auto-versioned) - POST
/content/itemsCreate a content item (Idempotency-Key required) - GET
/content/{content_item_id}Get an item + a page of versions (versions.items; ?latest_only=true, ?include=scoring,sources,assertion_provenance,compliance) - PATCH · DELETE
/content/items/{item_id}Update or soft-delete a content item - POST
/content/{item_id}/publishPublish / schedule / unpublish to the connected CMS (mode = draft, live, schedule, unpublish) → 202 + job
Generation & jobs
- POST
/content/generateGenerate from a topic → 202 + job - POST
/content/items/{item_id}/generateGenerate / rewrite / vary (direction, citation_mode) - GET
/jobs/{job_id}Poll a generation job
Pillar strategy
- POST
/pillar-strategiesGenerate a clusters-first pillar strategy — 200 on a matching snapshot, else 202 + run_id - GET
/pillar-strategies/{run_id}Poll a strategy run until completed / failed / cancelled
Taxonomy (full CRUD)
- GET · POST · …
/pillars · /authors · /categories · /tagsManage the org's content taxonomy
Documents (read)
- GET
/documentsDocument library metadata + summaries - GET
/imported-contentCMS-imported / external content
Rate Limits
A single fixed window applies per organization — there are no per-plan request tiers. Exceeding it returns 429; back off and retry.
| Default limit | 120 requests / 60 seconds, per organization |
| Over limit | 429 Too Many Requests — retry after the window |
Not in v1 yet
These are deliberately out of v1 — design around them:
- Public CMS connection / target management — connecting a CMS and choosing the default publish destination stay in the dashboard; the API exposes neither connection setup nor collection discovery. Publishing itself is exposed —
POST /content/{item_id}/publish. - Completion webhooks — completion is poll-based today (
GET /jobs/{job_id}); push webhooks are planned. - Direct document file-download URLs — metadata and summaries are available; original-file signed URLs are not yet exposed.
Changelog
Highlights. The complete dated record — including additive changes — is in the full API guide. /v1 response shapes are not frozen; check it before pinning a client.
2026-09-13 · Search, recovery and explicit brands
Breaking: property and brief creation now require a non-null brand_id, even for a single brand. Search drafts and briefs, recover recent jobs, and follow topic, brief and saved-version relationships. Job type names remain open strings so historical jobs stay readable. MCP property creation defaults to collecting drafts without CMS publication.
2026-09-06 · Brand setup and agent sign-in
Research a website, review its brand voice and save a brand through the public API. Property and brief creation can target that brand explicitly. Enabled agent installations support browser sign-in and organization consent; existing API keys continue to work.
2026-09-05 · A page you add by hand can name its entities, so it can be supplied
POST /programs/{program_id}/plan now takes an optional entities array naming what the page is about. A hand-added page carried none, and on a family that gates on facts (comparison, entity_review) a page's inputs are derived by matching supplied facts against that list — with no list there was nothing to match, so the page read generation_state: blocked on every run however many facts you sent. The names are stored as the page's expansion.entities and matched to facts exactly as a planned page's are: two spellings of one entity count once (case and surrounding whitespace ignored), blanks are dropped, and at most 50 names of up to 200 characters are accepted. How many a page may carry is the template family's own contract — a count outside entities_per_page_min / entities_per_page_max from GET /program-templates is a 422, deliberately distinct from the 409 returned while the stage that consumes plan pages is running. One documented claim narrows: expansion is empty for a hand-added page only when you supply no entities. Supplying entities does not lift an existing blocked marker — that is the last run's verdict, and only pages:generate clears it.
2026-09-05 · Every Programs operation declares its 403
Programs has been parked behind an organization allowlist since 2026-08-30, but the published OpenAPI document and the Scalar reference still presented all 22 Programs operations as live happy paths with no 403 declared anywhere, so a generated client or a coding agent that trusts OpenAPI over prose built against a surface that refuses its first call. Each Programs operation now declares 403 with the standard error body, and the document description names the parking. Contract documentation only — no runtime behaviour, path, field or status code changed.
2026-09-05 · Supply facts for program pages; a page still missing one reads blocked
POST /programs/{program_id}/facts records one (entity, attribute, value) per row (source_url optional; a repeat replaces the value, so the verb refuses an Idempotency-Key), and GET /programs/{program_id}/facts reads them back with origin supplied or research. A comparison page needs an attribute matrix covering every compared entity (your facts or the platform's research; yours win), and an entity_review page needs supplied facts about its entity. An approved page whose gated input is still unusable at pages:generate now reads generation_state: blocked with a new blocked_inputs array naming what is missing, instead of being skipped in silence; it keeps its approval and is re-checked on the next run. Hedged values and search-demand attributes are refused with 422; supplying while the program is generating is a 409. If you generate entity_review programs, supply facts for every entity first or expect every page blocked.
2026-09-04 · Attribution findings survive the finalize table strip
A comparison table whose every cell opened with Cannot be verified, Could not be confirmed, Unable to verify or another could-not-check phrase was removed at finalize whatever followed, so Cannot be verified by the issuer in every cell lost the table although each cell is a finding about who declined to confirm — the same shape as the Not verified by the issuer phrasing kept since 2026-08-29. From this date a cell that follows such an opening with by and a third party (issuer, registrar, manufacturer, vendor, provider, publisher, carrier, lender, bank, agency, network, operator, insurer, retailer, regulator, airline, company) counts as a value. The same opening followed by us, this page, a source or nothing (Cannot be verified by us, Cannot be verified from public filings, bare Cannot be verified) is still a hedge, a self-reference beside a third party still strips, and No verified … / Not established … still remove a table whatever follows. Nothing to change on the wire; tables removed earlier are not restored.
2026-09-03 · Opt-in per-assertion provenance on content downloads
GET /content/{content_item_id}?include=assertion_provenance returns a generation-time receipt of { sentence, sentence_index, url, marker } per cited sentence. It stays populated when citation_mode=structured_only strips the links from markdown_body. Additive: omit the include and the field is null.
2026-09-02 · Content slugs are unique within the brand, not the property
Two properties of one brand could each hold the same content-item slug. Storage now enforces one live slug per organization, brand and slug, and the server suffixes (-2, -3, …) against every property of the brand rather than only the target property. A slug you send to POST /content/items or PATCH /content/items/{item_id} may therefore come back suffixed where it previously came back verbatim. Nothing was renamed, retyped or removed, and no stored slug changed. Read the slug from the response rather than assuming the value you sent.
2026-08-31 · Comparison pages generate when research cites their attributes
Generate-time research now supplies the comparison attribute matrix: structured web search first, with a per-entity search fallback, and every cell carries an http(s) source URL. A comparison page whose research cannot cite at least one shared attribute for every compared entity is still skipped and counted as pages_skipped_missing_inputs — that counter still means a skipped page, not a failure. Keyword-metrics axes (search volume, difficulty, CPC) stay withdrawn. No endpoint, field, status code or response shape changed. If you generate comparison programs, a run may now dispatch pages when research succeeds; still read pages_skipped_missing_inputs before treating a short yield as a failure. Superseded on 2026-09-05: a page whose matrix is still unusable after research is now marked blocked rather than skipped, and you can supply the matrix yourself with POST /programs/{program_id}/facts.
2026-08-30 · Programs is parked behind an org allowlist
Breaking. Every Programs verb, including GET /program-templates, returns 403 Programs is not enabled for this organization. unless your organization is allowlisted. Paths are unchanged. Contact support to be enabled.
2026-08-30 · Accented titles keep their letters in the slug; non-Latin titles stop sharing one stem
Slug derivation collapsed everything outside a-z0-9 to a hyphen, so Cómo mejorar la conversión was stored as c-mo-mejorar-la-conversi-n and three different Chinese, Russian and Japanese titles were stored as untitled, untitled-2 and untitled-3. Latin letters now lose the diacritic rather than the letter (como-mejorar-la-conversion). That fold is a merge: Cañón, Cañon, Cânon and Canon now share the stem canon and compete for numeric suffixes. We do not transliterate other scripts: a title with no ASCII letter or digit gets a stable, per-title-distinct content-<hex> stem that is not readable in that language, so keep PATCHing the slug you want. No backfill; no field or response shape changed.
2026-08-29 · Program ideas now keep keywords that name a subject by its first word
A net widening of ideas:generate. Every rule above this one needs a subject’s whole name or at least two of its words, so on a scope of brand-plus-product names a keyword like chase points transfer partners scored 0 and was never written. Such a keyword is now written with relevance_score: 0.75 when it carries a subject’s first word, has at least three content words, shares no more than that one word with any single subject, and does not open one of your subjects’ words out into a longer one. ideas:approve-all still takes only high-confidence ideas and 0.75 sits below that floor, so nothing this rule writes is auto-approved — these arrive pending. Read the changelog entry for the worked examples and the cost a generic seed carries.
2026-08-29 · POST /content/rewrite items are created with a slug
The rewrite intake used to insert its source item with no slug, so the item read back "slug": null until you sent one with a PATCH. It now derives one from the title, made unique within the brand. Items created before this date are not backfilled, and slug is still published as string | null on read.
2026-08-29 · Comparison generate skips its approved pages until an attribute supplier ships
The attribute matrix added earlier the same day was built from our own keyword metrics for the entity names — search volume, difficulty, CPC — and handed to the writer as the page's verified attributes. Those are not attributes of the products being compared, so it was withdrawn. From 2026-08-31 generate-time research supplies cited product attributes when it can; a comparison page without a usable cited matrix is still skipped and counted on the job result as pages_skipped_missing_inputs. listicle and entity_review declare no attribute matrix and are not gated. The check now sits above the write rather than above the whole run, so a page stranded mid-generation is re-dispatched instead of skipped. Superseded on 2026-09-05: entity_review is now gated on supplied facts, an unusable input marks a page blocked rather than skipping it, and listicle stays ungated.
2026-08-29 · More comparison tables are deleted at finalize, four qualified phrasings fewer
The finalize pass that removes an all-hedge comparison table matched no confirmed, not confirmed and not verified as sentence openings, so a comparison page whose table read No confirmed devaluations, No confirmed blackout dates, Not verified by the issuer or Not confirmed by the registrar in every cell lost that table. Those four survive from this date. This release is also a net widening: a table whose every cell is exactly Insufficient data, Insufficient evidence, No data, Not applicable, Not documented, Not stated, Undisclosed or Unverified shipped before this date and is deleted after it, and so is one whose every cell merely opens with Not established, Not verifiable, Unable to verify, Unable to confirm, Cannot be verified, Cannot be confirmed, Could not be verified or Could not be confirmed — worked examples include a safety matrix of Not established in pediatric patients and, until 2026-09-04, an attribution matrix of Cannot be verified by the issuer (see the entry above). Nothing to change on the wire; pages generated earlier keep the body they were written with.
2026-08-29 · Approve-all no longer bulk-approves listicle ideas that cannot become pages
On a listicle the page is the facet; an idea whose keyword or title is exactly one of your scope.entities is one of the things each list ranks, never a page of its own. ideas:generate offers every scope entity back as an idea, and those are always high confidence — so ideas:approve-all approved precisely the ideas the plan stage refuses while the facets beside them stayed pending. Those entity ideas are now left pending on listicle programs. comparison and entity_review approve exactly what they did before, and a listicle seed that names no scope entity is a facet and is still approved. Approve any of them individually if you want them. Separately, a completed plan job now reports approved_ideas_not_page_bearing, so zero planned pages says why.
2026-08-29 · Confirmed teardowns from before 2026-08-28 stop advertising a dead URL
The 2026-08-28 clarification below — a confirmed unpublish clears published_url and moves status off published — reads an append-only record, so it applied to teardowns from that date onward. Items torn down earlier kept the stored URL, and re-sending unpublish could not repair them: with no remote document left to remove, that call is a no-op which deliberately changes nothing. Those older records are now corrected in place. An item whose teardown this API confirmed, and which has not been published live again since, returns published_url: null, and a published status is now draft. A no-op-only teardown, a failed unpublish job, an item published live again afterwards, and an item already storing no URL all keep what they had. A repaired item's updated_at moves to the moment of the repair, so a client that syncs on it sees those items again with no user edit behind them. No response shape changed.
2026-08-28 · Listicle plans no longer lose facets to your scope entities
The 50-subject budget on the plan stage was counted over every approved idea, and ideas naming one of your scope entities are ordered first — so they spent the budget even though only a facet becomes a listicle page. Facets past the cut were dropped, and with more than 50 scope-naming ideas GET /programs/{program_id}/plan returned total: 0 for a program that had facets to plan. The budget now counts facets alone; in-scope entities are the ranked shortlist (still at most 10 per page, in the order you wrote the scope) and no longer consume it. No response shape changed. comparison and entity_review are unaffected, and a listicle whose approved ideas are all bare scope entities still plans zero pages.
2026-08-28 · Declining bare-entity ideas no longer empties a listicle plan
On a listicle, only a facet becomes a page. Declining the bare-entity ideas — the obvious response after those names stopped being pages — used to empty the ranked shortlist and return total: 0, discarding facets you had approved. Rankings now come from scope.entities (still at most 10, in the order you wrote the scope), including entities you declined as ideas. No response shape changed. A listicle whose approved ideas are all bare scope entities still plans zero pages.
2026-08-28 · Off-topic program ideas no longer auto-approve
ideas:generate used to score a related keyword as on-topic whenever it merely contained every word of the scope entity that produced it, even when each of those words belonged to a different, unrelated term — capital one venture x and working capital venture debt both cleared the auto-approve floor against Venture Capital. The words must now appear together and in order for that score, so those keywords are no longer returned and ideas:approve-all approves fewer ideas. A keyword that keeps the words in order with a modifier between them is still returned and stays pending. A compound-word lookalike such as hedge fundraising against Hedge Fund is dropped. An idea with no relevance_score was never auto-approved and still is not.
2026-08-28 · Confirmed unpublish clears published_url
After POST /content/{item_id}/publish with mode unpublish reaches a successful terminal for a teardown this API confirmed,GET /content/{item_id} returns published_url: null. If status was published, it becomes draft. Other statuses stay put. A no-op (no remote document recorded) does not change those fields. No response shape changed.
2026-08-28 · The API calls itself RoboWrite, and the hostname is explained
No endpoint, parameter or response shape changed. In the OpenAPI document, the PublishPolicy description and the POST /properties description named the platform brand — the name that belongs to the shared api.roboad.ai host, not to this product — and now say RoboWrite. The servers description is the one surface that still carries the platform name, deliberately: it leads with RoboWrite and then explains what api.roboad.ai is, because that is the single sentence where naming the host's owner is what makes the base URL make sense. The two publish 409 bodies that name the product (draft_only property, and unpublish on an item with no publish target) changed wording for the same reason; match on the status code and the condition, not on the sentence. The base URL is unchanged, and the Overview now says why it is on a different domain from the product name.
2026-08-27 · Program page bodies now carry their citations inline
Every page written by POST /programs/{program_id}/pages:generate is generated with citation_mode=inline. The stage used to dispatch each page with no citation mode at all, so it fell back to structured_only — the writer's markdown links were stripped out of the body and no reference list was appended, leaving pages that named their sources in prose while linking none of them. Now the inline links stay in markdown_body and a numbered ## Sources section is appended whenever the page resolved any citations, using sanitized inline-HTML anchors. pages:generate takes no request body, so there is no per-call option on this verb — but it is not sealed on the page either: once a page has a content_item_id, that is an ordinary content item, and regenerating it through POST /content/items/{item_id}/generate writes the body under whatever citation_mode you send there. Sending none used to fall through to that verb's structured_only default and strip the links this stage wrote; since 2026-08-29, omitting citation_mode on a program-owned item keeps inline instead. An explicit structured_only still wins. No response shape changed; ?include=sources returns the same citation metadata as before. Pages generated earlier keep the body they were written with — there is no backfill, and that includes a page whose generation was already in flight when this shipped, because a replayed start request keeps the mode it was originally dispatched with.
2026-08-27 · Unpublish a live page, and what the no-auto-publish guarantee covers
POST /content/{item_id}/publish now accepts a mode of unpublish, which tears the remote CMS document down again. It was 422 before, so teardown was dashboard-only and a live page you did not mean to ship had no API verb to reverse it. It is allowed on a draft_only property, and an item with no publish target returns 409 rather than provisioning your default destination. Separately, the program no-auto-publish guarantee is now scoped to RoboWrite rather than to your CMS: status and published_url are RoboWrite's own record, not a live read of your CMS, so a page can be live on the property while this API still reports it as a draft. No response shape changed.
2026-08-14 · Breaking — null rejected on the content-item slug
On PATCH /content/items/{item_id}, an explicit null on slug now returns 422, and an empty string does too. Migrating: omit the key to leave the slug unchanged, or send a replacement — clearing was never something this endpoint could do. Previously both values returned 200 with the slug unchanged on a property-bound item, because the server put the previous slug back before writing. Every item created through this API is property-bound, so those requests are unaffected. published_url still accepts null to clear.
2026-08-13 · secondary_keywords accepts null again
On PATCH /briefs/{brief_id}, secondary_keywords accepts null again to empty the set, alongside []. This reverses part of the breaking change published earlier the same day. title and format are unaffected and still reject null. Both spellings now work, so nothing breaks if you already switched to [] — and briefs now match PATCH /pillars/{pillar_id}, which has always accepted both.
2026-08-13 · Breaking — null rejected on three brief PATCH keys
On PATCH /briefs/{brief_id}, an explicit null on title, format or secondary_keywords now returns 422. The other ten fields still accept null to clear. Migrating: omit the key to leave a field unchanged, and send [] to empty the keyword set — now the only way to do it. Previously the update wrote the nullstraight through: title and formatviolated their non-nullable columns and returned 500, while secondary_keywords stored a value that read back as [] anyway.
2026-08-13 · Breaking — null rejected on pillar name, goal, and status
On PATCH /pillars/{pillar_id}, an explicit null on name, goal or status now returns 422, and all three are published as non-nullable. description and keywords still accept null to clear. Migrating: omit the key to leave the field unchanged. Sending null previously: name returned a 500 and did not apply; status applied the change and then failed, after which GET /pillars/{pillar_id} kept failing until the row was repaired; goal returned a 200 with a defaulted value.
2026-08-13 · Breaking — null rejected on non-nullable taxonomy PATCH keys
On PATCH /authors/{author_id}, PATCH /categories/{category_id} and PATCH /tags/{tag_id}, an explicit null on a key backed by a non-nullable column now returns 422: name/slug/status on authors, name/slug/sort_order/status on categories, name/slug on tags. They are published as non-nullable, so a regenerated client no longer offers T | null for them. Migrating: omit the key to leave the field unchanged. Sending null previously either returned a 500, silently reset a category's sort_order to 0, or was silently ignored — depending on which key you picked. Clearing a genuinely nullable field is unchanged: email/bio/avatar_url on authors and description/parent_id/icon/color on categories.
2026-08-11 · Breaking — versions is a pagination envelope
On GET /content/{content_item_id} the versions field is now a PublicPage envelope instead of a bare array. Read versions.items for the window and versions.total for the full count; newest first, ?limit= 1–200 (default 50) and ?offset=. Migrating: versions[0] → versions.items[0], or use ?latest_only=true. The old shape returned every version with its full body in one unbounded response.
2026-07-01 · Review-state polling
Jobs can now report awaiting_review — a stop-polling state for topic generations that produced a draft but were routed to the dashboard review inbox (e.g. no connected CMS). The poll response exposes content_item_id and content_version_id so the parked draft is fetchable via the API. Published this agent-ready guide at /llms-full.txt.
