Public API · v1

Developer API

Drive the full content lifecycle programmatically — discover a brand, pick or create a topic, generate, poll, and read the draft. Every call is scoped to the organization that owns your API key.

Authenticate first

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

Authorization: Bearer YOUR_API_KEY

Base URL

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

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

Full endpoint reference

Browse every endpoint, schema, and status code in an interactive OpenAPI explorer.

Open the reference →

RoboWrite Public API — v1 Integration Guide

Status: v1, generally available. The full content lifecycle — discover → create → generate → edit → read — is callable programmatically, with uniform pagination, error, and idempotency conventions.

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). Breaking changes ship under a new version.
  • 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)

Pagination

Paginated collection endpoints return a uniform envelope:

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

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.

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:

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

Treat any non-2xx defensively and read detail.

Idempotency

Mutating endpoints accept an Idempotency-Key request header. 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. Send an Idempotency-Key on every write so a dropped connection never double-creates or double-charges.

  • It is required on POST /content/items and POST /content/rewrite; optional elsewhere. Supplying it on every write is recommended.
  • A malformed key returns 422.
  • Reusing a key with a different request body returns 409 (the key is bound to the first payload it saw).
  • For POST /content/items the dedupe is durable: a retry replays the original item even after a long delay or an infrastructure blip, so a billed create is never repeated.

Rate limits

Fixed-window per organization (default 120 requests / 60 seconds). Exceeding the limit returns 429 with a Retry-After response header (whole seconds to wait) — honor it when present before retrying, otherwise back off and retry.

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:

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

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

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

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

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. Polling every 15–30 seconds is reasonable. If a poll is rate-limited (429) or the backend is briefly unavailable (503), honor the Retry-After response header when present before the next poll.

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.

Notes:

  • A just-finished run may briefly still read running until its completion callback lands — poll until the status is terminal or awaiting_review rather than trusting a single non-terminal read.
  • Under rare concurrent-submit timing you may receive 202 with status: "queued" and an empty workflow_id. This is normal — the job exists; just poll it.
  • GET /content/{id}?include=scoring,sources fields are populated together per version, but scoring is only written once self-scoring completes — a version fetched before its generation run finishes may show sources populated with scoring: null briefly. Poll the job to a terminal/awaiting_review status before treating a missing scoring field as an error.
  • On GET /content/{content_item_id}, each version's seo_description now falls back to the version's meta description when the dedicated SEO-description value is empty. Generated (non-imported) drafts store the SEO summary as the meta description, so seo_description is populated for those versions where it was previously empty — treat a non-empty value as authoritative and prefer it over parsing the body.

All timestamps are UTC ISO-8601.


Endpoint reference

Organization settings

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

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

PATCH /org/settings

{
  "regulated_industries": ["finance"]
}

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

Brands

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

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

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

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

Properties

MethodPathPurpose
GET/propertiesList properties
GET/properties/{property_id}Get a property
POST/propertiesCreate a property

Topics

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

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

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

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

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

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

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

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

POST /brands/{brand_id}/topics

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

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

Briefs

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

Content items

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

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

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

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

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

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

Generation & jobs

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

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

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

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

POST /brands/{brand_id}/topics/rescore

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

Returns 202 Accepted with { "job_id": "…", "status": "queued" } (no workflow_id — this job runs on the API, not Temporal). Poll GET /jobs/{job_id} until terminal. For rescore jobs only, the polled job includes a result summary: { "total", "rescored", "eligible_before", "eligible_after", "band_counts", "truncated" }. Other job types keep result: null. A foreign or unknown brand_id returns 404. Omitting property_id returns 422. In-flight rescores for the same brand reuse the existing job_id instead of stacking duplicates.

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

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

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

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

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

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

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

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

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

Latency. The rewrite pipeline analyzes your paste, materializes a brief, and runs the full brand-voice rewrite engine — expect timing similar to other generation jobs (typically 10–17 minutes; poll every 15–30 seconds and allow at least 30 minutes before treating a run as stalled).

Pillar strategy (clusters-first engine)

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

Request body (POST /pillar-strategies):

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

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

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

  • 200 + completed snapshot (clusters, pillars, gaps, warnings) when status is completed
  • 200 + { "status": "failed", "error": { "detail": "…" } } on failure
  • 200 + { "status": "cancelled", "error": { "detail": "…" } } when cancelled
  • 200 + { "status": "generating" } while the run is in flight

Poll every 15–30 seconds. Strategy generation is LLM-backed and typically takes several minutes; treat anything under 15 minutes as normal.

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.

Taxonomy (full CRUD)

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

Taxonomy create errors. POST /categories, POST /tags, and POST /pillars 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.

Documents & imported content (read)

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

Health

MethodPathPurpose
GET/pingLiveness check

Typical end-to-end workflow

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

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

Request the cited sources for a content item:

GET /content/{content_item_id}?include=sources
{
  "id": "…", "title": "…", "status": "draft", "brand_id": "…",
  "versions": [
    {
      "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" }
      ]
    }
  ]
}

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

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

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

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

Request the compliance findings for a content item:

GET /content/{content_item_id}?include=compliance
{
  "id": "…", "title": "…", "status": "draft", "brand_id": "…",
  "versions": [
    {
      "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 }
      ]
    }
  ]
}

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 nullstructured_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

  1. The content item exists, belongs to the API-key org, and is not soft-deleted (otherwise 404).
  2. A resolvable CMS destination:
    • Exactly one existing publish target on the item → use it.
    • No target, but a valid human-selected default destination is set under Organization settings (and matches a valid publish profile) → provision a target for the item and publish to it.
    • No target and no valid default409 (set a default destination under Organization settings, or map the item to a CMS collection in the editor).
    • More than one target409 (destination is ambiguous; keep a single active target, then retry). This surface does not accept a target_id and will not pick a CMS for you.

Body

POST /content/{item_id}/publish

{
  "mode": "live",                 // required: "draft" | "live" | "schedule"
  "schedule_at": "2026-08-01T15:00:00Z"  // required only when mode=schedule; must be timezone-aware and in the future
}
  • draft — push as a CMS draft.
  • live — publish immediately on the remote.
  • schedule — publish at schedule_at (required, future, timezone-aware). Sending schedule_at with draft or live is 422.
  • unpublish is not accepted on the public surface (422).
  • Unknown keys (including a client-supplied triggered_by) are 422.

Response202 Accepted:

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

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

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


Not in v1 yet (roadmap)

These are deliberately out of v1 — design accordingly:

  • 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.
  • 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.

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.