Public API · v1

Developer API

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.

Authenticate first

Every request carries your organization API key as a Bearer token. Create keys in your dashboard under Settings → API keys.

Authorization: Bearer YOUR_API_KEY

Base URL

JSON REST, path-versioned. Breaking changes ship under a new version prefix.

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

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.

Full endpoint reference

Browse every endpoint, schema, and status code in an interactive OpenAPI explorer generated from the published contract.

Open the reference
Integration guide

How the public API fits together

Conventions, authentication, pagination, errors, and the full endpoint narrative — the same source we ship as API.md.

RoboWrite Public API — v1 Integration Guide

Status: v1, live and supported — but not frozen. The full content lifecycle — discover → create → generate → edit → read — is callable programmatically, with uniform pagination and error conventions, and an Idempotency-Key rule declared per POST route (see Idempotency). Read Versioning and stability before you pin a client to a response shape.

This document is the consumer-facing reference. For exact request/response field shapes, the published OpenAPI schema is the source of truth (it is CI-guarded against drift). This guide explains how the surface fits together.


Overview

The Public API lets you drive content operations end-to-end without the dashboard: manage brands / properties / taxonomy, create briefs and content items, kick off AI generation, and read everything back. Every endpoint is scoped to the organization that owns your API key.

  • Base URL: https://api.roboad.ai/api/public/v1
  • Why that hostname? 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. Point your client at the host above.
  • Versioning: path-versioned (/v1), and not yet frozen — a response shape can change under this path. See Versioning and stability for what is and is not promised, and the Changelog for every change made so far.
  • Format: JSON request and response bodies.
  • Client generation: an OpenAPI specification is published for the surface; we maintain a generated, drift-checked TypeScript client, so you can generate a typed client in any language.

Authentication

Every request requires an API key as a Bearer token:

Authorization: Bearer <YOUR_API_KEY>

Keys are created, listed, and revoked from your dashboard. Each key is bound to a single organization; all reads and writes are automatically scoped to that org — there is no cross-tenant access. A missing or malformed Authorization header returns 401.


Conventions (apply to every endpoint)

Versioning and stability

/v1 names the path, not a frozen contract. While v1 is in active development a response shape can change under the same /v1 path — and one already has: GET /content/{content_item_id} re-nested versions into a pagination envelope on 2026-08-11. If you have read a promise elsewhere in this guide that breaking changes only ever ship under a new version number, that promise was wrong; this section replaces it.

What is promised:

  • Additive wherever it is possible. New fields, new endpoints, and widened enums are the default. An existing field is re-shaped only when the old shape is a defect — an unbounded array that cannot be paged, or a schema that misdescribes what the server already returns.
  • From the Changelog's start date forward, every observable change is dated, additive ones included. That section is the record going forward — not commit history, and not the prose in the rest of this guide. Pre-start history is not claimed to be complete.
  • A breaking change is labelled as one. If a change can break a client written against the previous shape, its Changelog entry says Breaking and states what to read instead. CI compares every published response shape against a recorded baseline and fails the compat check on a breaking difference until someone accepts it deliberately — and it refuses that acceptance unless the dated Changelog entry exists and is marked Breaking. That check is merge-blocking: it reports into the aggregate status check our main branch requires, so a breaking difference nobody accepted cannot be merged. Announcing the change is a precondition of accepting a new baseline.
  • The OpenAPI schema moves with the code. It is regenerated and published on every change and is the source of truth for exact fields; this guide explains how the surface fits together. Regenerate your client when a Changelog entry lands.

What is not promised today: a deprecation window, dual-serving an old and a new shape behind a header or query parameter, or a /v2 for breaking changes. Those are deliberately not built. If you need one before you can integrate, open an issue — demand is what moves it up the roadmap.

How to survive a shape change. Read narrowly: prefer ?latest_only=true and ?version=N over consuming a whole nested collection. Treat an unexpected type as a hard error rather than coercing it — the failure that prompted this section was a client that iterated a dict and got its keys, which surfaced far from the cause. And watch this document: /api renders the current version, and the Changelog is at the bottom.

Pagination

Paginated collection endpoints return a uniform envelope:

{ "items": [ ... ], "total": 137, "limit": 50, "offset": 0, "has_more": true, "next_cursor": null }

Control with ?limit= (1–200, default 50) and ?offset=. total is the full filtered count; has_more indicates whether another page exists after this window. Page deterministically off those fields.

next_cursor is present on every paginated envelope. It is non-null only when the endpoint supports keyset cursor paging and another page exists. Today that is GET /programs only — see that endpoint for the contract.

One endpoint has a different default: GET /brands defaults limit to 100 (the 1–200 bound is the same). Send an explicit ?limit= if your pager assumes a fixed page size rather than reading limit back off the envelope.

Endpoints that return nested resource arrays or aggregates use their documented shape instead of the pagination envelope. Examples: GET /brands/{brand_id}/keywords returns { "brand_id": "…", "clusters": [ … ], "counts": { … } }, GET /topics/{topic_id}/scores returns PublicTopicScore[], and GET /briefs/{brief_id}/sections returns PublicBriefSection[].

Errors

Failures return the documented status code with a JSON body carrying a detail:

{ "detail": "Human-readable explanation" }

detail has two shapes, and 422 is where you meet both. Every error the API raises itself — including every 404, 429, 503, the business-rule 422s described in this guide, and almost every 409 — uses the string form above. The one 409 exception is POST /content/items/{item_id}/generate: its detail is an object {code, message, details} (see Idempotency). A request that fails schema validation before reaching the handler returns the framework's field-error form instead:

{ "detail": [ { "type": "uuid_parsing", "loc": ["body", "brand_id"], "msg": "Input should be a valid UUID", "input": "not-a-uuid" } ] }

Both can come back from the same route with the same 422: send a malformed brand_id and you get the array; send a well-formed body with an Idempotency-Key a route does not accept and you get the string. So branch on the type, not on the status:

const detail = body.detail;
const message = typeof detail === "string"
  ? detail
  : detail.map((e) => `${e.loc.join(".")}: ${e.msg}`).join("; ");

Note for generated clients: most routes publish 422 as the field-error form (the framework default), so a typed client will need the guard above rather than trusting the generated type for the string form. Program approve/decline verbs that refuse Idempotency-Key are the exception: they publish both shapes via oneOf, so a generated client can see either branch. Every other status is typed as the string form and matches.

CodeMeaning
400 / 422Invalid or malformed input
401Missing/invalid API key
404Resource not found, or not owned by your org
409Conflict — e.g. the resource is in the wrong state for the operation
429Rate limit exceeded — honor the Retry-After response header when present, then retry
503Backend temporarily unavailable — honor the Retry-After response header when present, then retry

Treat any non-2xx defensively and read detail through the type guard above.

Idempotency

Idempotency-Key is a per-request header. Where a route accepts one, a retry with the same key replays the original response instead of repeating the operation, and the replay carries an Idempotency-Replayed: true response header. The route also echoes the key back in an Idempotency-Key response header, so you can tell "accepted" from "not accepted" without guessing.

There is no blanket rule — the rule is per route, and the scheme covers POST. Every public POST declares one of the three rules below, so a key sent to a POST that does not accept one comes back as a 422 rather than being silently discarded. PATCH and DELETE are outside the scheme: they address one existing resource by id, so a repeat converges on the same state instead of creating or billing a second one. They declare no key, and a key sent to one is ignored — neither honored nor refused — so do not rely on one there.

RuleWhat it meansRoutes
RequiredAbsent header → 422. These start charged work whose only handle is the response you might lose.POST /content/items, POST /content/rewrite, POST /brands/{brand_id}/topics/generate, POST /programs/{program_id}/ideas:generate, POST /programs/{program_id}/plan:generate, POST /programs/{program_id}/pages:generate
Optional (creates)Accepted and honored; absent means no deduplication, so a blind retry writes a second row.POST /brands/{brand_id}/topics, POST /briefs, POST /properties, POST /categories, POST /tags, POST /pillars, POST /authors, POST /audiences, POST /programs, POST /programs/{program_id}/ideas, POST /programs/{program_id}/plan
Optional (generation)Accepted and honored, but these also deduplicate on their own — a keyless retry does not necessarily start a second run. See the note below.POST /content/generate, POST /content/items/{item_id}/generate
Not acceptedPresent header → 422. These are already idempotent on the request itself, so a client key adds nothing and would defeat the mechanism that is there.POST /content/{item_id}/publish, POST /brands/{brand_id}/topics/rescore, POST /pillar-strategies, every .../approve, .../decline and ...:approve-all verb

Rules that hold wherever a key is accepted:

  • A malformed key (empty, whitespace-only, or over-long) returns 422.
  • Reusing a key with a different request body returns 409 — mint a new key for the new body; the original key stays bound to the first payload.
  • A concurrent second request under the same key, while the first is still in flight, returns 409 — opposite remedy from payload mismatch: keep the same key and retry.
  • An unreadable idempotency record returns 409 — retry without the key.

Do not branch on the detail prose of those three 409s. The condition and the remedy are stable; the exact sentence is not, and it differs by route family:

conditionPOST /programs… routesL1 spine routes listed below
payload mismatchThis Idempotency-Key was already used with a different request payload.Idempotency-Key reused with a different request payload
in flightA request with this Idempotency-Key is in progress. Retry the same key.A request with this Idempotency-Key is in progress
unreadable recordIdempotency record unreadable; retry without the key.Idempotency record unreadable; retry without the key

The left column covers POST /programs, /programs/{program_id}/ideas, /programs/{program_id}/plan, and the three …:generate program verbs. The right column is the L1 reservation spine (detail=value) on POST /briefs, /properties, /categories, /tags, /pillars, /authors, /audiences, /brands/{brand_id}/topics, /brands/{brand_id}/topics/generate, /content/items, /content/generate and /content/rewrite. Note the right column has no trailing full stop, and words payload mismatch differently.

POST /content/items/{item_id}/generate is a third case: it accepts a key and replays the original job, but a 409 from it is not an idempotency conflict. It means the item is not generatable (brief_not_approved or item_not_generatable) and the body is {"detail": {"code": "…", "message": "…", "details": …}} — none of the three sentences above, and not a top-level {code, message, details} object. The type guard under Errors must treat this detail as an object, not call .map on it.

On POST /content/items, a durable (L2) payload mismatch after the L1 record is gone uses a third sentence, Idempotency-Key reused with a different payload (no request). Match on status + your key, not on that prose either.

Match on the 409 status plus your own record of the key you sent. A future release may align this wording, and doing so would not be treated as a breaking change.

  • If our deduplication store is briefly unavailable, an Optional-rule route proceeds without deduplicating rather than failing (your write succeeds; a simultaneous retry could create a second row). POST /content/rewrite is the exception: it fails closed with 503 + Retry-After rather than risk an undeduplicated billed rewrite.
  • For POST /content/items the deduplication is durable: a retry replays the original item even after a long delay or an infrastructure blip, so a billed create is never repeated. The Optional (creates) routes deduplicate for 24 hours; a retry after that creates a new row.

The two generation routes are the exception to the rule above. Their deduplication is keyed on the work, not only on your key, and it is bounded by that work's lifetime rather than by a fixed 24 hours:

  • POST /content/generate dedupes per topic_id whether or not you send a key: while a run for that topic is still active you get the original job_id back. Once that job is terminal (or has been running without progress for a long time), the same request — and the same Idempotency-Key — starts a fresh, separately charged attempt. See Re-running after failure for the full rule.
  • POST /content/items/{item_id}/generate dedupes on the item and the key together, and supplies its own key when you omit one — echoed back in the Idempotency-Key response header. That protects a capture-and-resend retry: resend the echoed key and you replay. A blind keyless retry gets a freshly synthesized key instead, so it is not deduplicated by one and does generate again. A previously failed generation is never replayed either — the same key re-dispatches as new, charged work.

The practical consequence: "I sent the same key" does not mean "this can never charge twice", and on POST /content/generate "I sent no key" does not mean "a retry is unprotected". Use a fresh key whenever you intend a new run — and note a same-key retry that changes citation_mode replays the original job rather than applying the new value.

All six Required routes publish the requirement. Each marks Idempotency-Key as a required parameter in the schema, so a typed client generated from it will not let you omit one. The three program stage verbs — ideas:generate, plan:generate, pages:generate — used to enforce it inside the handler instead, which published the parameter as optional and let a generated client omit a header the route then rejected; that is fixed. The one consequence to know: because the framework now refuses the absent header, that specific 422 carries the field-error detail described under Errors, not the string form. A malformed key still returns the string form.

Why "not accepted" is a 422 and not a shrug. Those three routes already refuse to do the work twice: publish is exactly-once per item/version/mode and returns the original job_id with was_duplicate_lookup: true; rescore reuses the brand's in-flight job; pillar-strategies returns the matching completed snapshot (and force_refresh: true is the deliberate way to bypass that). Accepting a key you could not act on would tell you a retry was protected by something that was not reading the header at all.

Rate limits

Fixed-window per organization: 120 requests / 60 seconds, counted across every API key in the org and every process you run. Exceeding it returns 429 with a Retry-After response header (whole seconds to wait, 1–60) — honor it before retrying.

The window is fixed, not sliding. It is a wall-clock bucket, so the counter resets at the boundary rather than rolling off gradually. Two consequences:

  • Retry-After is the time left in the current bucket, so it shrinks as the window ages — a 429 early in a window costs you most of a minute, one at the end costs a second.
  • Straddling a boundary lets through up to 240 requests in a ~2-second span (120 at the end of one bucket, 120 at the start of the next) and then locks you out for the rest of that minute. Do not calibrate your client on that burst — it is an artifact of the window shape, not headroom you can rely on.

It fails open. If the counter store is unavailable the limit is not enforced and requests proceed — the limiter is a spend and fairness guardrail, not an availability dependency, so an outage in it must never take the API down with it. The same is true of the daily cap below. Never read a missing 429 as permission to exceed the budget: enforcement can resume on any request.

Daily cap on content-item creates

On top of the per-minute window, POST /content/items carries a separate per-organization daily cap of 1,000 creates. It exists because that route is billable at create time, independently of generation.

  • Exceeding it returns 429 with {"detail": "Daily content-item create limit exceeded"}. Unlike the per-minute limit, this 429 carries no Retry-After — the window is the UTC day, so retry after the next UTC midnight rather than by header.
  • The day is a fixed UTC bucket (not a rolling 24 hours), so the allowance resets at 00:00 UTC.
  • Replays and rejected requests do not consume allowance. An Idempotency-Key replay returns the original item for free, and a request that fails validation (404 on the brief/property, 422 on a brand mismatch) gives its slot back.
  • It fails open. If the counter store is unavailable the cap is not enforced and creates proceed — it is a spend guardrail, not an availability dependency, so never treat its absence as permission.
  • No other route is capped this way; generation volume is governed by your plan quota (GET /account), which returns 429 on its own when a metered resource is exhausted.

