Skip to main content

List Content

/marketing/content
List all content items for the current organization.
Response
The grounding. Every row carries the five fields that say who the piece is for, avatar_id, journey_id, journey_stage, before_after_grid_id and target_keyword (each present, null when unset; target_keyword reads settings.target_keyword on rows written before 2026-09-12), plus customer_avatar, customer_journey and before_after_grid as { id, name } or null. The same keys come back from every create, get and update below. Filters (Olympus). GET /api/olympus/marketing/content under an API key also filters on avatar_id, journey_id and before_after_grid_id (exact; a value that is not a UUID is a 400) and journey_stage (case-insensitive exact match on the stage name), beside status, content_type, category_id, tags and search. The content_list MCP tool takes the same. See Who this is for. Newest published first (Olympus). published_since (an ISO 8601 instant; only items whose published_at is at or after it; a value that is not a date is a 400) and sort (updated, the default, by updated_at descending; published, by published_at descending with never-published rows last; or refresh_priority, by refresh_priority descending with items that never decayed last, then updated_at descending; any other value is a 400 sort must be one of: updated, published, refresh_priority.) join the filters, so status=published&published_since=<a week ago>&sort=published is everything that went live since the last digest, newest first. The content_list tool and the List Marketing Content workflow node take the same two (the node also takes published_within_days; the node’s sort stays updated or published). The refresh queue in one call (2026-09-12). decay_status filters on one of three states, never a raw status: decaying (the column is set and not one of resolved, refreshed, ignored, dismissed, recovered), recovered (one of those five) or none (the page never decayed). A raw status such as detected, or any other value, is a 400 decay_status must be one of: decaying, recovered, none. before any read; blank is no filter. So status=published&decay_status=decaying&sort=refresh_priority (content_list({ status: "published", decay_status: "decaying", sort: "refresh_priority" })) is the whole refresh queue, and the count and the pagination use the same predicate, so page 2 is the same queue. The dashboard list (GET /api/marketing/content) takes the same decay_status and sort values; all is its no-filter value. Before this date the queue was the published list filtered client-side. The elite fields (2026-09-12). Every row also carries author_id with author { id, name }, faq (always an array), answer_block, search_intent, page_type, review_disposition, topic_cluster_id with topic_cluster { id, pillar_keyword }, cluster_role, page_role, lead_magnet, offer_id, and the six columns other writers stamp: serp_brief and serp_brief_captured_at (the SERP brief), decay_status, refresh_priority and top_declining_keywords (the Sunday decay run, see Refresh Brief) and refreshed_at (the publish path). Every key is present and null when unset (faq and top_declining_keywords are []). The Olympus list adds six filters: author_id and topic_cluster_id (UUIDs; malformed is a 400), search_intent (informational, commercial, transactional, navigational), review_disposition (double_down, refresh, rewrite, consolidate, prune), cluster_role (pillar, spoke) and page_role (money, pillar, support, utility); a value outside a vocabulary is a 400. The dashboard list takes the same six and accepts all as the no-filter value. content_type now also accepts comparison, alternatives and research. The list returns serp_brief in full per row.

Create Content

/marketing/content
Create a new content item.
Request Body
The grounding on create. avatar_id, journey_id and before_after_grid_id must name an avatar, a customer journey map and a before/after grid in this account (or be null); journey_stage is the stage name as the journey map spells it (up to 255 characters); target_keyword is up to 255 characters and lands on the target_keyword column, mirrored into settings.target_keyword. The Olympus POST /api/olympus/marketing/content and the content_create MCP tool take the same five. The eleven elite fields (2026-09-12). Accepted on create and update, on the Olympus routes, the dashboard routes and the content_create / content_update tools; null clears each. An id outside the account, a malformed id or an unknown offer is 400 invalid_reference naming the field (and the tool that lists valid ids); a value outside a vocabulary is a 400 naming the list; nothing is written on either. When target_keyword is sent and another item in the account (status not archived or deleted) already targets the same keyword, case-insensitively, the 201 carries warnings[] with one keyword_already_targeted: ... line and keyword_conflicts[] with { code, content_item_id, title, slug, status, target_keyword }; the item is still created, and a writer relays the warning and offers to consolidate rather than retrying. On the Olympus route the Idempotency-Key header is honoured: a retry after a timed-out call replays the first answer (the 201, or the 4xx it got) for an hour instead of creating a second draft.

Get Content

/marketing/content/{id}
Get a specific content item by ID. The item carries the five grounding fields and the customer_avatar, customer_journey and before_after_grid objects described under List Content; the Olympus GET /api/olympus/marketing/content/:contentId and content_get add url, url_source and scheduled_publish.

Update Content

