CitationBenchTalk to Sales
API referenceProduction

Blog Post API — Generate, Refine, and Publish Long-Form Content

REST API for generating, editing, and managing AI-written blog posts. Supports quick, research-backed, and deep-technical generation modes with multi-step refinement chains and bulk workflows.

Generate, edit, and manage long-form blog content. Each post is a first-class persistent object linked to one or more keywords; under the hood, generation runs a multi-step workflow (research → outline → draft → refine).

Conceptual overview

A blog post in CitationBench is two records:

  • BlogPost — the metadata: title, slug, source, writingInstructions, the linked keyword(s), additional prompts, archival state
  • Content — the body: title, full markdown/HTML content, descriptions (meta/OG), refinements applied, intermediate generation data, publish URL

You normally don't touch Content directly; the blog-post endpoints abstract them as one resource. But the two-table design matters because:

  • Multiple revisions of Content can attach to one BlogPost (refinement chains).
  • A BlogPost can be archived without losing its Content.
  • Bulk content generation creates BlogPosts first and queues Content generation as background jobs.

Generation modes:

  • quick — LLM-only, single-pass, ~3 min, ~10 credits
  • with-research — LLM + Reddit discussions + SERP context, ~5 min, ~25 credits
  • deep-technical — multi-step plan-then-write with citations, ~12 min, ~50 credits

→ Concept: Content Refiners for the post-generation style/voice layer.

Endpoints

MethodPathPurpose
POST/v1/produce/blog-postCreate + generate one blog post
POST/v1/produce/blog-post/bulkBulk create from a keyword list
GET/v1/produce/blog-postList blog posts with filters
GET/v1/produce/blog-post/{id}Get one (with content body)
PATCH/v1/produce/blog-post/{id}Update metadata or body
DELETE/v1/produce/blog-post/{id}Archive
POST/v1/produce/blog-post/{id}/regenerateRe-run a section or the whole thing
POST/v1/produce/blog-post/{id}/refineApply a refiner to this post
POST/v1/produce/blog-post/{id}/evaluateScore this post (see evaluate)
POST/v1/produce/blog-post/{id}/publishSend to a connected platform

POST /v1/produce/blog-post

Request

POST /v1/produce/blog-post HTTP/1.1
Host: api.citationbench.com
Authorization: Bearer sk_live_***
X-Workspace-Id: ws_acme
Content-Type: application/json
Idempotency-Key: 7f3a1b18-7d8b-4f3e-9c4b-2c1a3e0a9b8f

{
  "keywordId": "kw_01HVZ...",
  "pillarSlug": "performance",
  "mode": "with-research",
  "writing": {
    "length": "long",
    "targetWordCount": 2200,
    "instructions": "Write in second person. Reference open-source tools where relevant. Avoid corporate cliches."
  },
  "research": {
    "redditUrls": ["https://www.reddit.com/r/projectmanagement/comments/abc123/..."],
    "additionalContentUrls": ["https://news.ycombinator.com/item?id=12345"]
  },
  "model": "claude-sonnet-4-6",
  "refinerIds": ["rfn_brand-voice", "rfn_seo-cleanup"],
  "approval": { "required": false },
  "tags": ["q2-2026"]
}

Parameters

FieldTypeRequiredDefaultNotes
keywordIdstringyes (one of)Existing keyword in your workspace
keywordstringyes (one of)Bare keyword (auto-creates a Keyword row)
topicstringyes (one of)Free-form topic (no keyword link)
pillarSlugstringnoUsed for default voice + refiner set
mode"quick" | "with-research" | "deep-technical"no"with-research"Generation strategy
writing.length"short" | "medium" | "long"no"medium"Maps to target word count if not set
writing.targetWordCountnumbernoOverrides length
writing.instructionsstringnoFree-form steering; appended to the system prompt
research.redditUrlsstring[]noSpecific Reddit URLs to include as research context
research.additionalContentUrlsstring[]noAny URLs (Hacker News, blogs, docs)
modelenumnoworkspace defaultclaude-sonnet-4-6, gpt-4o, gemini-2.5-pro
refinerIdsstring[]nopillar defaultRefiners to auto-apply after draft
approval.requiredbooleannoworkspace policyPause before refine + publish
tagsstring[]no[]Free-text tags on the BlogPost

Response

HTTP/1.1 202 Accepted

{
  "blogPostId":   "bp_01HVZ...",
  "contentId":    "cnt_01HVZ...",
  "invocationId": "inv_01HVZ...",
  "agentId":      "agt_01HVZ...",
  "skill":        "produce.blog_post",
  "status":       "RUNNING",
  "title": "(generating)",
  "slug":  null,
  "estimatedCost": { "credits": 28, "durationSeconds": 320 },
  "links": {
    "self":   "https://api.citationbench.com/v1/produce/blog-post/bp_01HVZ...",
    "events": "https://api.citationbench.com/v1/agent/invocations/inv_01HVZ.../events"
  }
}

