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.
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 and ?include=sources for the research sources cited in each version (combine with a comma). Both 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,compliance,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}/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,compliance) - PATCH · DELETE
/content/items/{item_id}Update or soft-delete a content item
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
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:
- Publishing to a connected CMS — publish from the dashboard for now. (This is why headless generation for a no-CMS brand lands in
awaiting_review.) - 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.
- Pillar-strategy endpoints — a separate upcoming capability.
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-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.