/marketing/content/{id}
Update a content item. The body is the whole item as the editor sends it.
The grounding on update. avatar_id, journey_id, journey_stage, before_after_grid_id and target_keyword are accepted with the same rules as on create; null clears a field, and a key that is absent leaves its column alone. The three ids are checked against the account before any field is written, so a 400 invalid_reference writes nothing. settings is merged into the stored object, never replaced, so keys another writer added (linked tasks, the published route) survive an autosave; a keyword typed into settings.target_keyword wins over the echoed top-level target_keyword, and both end up equal. The Olympus PATCH /api/olympus/marketing/content/:contentId and the content_update MCP tool take the same five, with the top-level target_keyword winning there. The distribution plan. settings.distribution_plan is the piece’s channel plan, written at brief time and updated as derivatives are made: { owned_first: true, channels: [{ channel, format, status, scheduled_for?, link?, note? }], written_at }, where channel is email_digest, social, community, partner, outreach or paid, status is planned, drafted, scheduled, done or skipped, and link names the derivative (social_post:<id>, campaign:<id>, task:<id> or a URL). At most 12 rows. Because settings merges, a writer sends settings: { distribution_plan } alone and every sibling key survives; a malformed plan is read as no plan. The same key is accepted on create, on the Olympus routes and by content_create and content_update. See The distribution plan on the row. The elite fields on update. The eleven fields above are accepted with the same rules; a key that is absent leaves its column alone. The six read-only columns (serp_brief, serp_brief_captured_at, decay_status, refresh_priority, top_declining_keywords, refreshed_at) are 400 read_only_field unless the value sent is exactly what the row holds: the editor echoes the whole row on every autosave and an agent that PATCHes a whole GET response back is echoing, and both are accepted (dates compare by instant, JSON by value with object keys in any order). The same echo rule skips the lookup for an unchanged author_id, topic_cluster_id, offer_id or lead_magnet. A keyword collision comes back as the same warnings[] and keyword_conflicts[] as on create, never a block; the dashboard PUT answers { contentItem, publishResult, publishError, warnings?, keyword_conflicts?, scheduled_publish? } and the Olympus PATCH { data, warnings?, keyword_conflicts?, scheduled_publish? }. Settings keys the round-B writers keep on the item, all merged: settings.serp_brief (the brief’s 30-day stamp), settings.title_candidates, settings.hook_pattern, settings.title_picked and settings.title_results (write hook_pattern yourself; never the other three), settings.json_ld and settings.json_ld_generated_at (the last publish), settings.comparison_table, settings.decision_cta, settings.methodology, settings.dataset (the bottom-funnel blocks), settings.cta_stage_table (the check’s stage table), settings.bofu (a seeded draft’s plan facts) and settings.decay_episode (the decay run’s claim; never clear it). Scheduling. A scheduled_publish_at (ISO 8601, in the future) whose instant differs from the stored value is a schedule request and puts the item’s site entry on the CMS scheduler; the value the row already holds is ignored as an autosave echo, and null never cancels. Send cancel_scheduled_publish: true to cancel. deploy_target is production (default), staging, dev, none or publish (Webflow). See Scheduling and the Content Calendar.

Pre-Publish SEO Check

/marketing/content/{id}/seo-check
Run the on-page SEO check on the stored item. Requires marketing.content read.
Response
level is error or warn; field names the column to fix (title, slug, meta_title, meta_description, content, featured_image_alt, target_keyword, author_id, answer_block or faq). ok is false only on errors; score is 100 minus 15 per error and 5 per warning. The same check is served under an Olympus API key at GET /api/olympus/marketing/content/{id}/seo-check (the content_seo_check MCP tool), and POST /api/olympus/marketing/content/{id}/publish-to-site returns it as warnings and seo_check. Since 2026-09-12 the rule ids also include author_missing (error), answer_block_missing (warn), faq_schema_mismatch (warn), title_generic (warn), money_link_missing (error at consideration and decision), pillar_link_missing (error), keyword_already_targeted (warn), comparison_table_missing (error), competitor_claim_unsourced (error), cta_missing (warn) and cta_stage_mismatch (warn); stats adds answer_block_words, faq_count, faq_in_body, title_generic, cta_count, money_link_count, pillar_linked, table_count and table_rows_unsourced; and context echoes journey_stage, content_type, author, answer_block, faq, money_page_urls, cluster, keyword_collision, comparison_table, offer and cta_stage_table for a local re-run. Every check is listed on Pre-Publish SEO Check.

Content Calendar

/marketing/content/calendar
Everything scheduled, published, failed or recorded in a window. Query from and to (ISO 8601 or YYYY-MM-DD), each defaulting to 45 days from now; a window wider than 190 days is a 400.
Response
kind is scheduled, published, failed or recorded_only; executes is false only for recorded_only, a date stored on the item that nothing publishes. The cadence counts are account-wide. See Scheduling and the Content Calendar.
/olympus/marketing/content/site-links
Olympus API key (the content_site_links MCP tool). Every published post and page on one website project with its live URL, for internal linking, ordered for the site’s architecture: money pages first, then the pillar of the item being written, then the other pillars, then the rest by recency. Query project_id (required, a website project UUID in the account), content_id (the item being written, a UUID in the account; 400 invalid_content_id when malformed, a note when unknown) and limit (default and maximum 500).
Returns { data: [{ id, title, url, type, published_at, slug, source, role, suggested_anchor, is_pillar_for_item }], total, capped, project: { id, name, host }, posts: { listed, without_url }, pages: { listed }, roles: { money, pillar }, item: { id, topic_cluster_id, cluster_role } | null, pillar: { id, title, url, cluster: { id, name } } | null, notes }. id is the content item UUID, or page:<route>, crawl:<url> or pillar:<url> for a pillar URL nothing else lists; type is post or page; source is content_item, page_list or crawl; role is the stored page role or null; suggested_anchor is the target’s keyword, else its title without a brand suffix; is_pillar_for_item marks the item’s own pillar. The item named by content_id is never listed, and when it is itself its cluster’s pillar the response has pillar: null and a note. notes explains anything missing (no money page marked, no pillar found). 400 project_id_required and 404 project_not_found are the errors. The Olympus content list and get also carry url (and get carries url_source) per item. See Links that build the architecture.