Clients & User-Agent

api.roboad.ai sits behind Cloudflare bot protection. Non-browser clients must send a normal, non-empty User-Agent header. Default library agents such as Python's urllib are blocked at the edge — you will see an HTTP 403 with an empty body (Cloudflare error 1010), before the request reaches the API, so there is no { "detail": … } envelope. Most HTTP clients (httpx, requests, curl) send an acceptable UA by default; if you build raw urllib requests, set one explicitly, e.g. User-Agent: my-app/1.0.

Asynchronous generation

AI generation is asynchronous. Generation endpoints return 202 Accepted with { "job_id": "…", "workflow_id": "…", "status": "…" }. Poll GET /jobs/{job_id} until the status stops polling:

Product context. Generated product context is selected from the run's prompt-eligible active products using authoritative name or alias matches, semantic relevance when available, and a safe lexical fallback. If no product is relevant, generation proceeds without product context rather than inserting unrelated catalog entries.

  • In-flight: queued, running
  • Terminal: completed, completed_with_errors, failed, cancelled
  • Needs review (stop polling): awaiting_review — see below

The polled job has shape { "job_id": "…", "status": "…", "content_item_id": "…", "content_version_id": "…", "error": "…", "error_type": "…" }:

  • content_item_id on the 202 depends on the kickoff path. POST /content/rewrite returns { "job_id", "content_item_id", "status" } on the 202 because the source item is created synchronously before dispatch. Other generation paths (POST /content/generate, POST /content/items/{item_id}/generate) return only job_id / workflow_id / status on the 202 — read content_item_id from the polled job once it is populated, typically by the time the job reaches a terminal (or awaiting_review) status.
  • content_version_id identifies the generated draft version and is populated alongside content_item_id.
  • error carries the human-readable failure reason on failed / completed_with_errors.
  • error_type carries a machine-readable failure classification alongside error, so you can branch automatically instead of parsing the message. Common values: generation_error (the draft pipeline failed — often a transient upstream/model error; safe to retry the topic, ideally with backoff and not in a large concurrent burst), rate_limit_error (back off before retrying), timeout / reconciled_failed (the run exceeded its budget or was reconciled by the stuck-run watchdog — retry, and prefer serializing rather than firing many generations at once), save_error (the draft generated but persistence failed — retry), not_found / validation_error / content_policy_error (permanent for that input — do not blind-retry; fix the request), cms_write_rejected (a CMS publish whose destination CMS refused the write and gave no reason — see the 2026-08-29 Changelog entry; re-sending the same publish is not the remedy). null for non-failed jobs. Treat unrecognized values as retryable-with-backoff.

awaiting_review is a stop-polling outcome for POST /content/generate (topic autopilot): the run produced a draft but the quality/publish gate routed it to a human review inbox rather than auto-publishing (for example, the brand has no connected CMS). No further progress happens via the API — treat awaiting_review like a terminal state for polling. The draft is ready: content_item_id and content_version_id are populated, so fetch it with GET /content/{content_item_id}. Resolving the review (approve / hand to an editor / dismiss) is done from the dashboard inbox, not the API.

Avoiding awaiting_review entirely (headless integrations). If you have no CMS connected to RoboWrite and intend to collect finished pages over the API, set the target property's publish_policy to draft_only (see Properties below). Runs for that property then finish as completed instead of parking in the dashboard inbox: the page is downloaded with GET /content/{content_item_id} exactly as above, and POST /content/{item_id}/publish answers 409 because there is no destination to publish to. Content-quality escalations (for example a failed fact-check) still route to the review inbox under either policy — draft_only only removes the CMS-connectivity ones.

"Ready for export" is that outcome's name, not a status you will read. Internally a draft_only topic-autopilot run ends in a distinct terminal success called ready_for_export: finished, deliberately never published, and — unlike awaiting_review — carrying no inbox card for anyone to clear. (It is specific to that path; a programmatic-SEO program page never reaches it under either policy.) Over the API it surfaces as plain status: "completed". GET /jobs/{job_id} does not expose a ready_for_export value or a terminal_status field, so do not branch on that string. The observable contract for a draft_only run is: completed, with content_item_id and content_version_id populated and the page downloadable. You may still see ready_for_export in dashboard and support conversations, which is why it is named here.

Expected latency. A full generation run — brief creation, multi-agent article drafting/editing, and self-scoring — typically takes 10–17 minutes end-to-end (wider variance during content-quality repair passes). Treat anything under 25 minutes as normal; set poll ceilings/timeouts to at least 30 minutes before considering a run stalled (a stalled run self-heals or escalates via the reconciler shortly after). Sub-minute completion is not representative and should not be assumed.

How often to poll. Polling shares one budget with everything else you call: 120 requests / 60 seconds for the whole organization (see Rate limits). Pick an interval from the number of things you are polling, not from a fixed habit:

Polling N jobs every T seconds costs 60 × N ÷ T requests per minute. Keep that under half the window — leaving the other half for the reads and writes the polling exists to serve — which means T ≥ N seconds, and never below 30.

Jobs in flightMinimum intervalPolling cost
1–3030 s≤ 60 req/min
6060 s60 req/min
3005 min60 req/min

Because a run takes 10–17 minutes, a 30-second floor already resolves it within ~0.3 % of its duration — polling faster buys nothing and only brings the 429 forward. Earlier versions of this guide suggested 15–30 seconds without qualification; at anything past a couple of dozen concurrent runs that is not achievable alongside the rate limit, and program-scale workloads are well past it.

At program scale, poll the stage — not the pages. A program's pages:generate returns one job_id for the whole stage no matter how many pages it covers. Poll that single job, and read GET /programs/{program_id}/pages on the same cadence to see pages turn ready one at a time. That is two polls per interval for a 1,000-page program; polling each page's content item instead would exhaust the window immediately.

If a poll is rate-limited (429) or the backend is briefly unavailable (503), honor the Retry-After response header before the next poll, and let that response — not your timer — set the next wake-up.

