Public API · v1

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.

Authorization: Bearer YOUR_API_KEY

Base URL

The surface is path-versioned. Breaking changes ship under a new version prefix.

https://api.roboad.ai/api/public/v1

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"
}
Autopilot eligibility. 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

pendingqueuedrunning

The run is still working.

Terminal — stop

completedcompleted_with_errorsfailedcancelled

The run finished; read the item (or read error).

Needs human review — stop

awaiting_review

A 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 limit120 requests / 60 seconds, per organization
Over limit429 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.