Article Images

/marketing/content/{id}/images
Preview an article images run: no model call, no spend. Requires marketing.content read. The Olympus twin is GET /api/olympus/marketing/content/{id}/images under an API key.
Response
sections is every H2 in the body with whether an image already sits directly below it; candidates counts the ones a run could illustrate. allowance.remaining is null on an unlimited plan.
/marketing/content/{id}/images
Run the pipeline: plan a hero and one image per H2 that adds information, render them (hero first, then the sections with the hero and the brand references as reference images), register each in the Media Library, place each under its heading, and set the featured image. Requires marketing.content update; the account must be in good billing standing. The Olympus twin is POST /api/olympus/marketing/content/{id}/images. Runs up to 300 seconds.
Request Body (every field optional; an empty body runs the default)
Response
Every image is registered in the Media Library with its alt text, prompt and model under the tags content_image and content:<item id>; an asset_id of null means the image is hosted and placed but the library row could not be written. warnings names a heading that already had an image (left alone), a render that failed (its generation refunded) and, when the allowance ran out part-way, the images that were not made. See Images for the piece.

Brand Image Profile

/olympus/marketing/brand/image-profile
Olympus API key. The account’s brand image profile exactly as every branded image generation sends it. Read-only. Query project_id (optional website project UUID; that project’s own guide wins over the account-level guide). The brand_image_profile_get MCP tool is the same read.
Response
prompt_block is plain sentences rendered from the imagery side of the brand guide; avoid_list is the forbidden phrases plus the imagery don’ts; reference_images is the primary logo first (role logo, id logo_primary), then up to three Media Library images tagged brand_reference (role brand, id = the media asset id), newest first. To mark an approved brand image, add brand_reference to its tags with PATCH /api/olympus/marketing/media/{assetId}. See The brand image profile.

Performance and Leads

/marketing/content/views
Batch page and lead stats for up to 200 content items, the read behind the list’s Leads column. Requires marketing.content read. The Olympus twin is POST /api/olympus/marketing/content/views.
Request Body
Response
path is site-absolute; the query string and fragment are stripped and the trailing slash removed, and the key in stats carries that normalised path. form_submits is what the tracking embed saw in the browser; leads is what reached the forms ledger, with spam, duplicates and deleted rows excluded; contacts is the CRM contacts those rows resolved to. A pair with no page views and no ledger rows is absent from stats, not zero-filled, and a project the account does not own is omitted without an error. When the analytics collector is unreachable the response is a 200 with stats: {} and degraded: true: read degraded before reading an absent key as zero. More than 200 items, or no items, is a 400. Nothing is written.
/marketing/content/{id}/scorecard
The per-piece scorecard the editor’s Performance section shows, computed now for ?window (7d, 30d, 90d or all; default 30d), plus last_stored, the date and computed_at of the newest nightly row, or null before the first nightly run. Requires marketing.content read.
/olympus/marketing/content/{id}/analytics
Olympus API key (the content_analytics_get MCP tool). The same scorecard and last_stored, plus data, the stored daily rows the nightly writer keeps (one per closed UTC day; views and unique_views are that day’s own counts, external_analytics carries the rolling 30-day scorecard as of that night), newest first, paged with page and limit (default 30, max 100) and filtered with from_date and to_date, with pagination and a content_summary sourced from the live scorecard.
Response (the scorecard, the same on both)
views, visitors and form_submits are null when the piece has no site page (path is null) or the collector could not answer (degraded.clickhouse is true); they are never 0 in either case. leads, contacts and attributed_contacts come from Postgres and always answer. deals is null when there were no contacts to roll up; lead_rate is leads / views to four decimals, null when views is null or zero. rank.keywords are the rank-tracking rows publish-to-site seeded for the item; social sums the latest snapshot of every post carrying linked_content_id. See The scorecard.

Campaign ROI

/marketing/reports/campaign-roi
Attributed contacts, deals and revenue per marketing asset, content items included, from the credit table through the shared revenue engine: the same builder as the Reports hub. Requires marketing.reports read. The Olympus twin is GET /api/olympus/marketing/reports/campaign-roi (the marketing_campaign_roi MCP tool). Read-only.
Query Parameters Response
Money is integer cents in each source’s own currency, not FX-normalised; mixed_currency is true when the sums span more than one currency. truncated is true when more than 200 assets were cut, the biggest contact sets kept. A content item is credited when a visitor arrived on a link carrying utm_medium=content and utm_content=<its slug>, the keys the repurpose source package and the weekly digest stamp. See UTM crediting.