Inline citations (notation). With citation_mode=inline on POST /content/items/{item_id}/generate, cited claims appear in the prose as markdown hyperlinks[descriptive anchor](https://source…) — plus a numbered ## Sources section at the end of markdown_body. These links are not numeric [n] markers; a numeric marker appears only in the rare case where a link's anchor text had to be replaced. To verify inline body citations programmatically, look for ](http in markdown_body, not [n]. On an ordinary content item, omit citation_mode and you get structured_only (links stripped from the body — verify citations via GET /content/{id}?include=sources). On a program-owned item, omit keeps inline (see the 2026-08-29 Changelog entry). pages:generate always writes inline — see Programs.

Generated ## Sources rows in markdown_body use sanitized inline-HTML <a> anchors with <wbr> soft-break tags in their visible URL labels. Treat markdown_body as Markdown that may contain sanitized inline HTML; do not parse Sources rows as plain-text URLs. For stable machine-readable citation data, use GET /content/{id}?include=sources.

Notes:

  • A just-finished run may briefly still read running until its completion callback lands — poll until the status is terminal or awaiting_review rather than trusting a single non-terminal read.
  • Under rare concurrent-submit timing you may receive 202 with status: "queued" and an empty workflow_id. This is normal — the job exists; just poll it.
  • GET /content/{id}?include=scoring,sources fields are populated together per version, but scoring is only written once self-scoring completes — a version fetched before its generation run finishes may show sources populated with scoring: null briefly. Poll the job to a terminal/awaiting_review status before treating a missing scoring field as an error.
  • On GET /content/{content_item_id}, each version's seo_description and seo_title resolve with the same precedence: dedicated SEO column, else a string value in the version metadata blob, else the related meta column (meta_description / meta_title). Description: generated (non-imported) drafts commonly store the SEO summary only as meta_description, so seo_description is populated where it was previously empty. Title: the same fallback applies when meta_title or metadata carries a title (imported/legacy rows); native generation today often leaves both seo_title and meta_title empty, so seo_title may still be null while versions.items[].title holds the page title — treat a non-empty seo_title as authoritative SEO override and prefer it over parsing the body.

All timestamps are UTC ISO-8601.


Endpoint reference

Account

MethodPathPurpose
GET/accountBootstrap: org bound to the key, brands it can use, monthly quota remaining, overage flags

Use this after minting a key to discover which organization and brands you can act on and how much monthly allowance remains before writes return 429.

GET /account
{
  "organization": { "id": "org_…", "name": "Acme Co" },
  "brands": [
    { "id": "…", "name": "Acme", "status": "active" }
  ],
  "quota": {
    "plan_code": "growth",
    "period_start": "2026-07-01T00:00:00Z",
    "period_end": "2026-08-01T00:00:00Z",
    "resources": [
      {
        "resource_type": "content",
        "current": 12,
        "limit": 100,
        "remaining": 88,
        "overage_enabled": true,
        "is_blocked": false
      }
    ]
  }
}
  • organization — the Clerk org the API key is bound to. If the org row is missing, the response is 404.
  • brands — slim refs (id, name, status) for brands the key can use (the whole org). Capped at 200; use GET /brands to page further. Full brand/voice detail remains on GET /brands/{brand_id}.
  • quota.resources — monthly metered resources relevant to the public surface: content, topics, revisions, briefs, pillar_strategy. Each row has:
    • remaining — included allowance left this period (max(limit - current, 0)). This is not unlimited overage headroom.
    • overage_enabled — plan allows metered spend past the included limit for that resource (today this is typically content on Growth). When remaining is 0 but overage_enabled is true and is_blocked is false, further creates still succeed and bill overage.
    • is_blocked — further spend of that resource is hard-capped (no remaining included allowance and no overage).

An unknown/missing org for the key returns 404. Auth and rate-limit behavior match every other endpoint (401 / 429).

Organization settings

MethodPathPurpose
GET/org/settingsRead organization-level generation settings
PATCH/org/settingsUpdate organization-level generation settings

regulated_industries controls the org-wide regulated-content policy. Allowed values are finance, healthcare, and legal; an empty array means the policy is off. The field is required on PATCH — an empty body or omitted key returns 422 so clients cannot accidentally clear policy.

PATCH /org/settings

{
  "regulated_industries": ["finance"]
}

When enabled, generation prompts and evaluation gates use a stricter educational-tone policy: regulated figures need inline source grounding, prescriptive advice phrasing is repaired or escalated, and unresolved findings block autopublish by routing the job to awaiting_review.

Brands

MethodPathPurpose
GET/brandsList brands
GET/brands/{brand_id}Get a brand
PATCH/brands/{brand_id}Update a brand's voice fields (partial)
GET/brands/{brand_id}/keywordsKeyword inventory for a brand, including market data (search volume, difficulty, CPC). Does not use the standard list envelope — see Pagination below.

Update brand voicePATCH /brands/{brand_id}. Partial update of the fields that shape how content is written for the brand. Only these keys are accepted:

PATCH /brands/{brand_id}
{
  "description":        "string | null",   // nullable — send null to clear
  "mission_statement":  "string | null",   // nullable — send null to clear
  "tone":               "professional",    // closed enum (see below); null is rejected
  "phrases_to_avoid":   ["string", "…"],   // send [] to clear; null is rejected
  "dos":                ["string", "…"],    // send [] to clear; null is rejected
  "donts":              ["string", "…"]     // send [] to clear; null is rejected
}

Every field is optional — an omitted key is left unchanged. description and mission_statement are nullable text, so an explicit null clears them. tone and the three arrays reject an explicit null (422): an array is cleared by sending []. Accepted tone values (closed enum): professional, casual, friendly, authoritative, playful, empathetic, technical, conversational. Any other key — including name, industry, is_primary, status, or voice-directive/embedding/regulated-industry fields — is not editable here and returns 422. A missing or cross-org brand_id returns 404; the response echoes the updated brand (including the voice fields). Editing a voice field marks any AI-generated voice directive as stale (it is regenerated on the next content run), and the new voice guidance is applied to subsequent generation. This endpoint is not billable and needs no Idempotency-Key.

Properties

MethodPathPurpose
GET/propertiesList properties
GET/properties/{property_id}Get a property
POST/propertiesCreate a property
PATCH/properties/{property_id}Change publish_policy on an existing property

Publish policy. Every property carries publish_policy, returned on list/get/create/patch and settable on create or via the scoped patch:

  • cms_publish (default) — a finished topic-autopilot page (POST /content/generate) is auto-published to the org's connected CMS. If the org has no connected CMS, those runs end in awaiting_review and must be resolved from the dashboard; POST /content/{item_id}/publish works normally once a destination is resolvable. Topic autopilot is the only path that auto-publishes. The program cycle does not read publish_policy — a programmatic-SEO program page is never published for you under either policy (see Programs (programmatic SEO)), so you call POST /content/{item_id}/publish for it yourself.
  • draft_only — RoboWrite publishes nowhere. Generation runs finish as completed and you collect the page with GET /content/{content_item_id}. POST /content/{item_id}/publish for an item on such a property returns 409 with a policy-specific detail.

draft_only is a policy, not a fallback: a draft_only property does not start auto-publishing if a CMS is connected later. Send "publish_policy": "draft_only" in the create body; any other value returns 422.

Change it on an existing propertyPATCH /properties/{property_id}:

PATCH /properties/{property_id}

{ "publish_policy": "draft_only" }

Body requires publish_policy (cms_publish | draft_only). Explicit null and unknown keys return 422. A missing or foreign property returns 404. Only this field is writable on the public surface; other property fields stay on the session-authed internal API. No Idempotency-Key (PATCH converges). A programmatic-SEO program needs no particular policy — it closes over the API under either one (see Programs (programmatic SEO)).

Existing properties are unaffected. Every property that existed before this field was introduced reads cms_publish, which is exactly what it did before — nothing changed for an organization already publishing to a CMS. draft_only is only ever reached by asking for it on create.

POST /properties takes an optional Idempotency-Key; a retry of the same body replays the original property rather than creating a second. (A duplicate url is refused with 409 independently of any key.)

Audiences

MethodPathPurpose
GET/audiences?brand_id=List audiences for a brand (PublicPage)
GET/audiences/{audience_id}Get an audience
POST/audiencesCreate an audience
PATCH/audiences/{audience_id}Update an audience (partial)
DELETE/audiences/{audience_id}Delete an audience

Audiences are brand-scoped. GET /audiences requires brand_id. Optional status filters the list. Pagination is the standard envelope (limit 1–200, default 50).

Create. POST /audiences takes brand_id and name. Optional: description, decision_role, sophistication_level, technical_depth, status, goals, pain_points. Unknown keys return 422. A brand_id that is unknown or not owned by the key's org returns 404. Idempotency-Key is optional.

decision_role values: end_user, influencer, decision_maker, economic_buyer, technical_buyer. sophistication_level: novice, intermediate, expert. technical_depth: non_technical, semi_technical, technical, highly_technical.

Each goals item is { "goal": "…", "priority": "…", "timeline": "…" } (goal required). Each pain_points item is { "pain_point": "…", "severity": "…", "current_solution": "…" } (pain_point required).

PATCH. Omitted keys are left unchanged. Explicit null on name or status returns 422. description, decision_role, sophistication_level, technical_depth, goals, and pain_points accept null to clear (send [] to empty an array).

Audience ids returned here are what POST/PATCH /pillars accept on target_audience_ids.

Topics

MethodPathPurpose
GET/topicsList topics — ?brand_id= is required; omitting it returns 422
GET/topics/{topic_id}Get a topic
GET/topics/{topic_id}/contentList content generated from a topic
GET/topics/{topic_id}/scoresCached opportunity scores for a topic, including market metrics
POST/brands/{brand_id}/topicsCreate a topic from your own input
POST/brands/{brand_id}/topics/generateGenerate fresh topics for a brand (async) → 202 + job
POST/brands/{brand_id}/topics/rescoreRe-score a brand's curated topics on demand → 202 + job

Topic create — pillar anchoring. Every topic must belong to a content pillar (brief generation requires a pillar context). When pillar_id is omitted, the API auto-assigns the best-match active pillar for the brand (semantic match when available, with a deterministic keyword/name-overlap fallback). Returns 400 when the brand has no active pillars, or when a supplied pillar_id is not an active pillar for that brand. A foreign/unknown brand_id still returns 404 (resolved before create).

Topic create — input bounds. premise is optional and capped at 5,000 characters. secondary_keywords is optional, capped at 20 items, and each keyword is capped at 255 characters. Payloads over these limits return 422 (same ingress bounds as brief create — prevents unbounded prompt/token spend).

Generate topicsPOST /brands/{brand_id}/topics/generate (the Idempotency-Key header is required). Generates fresh topic-bank ideas for the brand's active pillars asynchronously. Body:

POST /brands/{brand_id}/topics/generate
Idempotency-Key: <opaque-key>

{
  "pillar_id":       "<uuid> | null",  // optional — pin one active pillar; omit to use all of the brand's active pillars
  "property_id":     "<uuid> | null",  // optional — target scoring at a specific property
  "requested_count": 50                 // optional, 1–500, defaults to 50
}

Returns 202 Accepted with { "job_id": "…", "batch_id": "…", "workflow_id": "…", "status": "…" }. Poll GET /jobs/{job_id} until terminal, then list the generated topics with GET /topics?brand_id=…. A supplied pillar_id that is not an active pillar for the brand, or a brand with no active pillars when pillar_id is omitted, returns 400; a foreign/unknown brand_id or property_id returns 404; over-quota returns 429. This is a spend-charged operation, so a retry with the same key replays the original 202 (Idempotency-Replayed: true) instead of starting a second batch; reusing a key with a different body returns 409. If kickoff fails after a batch was created (transient dispatch), the API returns 503 and freezes the key so a same-key retry does not start a second batch (it may replay the original job_id/batch_id for polling). strategy and seed_keywords are not accepted in v1 (unknown keys return 422). When plan remaining quota is partial, the server may cap requested_count below the requested value without a 429 (429 only when nothing remains).

Create a topicPOST /brands/{brand_id}/topics. Body:

POST /brands/{brand_id}/topics

{
  "title":              "<string>",        // required, 1–500 chars
  "primary_keyword":    "<string>",        // required, 1–200 chars
  "pillar_id":          "<uuid> | null",   // optional — omit to auto-assign the best-match active pillar; an inactive or foreign pillar returns 400
  "premise":            "<string> | null", // optional, max 5,000 chars
  "intent":             "informational",   // optional search intent
  "funnel_stage":       "tofu",            // optional funnel stage
  "suggested_format":   "blog_post",       // optional, defaults to blog_post
  "secondary_keywords": ["<string>", "…"]  // optional, ≤20 items, each ≤255 chars
}

Unknown keys are rejected with 422. A supplied pillar_id is validated against the brand's active pillars before insert (see pillar anchoring above).

Briefs

MethodPathPurpose
POST/briefsCreate a brief
GET/briefsList briefs
GET/briefs/{brief_id}Get a brief
GET/briefs/{brief_id}/sectionsGet the brief's section breakdown
PATCH/briefs/{brief_id}Edit a brief (every edit is versioned automatically)

POST /briefs takes an optional Idempotency-Key: with one, a retry of the same body replays the original brief (Idempotency-Replayed: true) instead of creating a second; without one, a retried POST creates a second brief. PATCH needs no key (re-applying the same patch lands on the same brief).

Explicit null on PATCH /briefs/{brief_id}. Omitting a key leaves it unchanged. null clears primary_keyword, target_word_count, audience_details, tone, search_intent, funnel_stage, pillar_id, description, summary and content_goal. It is rejected with a 422 on title and format, which are published as non-nullable. To empty the keyword set send either [] or null.

Content items

MethodPathPurpose
POST/content/itemsCreate a content item (Idempotency-Key required)
GET/content/itemsList content items in the create/update shape (filters: brief_id, property_id, status)
GET/contentList content items
GET/content/{content_item_id}Get a content item (add ?include=scoring for quality scores, ?include=sources for cited research sources, ?include=compliance for regulated-content policy findings; combine with a comma)
PATCH/content/items/{item_id}Update title / status / format / author / slug / published_url
DELETE/content/items/{item_id}Remove a content item

versions is a pagination envelope, not an array. On GET /content/{content_item_id} the versions field is a PublicPage (items, total, limit, offset, has_more, next_cursor), newest first, with the standard ?limit= (1–200, default 50) and ?offset= bounds. Read versions.items. Use ?latest_only=true for just the current version or ?version=N to pin one — those two are mutually exclusive (422) and an unknown number is a 404. This replaced a bare array on 2026-08-11; see the Changelog and Versioning and stability.

Published URL. Content item responses (GET /content, GET /content/items, GET /content/{id}, create/PATCH) include published_url — the absolute http(s) URL of the live page when known, otherwise null. CMS-connected orgs usually get this filled automatically after a successful live publish (from the property site URL + a per-profile path template + the item slug). Headless orgs (or any integrator that renders the page themselves) report it back with PATCH /content/items/{item_id}:

PATCH /content/items/{item_id}

{ "published_url": "https://example.com/blog/my-post" }

Rules: value must be an absolute http or https URL (blank string → 422; javascript: / data:422). Explicit null clears the field (omit leaves it unchanged). After a clear, the next successful live CMS publish may re-derive it.

Slug cannot be cleared — and can read back null. On PATCH /content/items/{item_id}, slug takes a new value or is omitted; null and "" both return 422. Send a replacement rather than a clear. A value you send is normalised to a URL token — lowercased; Latin letters lose their diacritics (Cómo becomes como, Straße becomes strasse); everything else outside a-z0-9 collapses to - — and made unique within the property, so the slug you read back may differ from the one you sent. A value with no ASCII letter or digit at all does not become an empty token or a row of hyphens: it takes the same content- plus ten hexadecimal characters that a title in that script gets, described under the 2026-08-30 changelog entry — so PATCHing 如何提高转化率 as a slug is normalised to content-c2eaa9f6b9 rather than stored verbatim. That normalisation is part of the same within-property uniqueness step described above, so it applies wherever that step does. Read the slug from the response.

On read, slug is published as string | null and an item can still have none. POST /content/items derives a slug from the title at creation. From 2026-08-29 POST /content/rewrite does too — from the title you send, or from the title derived from your Markdown when you omit it — so its source item no longer reads back "slug": null. The two do not derive the same slug, so do not predict one surface's value from the other. They agree on the readable stem, and they differ on three things we have measured: POST /content/items makes the value unique within the property and ignores items you have deleted, while POST /content/rewrite makes it unique within the brand and counts them; once a stem and its -2-100 suffixes are all taken the first appends a timestamp and stores it without re-checking, while the second keeps searching and can fail instead; and a title nothing can be derived from at all falls back to content on the first and untitled on the second. POST /content/generate usually assigns one too, but that item can still read back null. The slug is the identity the CMS publish path and the derived published_url above are keyed on, and the automatic derivation described above produces nothing for an item that has no slug — so if you do read null back, PATCH a slug on before you publish that item through a CMS connection, or report its published_url yourself.

Create a content itemPOST /content/items (the Idempotency-Key header is required). The item is brief-driven, not topic-driven; its brand is derived from the property. Body:

POST /content/items
Idempotency-Key: <opaque-key>

{
  "brief_id":    "<uuid>",      // required — the brief this item is built from
  "property_id": "<uuid>",      // required — the publishing target (must belong to the same brand as the brief)
  "title":       "<string>",    // required, 1–500 chars
  "format":      "blog_post"    // optional, defaults to blog_post
}

Unknown keys are rejected with 422 — e.g. sending topic_id or brand_id here is invalid (the brand comes from the property, and topic-based kickoff is POST /content/generate, below). A brief and property whose brands differ returns 422; a missing or foreign brief_id / property_id returns 404.

Because creating a content item can be a billed action, the Idempotency-Key dedupe here is durable: the original item is returned (Idempotency-Replayed: true) on any retry with the same key and body — even hours later or after a transient backend error — so the same key never creates (or bills for) a second item. Reusing the key with a different body returns 409.

This route also carries a per-organization daily cap of 1,000 creates, which answers 429 ({"detail": "Daily content-item create limit exceeded"}, no Retry-After) and resets at 00:00 UTC. Replays and rejected requests do not consume it. See Daily cap on content-item creates.

Generation & jobs

MethodPathPurpose
POST/content/generateGenerate from a topic (autopilot) → 202 + job. Body is { "topic_id": "<uuid>" } only
POST/content/items/{item_id}/generateGenerate / rewrite / vary an existing item (supports a direction: draft, rewrite, variation; and citation_mode: structured_only / inline) → 202 + job
POST/content/rewriteRewrite pasted Markdown to brand voice → 202 + job + content_item_id (Idempotency-Key required)
POST/content/{item_id}/publishPublish or schedule a live item to the connected CMS → 202 + job_id
GET/jobs/{job_id}Poll job status until terminal

POST /content/generate takes only { "topic_id": "<uuid>" }. It runs the topic autopilot and does not accept direction or citation_mode — sending either returns 422. Those options live only on the item path: to control citations or styling, create an item (POST /content/items) and call POST /content/items/{item_id}/generate.

Autopilot eligibility. POST /content/generate only runs for topics whose decision band is autopilot-eligible; ineligible topics return 409. Eligible bands: auto_brief, light_review, plus topics with no decision band yet (decision_band: null). Ineligible: expert_review, reject_or_hold. A topic's band reflects a demand/fit quality gate — freshly-seeded, not-yet-enriched topics (low/zero search volume) often score into the ineligible bands, so a large fraction of a brand-new topic bank may be ineligible until keyword/SEO enrichment runs. Select eligible topics up front with GET /topics?brand_id=… (the response carries decision_band and an eligible_for_autopilot flag) rather than firing blindly into a 409.

Topic rescore. POST /brands/{brand_id}/topics/rescore re-evaluates scoring gates (including semantic ICP-relevance when enabled for the brand) and persists new decision bands. Body:

POST /brands/{brand_id}/topics/rescore

{
  "property_id": "<uuid>",           // required — scoring is property-scoped
  "topic_ids": ["<uuid>", "…"],      // optional — subset; omit to rescore the whole brand (capped at 200 server-side)
  "force_seo_refresh": false         // optional — re-fetch keyword/SERP data before scoring
}

Returns 202 Accepted with { "job_id": "…", "status": "queued" } (no workflow_id on this job type). Poll GET /jobs/{job_id} until terminal. For rescore jobs, the polled job includes a result summary: { "total", "rescored", "eligible_before", "eligible_after", "band_counts", "truncated" }. Job types that publish no summary keep result: null — the ideas stage of a program publishes one of its own, see Programs. A foreign or unknown brand_id returns 404. Omitting property_id returns 422. In-flight rescores for the same brand reuse the existing job_id instead of stacking duplicates.

Re-running after failure. A topic's first autopilot kickoff is deduped under autopilot-content:{org}:{topic_id}. If that job finishes in a terminal state (failed, cancelled, completed, etc.) or has been running without progress for an extended period, a later POST /content/generate for the same topic starts a fresh job (new job_id) instead of replaying the old one. In-flight jobs still replay — you get the same job_id/workflow_id and can keep polling. The optional Idempotency-Key follows the same rule: it replays the cached 202 only while the linked job is still active; once that job is terminal or stale-running, the same key dispatches a new charged attempt.

Rewrite pasted MarkdownPOST /content/rewrite (the Idempotency-Key header is required). Submit your own Markdown and get it rewritten to the brand voice. The route creates a source content item (v1 = your paste) and kicks off an async rewrite job that writes v2. Body:

POST /content/rewrite
Idempotency-Key: <opaque-key>

{
  "brand_id":     "<uuid>",              // required — must belong to your org
  "property_id":  "<uuid>",              // required — publishing property (same brand); enables CMS publish target scope
  "content":      "<markdown>",          // required, 1–50,000 chars
  "title":        "<string> | null",     // optional; else derived from the first H1 (or first non-empty line)
  "instructions": "<string> | null",     // optional rewrite guidance, max 5,000 chars
  "pillar_id":    "<uuid> | null",       // optional pillar override; must belong to brand_id when supplied
  "documents":    {                      // optional; defaults to { "mode": "auto", "ids": [] }
    "mode": "auto" | "none" | "selected",
    "ids":  ["<uuid>", "…"]              // required non-empty when mode is "selected"
  }
}

Returns 202 Accepted with { "job_id": "…", "content_item_id": "…", "status": "running" }. Poll GET /jobs/{job_id} until terminal, then fetch the rewritten draft with GET /content/{content_item_id} — the rewrite lands as version 2 (version_number: 2); v1 is your original paste.

Document-library injection (documents.mode). Controls which of your uploaded knowledge-library documents are blended into the rewrite alongside the brand voice:

  • auto (default) — use the documents the auto-derived brief attaches.
  • none — brand voice only; no document context.
  • selected — use exactly documents.ids. Each id should be an org-owned, ready document; ids that aren't (foreign, unready, or deleted) are silently skipped rather than erroring, and if the selection is too large to fit the rewrite's context budget the overflow is dropped too. An empty ids list with mode: "selected" is rejected at request time (422).

Idempotency. A retry with the same key and payload replays the stored 202 (Idempotency-Replayed: true) instead of creating (and billing) a second rewrite. Reusing a key with a different payload, or a concurrent in-flight rewrite with the same key, returns 409. If the idempotency backend is unavailable, the route returns 503 with Retry-After rather than proceeding undeduped.

Quota & errors. Each accepted rewrite counts against your org's REVISIONS allowance; over-quota returns 429. A missing, foreign, or cross-org brand_id returns 404. A missing or foreign property_id returns 404; a property_id that belongs to a different brand than brand_id returns 422. A pillar_id that is absent, foreign, or belongs to a different brand returns 404. Validation failures (empty content, missing property_id, oversized fields, documents.mode: "selected" with an empty ids list, unknown keys) return 422. If job infrastructure is temporarily unavailable before the billable row is created, returns 503 with Retry-After.

Latency. The rewrite pipeline analyzes your paste, materializes a brief, and runs the full brand-voice rewrite engine — expect timing similar to other generation jobs (typically 10–17 minutes; allow at least 30 minutes before treating a run as stalled). Poll on the interval from How often to poll above — 30 seconds for a handful of runs, longer as concurrency rises.

Pillar strategy (clusters-first engine)

MethodPathPurpose
POST/pillar-strategiesGenerate a clusters-first pillar strategy for a brand
GET/pillar-strategies/{run_id}Poll a strategy run until completed, failed, cancelled, or still generating

Request body (POST /pillar-strategies):

{
  "brand_id": "<uuid>",
  "keyword_segment": { "seeds": ["invoice factoring", "…"] },  // optional — omit for brand-wide cold path
  "num_pillars": 5,        // optional, clamped to 3–10 (default 5)
  "force_refresh": false   // optional — bypass idempotency and start a fresh charged run
}

200 vs 202. A matching completed snapshot (same org, brand, request seeds, and num_pillars) returns 200 OK with the full strategy snapshot immediately — no quota charge, no new LLM run. An in-flight run for the same signature returns 202 Accepted with { "status": "generating", "run_id": "…", "poll_url": "/api/public/v1/pillar-strategies/{run_id}" } — also free. Any fresh label/generate pass (including force_refresh: true) returns 202 and charges the org's pillar-strategy allowance once per newly created run.

Polling. GET /pillar-strategies/{run_id} returns:

  • 200 + completed snapshot (clusters, pillars, gaps, warnings) when the run finished. The snapshot's own status is snapshot quality, not run state: it is completed, or completed_with_errors when enrichment was degraded. completed_with_errors is a usable strategy — read warnings for what was thin — so branch on the presence of clusters / pillars, not on the status being exactly completed.
  • 200 + { "status": "failed", "error": { "detail": "…" } } on failure
  • 200 + { "status": "cancelled", "error": { "detail": "…" } } when cancelled
  • 200 + { "status": "generating" } while the run is in flight

Strategy generation is LLM-backed and typically takes several minutes; treat anything under 15 minutes as normal. Poll on the interval from How often to poll above (30 seconds is the floor).

Errors. An unknown or cross-org brand_id returns 404. An unknown or cross-org run_id returns 404. Over-quota fresh runs return 429.

Programs (programmatic SEO)

Programs is parked. Every verb below returns 403 {detail: "Programs is not enabled for this organization."} unless your organization has been allowlisted. The paths stay reserved so a later resume does not invent a new surface.

MethodPathPurpose
POST/programsCreate a program from a brand, property, template family, scope, and a bulk set of seeds
GET/programsList your programs, newest first
GET/programs/{program_id}Get one program with its per-stage counts

A program is how you seed a body of related pages: you supply what the program covers (its scope), which template family turns that scope into a page list, and the seeds to research from. Seeds enter here rather than on POST /brands/{brand_id}/topics/generate, which still rejects strategy and seed_keywords with a 422.

Create a programPOST /programs. Body:

POST /programs
Idempotency-Key: <opaque-key>     // optional

{
  "brand_id":         "<uuid>",                       // required
  "property_id":      "<uuid>",                       // required — must belong to the same brand
  "template_family":  "comparison",                   // required — one of GET /program-templates (comparison | listicle | entity_review)
  "scope": {
    "entities": ["Card A", "Card B", "Card C"]        // required, 1–500 entries; blanks rejected, duplicates collapsed
  },
  "seeds": [                                          // optional, up to 500
    { "seed": "best travel card", "client_reference": "CUST-1" },
    { "seed": "cash back card",   "client_reference": "CUST-2" }
  ],
  "client_reference": "your-own-id"                   // optional
}

Returns 201 Created with the program in state draft. Unknown keys are rejected with 422, including unknown keys inside scope (a typo such as "entites" is a 422, not a program that covers less than you intended). A brand_id or property_id that is unknown or not owned by your organization returns 404, as does a property_id that belongs to a different brand.

The 500-entry bounds are storage bounds, not research bounds. Everything you submit here is stored and correlated, but one ideas:generate run researches at most 100 subjects drawn from both lists — see Generate ideas below for how the budget is split between seeds and scope entities.

Correlating what you submitted with what we generate. client_reference is your own identifier, opaque to us. Set it on each seed and it is carried onto every idea and every planned page derived from that seed, so you can match our rows back to your records without storing our IDs. The program itself takes a client_reference too.

Seeds are deduplicated per program. Seeds are compared case-insensitively after trimming, so submitting "best travel card" and " Best Travel Card " in the same program yields one seed. Re-submitting a seed you already sent adds nothing. Blank seeds are dropped. The first occurrence wins, so the surviving client_reference is the one you sent first.

Idempotency. POST /programs accepts an optional Idempotency-Key. With one, a retry of the same body replays the original 201 (Idempotency-Replayed: true) instead of creating a second program; reusing a key with a different body returns 409. Without one there is no deduplication and a retry creates a second program.

Reading a program. GET /programs/{program_id} returns the program plus per-stage counts:

{
  "id": "<uuid>",
  "state": "draft",
  "template_family": "comparison",
  "scope":  { "entities": ["Card A", "Card B", "Card C"] },
  "client_reference": "your-own-id",
  "counts": {
    "seeds": 2,
    "ideas":      { "total": 0, "pending": 0, "approved": 0, "declined": 0 },
    "plan_pages": { "total": 0, "pending": 0, "approved": 0, "declined": 0 }
  }
}

Scope is strict on write, lenient on read. Create rejects unknown keys inside scope with 422. On read, unknown keys that a later template version may have stored are preserved in the scope object (not stripped, not a 500) so forward-compatible fields round-trip. Clients should ignore keys they do not understand.

state runs draftresearchingideas_readyplanningplan_readygeneratingpages_ready, with a single failed state. An unknown program_id, or one belonging to another organization, returns 404 — never an empty result.

GET /programs pages with limit (1–200, default 50) and offset, and returns the standard { items, total, limit, offset, has_more, next_cursor } envelope.

Prefer cursor for multi-page walks. Offset paging can skip or duplicate rows when programs are created between requests. Pass ?cursor= with the opaque next_cursor from the previous page; when cursor is set, offset is ignored. An invalid or malformed cursor returns 422. next_cursor is non-null only when has_more is true.

Ideas, and approving them

MethodPathPurpose
POST/programs/{program_id}/ideas:generateResearch the program's seeds and scope and propose ideas
GET/programs/{program_id}/ideasList the ideas, optionally filtered by approval state
POST/programs/{program_id}/ideasAdd an idea of your own
POST/programs/{program_id}/ideas/{idea_id}/approveApprove one idea
POST/programs/{program_id}/ideas/{idea_id}/declineDecline one idea
POST/programs/{program_id}/ideas:approve-allApprove every high-confidence idea still pending
PATCH/programs/{program_id}/ideas/{idea_id}Reserved — returns 501

Approval is the gate. Every idea starts pending. Only approved ideas become planned pages. A declined idea is kept — so you can change your mind and approve it later — but it never advances.

Generate ideasPOST /programs/{program_id}/ideas:generate. This researches your seeds and your scope entities for keyword ideas and their competition data, so it bills data providers and an Idempotency-Key header is required (absent → 422). Retrying with the same key replays the original response instead of starting a second, separately-charged run; the same key against a different program is a 409.

POST /programs/{program_id}/ideas:generate
Idempotency-Key: <opaque-key>          // required

→ 202 Accepted
{ "job_id": "<uuid>", "status": "queued", "program_state": "researching" }

How many subjects one run researches. A run researches at most 100 subjects — your seeds and your scope entities together — even though POST /programs accepts up to 500 of each. Every subject is a paid data-provider read, so the ceiling is a cost control, not a payload limit.

The 100 slots are split so neither side can crowd out the other: seeds and scope entities get 50 each, and whichever side uses fewer than 50 gives the remainder to the other, seeds first. So 500 seeds and 500 entities research 50 of each; 40 seeds and 500 entities research all 40 seeds and 60 entities; a program with seeds and no scope entities researches 100 seeds. Subjects are deduplicated across both sides (compared case-insensitively after trimming), and a scope entity that repeats a seed costs neither side a slot. Subjects past the budget are skipped — they are kept on the program, they are simply not researched, and nothing downstream can produce a page for a subject that was never researched. A single run also writes at most 200 ideas.

Poll GET /jobs/{job_id} until the job reaches a terminal status, then read the ideas. While the research runs the program sits in researching; a second stage verb during that window returns 409. A completed ideas job carries a summary in result:

{
  "job_id": "<uuid>",
  "status": "completed",
  "result": {
    "program_id": "<uuid>",
    "subjects_researched": 100,
    "ideas_proposed": 200,
    "ideas_written": 200,
    "ideas_by_source_subject": {
      "Chase Ultimate Rewards": 5,
      "Citi ThankYou Points": 3
    },
    "related_read_failures": 0,
    "metrics_chunk_failures": 0,
    "serp_read_failures": 0,
    "candidates_without_metrics": 0,
    "is_degraded": false
  }
}

subjects_researched is the number of subjects this run actually sent to the data providers — compare it with what you submitted to see whether the budget skipped anything. ideas_proposed is what the research produced and ideas_written is what was stored (they differ when a re-run re-proposes an idea the program already has). ideas_by_source_subject is the per-subject idea count for this run (keyed by the seed or scope entity that produced each idea) — use it to see portfolio skew before ideas:approve-all. Multi-subject runs also balance related-keyword accepts across subjects and drop single-token / source-subset generics plus near-duplicate sub-clusters, so one entity cannot dominate the set with paraphrases of one intent.

The five degradation fields report partial DataForSEO / provider outages that still complete the stage with a non-empty idea set: related_read_failures, metrics_chunk_failures, and serp_read_failures count failed provider reads; candidates_without_metrics is how many accepted keywords had no volume/difficulty payload; is_degraded is true when any of those counters is positive. Treat is_degraded: true as a soft warning on the result — the job status remains completed, but the idea set may be thinner or less scored than a clean run. Until the run finishes, result is an empty object; job types that publish no summary keep result: null.

Read the ideasGET /programs/{program_id}/ideas. Pages with limit (1–200, default 50) and offset; add ?state=approved (or pending / declined) to filter. ?state=approved is exactly the set that will advance.

The response is the standard pagination envelope; one items entry looks like this:

{
  "id": "<uuid>",
  "program_id": "<uuid>",
  "title": "Best travel card",
  "primary_keyword": "best travel card",
  "state": "pending",
  "client_reference": "CUST-1",
  "research": {
    "search_volume": 9000,
    "keyword_difficulty": 42,
    "cpc": 1.5,
    "competition_level": "MEDIUM",
    "competitor_domains": ["example.com"],
    "source_subject": "best travel card",
    "source_kind": "related_keyword",
    "relevance_score": 0.9
  }
}

client_reference is carried forward from the seed the idea came from, so you can match our ideas back to what you submitted. research is empty for an idea you added by hand. relevance_score is a 0–1 signal from research: subject terms score 1.0; related keywords that are too off-brand are dropped before write, and survivors carry the score so you can filter without reading every row.

Add your own ideaPOST /programs/{program_id}/ideas with { "title": "...", "primary_keyword": "...", "client_reference": "..." }. Returns 201 with the idea in pending. Ideas are deduplicated within a program on the primary keyword (falling back to the title), so adding one that already exists — whether you added it or the research run found it — returns the idea you already have rather than a second one. An optional Idempotency-Key replays the original 201.

Approve and declinePOST .../ideas/{idea_id}/approve and .../decline return 200 with the updated idea. Both are naturally repeatable: restating an idea's current state is a successful no-op, so a dropped response is safe to retry. For that reason they do not accept an Idempotency-Key — sending one returns 422 rather than being quietly ignored.

Approve everything pendingPOST /programs/{program_id}/ideas:approve-all returns the resulting counts:

{ "total": 12, "pending": 2, "approved": 7, "declined": 3 }

It approves only high-confidence ideas that are still pending: subject- sourced ideas, hand-added ideas, and related keywords whose relevance_score clears the high-confidence floor. A related keyword carrying no relevance_score is not high-confidence: an absent or null score is refused rather than read as passing, so such an idea is left pending for you to curate. Lower-confidence related keywords stay pending so you can curate them (or approve them individually). Ideas you declined stay declined — this convenience verb never reverses a decision you made deliberately. It does not accept an Idempotency-Key either.

It also skips ideas your template family cannot turn into a page. On a listicle the page is the facet, and an idea whose idea_key, title or primary_keyword is exactly one of your scope.entities is one of the things each list ranks — never a page of its own. Those ideas are left pending however high their score, because approving them adds nothing to the plan. Everything else is unchanged: on comparison and entity_review every subject is a page, so nothing is skipped for this reason, and on a listicle a seed that names no scope entity is a facet and is still approved. You can still approve any of them one at a time with POST .../ideas/{idea_id}/approve — this only changes what the bulk verb picks.

While a stage is running the idea list is frozen. Approving, declining or adding an idea while the program is in researching or planning returns 409: the stage that consumes the approved set must not have it rewritten underneath it. Wait for the job to finish, then make your changes.

The freeze is reported ahead of whether the idea exists. Approving or declining an unknown idea_id on a frozen program returns 409, not 404 — while a stage is running no idea on that program can be mutated, so the lock is the answer regardless of the id. Once the program is out of researching / planning, an unknown idea_id returns 404 as usual. Treat a 409 here as "retry after the job finishes" and only then trust a 404 to mean the idea is genuinely gone.

Editing an idea is not available yet. PATCH /programs/{program_id}/ideas/{idea_id} is reserved and always returns 501. Decline the idea and add a corrected one instead.

The plan, and approving it

MethodPathPurpose
POST/programs/{program_id}/plan:generateExpand the template over your approved ideas into a page list
GET/programs/{program_id}/planList the planned pages, optionally filtered by approval state
POST/programs/{program_id}/planAdd a page of your own
POST/programs/{program_id}/plan/{page_id}/approveApprove one page
POST/programs/{program_id}/plan/{page_id}/declineDecline one page
POST/programs/{program_id}/plan:approve-allApprove every page still pending
PATCH/programs/{program_id}/plan/{page_id}Reserved — returns 501

The plan is the concrete page list. It is what your template family produces from the ideas you approved: for a comparison program over a set of cards it is "Card A vs Card B", "Card A vs Card C", … For entity_review it is one page per approved idea, and for listicle one page per approved facet. Approval is the second gate. Every page starts pending; only approved pages are generated.

Generate the planPOST /programs/{program_id}/plan:generate. An Idempotency-Key header is required (absent → 422), so a retried request replays the original response instead of starting a second run.

POST /programs/{program_id}/plan:generate
Idempotency-Key: <opaque-key>          // required

→ 202 Accepted
{ "job_id": "<uuid>", "status": "queued", "program_state": "planning" }

Poll GET /jobs/{job_id} until the job reaches a terminal status, then read the plan. While the expansion runs the program sits in planning; a second stage verb during that window returns 409. A completed plan job carries a summary in result:

{
  "job_id": "<uuid>",
  "status": "completed",
  "result": {
    "program_id": "<uuid>",
    "approved_ideas": 12,
    "approved_ideas_unread": 0,
    "approved_ideas_not_page_bearing": 0,
    "pages_expanded": 48,
    "pages_planned": 40,
    "pages_skipped_unapproved": 0,
    "pages_skipped_existing": 8
  }
}

approved_ideas_unread is how many approved ideas this run did not load (the plan stage paginates the full approved set in windows of 200, up to a hard safety ceiling of 2000 — non-zero only when that ceiling truncates the drain or the list ends early); approved_ideas_not_page_bearing is how many of the ideas it did load this template family never turns into a page of their own (on listicle, the ones that name a scope entity — they are the ranked shortlist instead; always 0 on comparison and entity_review), so pages_expanded: 0 with a non-zero count here tells you the approved set holds no facets rather than that the run failed; pages_planned is what was stored; pages_skipped_existing is pages left out because matching content already exists on the property. Until the run finishes, result is an empty object.

Three rules shape what you get:

  • Only ideas you approved expand. A declined or still-pending idea never produces a page. Approve at least one idea first — with nothing approved, :generate returns 409.
  • Re-running is safe. The expansion is deterministic, so a second run adds only pages that are genuinely new. It never duplicates a page you already have, and it never re-creates one you declined.
  • Pages you already have are skipped. A planned page whose title matches content that already exists on the program's property is left out, so a program does not plan work you have already done.
  • Hard ceilings apply (and are visible). Expansion uses at most 50 approved subjects and materialises at most 500 pages per run. Extra approved ideas beyond the subject budget are left unplanned; extra expansion beyond the page budget is truncated. On listicle the subject budget counts facets only: in-scope entities are the ranked shortlist every page carries (at most 10, in the order you wrote the scope) rather than pages of their own, so they do not spend it. The resulting plan total on GET /programs/{program_id}/plan is the number that was written — if you expected more pages, check whether either ceiling bit. Template entities_per_page_max: null means the family does not impose a per-page entity ceiling; it does not mean plan expansion is unbounded.

Read the planGET /programs/{program_id}/plan. Pages with limit (1–200, default 50) and offset; add ?state=approved (or pending / declined) to filter. ?state=approved is exactly the set that will be generated.

The response is the standard pagination envelope; one items entry looks like this:

{
  "items": [
    {
      "id": "<uuid>",
      "program_id": "<uuid>",
      "title": "Card A vs Card B",
      "target_keyword": "card a vs card b",
      "template_family": "comparison",
      "state": "pending",
      "client_reference": "CUST-SEED-1",
      "content_item_id": null,
      "expansion": {
        "expansion_unit": "entity_pair",
        "entities": ["Card A", "Card B"],
        "source_idea_keys": ["card a", "card b"]
      }
    }
  ],
  "total": 1225,
  "limit": 50,
  "offset": 0,
  "has_more": true
}

client_reference is carried forward from the idea (and the seed) the page came from. expansion shows why the page is in your plan; it is empty for a page you added by hand. content_item_id stays null until the page has been generated.

Add your own pagePOST /programs/{program_id}/plan with { "title": "...", "target_keyword": "...", "client_reference": "..." }. Returns 201 with the page in pending. Pages are deduplicated within a program on the title, so adding one that already exists returns the page you already have rather than a second one. Unlike the automatic expansion, a page you add by hand is not skipped for matching existing content — you asked for it by name. An optional Idempotency-Key replays the original 201.

Approve and declinePOST .../plan/{page_id}/approve and .../decline return 200 with the updated page. Both are naturally repeatable, so they do not accept an Idempotency-Key — sending one returns 422 rather than being quietly ignored.

Approve everything pendingPOST /programs/{program_id}/plan:approve-all returns the resulting counts, in the same shape as the idea verb. It approves only what is still pending; pages you declined stay declined.

While a stage is running the plan is frozen. Approving, declining or adding a page while the program is in planning or generating returns 409: the stage that consumes the approved set must not have it rewritten underneath it.

Editing a plan page is not available yet. PATCH /programs/{program_id}/plan/{page_id} is reserved and always returns 501. Decline the page and add a corrected one instead.

Generating the pages, and downloading them

MethodPathPurpose
POST/programs/{program_id}/pages:generateWrite the pages you approved
GET/programs/{program_id}/pagesList every page and how far it has got

GeneratePOST /programs/{program_id}/pages:generate. An Idempotency-Key header is required (absent → 422): this verb bills, so a retried request must replay the original response rather than write and charge for the same articles twice.

POST /programs/{program_id}/pages:generate
Idempotency-Key: <opaque-key>          // required

→ 202 Accepted
{ "job_id": "<uuid>", "status": "queued", "program_state": "generating" }

Poll GET /jobs/{job_id} until the job reaches a terminal status, then read GET /programs/{program_id}/pages. Each page gets its own generation job behind the scenes, so pages become downloadable one at a time rather than all at once. A completed generate job carries a summary in result:

{
  "job_id": "<uuid>",
  "status": "completed",
  "result": {
    "program_id": "<uuid>",
    "approved_pages": 500,
    "approved_pages_unread": 0,
    "pages_materialized": 480,
    "pages_dispatched": 480,
    "pages_already_generated": 20,
    "pages_refused_unapproved": 0,
    "pages_failed": 0,
    "pages_quota_exceeded": 0,
    "pages_skipped_missing_inputs": 0
  }
}

approved_pages_unread is how many never-generated (or soft-deleted) approved pages sat past this run's dispatch ceiling — re-run pages:generate to drain them. pages_already_generated counts live pages that already had a content item (safe no-ops). pages_quota_exceeded counts pages whose per-page generation dispatch was refused because the monthly content allowance was exhausted mid-run — distinct from pages_failed (crashes / unavailable). pages_skipped_missing_inputs counts approved pages the stage declined to write because a structured input it needs was not available — today that is a comparison page with no researched attribute matrix (see Programmatic-SEO templates). A skipped page is not a failure and not a charge: the skip changes nothing about the page, so it keeps its approved state and a run that has a matrix for it writes it then. The job reaches completed either way, so compare pages_dispatched against approved_pages rather than reading the terminal status as a full yield. Until the run finishes, result is an empty object.

  • Every page this verb writes carries its citations inline. pages:generate takes no request body, and every page it writes is generated with citation_mode=inline: the writer's [anchor](https://source…) links stay in markdown_body, and a numbered ## Sources section is appended whenever the page resolved any citations. A program page is published body-and-nothing-else, so a body that named its sources without linking them had no second surface to recover them from — that is why this path pins inline rather than the item verb's ordinary clean-prose default. A generated page's content_item_id is still an ordinary content item for POST /content/items/{item_id}/generate: an explicit mode you send there wins, and omitting the field on a program-owned item keeps the inline body this verb wrote (see the 2026-08-29 Changelog entry). Ordinary (non-program) items that omit the field still get the clean-prose default. See Inline citations (notation) for the exact notation and ?include=sources for the same citations as machine-readable metadata.
  • Only pages you approved are written. A declined page and a page you have not decided on both produce nothing. Approve at least one page first — with nothing approved, :generate returns 409.
  • Quota is preflighted after startability. If the program is missing, has no approved pages, or is already in flight / not allowed to enter generating, you still get 404 / 409 even when the org is over quota. Only when the stage is otherwise startable and the monthly content-generation allowance is already exhausted (and overage is not enabled) does :generate return 429 with {"detail": "The monthly allowance for this resource is exhausted."} before the program enters generating. Check GET /account for remaining allowance. If allowance runs out mid-run, remaining pages are counted in result.pages_quota_exceeded rather than a generic failure counter.
  • Re-running is safe. A page that already has a live content_item_id is never written a second time, so a retry — or a run days later — adds only pages that are genuinely new. A page whose content item you soft-deleted (generation_state=content_deleted) is rematerialised on the next generate run (new brief + content item); the deleted item and its versions stay for audit.
  • While the stage runs the plan is frozen. Approving, declining or adding a page while the program is in generating returns 409.

Read the pagesGET /programs/{program_id}/pages. Same pagination and ?state= filter as the plan read, plus two fields:

{
  "id": "<uuid>",
  "program_id": "<uuid>",
  "title": "Card A vs Card B",
  "template_family": "comparison",
  "state": "approved",
  "content_item_id": "<uuid>",
  "content_status": "draft",
  "generation_state": "ready"
}

generation_state is one of:

ValueMeaning
not_approvedYou have not approved this page, so it will not generate
awaiting_generationApproved and queued
generatingBeing written
readyFinished — download it
content_deletedYou deleted this page's content item. The page still names the old content_item_id, but GET /content/{content_item_id} returns 404. Re-running POST …/pages:generate rematerialises a new content item (and clears the dead back-link); the deleted item and its versions stay for audit

Download a finished pageGET /content/{content_item_id}. The versions field is a PublicPage envelope (items, total, limit, offset, has_more, next_cursor) — not a bare array. This changed on 2026-08-11 and is breaking against the former full array; see the Changelog. Page with the standard public bounds (?limit= 1–200, default 50; ?offset=) — newest first. Read versions.items for the window, versions.total for the full history count, and versions.has_more to know whether another page exists. For page-by-page download pass ?latest_only=true to get just the current version, or ?version=N to pin one. Those two are mutually exclusive (422), and an unknown version number is a 404.

GET /content/{content_item_id}?latest_only=true

RoboWrite never publishes a program page for you. The program cycle does not read publish_policy. Both policies produce the same program terminal states; policy only affects an optional later POST /content/{id}/publish and topic-autopilot (POST /content/generate). The generate stage starts content generation and stops there — under either publish policy it never hands a finished page to your CMS. generation_state=ready means the page is written and downloadable, not that RoboWrite made it live anywhere.

This is a guarantee about RoboWrite, not about your CMS. It bounds what this product does; it cannot bind your destination. A page can still become live without any call to this API — a scheduler or workflow in your CMS, an automation on the property, or a person in the CMS admin. It has happened: a program run's pages went live and were indexed on a customer property about an hour after creation, while this API still reported them as drafts.

Two consequences worth planning for. status and published_url are RoboWrite's own record, not a live read of your CMSstatus: "draft" means we did not publish it, not that the URL is dark, so treat your CMS and your sitemap.xml as the authority on what is live. And if you find a page you did not mean to ship, POST /content/{item_id}/publish with "mode": "unpublish" tears the remote document down again.

To publish a page, call POST /content/{item_id}/publish for it. Under the default cms_publish policy that enqueues the publish to your connected CMS. If the page's property uses publish_policy=draft_only the same call returns 409 because there is no destination — for those properties the download above is the intended last step.

The headless shape, end to end. Because the program cycle never consults publish_policy, a program needs no particular policy — you neither have to pick one when you create the property nor replace a property you already have. Each approved page's generation job reaches completed, the page reads generation_state: "ready", and you download it. Program pages run the plain content-generation workflow, which has no publish gate: a program page never reaches awaiting_review, never terminates at ready_for_export (see Asynchronous generation — that is a topic-autopilot outcome), and generating one never parks anything in a dashboard inbox for a human to clear. The policy's only effect on a program is the answer to POST /content/{item_id}/publish described just above.

Bulk export is not available yet. Download is page-by-page in this release — see Not in v1 yet.

Taxonomy (full CRUD)

ResourcePaths
PillarsGET/POST /pillars · GET/PATCH/DELETE /pillars/{pillar_id}
AudiencesGET/POST /audiences · GET/PATCH/DELETE /audiences/{audience_id}
AuthorsGET/POST /authors · GET/PATCH/DELETE /authors/{author_id}
CategoriesGET/POST /categories · GET/PATCH/DELETE /categories/{category_id}
TagsGET/POST /tags · GET/PATCH/DELETE /tags/{tag_id}

Taxonomy create errors. POST /categories, POST /tags, POST /pillars, and POST /audiences all accept a brand_id in the request body. If that brand_id is unknown or does not belong to the org tied to your API key, the call returns 404 Not Found rather than a 500 — treat it the same as any other not-found resource.

Slug-rename conflicts. PATCH /categories/{id} and PATCH /tags/{id} accept an optional slug field. If the new slug is already in use by another category (or tag) within the same brand, the call returns 409 Conflict. Choose a different slug and retry.

Explicit null on a PATCH. Every PATCH field is optional, and an omitted key is left unchanged. An explicit null clears a field only when that is a real operation; otherwise null is rejected with a 422 — omit the key instead.

Resourcenull clearsnull422
Authorsemail, bio, avatar_urlname, slug, status
Categoriesdescription, parent_id, icon, colorname, slug, sort_order, status
Pillarsdescription, keywords, target_audience, business_goal, target_audience_idsname, goal, status
Audiencesdescription, decision_role, sophistication_level, technical_depth, goals, pain_pointsname, status
Tags— (both fields are required on the row)name, slug

The rejected keys are published as non-nullable in the OpenAPI schema, so a generated client will not offer null for them at all.

goal and status on a pillar reject null even though they look optional on write: both are required and non-nullable on the pillar response, so a stored null could never be read back honestly. status on authors and categories already rejects null. To empty a pillar's keyword list you may send either [] or null.

Idempotency. All five creates (POST /pillars, /authors, /categories, /tags, /audiences) take an optional Idempotency-Key. A retry of the same body under the same key replays the original 201 (Idempotency-Replayed: true); the same key with a different body returns 409. Without a key a retry creates a second row — these resources have no uniqueness constraint beyond the category/tag slug.

Pillar strategy fields. target_audience and business_goal are free-text. target_audience_ids is the list of audience ids from GET /audiences. IDs that are not audiences of the pillar's brand return 422. Omit a key to leave it unchanged; send null or [] to clear target_audience_ids.

Documents & imported content (read)

MethodPathPurpose
GET/documentsList the document library (filter by category, status, q)
GET/documents/{document_id}Get document metadata and summary
GET/imported-contentList imported / external content
GET/imported-content/{content_item_id}Get an imported content item

Roadmap note. Orgs can now trigger CMS content imports from the app (Settings → CMS); once an import runs, these existing /imported-content endpoints simply start returning the imported items — no request or response contract change.

Programmatic-SEO templates

MethodPathPurpose
GET/program-templatesList the template families a program can be built from

A template family is the page archetype a programmatic-SEO program builds. Three are available, all first-party:

familyWhat it producesexpansion_unitentities per page
comparisonOne page per combination of in-scope entities, lined up against a shared set of attributesentity_pair2 → unbounded
listicleOne page per facet, ranking a selection of in-scope entities against a stated criterionentity_facet2 → unbounded
entity_reviewOne page per in-scope entity, evaluated against a stated rubric and ending in a verdictsingle_entityexactly 1

Each entry also carries display_name, summary, and required_generator_inputs — the structured input kinds a page of that family is written against (for example a listicle is written against a facet_definition and a ranking_criterion; a comparison against an attribute_matrix). They shape the generation prompt for pages of the family, and they are not something you supply — there is no field for them on any request body.

attribute_matrix does one thing more: it gates the write. Before this API writes a comparison page, it checks that the platform has researched an attribute matrix for that page; without one the page is skipped rather than written into an empty comparison table, and counted on the generate job result as pages_skipped_missing_inputs. Generate-time research uses structured web search first (schema-shaped {name, value} plus a source URL) and a per-entity search fallback. The fallback stores a cited snippet excerpt that names both the entity and the attribute, not a schema-shaped cell. A comparison page with no usable cited matrix is still skipped. A supplier that built the matrix out of our own keyword metrics — monthly search volume, keyword difficulty and CPC for the entity names as search terms — shipped on 2026-08-29 and was withdrawn: search-demand figures are not attributes of the products on the page.

The check sits above the write and not above the whole run, so it does not block recovery: a page whose content item this API already created, but whose generation job a failed run never started, is re-dispatched by the next pages:generate rather than skipped. listicle and entity_review declare no attribute_matrix and are not gated.

What the platform does to the page when the generator finds nothing. These two mechanisms act on a page the stage has already decided to write, so they sit behind the gate above: a comparison page does not reach them while that gate holds, and what follows describes the comparison pages written before 2026-08-29 plus any future page the gate lets through. Both degrade the body rather than validating anything: the writer is instructed to cover the comparison in prose and omit the table when it cannot support at least two attributes, and a table whose every data cell is an admission that we found nothing (Not established, Unable to verify, N/A) is removed at the end of generation, before the draft is stored — so a draft can carry a comparison headline with no table under it.

A table is left alone as soon as one of its value cells carries a real value. Read "value cell" strictly: the first column of each row is the entity label, and a row of real card names beside two hedged cells is still a hedged row — labels are not evidence. A table of nothing but dashes is also kept, on the grounds that in a feature matrix a dash is itself a finding.

Beyond that, the removal works from a fixed vocabulary of hedging phrases, and phrasings that read as absences but are findings are deliberately kept out of it — a table whose cells all say Not reported to the credit bureaus, Not disclosed to third parties, No record of a breach, No evidence of adverse effects, Not documented in the public reference, Not verified by the issuer, Not available on the Basic plan or No data caps survives. Read that as a vocabulary, not as a judgement about your subject: it is a list we maintain, not an understanding of what an absence means in your niche, so a phrasing that happens to open with one of the remaining hedges is removed even where it was the finding you meant. Ones we know read as findings and remove the table anyway include No verified … (No verified adverse events), Not established … (Not established in pediatric patients), and attribution forms of the epistemic family such as Cannot be verified by the issuer (the same shape as the Not verified by the issuer phrasing that is kept); we have not enumerated the rest. Treat the removal as a floor under diligence theatre rather than a guarantee about any particular table, and read the list of required_generator_inputs as a description of the page shape you will get, not as a promise that the platform checked your data.

Reading the numbers. expansion_unit plus entities_per_page_min / entities_per_page_max is how many pages a given scope will produce, and how they are keyed. entities_per_page_max: null means the family imposes no per-page entity ceiling — your program's scope supplies the entity count for each page. It is not a promise that plan expansion is unbounded: the plan stage still caps at 50 subjects and 500 pages (see Generate the plan above).

Not paginated. The response is a plain array. The catalogue is a fixed set, so there is no limit/offset. If the catalogue cannot be served completely the call returns 503 rather than a partial list, so a short array is never something you have to detect yourself.

Nothing here is site-specific. Families describe expansion and generator inputs only — no URL patterns, title templates, or section outlines. Those come from the program you create.

Health

MethodPathPurpose
GET/pingLiveness check

Typical end-to-end workflow

  1. DiscoverGET /brands to pick a brand; optionally GET /brands/{brand_id}/keywords for opportunities.
  2. Choose a topicPOST /brands/{brand_id}/topics (your own input) or GET /topics.
  3. Shape a briefPOST /briefs (angle, audience, keywords). Refine later with PATCH /briefs/{brief_id}.
  4. Create & generatePOST /content/items, then POST /content/items/{item_id}/generate (or POST /content/generate from a topic, or POST /content/rewrite to rewrite pasted Markdown). Send an Idempotency-Key.
  5. PollGET /jobs/{job_id} until the status is terminal.
  6. Read & refineGET /content/{content_item_id} (?include=scoring for quality metrics, ?include=sources for the research sources cited in each version, ?include=compliance for regulated-content policy findings); PATCH /content/items/{item_id} to adjust.

Generated body title hygiene. New article-draft versions that run the common article finalize/export path use versions.items[].title as the page title. When versions.items[].markdown_body begins with an equivalent ATX H1, generation removes at most that first duplicate heading; it does not recursively delete a later H1. A different leading H1 is preserved, and exporter validation cannot rename the title to that distinct heading or invent a title when finalize produced none. Imported, older, legacy-DRAFT, rewrite, variation, and refresh versions may still contain a leading H1. Reads do not normalize stored content.

  1. Publish — after the draft is ready, POST /content/{item_id}/publish (see CMS publish below). The item needs either an existing CMS publish target or a human-selected org default destination under Organization settings. If you have no CMS and intend to collect pages over the API instead, skip this step and create the property with publish_policy: "draft_only" (see Properties).

Cited sources. ?include=sources returns, per version, the research sources actually cited in the generated body as sources: [{ marker, source_name, source_url }] (ordered by first appearance). It is opt-in and additive: without include=sources, sources is null; with include=sources, sources is [] when a version cited nothing or was generated before source capture was available (no backfill). To control whether the published body shows those citations, pass citation_mode on generate: structured_only (default — clean prose) or inline (keeps inline links + a Sources section). The captured sources metadata is the same either way.

Request the cited sources for a content item:

GET /content/{content_item_id}?include=sources
{
  "id": "…", "title": "…", "status": "draft", "brand_id": "…",
  "versions": {
    "items": [
      {
        "id": "…", "version_number": 2, "title": "…", "markdown_body": "…",
        "sources": [
          { "marker": 1, "source_name": "Stanford WFH Study", "source_url": "https://stanford.edu/wfh" },
          { "marker": 2, "source_name": "Buffer State of Remote Work", "source_url": "https://buffer.com/state-of-remote-work" }
        ]
      }
    ],
    "total": 1, "limit": 50, "offset": 0, "has_more": false
  }
}

Guard on presence, not on null vs [] — treat both as "no sources to show":

const cited = version.sources ?? [];   // null = not requested; [] = requested but none/legacy

Combine projections with a comma — ?include=scoring,sources,compliance returns scoring, sources, and compliance on each version.

Regulated-content compliance. ?include=compliance returns, per version, the regulated-content policy findings recorded when the org configures regulated industries (finance / healthcare / legal), as compliance: [{ kind, message, excerpt }]. kind is a stable enum — unsourced_figure, advice_tone, off_brand_promotion, or missing_disclaimer — so you can branch on it rather than parse message; excerpt is the offending body text for text findings and null for structural findings (missing_disclaimer). It is opt-in and additive: without include=compliance, compliance is null; with include=compliance, compliance is [] for an unregulated org or a clean regulated draft. Findings reflect the shipped body (the generator clears missing_disclaimer once its guaranteed disclaimer footer lands).

Request the compliance findings for a content item:

GET /content/{content_item_id}?include=compliance
{
  "id": "…", "title": "…", "status": "draft", "brand_id": "…",
  "versions": {
    "items": [
      {
        "id": "…", "version_number": 2, "title": "…", "markdown_body": "…",
        "compliance": [
          { "kind": "advice_tone", "message": "Rewrite prescriptive advice tone as educational options and tradeoffs.", "excerpt": "You should move your savings into…" },
          { "kind": "missing_disclaimer", "message": "Add the required finance regulated-content disclaimer…", "excerpt": null }
        ]
      }
    ],
    "total": 1, "limit": 50, "offset": 0, "has_more": false
  }
}

Guard on presence, not on null vs [] — treat both as "no findings to show":

const findings = version.compliance ?? [];  // null = not requested; [] = requested but none

Generate with inline citations shown in the body:

POST /content/items/{item_id}/generate
Idempotency-Key: <opaque-key>

{ "citation_mode": "inline" }

citation_mode is optional. On an ordinary content item, omit or send nullstructured_only (the default clean-prose body). On a program-owned item (one produced by POST /programs/{program_id}/pages:generate), omit defaults to inline so regenerate does not strip the links that stage wrote — see the 2026-08-29 Changelog entry. Non-null values must be structured_only or inline; unsupported strings are rejected with 422. It coexists with direction in the same body. Either way the cited sources are captured and readable via ?include=sources once the job finishes — inline additionally keeps the links in markdown_body and appends a Sources section.

Idempotency note: a same-key retry replays the original job, so a retry that changes citation_mode under the same Idempotency-Key will not start a new generation with the new value. Use a fresh key when intentionally changing generation options.

CMS publish

POST /content/{item_id}/publish enqueues a publish (or schedule) of a live content item to the org's connected CMS. CMS connections are configured in the dashboard. Publish targets may already exist on the item, or — when the item has none — this endpoint can provision one from the org's human-selected default publish destination (Organization settings). It never invents a CMS collection.

Prerequisites

  1. The content item exists, belongs to the API-key org, and is not soft-deleted (otherwise 404).
  2. The item's property is not draft_only. A draft_only property means RoboWrite publishes nowhere for it, so this endpoint returns 409 before touching any destination — collect the page with GET /content/{content_item_id} instead, or switch the property to cms_publish (see Properties). An item with no property is unaffected by this check.
  3. A resolvable CMS destination:
    • Exactly one existing publish target on the item → use it.
    • No target, but a valid human-selected default destination is set under Organization settings (and matches a valid publish profile) → provision a target for the item and publish to it.
    • No target and no valid default409 (set a default destination under Organization settings, or map the item to a CMS collection in the editor).
    • More than one target409 (destination is ambiguous; keep a single active target, then retry). This surface does not accept a target_id and will not pick a CMS for you.

Body

POST /content/{item_id}/publish

{
  "mode": "live",                 // required: "draft" | "live" | "schedule" | "unpublish"
  "schedule_at": "2026-08-01T15:00:00Z"  // required only when mode=schedule; must be timezone-aware and in the future
}
  • draft — push as a CMS draft.
  • live — publish immediately on the remote.
  • schedule — publish at schedule_at (required, future, timezone-aware). Sending schedule_at with any other mode is 422.
  • unpublish — tear the remote document down again. Use this when a page is live that you did not mean to ship, including one this API never published (see Programs — your CMS can publish without us). After the job reaches a successful terminal for a teardown this API confirmed (the remote document was removed, or it was already gone), GET /content/{item_id} returns published_url: null. If status was published, it becomes draft. Other statuses are left alone. A no-op (RoboWrite has no remote document recorded) does not change those fields.
  • Unknown keys (including a client-supplied triggered_by) are 422.

unpublish skips both publish-readiness prerequisites, because they gate creating a remote document and this mode only removes one:

  • Prerequisite 2 does not apply: unpublish works on a draft_only property. That policy says RoboWrite publishes nothing there — which is exactly where a page you never meant to publish is most surprising and most worth removing.
  • Prerequisite 3 does not provision: an item with no publish target returns 409 rather than provisioning your default destination, because linking a CMS is the opposite of what the call asked for. If the page is live on a CMS RoboWrite has no target for, remove it in that CMS directly.

Prerequisite 1 (live item → otherwise 404) and the ambiguous-target 409 apply to every mode, unpublish included.

Response202 Accepted:

{
  "job_id": "<uuid>",
  "was_duplicate_lookup": false,
  "scheduled_at": null
}

Poll GET /jobs/{job_id} until terminal. Exactly-once: a retry of an already-enqueued publish for the same item/version/mode returns 202 with the original job_id and "was_duplicate_lookup": true — it does not enqueue a second publish and is not a 409. Clients may retry freely.

When mode=schedule, scheduled_at echoes the resolved schedule time. Readiness or schedule validation failures from the publish pipeline return 422 with { "detail": "…" }.


Not in v1 yet (roadmap)

These are deliberately out of v1 — design accordingly. Everything listed here is absent, not merely undocumented: if you need one of these behaviours today, the workaround beside it is the whole answer.

  • Completion webhooks — generation completion is poll-based today (GET /jobs/{job_id}); there is no callback/push delivery in v1, so design around polling. Push webhooks are planned.
  • Editing a program idea or a planned pagePATCH /programs/{program_id}/ideas/{idea_id} and PATCH /programs/{program_id}/plan/{page_id} are reserved paths that always return 501. They exist so the URLs are not claimed by something else later; they do nothing today and no request body makes them work. Edit-before-approve is deferred to a future milestone. Workaround: decline the row and add a corrected one (POST .../ideas / POST .../plan), which is why both add-verbs accept your own title and keyword.
  • Bulk export of program pages — download is page-by-page (GET /content/{content_item_id}, ideally with ?latest_only=true). There is no archive endpoint and no multi-item download.
  • Direct document file download URLs — document metadata and summaries are available; original-file signed URLs are not yet exposed.
  • Public CMS connection / target management — connect a CMS and choose the default publish destination in the dashboard; the API publishes against an existing single target or provisions one from that human-selected default (it does not expose connection setup or collection discovery).

Changelog

Every change an integrator can observe, newest first. Breaking marks a change that can break a client written against the previous shape — see Versioning and stability for the policy these entries implement.

The dated record starts at 2026-07-18, when the public OpenAPI schema was first published as a committed artifact. The 2026-07-01 awaiting_review line below is a pre-artifact exception, kept so the review-state rename is not lost; it is not a claim that July 1–17 is itemised.

2026-08-31

  • pages:generate can write a comparison page when research returns a usable cited attribute matrix. Generate-time enrichment fills shared product-attribute axes with values that carry an http(s) source URL: structured web search first, then a per-entity search fallback that stores a cited snippet excerpt naming both the entity and the attribute (not a schema-shaped cell). A page whose research cannot cite at least one axis covering 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. Migrating: 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.

2026-08-30

  • Breaking — Programs is parked and returns 403 unless your organization is allowlisted. POST /programs and every other Programs verb (/programs…, GET /program-templates) now refuse a caller whose organization is not on an operator allowlist, with {"detail": "Programs is not enabled for this organization."}. The surface stays documented; it is no longer open to every API key while the product has no UI and cannot run a 500-page program. Migrating: if you are not on the allowlist, treat 403 on those paths as expected. Contact support to be enabled. No field or path was added, removed or retyped.

  • A title with accents is no longer mangled into the slug, and titles written outside the Latin alphabet no longer share one stem. Every place this API derives a slug from a title — POST /content/items, POST /content/rewrite since 2026-08-29, and the assignment POST /content/generate makes after it creates an item — collapsed everything outside a-z0-9 to a hyphen. Measured through POST /content/rewrite before this change: Cómo mejorar la conversión was stored as c-mo-mejorar-la-conversi-n, Café Crème & Naïveté as caf-cr-me-na-vet, and 如何提高转化率, Продвижение сайта and 日本語のタイトル — three different titles — as untitled, untitled-2 and untitled-3. What changed. A Latin letter now loses its diacritic instead of its place: Cómo mejorar la conversión gives como-mejorar-la-conversion, Marketing für Anfänger gives marketing-fur-anfanger, Søren Østergaard gives soren-ostergaard, Straße gives strasse, Łódź gives lodz, Æther Œuvre Þór gives aether-oeuvre-thor. What we do not do, and you should plan around it: we do not transliterate. Chinese, Japanese, Korean, Cyrillic, Arabic, Hebrew, Greek and Thai titles are not romanised — doing that correctly needs per-script tables, and for Chinese and Japanese a dictionary, that this API does not carry, and a wrong romanisation in a URL you have already published is worse than a neutral one. A title that carries no ASCII letter or digit at all now gets content- plus ten hexadecimal characters derived from the title itself: 如何提高转化率 gives the stem content-c2eaa9f6b9, the same value on POST /content/items and POST /content/rewrite, before whatever suffix uniqueness adds. That stem is stable — the same title derives the same ten characters, so it does not drift between items or between the two surfaces — and it separates titles that used to share one: ten hexadecimal characters is a digest rather than a serial number, so two different titles can in principle land on the same stem, and when they do the numeric suffix separates them exactly as it does for a title used twice. It is not readable in the language you wrote the title in. Whatever ASCII a mixed title does contain still survives on its own — 如何提高转化率 SEO gives seo. A title that is empty or only whitespace is unaffected — there is nothing in it to derive from — and keeps the fallback token it had: content on POST /content/items, untitled on the create RPCs behind POST /content/rewrite and POST /content/generate. Nothing in the wire shape moved: the committed OpenAPI document is byte-identical to the one this release replaced, so no endpoint, field or response schema was added, removed or retyped; slug is still string | null on read, and PATCH /content/items/{item_id} still rejects null and "" with 422. Unlike the change on 2026-08-29, this one adds no way for a create to fail, but it moves the exhaustion that entry describes in both directions, and which one you get depends on your titles. It becomes harder to reach for a brand whose titles are written outside the Latin alphabet, because the titles that used to pile onto one untitled stem no longer do. It becomes easier to reach for a brand whose Latin titles differ only by their diacritics, because folding is a merge: Cañón, Cañon, Cânon and Canon produced four different slugs before today and all four now derive the stem canon, so they compete for canon, canon-2, canon-3 where previously they did not compete at all. If your titles look like that, expect more numeric suffixes than before, and read the 2026-08-29 entry for what happens when a stem's suffixes run out. This release ships no backfill — an item created before today keeps the slug it was given, mangled or shared. Migrating: if your titles are Latin, expect a different (and better) slug on items created from today, and re-read any URL you cached from a create response rather than rebuilding it. If your titles are written in a script we do not transliterate, keep PATCHing the slug you want onto each item before you publish it: PATCH /content/items/{item_id} is the one place this API takes a content item's slug from you — no content-item create body carries a slug field — and it overwrites the derived value. If you have already published items that share the untitled stem, we will not renumber them for you; PATCH the ones you care about.

2026-08-29

  • ideas:generate writes related keywords that name a subject only by the word it starts with — a net widening; read it against your own scope. 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 (Chase Ultimate Rewards, Southwest Rapid Rewards) a keyword such as chase points transfer partners or southwest companion pass promotion scored 0 and was dropped before it became an idea — neither ideas:approve-all nor POST .../ideas/{idea_id}/approve could reach it, because there was no row. Both are now written with relevance_score: 0.75. Under this new rule (other rules could already award 0.75 and are unchanged) a keyword reaches it only when all four of these hold: it carries the first word of one of your subjects (a subject of two words or more); it has at least three content words; it shares no more than that one word with any single subject; and it does not open one of your subjects' words out into a longer one. Worked examples against the scope above that are still dropped: capital one venture x (two words of Capital One Miles), one way award flights (one is the second word of Capital One Miles, not its first), rewards credit card annual fee (rewards starts none of those subjects), points transfer partners (no subject's first word in it), chase points (two content words), hedge fundraising for nonprofits (opens fund out into a longer word — measured against a Hedge Fund subject, as is real estate broker vs agent, which holds two words of a Real Estate Syndication subject). What is unchanged: this is the last rule checked, so any keyword that already matched an earlier rule keeps the score that rule gave it; 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 ideas arrive pending and you approve them one at a time with POST .../ideas/{idea_id}/approve; no request, response or job field was added, removed or retyped; this release ships no backfill. A cost worth knowing: the first-word rule reads your seeds as well as your scope.entities, and a seed is free text rather than a name — a seed like best travel credit cards makes best a first word, so a keyword sharing only that word (best mattress 2026) is now written pending too. Migrating: expect a larger pending count on GET /programs/{program_id}/ideas, and decline what you do not want.

  • A CMS publish your CMS refused now reports its own error_type instead of unknown. When a publish job (POST /content/{item_id}/publish) fails because the destination CMS answered and declined the write without saying why, GET /jobs/{job_id} now returns error_type: "cms_write_rejected". It previously returned "unknown", which is also what a job that carries no classification at all reports, so a refusal could not be told apart from one. What the value means: the remote was reachable, read the request and refused it, and the wire carried no reason — the discriminating detail is in your CMS's own server log. RoboWrite does not retry this class internally — the publish job fails on its first attempt rather than burning a retry budget; the general "treat an unrecognized value as retryable-with-backoff" guidance under Asynchronous generation still covers values you do not recognise. Migrating: nothing to change unless you branch on the literal "unknown" for a failed publish. No response shape changed and no field was added or removed — error_type is a string, and the list under Asynchronous generation is examples rather than an enumeration. This is the only failure classification this release re-buckets.

  • POST /content/rewrite now gives its source item a slug at creation, so it no longer reads back "slug": null. The intake previously inserted the item with no slug at all; the slug is derived from the title you send, or from the title derived from your Markdown when you omit it. Items created before this date are not backfilled — this release ships no data migration, and neither does the 2026-08-30 entry above. slug is still published as string | null on read, because an item created outside this API can still have none, and PATCH /content/items/{item_id} still rejects null and "" with 422. Three claims in the first version of this entry were wrong; they are corrected here rather than left standing. (1) It described the derivation as "lowercased with non-alphanumerics collapsed to -, and made unique within the brand with a numeric suffix when the same title is used twice". The collapse was accurate; the numeric suffix was not only for repeats. A title with no ASCII letters or digits reduced to nothing and took a shared fallback token, so three different titles in one brand were stored as untitled, untitled-2 and untitled-3 — measured through this endpoint. The 2026-08-30 entry above replaces that derivation; read it for what a title outside ASCII gets now. (2) It said "No endpoint, field, status code or response shape changed". No endpoint, field or response shape did. But the slug step this entry added can fail — a brand can have used up every candidate this API builds for one title — and that failure is not turned into a 4xx: only the not-found and brand/property-scope cases are. The create fails instead of returning its 202. Before 2026-08-29 the intake had no slug step and could not fail for that reason at all. The 2026-08-30 entry above makes reaching it harder for titles outside the Latin alphabet, which used to pile onto one stem — and easier for Latin titles that differ only by their diacritics, which that entry now merges onto a shared stem. Read it before assuming the risk only went down. (3) Migrating said that if you were PATCHing a slug onto every rewrite item before publishing it, "that step is no longer needed". For a brand whose titles are written in Latin script that now holds. For a brand whose titles carry no ASCII letters or digits it was the wrong way round on the day it shipped, and the 2026-08-30 entry above says what is true after it. A PATCH still overwrites whatever was derived.

  • Superseded on 2026-08-31 for the “writes no comparison page” claim — generate-time research can now supply a cited matrix; the skip-when-unusable gate and stranded-page recovery still hold. pages:generate writes no comparison page, and the OpenAPI description that said it always writes one has been corrected. Two things changed that day. First, the attribute-matrix research added earlier the same day built the page's comparison table out of our own keyword metrics for the entity names as search terms — monthly search volume, keyword difficulty and CPC — and handed them to the writer as the page's verified attributes. Search-demand figures are not attributes of the products being compared, so that supplier was withdrawn rather than relabelled. Until 2026-08-31 nothing supplied attribute values in its place, so an approved comparison page was skipped and counted on the completed job's result as pages_skipped_missing_inputs; a comparison program planned and approved as before but its generate run dispatched nothing. listicle and entity_review declare no attribute_matrix, so this gate does not apply to them. Second, a page stranded mid-generation — one whose content item we created before a failed run could start its generation job — is no longer caught by that gate: the gate now sits above the write it was meant to guard, so the next run re-dispatches the stranded page instead of skipping it on every retry. In the OpenAPI document, the TemplateGeneratorInput description said "a page is still generated when one of them is unavailable … the writer is told to cover the comparison in prose instead of rendering a table it cannot fill", which contradicted this page; it and PublicProgramTemplate.required_generator_inputs now describe the skip. The pages:generate operation description gained the same note. No endpoint, field, status code or response shape changed, and the five TemplateGeneratorInput values are unchanged. Migrating: if you generate comparison programs, expect pages_dispatched: 0 and pages_skipped_missing_inputs equal to approved_pages until an attribute supplier ships (a run that also re-dispatches a stranded page reports that page under pages_already_generated); the approved pages keep their state and a run that has a matrix for them writes them then. Read pages_skipped_missing_inputs before treating a short yield as a failure. Regenerate your client if you surface descriptions to anyone.

  • Superseded the same day, for rewrite items, by the POST /content/rewrite entry at the top of this date. Corrected documentation — POST /content/rewrite creates its source item with no slug. That rewrite claim was true for part of 2026-08-29 and is false from the later change: a rewrite item created from that later entry onward stores a title-derived slug. The rest of this correction still holds. A content item's slug can be nullPOST /content/generate can leave null if post-create assignment fails, an item created outside this API can have none, and rewrite items created before the later entry were not backfilled. GET /content/{item_id} returns "slug": null for those items, and the automatic published_url derivation produces nothing while they have none. No endpoint, field, status code or response shape changed — the four content read schemas (PublicContentItem, PublicContentItemDetail (the create/PATCH response), PublicContentSummary, PublicImportedContentDetail) already published slug as string | null, and PATCH /content/items/{item_id} still rejects null and "" with 422. Only the prose was wrong. The PATCH /content/items/{item_id} description in the OpenAPI document was reworded to match, so regenerate your client if you surface descriptions to anyone. Migrating: handle null on generate items and on rewrite items created before this date; do not assume every rewrite item needs a PATCH before publish — only those created before the later entry, or any item you read back as null.

  • Superseded the same day by the entry above; read that one for current behaviour. Additive — pages:generate may skip a comparison page that still has no usable attribute matrix after research enrichment. The keyword-metrics research this entry describes was withdrawn hours later, so a comparison page is now skipped whatever its entities' metrics say. Kept for the record: for part of 2026-08-29 the stage researched shared attribute axes from keyword metrics for the page's entities between plan and generate, and refused to materialise a comparison page that lacked at least one axis with a verified value for every entity on it. The pages_skipped_missing_inputs counter that entry introduced is still on the completed job's result and still means what it said — a skipped page, not a failure.

  • Regenerating a program page without citation_mode keeps its inline citations. POST /content/items/{item_id}/generate used to fall through to structured_only whenever citation_mode was omitted — including for content items that came from POST /programs/{program_id}/pages:generate, which always write inline. That silently stripped the markdown links and ## Sources block the stage had just produced (the #4680 symptom, reachable by an ordinary regenerate). Omitting the field on a program-owned item now defaults to inline. An explicit structured_only (or inline) still wins. Ordinary (non-program) items are unchanged: omit still means structured_only. Migrating: nothing required if you already send citation_mode on regenerate; if you regenerate program pages without it, expect the links to stay.

  • More comparison tables are deleted at finalize than before, and four qualified phrasings stop being deleted. The finalize pass that removes an all-hedge comparison table (see Programmatic-SEO templates) 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 even though each of those cells is a finding. Those four survive from this date; bare Not verified and bare Not confirmed are removed as they were. This release is a net widening — check it against your own tables. 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 — a safety matrix reading Not established in pediatric patients in every cell, and an attribution matrix reading Cannot be verified by the issuer in every cell, are worked examples of tables that shipped before and are removed now. The page keeps its comparison headline with no table under it. Migrating: nothing to change — no request, response or job field was added, removed or retyped, and pages generated before this date keep the body they were written with; this release ships no backfill. (The TemplateGeneratorInput schema description in the OpenAPI document was reworded to say these input kinds are a generation directive rather than a precondition; its five values are unchanged.)

  • Additive — the plan job reports how many approved ideas its template family can never page. A completed plan:generate job's result now carries approved_ideas_not_page_bearing alongside the counts it already had. On listicle it is the number of approved ideas that name one of your scope.entities: those are the ranked shortlist every list carries, not pages of their own, so a run that reports approved_ideas: 5, pages_expanded: 0 and approved_ideas_not_page_bearing: 5 is telling you the approved set holds no facets — previously that was indistinguishable from a failed expansion. Always 0 on comparison and entity_review, where every approved idea is a page. Migrating: nothing to change; a new key on a result object is additive, and no existing key changed meaning.

  • ideas:approve-all no longer bulk-approves listicle ideas that cannot become pages. On a listicle the page is the facet; an idea whose idea_key, title or primary_keyword is exactly one of your scope.entities is one of the things each list ranks and never a page of its own. ideas:generate offers every scope entity back as a subject-sourced idea, and subject-sourced ideas are always high confidence — so this verb approved precisely the ideas the plan stage refuses, and the facets beside them stayed pending. On a program whose research produced no facet above the high-confidence floor that left an approved set that expands to nothing. Those entity ideas are now left pending on listicle programs, whatever their relevance_score. What is unchanged: comparison and entity_review approve exactly what they did before (every subject is a page there); on a listicle, a seed or related keyword that names no scope entity is a facet and is still approved; declined ideas are still never touched; the response shape is the same counts object. Migrating: if you relied on approve-all to approve your listicle scope entities, approve them individually with POST .../ideas/{idea_id}/approve — but note they still do not become pages, because a bare entity name is not a facet. Expect a larger pending count on listicle programs.

  • Items whose teardown this API confirmed before 2026-08-28 have stopped advertising the URL they used to live at. The 2026-08-28 clarification below — a confirmed unpublish clears published_url and moves status off published — applies to teardowns from that date onward, because the record it reads is append-only. Items torn down earlier kept the stored URL, so GET /content/{item_id} went on returning a link to a page that was gone, and re-sending "mode": "unpublish" did not repair them: with no remote document left to remove, that call is the no-op described below, which deliberately changes nothing. Those older records have now been corrected in place. Which ones changed: an item whose teardown this API confirmed (the remote document was removed, or it was already gone) and that has not been published live again since now returns published_url: null, and if its status was published it is now draft. Which ones did not: a teardown that was only a no-op, an unpublish job that failed, an item published live again after the teardown, and an item already storing no URL — all keep the published_url and status they had. Other statuses are untouched, and an item's published_at is unchanged. One side effect worth knowing: a repaired item's updated_at on GET /content/{item_id} moves to the moment of the repair, so a client that syncs on updated_at sees those items again with no user edit behind them. Migrating: nothing to change; if you mirror published_url, re-read the items you have unpublished. No response shape changed. As always, published_url and status are RoboWrite's own record rather than a live read of your CMS — see the 2026-08-27 entry on the program guarantee.