Final result (when complete)

GET /v1/produce/blog-post/{blogPostId} returns the blog post + the agent invocation envelope that produced it:

{
  "id": "bp_01HVZ...",
  "title": "How engineering teams track capacity without slowing delivery",
  "slug": "engineering-team-capacity-tracking",
  "status": "DRAFT",
  "pillarSlug": "performance",
  "keywords": [
    {
      "id": "kw_***",
      "keyword": "track team capacity in real time",
      "isPrimary": true
    }
  ],
  "tags": ["q2-2026"],
  "source": "USER",
  "additionalPrompts": null,
  "writing": {
    "length": "long",
    "instructions": "Write in second person. ...",
    "model": "claude-sonnet-4-6"
  },
  "content": {
    "id": "cnt_01HVZ...",
    "body": "# How engineering teams track capacity ...",
    "wordCount": 2247,
    "descriptions": {
      "meta": "Stop guessing at team capacity. Five engineering-team patterns ...",
      "og": "Real-time capacity tracking for engineering teams — 5 patterns",
      "twitter": "5 patterns engineering teams use to track capacity in real time"
    },
    "refinements": [
      { "refinerId": "rfn_brand-voice", "appliedAt": "2026-05-24T08:08:14Z" },
      { "refinerId": "rfn_seo-cleanup", "appliedAt": "2026-05-24T08:08:42Z" }
    ],
    "intermediateData": {
      "outline": [
        /* outline blocks */
      ],
      "researchSnippets": [
        /* sources used */
      ]
    }
  },
  "publishedUrl": null,
  "lastInvocation": {
    "invocationId": "inv_01HVZ...",
    "agentId": "agt_01HVZ...",
    "skill": "produce.blog_post",
    "skillsUsed": ["produce.blog_post", "research.discuss", "produce.refine"],
    "status": "SUCCEEDED",
    "raw": "Outlined the post around five concrete patterns. I noticed the existing top SERP result skips capacity *forecasting* entirely — I gave it its own H2 to differentiate ...",
    "files": [
      "agent-workspace/outline.md",
      "agent-workspace/research-sources.md",
      "agent-workspace/draft-v1.md"
    ]
  },
  "createdAt": "2026-05-24T08:03:00Z",
  "updatedAt": "2026-05-24T08:08:42Z"
}

lastInvocation is the agent invocation envelope (Agent · invoke § Universal response envelope) for the most recent generation or regeneration. Older invocations are queryable via GET /v1/agent/invocations?resourceId=bp_01HVZ....


POST /v1/produce/blog-post/bulk

curl -X POST "https://api.citationbench.com/v1/produce/blog-post/bulk" \
  -H "Authorization: Bearer sk_live_***" \
  -d '{
    "keywordIds": ["kw_A", "kw_B", "kw_C", "kw_D"],
    "pillarSlug": "performance",
    "mode": "with-research",
    "refinerIds": ["rfn_brand-voice"],
    "concurrency": 3
  }'

Response:

{
  "batchId": "batch_01HVZ...",
  "blogPosts": [
    { "blogPostId": "bp_A", "invocationId": "inv_A", "status": "PENDING" },
    { "blogPostId": "bp_B", "invocationId": "inv_B", "status": "PENDING" },
    { "blogPostId": "bp_C", "invocationId": "inv_C", "status": "PENDING" },
    { "blogPostId": "bp_D", "invocationId": "inv_D", "status": "PENDING" }
  ],
  "totalEstimatedCost": { "credits": 112 }
}

GET /v1/produce/blog-post

curl -G "https://api.citationbench.com/v1/produce/blog-post" \
  -H "Authorization: Bearer sk_live_***" \
  --data-urlencode "status=DRAFT,PUBLISHED" \
  --data-urlencode "pillar=performance" \
  --data-urlencode "keyword=kw_***" \
  --data-urlencode "limit=20"
ParamNotes
statusDRAFT, PUBLISHED, ARCHIVED
pillarPillar slug
keywordFilter to posts linked to this keyword ID
tagTag slug
qSubstring on title
archivedboolean
limit, cursorPagination

Response: { data: [...], nextCursor, total } — same shape as the GET-by-id but with content.body truncated (use ?includeBody=true to get full bodies).


GET /v1/produce/blog-post/{id}

Full record + content body. Add ?includeRefinementHistory=true to get every refinement chain.


PATCH /v1/produce/blog-post/{id}

curl -X PATCH "https://api.citationbench.com/v1/produce/blog-post/bp_***" \
  -H "Authorization: Bearer sk_live_***" \
  -d '{
    "title": "Engineering team capacity tracking — 5 patterns that scale",
    "writing": { "instructions": "Pivot to a more skeptical tone in section 3." },
    "content": { "body": "# Engineering team capacity tracking ..." }
  }'

Partial updates. If content.body is set, a new revision of Content is created (the original is preserved). If title, slug, or writing.* change, the next regenerate respects the new values.