Proof Pack

/marketing/content/proof-pack
The account’s proof in one read: public consented testimonials, review text, the first inbound message of support tickets in the segment, call summaries, completed-survey free text, measured results from the active before/after grids and the objection library with its best response. Requires marketing.content read. The Olympus twin is GET /api/olympus/marketing/content/proof-pack (the content_proof_pack MCP tool). Read-only.
Query Parameters Response
consent is read from the rows, never assumed: true (testimonial, review, grid result) may be quoted verbatim with its attribution; false (ticket, call, survey, objection) is internal voice of customer to paraphrase without a name. citation is the marker the pre-publish check accepts as a source beside a figure. Entries are interleaved in the order the stage calls for; text is at most 600 characters. A source that cannot be read is reported with ok: false in sources and a line in notes, and the other sources still return.

Case Study

/marketing/content/case-study
Draft a case study from a won CRM deal and save it as a content item of type case_study in draft, at the Decision stage. Requires marketing.content create; the row is attributed to the user. The Olympus twin is POST /api/olympus/marketing/content/case-study (the content_case_study_draft MCP tool). The draft holds the request open for up to three minutes and honours Idempotency-Key, so a retry replays the first answer instead of saving a second row.
Request Body
Response
The body has five sections (The problem, What we did, Results, In their words, What this means for you); every result line carries [source: before_after_grid:<id>] and the quote [source: reputation_testimonial:<id>]. Numbers never come from the model: the results are parsed from the grid’s measurable results, and the quote is copied from the testimonial row and never rewritten. The editor shows the record in its five case-study sections, with the sources read-only. See Proof and case studies.

Research

/marketing/content/{id}/research
The stored research for the item: settings.research and the knowledge artifact it points at, each null before the first run. Requires marketing.content read. The Olympus twin is GET /api/olympus/marketing/content/{id}/research (the content_research_get MCP tool).
/marketing/content/{id}/research
Run the research for the item: every knowledge base in the account first, then the SERP for the target keyword, then up to max_sources web pages, extraction with a verbatim quote per claim, indexing of every page used into the account’s Content research knowledge base, the artifact, and settings.research stamped last. Requires marketing.content update. The Olympus twin is POST /api/olympus/marketing/content/{id}/research (content_research_run). Synchronous, up to 300 seconds: use a client timeout of 240 seconds or more and send an Idempotency-Key.
Request Body (every field optional) Response (201)
Every claim’s quote is copied verbatim from the passage it cites; a quote the run cannot find in the passage is dropped and counted in warnings. kind is web for a fetched page (with source_url) and kb for a knowledge-base passage (source_url is null; cite the knowledge base). extraction is fallback and confidence_score is 0 when no model key is configured: the passages themselves are the claims. A monthly SEO research cap hit returns serp: null and a gap, not an error; a page over 60,000 characters is skipped and reported as a gap; one source failing to index is reported (indexed: false) and the run continues. A re-run refreshes the same artifact (artifact_created: false) and never re-indexes a URL the knowledge base already holds (already_indexed: true). The stamp is at most 8,000 characters (sources and gaps trimmed to 10) and is merged into settings, so sibling keys survive.
/olympus/marketing/content/research
Olympus API key (content_research_topic). The same run for a topic that has no content item yet: topic (required), keyword, avatar_id, project_id and the same knobs. Nothing is stamped; the artifact is the deliverable and its id can be attached to an item later. GET on the same path lists the artifacts in the account’s Content research knowledge base (every type), newest first, with artifact_type, content_id, is_verified, page and limit as filters; knowledge_base_id is null and data empty before the first run created the KB.
See Research with the knowledge base.

Knowledge Documents

/olympus/marketing/knowledge-bases/{kbId}/documents
Olympus API key (kb_documents_index_text). Stores a raw text document in the knowledge base and indexes it for kb_search: the server chunks and embeds the full text and keeps a 1000-character preview on the row. Body content (required; 400 content is required), title (default Untitled document, stored as metadata.filename), source_url, tags, metadata (an object merged into the row) and embed (false skips the vector index). Idempotency-Key honoured. 404 when the knowledge base is not in the account.
source_url and tags (2026-09-12). These are the two keys the kb_documents_index_text tool has always sent; until this date the route dropped both, so an indexed passage had no URL to cite and a research run could not tell the page was already in the knowledge base. source_url is stored as metadata.url in the normalised form the research run compares against (fragment dropped; utm_*, fbclid, gclid, mc_cid, mc_eid and ref parameters stripped; lower-case host; no trailing slash), so a later run finds the page already indexed instead of indexing it twice and a search hit cites it. Absent, null or blank writes no URL; a value that is not an http(s) URL is a 400 source_url must be an http(s) URL, a non-string a 400 source_url must be a URL string, both before anything is written. tags is stored as metadata.tags, trimmed, blank entries dropped, de-duplicated, each cut to 100 characters and at most 50 kept; anything but an array of strings is a 400 tags must be an array of strings. Both win over a url or tags key inside metadata, and the same provenance goes on every vector. Response
The example was sent with source_url https://www.Example.com/pricing/?utm_source=newsletter#plans; the stored metadata.url is the normalised form. GET on the same path pages the documents (page, limit up to 100) as { data, pagination: { page, limit, total, total_pages } }.