2026-08-28

  • plan:generate on a listicle program no longer loses facets to the scope entities you approved. The plan stage's 50-subject budget was counted over every approved idea, and ideas that name a scope entity are ordered first — so those ideas spent the budget even though, on listicle, only a facet becomes a page. Facets past the cut were dropped: partially once the approved set passed 50 ideas, and entirely once more than 50 of them named scope entities, in which case GET /programs/{program_id}/plan returned total: 0 for a program that had facets to plan. The budget is now counted over the 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 — the same program simply plans the pages it should have. comparison and entity_review are unaffected: their budget already counted exactly the ideas that become pages. Unchanged too: a listicle whose approved ideas are all bare scope entities still plans zero pages, because a bare entity name is not a facet.

  • plan:generate on a listicle program still plans the facets you approved after you decline the bare-entity ideas. Declining those ideas used to empty the ranked shortlist (it was drawn from approved ideas that named a scope entity), trip the per-page entity floor, and return total: 0 — discarding facets you had approved. The ranking now comes from scope.entities (at most 10, in the order you wrote the scope), including entities you declined as ideas. Only a facet becomes a page; a declined entity is still named as an also-ran on those pages. No response shape changed. A listicle whose approved ideas are all bare scope entities still plans zero pages, because a bare entity name is not a facet.

  • ideas:generate no longer treats scattered word coverage as a topic match, so ideas:approve-all approves fewer related keywords. A related keyword that merely contained every word of the scope entity that produced it used to score high enough to be auto-approved, even when each of those words belonged to a different, unrelated term. Against Venture Capital that admitted capital one venture x and working capital venture debt; against Private Equity, home equity private lender. The entity's words must now appear together and in order for that high-confidence score. Those three keywords are no longer returned. A keyword that keeps the words in order with a modifier between them — real estate investment syndication from Real Estate Syndication, angel investor investing guide from Angel Investingis still returned and stays pending. A compound-word lookalike (hedge fundraising against Hedge Fund) is dropped: character-prefix is not a mention. Migrating: nothing to change; approve any borderline idea individually if you want it. Clarified, not changed: a related keyword with no relevance_score was never auto-approved and still is not — approve-all leaves it pending.

  • Clarification — confirmed unpublish clears published_url. After POST /content/{item_id}/publish with "mode": "unpublish" reaches a successful terminal for a teardown this API confirmed (the remote document was removed, or it was already gone), GET /content/{item_id} returns published_url: null. If status was published, it becomes draft. Other statuses are unchanged. A no-op (no remote document recorded) does not change those fields. No response shape changed; this is what the stored record does after the job you already poll.

  • Copy — the API calls itself RoboWrite everywhere, and the Overview says why the hostname does not. No endpoint, parameter or response shape changed; two things a client can observe did. (1) In the OpenAPI document, the PublishPolicy schema 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. info.title is RoboWrite Public API. Regenerate your client if you surface those descriptions to anyone. (2) The two publish 409 bodies whose text names the product — draft_only property, and unpublish on an item with no publish target — changed wording for the same reason. Both detail strings are prose: match on the status code and the condition, not on the sentence. The base URL is unchanged.