DELETE /v1/produce/blog-post/{id}

Soft delete — sets archived: true and archivedAt. The Content record is retained; you can unarchive with PATCH setting archived: false.


POST /v1/produce/blog-post/{id}/regenerate

Re-run generation. Useful when you change the writing instructions or want a fresh attempt.

curl -X POST "https://api.citationbench.com/v1/produce/blog-post/bp_***/regenerate" \
  -d '{
    "scope": "section:introduction",
    "preserveRefinements": true
  }'
FieldNotes
scope"all", "section:<id>", "outline", "descriptions". Default "all".
preserveRefinementsIf true, re-apply all previously applied refiners to the new draft.

POST /v1/produce/blog-post/{id}/refine

Apply a refiner. Convenience wrapper over Production · refine scoped to this blog post.

curl -X POST "https://api.citationbench.com/v1/produce/blog-post/bp_***/refine" \
  -d '{ "refinerId": "rfn_brand-voice" }'

POST /v1/produce/blog-post/{id}/evaluate

Score the post against your workspace rubric. Convenience wrapper over Production · evaluate.


POST /v1/produce/blog-post/{id}/publish

Convenience wrapper over Production · publish.

curl -X POST "https://api.citationbench.com/v1/produce/blog-post/bp_***/publish" \
  -d '{
    "platformConfigId": "pfm_wordpress-main",
    "isDraft": false
  }'

On success, also queues a distribute.gsc_index and distribute.indexnow submission if the workspace's auto-indexing is enabled.


MCP

> Write a long-form blog post for the "track team capacity in real time" keyword,
  with-research mode, brand-voice refiner.

Claude calls produce.blog_post.create.

> Show me all DRAFT blog posts older than 7 days in the performance pillar.

Claude calls produce.blog_post.list.

> Regenerate the introduction section of bp_*** with a more skeptical tone.

Claude calls produce.blog_post.regenerate with scope: "section:introduction" and a writing.instructions PATCH first.


Errors

StatusCodeCause
400validation_errorOne-of constraint (keywordId / keyword / topic) violated
404keyword_not_found
404pillar_not_found
402insufficient_credits
409concurrent_generationA regenerate is already running on this post
422content_too_longwriting.targetWordCount exceeds the model's safe window
503model_unavailableSelected model is upstream-degraded; pass another

Cost

ModeCredits
quick~10
with-research~25
deep-technical~50
Refiner application+5 per refiner
Regenerate (section)~10–20 (depends on scope)

Add ?dryRun=true to estimate.


Use cases (string things together)

A. Keyword research → 20 blog posts overnight

# 1. Find unaddressed PROBLEM_SOLUTION keywords
KW_IDS=$(curl -sf -X POST .../v1/keywords/search -d '{
  "intent": ["PROBLEM_SOLUTION"],
  "missingFromContent": true,
  "maxKd": 35,
  "limit": 20
}' | jq -r '.data[].id' | jq -Rsc 'split("\n")[:-1]')

# 2. Bulk fire blog posts
curl -X POST .../v1/produce/blog-post/bulk -d "{
  \"keywordIds\": $KW_IDS,
  \"pillarSlug\": \"performance\",
  \"mode\": \"with-research\",
  \"refinerIds\": [\"rfn_brand-voice\"],
  \"concurrency\": 4
}"

# 3. (Optional) on completion, auto-publish via webhook

B. Research-backed long-form via the content_factory skill

curl -X POST .../v1/agent/invoke -d '{
  "skill": "content_factory",
  "input": {
    "keywordIds": ["kw_***"],
    "pillarSlug": "performance",
    "refinerIds": ["rfn_brand-voice"],
    "publish": { "platformConfigId": "pfm_wordpress-main" }
  },
  "approval": { "required": true, "scope": "publish_or_outreach" }
}'

The agent chains skills: research.discussproduce.blog_postproduce.refineproduce.evaluate → (pause for approval) → produce.publish.

C. Refresh a stale post that dropped in rank

# Triggered by the refresh_stale skill when a rank drop is detected
curl -X POST .../v1/agent/invoke -d '{
  "skill": "refresh_stale",
  "input": { "blogPostId": "bp_***", "triggerReason": "rank_drop_8_positions" }
}'

# The skill internally chains:
#   produce.evaluate    → identifies weak sections
#   produce.blog_post.regenerate(scope: "section:X")
#   produce.refine
#   (approval gate)
#   produce.publish
#   indexing.gsc.submit

D. Conversational editing in Claude Code

> Show me draft blog posts in the performance pillar.
> Open bp_***. Rewrite the intro in a more skeptical tone.
> Apply the brand-voice refiner. Score it. If it's above 80, publish.

Claude orchestrates: produce.blog_post.listproduce.blog_post.getproduce.blog_post.regenerateproduce.blog_post.refineproduce.blog_post.evaluate → (conditional) produce.blog_post.publish.


On this page