Knowledge Artifacts

/olympus/knowledge-bases/artifacts
Olympus API key (kb_artifacts_list). Every knowledge artifact in the account, by type, without knowing the knowledge base id first, newest first. Query artifact_type (content_research, serp_brief, positioning, proof_pack, case_study, data_study, or any type a writer stored), kb_id, content_id (artifacts whose content_json.content_id matches), is_verified (true or false), page (default 1) and limit (default 20, max 100); a page or limit that is not a positive number is read as the default.
Response
/olympus/knowledge-bases/artifacts/{artifactId}
Olympus API key (kb_artifact_get). One artifact by id, the id a settings.research, settings.serp_brief or settings.proof_pack stamp carries. 404 when the artifact’s knowledge base is not in the account, 400 for a malformed id. Returns { data: artifact }.
The per-knowledge-base list at GET /api/olympus/marketing/knowledge-bases/{kbId}/artifacts is unchanged; these two are the account-wide view. An artifact’s markdown is also indexed as a document in its knowledge base, so kb_search finds it semantically and the hit carries metadata.artifact_id and metadata.artifact_type. See Artifacts, and how to find them.

Publish to Site

/olympus/marketing/content/{id}/publish-to-site
Olympus API key (the content_publish_to_site MCP tool). Publish a CMS-linked content item to its website collection through the same path the editor uses (mapping, validation, the versioned entry write, route resolution, rank-tracking enrolment for the target keyword), or take it down with { unpublish: true } (content_unpublish_from_site). A native entry lands in the project working tree and needs a deploy; a Webflow item is staged until an item publish. The on-page check runs first and its findings come back as warnings[] plus seo_check, never a block.
Since 2026-09-12 a publish also maps the item’s author (author_id, else the account default) onto the collection’s author reference, creating the entry in the scaffold’s authors collection when it is missing (a slug already typed into settings.cms_fields for that field wins); builds the page’s JSON-LD graph (WebSite, Organization, WebPage, an Article with the author as a Person, a BreadcrumbList, and a FAQPage when faq has questions), stores it as settings.json_ld and writes it into the collection’s JSON-LD field when one is mapped (a jsonLd, schemaMarkup, structuredData or customCode field; a value already typed into settings.cms_fields for it is published unchanged and the graph stays on the row); stamps refreshed_at when a live piece republishes on the same entry slug and marks the decay rows linked to the item refreshed; and regenerates the project’s llms.txt on every native write. An unpublish builds no graph. The ::cta and ::upgrade shortcodes publish verbatim on a markdown body for the site’s PostBody renderer; on an html-typed body they are removed at publish today, because this path hands the mapper no conversion plan yet. Response (200)
json_ld_target is field, row (the graph is on the item only; a Webflow collection without a custom code field also gets a publish_warnings line) or null on an unpublish. Relay every publish_warnings[] line the way warnings[] are relayed. The scheduled-publish cron flips an entry live without this path, so a scheduled go-live regenerates no llms.txt and stamps no refreshed_at. See The markup a publish emits.

Content Authors

/olympus/marketing/content-authors
Olympus API key (content_authors_list). The account’s content authors, the named practitioners its posts publish under, default first then by name. No parameters. Returns { data: [{ id, account_id, name, role, bio, headshot_url, credentials, same_as[], voice_notes, is_default, content_count, created_at, updated_at }], total }. Dashboard twin: GET /api/marketing/content-authors (marketing.content read).
/olympus/marketing/content-authors
Olympus API key (content_authors_create). Body name (required, up to 200 characters, unique per account), role, bio, headshot_url (http(s)), credentials, same_as (up to 20 http(s) URLs), voice_notes, is_default. The first author of an account becomes the default; is_default: true on another author makes it the default and clears the previous one in the same transaction. 201 { data }; 400 invalid_request; 409 duplicate_name. Idempotency-Key honoured. Dashboard twin: POST /api/marketing/content-authors (marketing.content create).
/olympus/marketing/content-authors/{authorId}
Olympus API key (content_authors_get). One author: { data }, or 404 not_found (also for a malformed id).
/olympus/marketing/content-authors/{authorId}
Olympus API key (content_authors_update). Any of the create fields; null clears a text field. is_default: false on the current default is 409 default_required (make another author the default instead); a taken name is 409 duplicate_name. 200 { data }.
/olympus/marketing/content-authors/{authorId}
Olympus API key (content_authors_delete). Unlinks the author’s items (author_id null) and removes the row: 200 { data: { id, deleted: true, items_unlinked } }. The default cannot be deleted while other authors exist (409 default_in_use); the last author can. Dashboard twins: GET, PATCH and DELETE /api/marketing/content-authors/{authorId}.
content_items.author_id points at an author; a publish maps the item’s author, else the default, onto the collection’s author reference, and the author_missing check rule fires when an item has no author and the account has no default. See A named author on every post.

