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
Full endpoint reference
Browse every endpoint, schema, and status code in an interactive OpenAPI explorer generated from the published contract.
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://<your-host>/api/public/v1 - 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
mainbranch 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.
| Code | Meaning |
|---|---|
400 / 422 | Invalid or malformed input |
401 | Missing/invalid API key |
404 | Resource not found, or not owned by your org |
409 | Conflict — e.g. the resource is in the wrong state for the operation |
429 | Rate limit exceeded — honor the Retry-After response header when present, then retry |
503 | Backend 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.
| Rule | What it means | Routes |
|---|---|---|
| Required | Absent 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 accepted | Present 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:
| condition | POST /programs… routes | L1 spine routes listed below |
|---|---|---|
| payload mismatch | This Idempotency-Key was already used with a different request payload. | Idempotency-Key reused with a different request payload |
| in flight | A request with this Idempotency-Key is in progress. Retry the same key. | A request with this Idempotency-Key is in progress |
| unreadable record | Idempotency 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/rewriteis the exception: it fails closed with503+Retry-Afterrather than risk an undeduplicated billed rewrite. - For
POST /content/itemsthe 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/generatededupes pertopic_idwhether or not you send a key: while a run for that topic is still active you get the originaljob_idback. Once that job is terminal (or has beenrunningwithout progress for a long time), the same request — and the sameIdempotency-Key— starts a fresh, separately charged attempt. See Re-running after failure for the full rule.POST /content/items/{item_id}/generatededupes on the item and the key together, and supplies its own key when you omit one — echoed back in theIdempotency-Keyresponse 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-Afteris the time left in the current bucket, so it shrinks as the window ages — a429early 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
429with{"detail": "Daily content-item create limit exceeded"}. Unlike the per-minute limit, this429carries noRetry-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-Keyreplay returns the original item for free, and a request that fails validation (404on the brief/property,422on 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 returns429on 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_idon the202depends on the kickoff path.POST /content/rewritereturns{ "job_id", "content_item_id", "status" }on the202because the source item is created synchronously before dispatch. Other generation paths (POST /content/generate,POST /content/items/{item_id}/generate) return onlyjob_id/workflow_id/statuson the202— readcontent_item_idfrom the polled job once it is populated, typically by the time the job reaches a terminal (orawaiting_review) status.content_version_ididentifies the generated draft version and is populated alongsidecontent_item_id.errorcarries the human-readable failure reason onfailed/completed_with_errors.error_typecarries a machine-readable failure classification alongsideerror, 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).nullfor 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 RoboAd 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
Njobs everyTseconds costs60 × N ÷ Trequests per minute. Keep that under half the window — leaving the other half for the reads and writes the polling exists to serve — which meansT ≥ Nseconds, and never below 30.
| Jobs in flight | Minimum interval | Polling cost |
|---|---|---|
| 1–30 | 30 s | ≤ 60 req/min |
| 60 | 60 s | 60 req/min |
| 300 | 5 min | 60 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]. With the default structured_only mode (omit citation_mode), links are stripped from the body — verify citations via GET /content/{id}?include=sources instead.
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
runninguntil its completion callback lands — poll until the status is terminal orawaiting_reviewrather than trusting a single non-terminal read. - Under rare concurrent-submit timing you may receive
202withstatus: "queued"and an emptyworkflow_id. This is normal — the job exists; just poll it. GET /content/{id}?include=scoring,sourcesfields are populated together per version, but scoring is only written once self-scoring completes — a version fetched before its generation run finishes may showsourcespopulated withscoring: nullbriefly. Poll the job to a terminal/awaiting_reviewstatus before treating a missingscoringfield as an error.- On
GET /content/{content_item_id}, each version'sseo_descriptionandseo_titleresolve 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 asmeta_description, soseo_descriptionis populated where it was previously empty. Title: the same fallback applies whenmeta_titleor metadata carries a title (imported/legacy rows); native generation today often leaves bothseo_titleandmeta_titleempty, soseo_titlemay still be null whileversions.items[].titleholds the page title — treat a non-emptyseo_titleas authoritative SEO override and prefer it over parsing the body.
All timestamps are UTC ISO-8601.
Endpoint reference
Account
| Method | Path | Purpose |
|---|---|---|
| GET | /account | Bootstrap: 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 is404.brands— slim refs (id,name,status) for brands the key can use (the whole org). Capped at 200; useGET /brandsto page further. Full brand/voice detail remains onGET /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). Whenremainingis0butoverage_enabledistrueandis_blockedisfalse, 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
| Method | Path | Purpose |
|---|---|---|
| GET | /org/settings | Read organization-level generation settings |
| PATCH | /org/settings | Update 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
| Method | Path | Purpose |
|---|---|---|
| GET | /brands | List brands |
| GET | /brands/{brand_id} | Get a brand |
| PATCH | /brands/{brand_id} | Update a brand's voice fields (partial) |
| GET | /brands/{brand_id}/keywords | Keyword inventory for a brand, including market data (search volume, difficulty, CPC). Does not use the standard list envelope — see Pagination below. |
Update brand voice — PATCH /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
| Method | Path | Purpose |
|---|---|---|
| GET | /properties | List properties |
| GET | /properties/{property_id} | Get a property |
| POST | /properties | Create 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 inawaiting_reviewand must be resolved from the dashboard;POST /content/{item_id}/publishworks normally once a destination is resolvable. Topic autopilot is the only path that auto-publishes. The program cycle does not readpublish_policy— a programmatic-SEO program page is never published for you under either policy (see Programs (programmatic SEO)), so you callPOST /content/{item_id}/publishfor it yourself.draft_only— RoboAd publishes nowhere. Generation runs finish ascompletedand you collect the page withGET /content/{content_item_id}.POST /content/{item_id}/publishfor an item on such a property returns409with a policy-specificdetail.
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 property — PATCH /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
| Method | Path | Purpose |
|---|---|---|
| GET | /audiences?brand_id= | List audiences for a brand (PublicPage) |
| GET | /audiences/{audience_id} | Get an audience |
| POST | /audiences | Create 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
| Method | Path | Purpose |
|---|---|---|
| GET | /topics | List topics — ?brand_id= is required; omitting it returns 422 |
| GET | /topics/{topic_id} | Get a topic |
| GET | /topics/{topic_id}/content | List content generated from a topic |
| GET | /topics/{topic_id}/scores | Cached opportunity scores for a topic, including market metrics |
| POST | /brands/{brand_id}/topics | Create a topic from your own input |
| POST | /brands/{brand_id}/topics/generate | Generate fresh topics for a brand (async) → 202 + job |
| POST | /brands/{brand_id}/topics/rescore | Re-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 topics — POST /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 topic — POST /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
| Method | Path | Purpose |
|---|---|---|
| POST | /briefs | Create a brief |
| GET | /briefs | List briefs |
| GET | /briefs/{brief_id} | Get a brief |
| GET | /briefs/{brief_id}/sections | Get 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
| Method | Path | Purpose |
|---|---|---|
| POST | /content/items | Create a content item (Idempotency-Key required) |
| GET | /content/items | List content items in the create/update shape (filters: brief_id, property_id, status) |
| GET | /content | List 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 is never empty, and cannot be cleared. Every item created through this API is bound to a property (property_id is required on POST /content/items), and a property-bound item always carries a slug — it is the identity the CMS publish path and the derived published_url above are keyed on. So slug on PATCH /content/items/{item_id} takes a new value or is omitted; null and "" both return 422. Send a replacement rather than a clear. Values are normalised to a URL token (lowercased, non-alphanumerics collapsed to -) and made unique within the property, so the slug you read back may differ from the one you sent — read it from the response.
Create a content item — POST /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
| Method | Path | Purpose |
|---|---|---|
| POST | /content/generate | Generate from a topic (autopilot) → 202 + job. Body is { "topic_id": "<uuid>" } only |
| POST | /content/items/{item_id}/generate | Generate / rewrite / vary an existing item (supports a direction: draft, rewrite, variation; and citation_mode: structured_only / inline) → 202 + job |
| POST | /content/rewrite | Rewrite pasted Markdown to brand voice → 202 + job + content_item_id (Idempotency-Key required) |
| POST | /content/{item_id}/publish | Publish 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 Markdown — POST /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 exactlydocuments.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 emptyidslist withmode: "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)
| Method | Path | Purpose |
|---|---|---|
| POST | /pillar-strategies | Generate 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) whenstatusiscompleted200+{ "status": "failed", "error": { "detail": "…" } }on failure200+{ "status": "cancelled", "error": { "detail": "…" } }when cancelled200+{ "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)
| Method | Path | Purpose |
|---|---|---|
| POST | /programs | Create a program from a brand, property, template family, scope, and a bulk set of seeds |
| GET | /programs | List 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 program — POST /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 draft → researching → ideas_ready → planning → plan_ready
→ generating → pages_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
| Method | Path | Purpose |
|---|---|---|
| POST | /programs/{program_id}/ideas:generate | Research the program's seeds and scope and propose ideas |
| GET | /programs/{program_id}/ideas | List the ideas, optionally filtered by approval state |
| POST | /programs/{program_id}/ideas | Add an idea of your own |
| POST | /programs/{program_id}/ideas/{idea_id}/approve | Approve one idea |
| POST | /programs/{program_id}/ideas/{idea_id}/decline | Decline one idea |
| POST | /programs/{program_id}/ideas:approve-all | Approve 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 ideas — POST /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 ideas — GET /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 idea — POST /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 decline — POST .../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 pending — POST /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. 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.
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
| Method | Path | Purpose |
|---|---|---|
| POST | /programs/{program_id}/plan:generate | Expand the template over your approved ideas into a page list |
| GET | /programs/{program_id}/plan | List the planned pages, optionally filtered by approval state |
| POST | /programs/{program_id}/plan | Add a page of your own |
| POST | /programs/{program_id}/plan/{page_id}/approve | Approve one page |
| POST | /programs/{program_id}/plan/{page_id}/decline | Decline one page |
| POST | /programs/{program_id}/plan:approve-all | Approve 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 plan — POST /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,
"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); 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
declinedor still-pendingidea never produces a page. Approve at least one idea first — with nothing approved,:generatereturns409. - 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. The resulting plan
totalonGET /programs/{program_id}/planis the number that was written — if you expected more pages, check whether either ceiling bit. Templateentities_per_page_max: nullmeans the family does not impose a per-page entity ceiling; it does not mean plan expansion is unbounded.
Read the plan — GET /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 page — POST /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 decline — POST .../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 pending — POST /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
| Method | Path | Purpose |
|---|---|---|
| POST | /programs/{program_id}/pages:generate | Write the pages you approved |
| GET | /programs/{program_id}/pages | List every page and how far it has got |
Generate — POST /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
}
}
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). Until
the run finishes, result is an empty object.
- Only pages you approved are written. A
declinedpage and a page you have not decided on both produce nothing. Approve at least one page first — with nothing approved,:generatereturns409. - 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 get404/409even 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:generatereturn429with{"detail": "The monthly allowance for this resource is exhausted."}before the program entersgenerating. CheckGET /accountfor remaining allowance. If allowance runs out mid-run, remaining pages are counted inresult.pages_quota_exceededrather than a generic failure counter. - Re-running is safe. A page that already has a live
content_item_idis 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
generatingreturns409.
Read the pages — GET /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:
| Value | Meaning |
|---|---|
not_approved | You have not approved this page, so it will not generate |
awaiting_generation | Approved and queued |
generating | Being written |
ready | Finished — download it |
content_deleted | You 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 page — GET /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
Publishing is never automatic for program pages. 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 it is live anywhere.
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)
| Resource | Paths |
|---|---|
| Pillars | GET/POST /pillars · GET/PATCH/DELETE /pillars/{pillar_id} |
| Audiences | GET/POST /audiences · GET/PATCH/DELETE /audiences/{audience_id} |
| Authors | GET/POST /authors · GET/PATCH/DELETE /authors/{author_id} |
| Categories | GET/POST /categories · GET/PATCH/DELETE /categories/{category_id} |
| Tags | GET/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.
| Resource | null clears | null → 422 |
|---|---|---|
| Authors | email, bio, avatar_url | name, slug, status |
| Categories | description, parent_id, icon, color | name, slug, sort_order, status |
| Pillars | description, keywords, target_audience, business_goal, target_audience_ids | name, goal, status |
| Audiences | description, decision_role, sophistication_level, technical_depth, goals, pain_points | name, 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)
| Method | Path | Purpose |
|---|---|---|
| GET | /documents | List the document library (filter by category, status, q) |
| GET | /documents/{document_id} | Get document metadata and summary |
| GET | /imported-content | List 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-contentendpoints simply start returning the imported items — no request or response contract change.
Programmatic-SEO templates
| Method | Path | Purpose |
|---|---|---|
| GET | /program-templates | List 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:
family | What it produces | expansion_unit | entities per page |
|---|---|---|---|
comparison | One page per combination of in-scope entities, lined up against a shared set of attributes | entity_pair | 2 → unbounded |
listicle | One page per facet, ranking a selection of in-scope entities against a stated criterion | entity_facet | 2 → unbounded |
entity_review | One page per in-scope entity, evaluated against a stated rubric and ending in a verdict | single_entity | exactly 1 |
Each entry also carries display_name, summary, and
required_generator_inputs — the structured inputs a page of that family cannot
be generated without (for example a listicle needs a facet_definition and a
ranking_criterion; a comparison needs an attribute_matrix).
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
| Method | Path | Purpose |
|---|---|---|
| GET | /ping | Liveness check |
Typical end-to-end workflow
- Discover —
GET /brandsto pick a brand; optionallyGET /brands/{brand_id}/keywordsfor opportunities. - Choose a topic —
POST /brands/{brand_id}/topics(your own input) orGET /topics. - Shape a brief —
POST /briefs(angle, audience, keywords). Refine later withPATCH /briefs/{brief_id}. - Create & generate —
POST /content/items, thenPOST /content/items/{item_id}/generate(orPOST /content/generatefrom a topic, orPOST /content/rewriteto rewrite pasted Markdown). Send anIdempotency-Key. - Poll —
GET /jobs/{job_id}until the status is terminal. - Read & refine —
GET /content/{content_item_id}(?include=scoringfor quality metrics,?include=sourcesfor the research sources cited in each version,?include=compliancefor 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.
- 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 withpublish_policy: "draft_only"(see Properties).
Cited sources.
?include=sourcesreturns, per version, the research sources actually cited in the generated body assources: [{ marker, source_name, source_url }](ordered by first appearance). It is opt-in and additive: withoutinclude=sources,sourcesisnull; withinclude=sources,sourcesis[]when a version cited nothing or was generated before source capture was available (no backfill). To control whether the published body shows those citations, passcitation_modeon generate:structured_only(default — clean prose) orinline(keeps inline links + a Sources section). The capturedsourcesmetadata 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=compliancereturns, per version, the regulated-content policy findings recorded when the org configures regulated industries (finance / healthcare / legal), ascompliance: [{ kind, message, excerpt }].kindis a stable enum —unsourced_figure,advice_tone,off_brand_promotion, ormissing_disclaimer— so you can branch on it rather than parsemessage;excerptis the offending body text for text findings andnullfor structural findings (missing_disclaimer). It is opt-in and additive: withoutinclude=compliance,complianceisnull; withinclude=compliance,complianceis[]for an unregulated org or a clean regulated draft. Findings reflect the shipped body (the generator clearsmissing_disclaimeronce 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 (omit or send null → structured_only, the default clean-prose body). 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
- The content item exists, belongs to the API-key org, and is not soft-deleted (otherwise
404). - The item's property is not
draft_only. Adraft_onlyproperty means RoboAd publishes nowhere for it, so this endpoint returns409before touching any destination — collect the page withGET /content/{content_item_id}instead, or switch the property tocms_publish(see Properties). An item with no property is unaffected by this check. - 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 default →
409(set a default destination under Organization settings, or map the item to a CMS collection in the editor). - More than one target →
409(destination is ambiguous; keep a single active target, then retry). This surface does not accept atarget_idand will not pick a CMS for you.
Body
POST /content/{item_id}/publish
{
"mode": "live", // required: "draft" | "live" | "schedule"
"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 atschedule_at(required, future, timezone-aware). Sendingschedule_atwithdraftorliveis422.unpublishis not accepted on the public surface (422).- Unknown keys (including a client-supplied
triggered_by) are422.
Response — 202 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 page —
PATCH /programs/{program_id}/ideas/{idea_id}andPATCH /programs/{program_id}/plan/{page_id}are reserved paths that always return501. 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).
- Public unpublish — tearing down a remote CMS document is not exposed; use the dashboard.
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-14
-
Additive — audiences and pillar strategy fields.
GET/POST/audiencesandGET/PATCH/DELETE/audiences/{audience_id}manage brand audiences.POST/PATCH/pillarsand pillar reads now accept and returntarget_audience,business_goal, andtarget_audience_ids. IDs must belong to the pillar's brand (422otherwise).POST /audiencestakes an optionalIdempotency-Key. -
Breaking —
PATCH /content/items/{item_id}: an explicitnullonslugnow returns422, andslugis published as non-nullable. An empty string returns422too, andslugnow publishesminLength: 1. Migrating: omit the key to leave the slug unchanged, or send a replacement value — clearing was never something this endpoint could do. Sendingnullpreviously returned200with the slug unchanged: the schema advertisedstring | nulland the operation description saidslug"may be cleared withnull", 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 returning200having 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 derivedpublished_urlare keyed on; an item with no slug would publish nothing and derive no URL.published_urlis unaffected and still acceptsnullto clear.
2026-08-13
PATCH /briefs/{brief_id}acceptsnullonsecondary_keywordsagain. Additive — it reverses part of the breaking entry below from the same day, which had madenulla422and left[]as the only way to empty the set.titleandformatare unaffected and still rejectnull. Why:nulland[]are not redundant. A snapshot restore has to be able to put a collection back tonull, and collapsing the two spellings loses that. This also realigns briefs withPATCH /pillars/{pillar_id}, wherekeywordshas always accepted both — the two endpoints now describe collections the same way. If you already switched to[], nothing breaks: both spellings work.POST /authorsandPATCH /authors/{author_id}return422where they used to return500. A whitespace-onlyname, aslugthat is not^[a-z0-9-]+$, or anemailwith no@is now refused at parse with a field error. Each of these already failed — they just failed as an opaque500, by two different routes: a blanknamesurvivedminLength(three spaces is length 3) and collapsed toNULLagainst a non-nullable column, while the others were rejected deeper inside the server in a way that escaped the422handler. No request that previously succeeded is affected.slugalso now publishes itspattern, so a regenerated client can catch it before sending. Leading and trailing whitespace onnameis stripped rather than rejected.- Breaking —
PATCH /briefs/{brief_id}: an explicitnullontitle,format, orsecondary_keywordsnow returns422, 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 acceptnullto 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. Sendingnullpreviously wrote it straight through — the brief update commits an explicitnullrather than rejecting it — sotitleandformatviolated their NOT NULL columns and returned500with the whole update rolled back, whilesecondary_keywordsstored a SQLNULLthat read back as[], indistinguishable through the API from the[]you get by clearing it properly. Note the deliberate difference fromPATCH /pillars/{pillar_id}, wherekeywordsstill acceptsnull: 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}/approveand.../decline: an unknownidea_idon a program inresearchingorplanningreturns409, not404. The freeze is reported ahead of whether the idea exists; a404is only trustworthy once the program is out of those states. Matches the already-published freeze rule; previously the handler answered404for this pair.- Breaking —
PATCH /authors/{author_id},PATCH /categories/{category_id},PATCH /tags/{tag_id}: an explicitnullon a key that backs a non-nullable column now returns422. Affected keys:name/slug/statuson authors,name/slug/sort_order/statuson categories,name/slugon tags. Those keys are also published as non-nullable in the OpenAPI schema, so a regenerated client no longer offersT | nullfor them. Migrating: omit the key to leave the field unchanged — that is the only thingnullcould honestly have meant on a column that cannot hold it. Sendingnullpreviously did one of three undocumented things depending on which key you picked:name/slugon a tag or category hit a not-null violation and returned500;sort_orderon a category silently reset the row's ordering to0and returned200;statuson a category andname/slug/statuson an author were silently ignored and returned200. Clearing a genuinely nullable field is unchanged —email/bio/avatar_urlon authors anddescription/parent_id/icon/coloron categories still acceptnull.PATCH /pillars/{pillar_id}is covered by the entry below, which shipped the same day. - Breaking —
PATCH /pillars/{pillar_id}: an explicitnullonname,goal, orstatusnow returns422, and all three are published as non-nullable.descriptionandkeywordsstill acceptnullto clear. Migrating: omit the key to leave the field unchanged; to empty a keyword list send[]ornull. Sendingnullpreviously:namereturned500and did not apply;statusapplied the change and then returned500, after whichGET /pillars/{pillar_id}kept failing until the row was repaired (the default list omitted that pillar);goalreturned200with a defaulted value.goalandstatusrejectnullbecause both are required and non-nullable on the pillar response, so a storednullcould never be read back honestly.
2026-08-11
- Breaking —
GET /content/{content_item_id}:versionsis now aPublicPageenvelope instead of a bare array. Readversions.itemsfor the window,versions.totalfor 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_cursoron 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 isGET /programsonly;?limit=/?offset=continue to work unchanged everywhere.- Published schema corrected — program approve/decline
422. These routes already returned the string-formdetailwhen anIdempotency-Keywas present, but the schema declared only the framework's field-error form. The published422is nowoneOfboth, 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}acceptspublish_policy. Additive.
2026-08-10
- Program reads preserve unknown
scopekeys. A program whose storedscopecarries 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 /programsstill rejects unknown keys with 422. Practical consequence for a strict client —scopeon a read may contain keys the schema does not name, so do not fail closed on extras. PROGRAM_QUOTA_EXCEEDEDis 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-Keyas required.POST /programs/{program_id}/ideas:generate,plan:generate, andpages:generateenforced 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 specific422carries the field-errordetailform 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
/programsresource, its ideas and plan approval gates,draft_onlypublish policy, template families, and per-page generation and download. All additive; no existing endpoint changed shape.
2026-07-19
GET /accountadded — org, brands, quota, and overage in one bootstrap read. Additive.
2026-07-01
awaiting_reviewjob status.GET /jobs/{job_id}can reportawaiting_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_idandcontent_version_idare 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.