2026-08-27

  • Program pages now ship their citations inside markdown_body. From this date, 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. What the body looks like now: inline [anchor](https://source…) links are kept, and a numbered ## Sources section is appended whenever the page resolved any citations (a page that resolved none still gets neither links nor a Sources block). Those rows use sanitized inline-HTML <a> anchors — see Inline citations (notation) for how to read them. Not selectable on this verb: pages:generate takes no request body, so unlike POST /content/items/{item_id}/generate there is no per-call option — citation_mode is not a knob on this path. 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 the item verb 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; superseded 2026-08-29 — omitting citation_mode on a program-owned item now keeps inline (see that entry). An explicit structured_only overrides that default. Migrating: treat a program page's markdown_body as Markdown that may contain links and sanitized inline HTML rather than as plain prose, and expect a trailing ## Sources heading. No response shape changed and no field was added or removed — ?include=sources still returns the same machine-readable citation metadata it always did, for pages generated before and after this date. Pages generated earlier keep the body they were written with; there is no backfill. That includes a page whose generation was already in flight when this shipped: a replayed start request keeps the mode it was originally dispatched with.

  • Additive — POST /content/{item_id}/publish accepts "mode": "unpublish". Tears the remote CMS document down again. Previously unpublish was 422 on this surface and teardown was dashboard-only, so an integrator who found a live page they did not publish had no API verb to reverse it. It skips both publish-readiness prerequisites, which gate creating a remote document: it is allowed on a draft_only property, and an item with no publish target returns 409 instead of provisioning your default destination. The live-item 404 and the ambiguous-target 409 still apply.

  • Clarification — the program no-auto-publish guarantee is scoped to RoboWrite. "Publishing is never automatic for program pages" bounded what this product does and was read as a promise that the pages could not go live. It cannot bind your CMS: a program run's pages went live and were indexed on a customer property about an hour after creation while this API still reported them as drafts. status and published_url are RoboWrite's own record, not a live read of your CMS — status: "draft" means we did not publish it. No response shape changed; the guarantee's wording did.

2026-08-14

  • Additive — audiences and pillar strategy fields. GET/POST /audiences and GET/PATCH/DELETE /audiences/{audience_id} manage brand audiences. POST/PATCH /pillars and pillar reads now accept and return target_audience, business_goal, and target_audience_ids. IDs must belong to the pillar's brand (422 otherwise). POST /audiences takes an optional Idempotency-Key.

  • Breaking — PATCH /content/items/{item_id}: an explicit null on slug now returns 422, and slug is published as non-nullable. An empty string returns 422 too, and slug now publishes minLength: 1. Migrating: omit the key to leave the slug unchanged, or send a replacement value — clearing was never something this endpoint could do. Sending null previously returned 200 with the slug unchanged: the schema advertised string | null and the operation description said slug "may be cleared with null", but the server put the previous slug back before the write (and derived one from the title if the row had none). An empty string took the same path, also returning 200 having changed nothing. Every item created through this API is property-bound, so those two values are exactly the two that silently did nothing on this surface. A property-less row (import / internal only — this API cannot create one) previously did store a null slug; both surfaces now refuse that write so the contract is one rule. Why it cannot be cleared: the item slug is the identity the CMS publish path and the derived published_url are keyed on; an item with no slug would publish nothing and derive no URL. published_url is unaffected and still accepts null to clear.

2026-08-13

  • PATCH /briefs/{brief_id} accepts null on secondary_keywords again. Additive — it reverses part of the breaking entry below from the same day, which had made null a 422 and left [] as the only way to empty the set. title and format are unaffected and still reject null. Why: null and [] are not redundant. A snapshot restore has to be able to put a collection back to null, and collapsing the two spellings loses that. This also realigns briefs with PATCH /pillars/{pillar_id}, where keywords has always accepted both — the two endpoints now describe collections the same way. If you already switched to [], nothing breaks: both spellings work.
  • POST /authors and PATCH /authors/{author_id} return 422 where they used to return 500. A whitespace-only name, a slug that is not ^[a-z0-9-]+$, or an email with no @ is now refused at parse with a field error. Each of these already failed — they just failed as an opaque 500, by two different routes: a blank name survived minLength (three spaces is length 3) and collapsed to NULL against a non-nullable column, while the others were rejected deeper inside the server in a way that escaped the 422 handler. No request that previously succeeded is affected. slug also now publishes its pattern, so a regenerated client can catch it before sending. Leading and trailing whitespace on name is stripped rather than rejected.
  • Breaking — PATCH /briefs/{brief_id}: an explicit null on title, format, or secondary_keywords now returns 422, and all three are published as non-nullable. The other ten fields (primary_keyword, target_word_count, audience_details, tone, search_intent, funnel_stage, pillar_id, description, summary, content_goal) still accept null to clear. Migrating: omit the key to leave the field unchanged; to empty the keyword set send [], which is now the only way to do it. Sending null previously wrote it straight through — the brief update commits an explicit null rather than rejecting it — so title and format violated their NOT NULL columns and returned 500 with the whole update rolled back, while secondary_keywords stored a SQL NULL that read back as [], indistinguishable through the API from the [] you get by clearing it properly. Note the deliberate difference from PATCH /pillars/{pillar_id}, where keywords still accepts null: a pillar's keyword list has no [] clear contract to be redundant with, whereas a brief's does, so the brief keeps one documented way to clear and the pillar keeps two.
  • POST /programs/{program_id}/ideas/{idea_id}/approve and .../decline: an unknown idea_id on a program in researching or planning returns 409, not 404. The freeze is reported ahead of whether the idea exists; a 404 is only trustworthy once the program is out of those states. Matches the already-published freeze rule; previously the handler answered 404 for this pair.
  • Breaking — PATCH /authors/{author_id}, PATCH /categories/{category_id}, PATCH /tags/{tag_id}: an explicit null on a key that backs a non-nullable column now returns 422. Affected keys: name/slug/status on authors, name/slug/sort_order/status on categories, name/slug on tags. Those keys are also published as non-nullable in the OpenAPI schema, so a regenerated client no longer offers T | null for them. Migrating: omit the key to leave the field unchanged — that is the only thing null could honestly have meant on a column that cannot hold it. Sending null previously did one of three undocumented things depending on which key you picked: name/slug on a tag or category hit a not-null violation and returned 500; sort_order on a category silently reset the row's ordering to 0 and returned 200; status on a category and name/slug/status on an author were silently ignored and returned 200. Clearing a genuinely nullable field is unchanged — email/bio/avatar_url on authors and description/parent_id/icon/color on categories still accept null. PATCH /pillars/{pillar_id} is covered by the entry below, which shipped the same day.
  • Breaking — 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; to empty a keyword list send [] or null. Sending null previously: name returned 500 and did not apply; status applied the change and then returned 500, after which GET /pillars/{pillar_id} kept failing until the row was repaired (the default list omitted that pillar); goal returned 200 with a defaulted value. goal and status reject null because both are required and non-nullable on the pillar response, so a stored null could never be read back honestly.

2026-08-11

  • Breaking — GET /content/{content_item_id}: versions is now a PublicPage envelope instead of a bare array. Read versions.items for the window, versions.total for the full count. Newest first, ?limit= 1–200 (default 50) and ?offset=. The old shape returned every version with its full body in one unbounded response, which had no page bound at all. Migrating: versions[0]versions.items[0]; a "latest version" scan is better served by ?latest_only=true.
  • next_cursor on every pagination envelope. Additive — the field is present everywhere and is non-null only where keyset cursor paging is supported and another page exists. Today that is GET /programs only; ?limit=/?offset= continue to work unchanged everywhere.
  • Published schema corrected — program approve/decline 422. These routes already returned the string-form detail when an Idempotency-Key was present, but the schema declared only the framework's field-error form. The published 422 is now oneOf both, matching what the server has always returned. No served behaviour changed; a client generated from the old schema had a type that was simply wrong.
  • PATCH /properties/{property_id} accepts publish_policy. Additive.

2026-08-10

  • Program reads preserve unknown scope keys. A program whose stored scope carries keys beyond the published ones is now returned as-is instead of failing validation. Read and create are separate models: reads allow additional properties, POST /programs still rejects unknown keys with 422. Practical consequence for a strict client — scope on a read may contain keys the schema does not name, so do not fail closed on extras.
  • PROGRAM_QUOTA_EXCEEDED is published as a program error code, raised as a preflight check before a stage verb dispatches work. Additive.

2026-08-09

  • Program stage verbs publish Idempotency-Key as required. POST /programs/{program_id}/ideas:generate, plan:generate, and pages:generate enforced the header inside the handler while publishing it as optional, so a generated client could omit a header the route then rejected. The schema now marks it required. The one observable difference: an absent key is refused by the framework, so that specific 422 carries the field-error detail form rather than the string form. A malformed key still returns the string form.

2026-08-08

  • Programs (programmatic SEO) added, over 2026-08-04 → 2026-08-08 — the /programs resource, its ideas and plan approval gates, draft_only publish policy, template families, and per-page generation and download. All additive; no existing endpoint changed shape.

2026-07-19

  • GET /account added — org, brands, quota, and overage in one bootstrap read. Additive.

2026-07-01

  • awaiting_review job status. GET /jobs/{job_id} can report awaiting_review — a stop-polling state for a topic generation that produced a draft but was routed to the dashboard review inbox (for example, no connected CMS). content_item_id and content_version_id are populated, so the parked draft is fetchable.

Notes

  • Internal billing/cost fields are never returned. Keyword and market data (search volume, difficulty, CPC) is included where relevant.
  • List endpoints are filterable where noted; filters combine (AND) and compose with pagination.
  • Generate against the published OpenAPI schema for exact field shapes — it is the source of truth and is CI-guarded against drift.