SERP Brief

/olympus/marketing/content/{id}/brief
Olympus API key (content_brief_build). Read the live Google SERP for the item’s target keyword (or the keyword sent; neither is 400 keyword_required), parse the top parse_top organic pages (default 3, at most 5), decide the search intent and the page type, derive the word-count band and what the top three all say, store the brief as a serp_brief knowledge artifact and the SERP into seo_serp_analysis, and stamp search_intent, page_type, serp_brief and serp_brief_captured_at on the row last, with settings.serp_brief in the shape the research run reuses for 30 days. Body (all optional): keyword, avatar_id, project_id, location_code or location_name, parse_top. Runs up to 300 seconds; Idempotency-Key honoured. Dashboard twin: POST /api/marketing/content/{id}/brief (marketing.content update).
Response (201)
intent is informational, commercial, transactional or navigational; page_type is guide, listicle, comparison, landing, tool, news or how_to; *_decided_by is rules, model (the tie-break, at most one completion) or default. location_name wins over location_code; the codes 2840, 2826, 2124, 2036, 2372 and 2554 map to a market and any other falls back to the account market with a warning. Each build spends one SERP read plus parse_top page parses against the monthly SEO research cap.
/olympus/marketing/content/{id}/brief
Olympus API key (content_brief_get). What is stored: { data: { content_id, search_intent, page_type, serp_brief, serp_brief_captured_at, artifact | null } }, or 404. Dashboard twin: GET /api/marketing/content/{id}/brief (marketing.content read).
/olympus/marketing/content/brief
Olympus API key (content_brief_topic). The same build for a topic that has no content item yet: topic or keyword is required (keyword defaults to topic), plus avatar_id, project_id, location_code or location_name and parse_top. Nothing is stamped; the artifact is the deliverable and content_id is null. 201 { data: ContentBriefResult, result_info }; 402 seo_research_limit; 502 serp_read_failed. Dashboard twin: POST /api/marketing/content/brief (marketing.content create).
The four brief columns are read-only on the content routes (400 read_only_field): this route is their writer. See The SERP brief.

Brand Positioning

/olympus/marketing/brand/positioning
Olympus API key (brand_positioning_get). The positioning on the active brand guide: the project’s own guide when project_id is given, else the account-level guide, else any active one; guide_id names a specific guide instead. Returns { data: { guide_id, guide_name, positioning: { thesis, beliefs[], we_are_against[], category_name, proof_points: [{ claim, source }] }, is_empty, updated_at } }; 404 no_active_brand_guide. Dashboard twin: GET /api/marketing/brand/positioning (marketing.branding read).
/olympus/marketing/brand/positioning
Olympus API key (brand_positioning_set). Replaces the whole object: send every key each time, a key left out is cleared. The body is the positioning object itself or { positioning: {...} }; guide_id and project_id may ride in the body or the query. Strings are trimmed and capped (thesis 500, category 120, each belief or against 300, twelve entries per list, twelve proof points), a proof point needs both claim and source, and a 400 names the field. The saved object is mirrored as a verified positioning knowledge artifact in the Content research KB; a failed mirror is a warning. Returns 200 { data, artifact: { id, knowledge_base_id } | null, warnings: string[] }; 404 no_active_brand_guide. Dashboard twin: PUT /api/marketing/brand/positioning (marketing.branding update).
See Positioning.

Title Candidates

/olympus/marketing/content/{id}/titles
Olympus API key (content_titles_generate). Ask the content department for count (default 5, at most 10) claim-shaped titles, each tagged with one of the sixteen hook patterns (specific-number, contrarian, mistake, before-after, unanswerable-question, persona-callout, curiosity-gap, objection-first, in-medias-res, hot-take, list-promise, myth-truth, customer-quote, timely, proof-teaser, definition-reframe) filtered to the item’s journey stage and argued from the positioning. Stored as settings.title_candidates, replacing the previous set; nothing else on the item changes. A line is dropped and named in dropped[] when its pattern is not open at the stage, it carries an exclamation mark, it duplicates another or it equals the current title. Idempotency-Key honoured; the department call is capped at 120 seconds. Dashboard twin: POST /api/marketing/content/{id}/titles (marketing.content update).
Response (201)
current_index is the 0-based index of the candidate whose text is live as meta_title, else title, or null; picked is settings.title_picked; results is settings.title_results once the weekly test has run. 404 outside the account (no writes); 502 titles_unavailable when the department does not answer, 502 titles_bad_response when it answers with nothing usable.
/olympus/marketing/content/{id}/titles
Olympus API key (content_titles_get). The same shape without patterns_offered, dropped and positioning_used. Dashboard twin: GET /api/marketing/content/{id}/titles (marketing.content read).
/olympus/marketing/content/{id}/titles/pick
Olympus API key (content_titles_pick). Body index (the 0-based position in settings.title_candidates), apply_to (both, the default, title or meta_title), change_summary. Writes the title and meta title, sets settings.hook_pattern to the candidate’s pattern when the H1 changes, stamps settings.title_picked { index, picked_at, picked_by, applied_to } and records a content_versions row. Returns 200 { data: { content_id, index, candidate: { title, pattern, rationale }, title, meta_title, hook_pattern, applied_to, version_number } }; 400 invalid_index; 404. Idempotency-Key honoured. The new title reaches the live page only on the next publish of a published piece. Dashboard twin: POST /api/marketing/content/{id}/titles/pick (marketing.content update).
The weekly title test (Monday 06:10 UTC) pools the live title’s Search Console CTR with the click rates of the social posts tagged title:<index> and the email links carrying utm_term=title-<index>, writes settings.title_results and, when a candidate beats the live title by the thresholds, the winner to meta_title with a version row. See Titles that make a claim.

Refresh Brief

/olympus/marketing/content/{id}/refresh-brief
Olympus API key (content_refresh_brief_get). The pre-filled refresh brief for one item, assembled from rows that already exist; nothing is spent. 400 on a non-UUID, 404 content_not_found outside the account. Dashboard twin: GET /api/marketing/content/{id}/refresh-brief (marketing.content read).
Response (200)
decay_status mirrors the decay row and becomes recovered when the row converges away; refresh_priority is higher sooner (severe 3000, moderate 2000, mild 1000, plus the clicks lost); suggested_disposition is refresh, rewrite, consolidate, prune or double_down and the route only suggests: record the decision with review_disposition on the content update. The same brief is filed as the Refresh: title PM task by the Sunday run when no workflow handles the decay trigger. See The decision loop.

Prune Candidates

/olympus/marketing/content/prune-candidates
Olympus API key (content_prune_candidates). Published items at least min_age_days old (30 to 1095, default 365) with zero views and zero leads across the last twelve months, oldest first, up to limit (1 to 200, default 50), optionally on one project_id. Read-only. Dashboard twin: GET /api/marketing/content/prune-candidates (marketing.content read).
Response (200)
measured_by is content_analytics (the nightly rows cover the window) or views_lookup (the live analytics query over the whole window, with a warning); an item that cannot be measured (no_page, clickhouse_unavailable) is under unmeasured, never a candidate. Money pages are excluded and counted. suggested_disposition is consolidate with consolidate_into when another published item holds the keyword or an open cannibalisation row (status detected, in_progress, open or monitoring, the one vocabulary the prune list, the refresh brief and the keyword map read since 2026-09-12) names it, else prune. default_action names the unpublish, which drafts the entry; nothing leaves the internet until the project deploys. 400 on a bad query. See Prune candidates.

Page Roles

/olympus/marketing/site-architecture/page-roles
Olympus API key (site_page_roles_get). Every page and post of one website project (project_id, required) with its page_role (money, pillar, support, utility or null), plus a suggestion for each unset row and the counts. Dashboard twin: GET /api/marketing/site-architecture/page-roles (marketing.content read).
Response (200)
reason is conversion_goal (a url-kind conversion definition; a goal on a confirmation route such as /thank-you is skipped), seo_memory (the SEO department’s Money pages list), route_pattern, cluster_pillar_url or cluster_role_pillar. 400 project_id_required, 404 project_not_found.
/olympus/marketing/site-architecture/page-roles
Olympus API key (site_page_roles_set). Two bodies. { pages: [{ page_id | content_id, page_role }] } sets roles in bulk (up to 500; each entry its own write; null clears; a row outside the account is not_found, never updated): 200 { data: { results: [{ ok, page_id, content_id, page_role } | { ok: false, page_id, content_id, error, code: invalid_target | invalid_role | not_found | write_failed }], updated, failed } }. { project_id, seed: true, apply?: boolean } proposes roles for the unset rows and, with apply: true, writes them, never over a role a person set: 200 { data: { suggestions, applied, failed, errors } }. 400 invalid_body names the bad entry (pages[2]: ...). Idempotency-Key honoured. Dashboard twin: POST /api/marketing/site-architecture/page-roles (marketing.content update).
Roles are stored, never inferred: the site-links order and the money_link_missing and pillar_link_missing check rules read them. See Page roles.

Keyword Map

/olympus/marketing/content/keyword-map
Olympus API key (content_keyword_map). Keyword to item to live URL to best rank position to decay status, grouped by topic cluster with the pillar row first, plus the collisions. Query project_id (optional website project UUID; 400 when malformed). Read-only. Dashboard twin: GET /api/marketing/content/keyword-map (marketing.content read).
Returns { data: { generated_at, project_id, groups: [{ cluster: { id, name, domain, pillar_keyword, pillar_url, coverage_score, internal_link_score, missing_subtopics } | null, rows: [{ keyword, normalized_keyword, content_id, title, status, content_type, url, project_id, cluster_id, cluster_role, page_role, search_intent, best_position, tracked_keywords, decay_status, refresh_priority, review_disposition, published_at }] }], collisions: [{ keyword, kind: duplicate_target | cannibalization, items: [{ content_id, title, url, status }], cannibalization: { id, domain, severity, status, recommended_primary_url, competing_pages } | null }], without_keyword: [{ content_id, title, status, url }], totals: { items, with_keyword, without_keyword, clusters, collisions, ranked, decaying } } }. Every cluster is a group (an empty one lists its missing subtopics); items with no cluster are the last group with cluster: null; best_position is the lowest current rank among the rank-tracking rows seeded for the item. Read it before choosing a keyword for a new piece. See The keyword map.

Bottom-Funnel Plan

/olympus/marketing/content/bofu-plan
Olympus API key (content_bofu_plan). Plan the brand vs rival, rival alternatives and best category for segment pages for one website project from the tracked competitors, the active avatars and the brand guide, with DataForSEO volume and difficulty (one keyword_overview call for the exact phrases and, with include_ideas, one keyword_ideas call, both against the monthly SEO research cap), and optionally seed the drafts. Idempotency-Key honoured; a retried POST does not seed twice. Dashboard twin: POST /api/marketing/content/bofu-plan (marketing.content create).
Request Body Response (201)
dropped[].reason is volume_below_floor, difficulty_above_cap, keyword_collision, keyword_too_long or not_serviceable. A seeded draft carries content_type comparison or alternatives, page_role: money, journey_stage: Decision, search_intent: commercial, the avatar, the keyword, the project, settings.bofu and an empty settings.comparison_table and settings.decision_cta. The three templates appear in the templates list as account rows after the first plan. Two plans for one project at the same moment seed each page once. An empty competitor set, no category or no avatars returns 201 with no candidates, a note per gap, and spends nothing.
/olympus/marketing/content/bofu-plan
Olympus API key (content_bofu_plan_get). The newest stored plan for project_id: { data: { artifact_id, knowledge_base_id, updated_at, plan, seeded } | null }; 400 without a UUID. Dashboard twin: GET /api/marketing/content/bofu-plan (marketing.content read).
See Bottom-funnel pages.

Brand Offers

/olympus/marketing/brand/offers
Olympus API key (brand_offers_get). The offers on the active brand guide (project_id resolves that project’s own guide first): { data: { guide_id, guide_name, cta_primary, lock_version, offers: [{ id, name, promise, guarantee, bonuses[], cta_label, cta_url, stages[] }] } }. Dashboard twin: GET /api/marketing/brand/offers (marketing.content read).
/olympus/marketing/brand/offers
Olympus API key (brand_offers_set). Body { offers, project_id?, expected_lock_version? }. A full replacement: read first, send the whole list back. Ids are minted by the server, and an incoming id survives only when it names an existing entry, so content items stay pointed at it. Limits: 20 offers; name unique and up to 120 characters; promise and guarantee 400; eight bonuses of 200; cta_label 60; cta_url an http(s) URL or a site path starting with /, at most 500 characters; ten stages of 80. Idempotency-Key honoured. Dashboard twin: PUT /api/marketing/brand/offers (marketing.content update).
stages are matched to a content item’s journey_stage case-insensitively; an offer with no stages fits every stage. content_items.offer_id is checked against this list at write time (an unknown id is 400 invalid_reference). See Offers on the brand guide.

Conversion Plan

/olympus/marketing/content/{id}/conversion
Olympus API key. The conversion plan of a content item (MCP tool content_conversion_plan_get): the journey-stage row it converts on, the offer it asks with, the lead magnet with everything it references, and the inline, end and upgrade blocks the ::cta and ::upgrade shortcodes render to. Read-only. Dashboard twin: GET /api/marketing/content/{id}/conversion (marketing.content read).
/olympus/marketing/content/{id}/conversion
Olympus API key (content_conversion_plan). The same plan, and it ensures the journey checkpoint: when the piece has a lead magnet with a form and sits on a journey stage, that stage gets a form_submit checkpoint for the form, once. Body { ensure_checkpoint: true } (the default). Idempotency-Key honoured. Dashboard twin: POST /api/marketing/content/{id}/conversion (marketing.content update).
Response (200)
warnings[].code is one of lead_magnet_missing, lead_magnet_reference_missing, offer_missing, offer_stage_mismatch, comparison_page_missing, form_missing, delivery_sequence_missing, journey_stage_missing. offer_source is item, stage, first or null; checkpoint.status is created (this call made it), exists, missing or skipped with a reason; delivery.status is sequence_linked, sequence_inactive, sequence_missing, no_form or no_lead_magnet. The lead magnet is read as stored (kind download, checklist, template, guide or other); a kind the plan has no copy for renders as a guide. The publish path does not call this route yet. 400 on a malformed id, 404 content_not_found, 503 brand_unavailable. See Conversion inside the piece.

Content Comments

List Comments

/marketing/content/{id}/comments
Get all comments on a content item.

Add Comment

/marketing/content/{id}/comments
Add a comment to a content item.

Content Reviews

/marketing/content/{id}/reviews
Get review status and history for a content item.
/marketing/content/{id}/reviews
Submit a review (approve or request changes).

Version History

/marketing/content/{id}/versions
List all versions of a content item.

Image Upload

/marketing/content/{id}/upload-image
Upload an image to attach to a content item.
/marketing/content/{id}/delete-image
Remove an image from a content item.