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 stateContent— 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
Contentcan attach to oneBlogPost(refinement chains). - A
BlogPostcan be archived without losing itsContent. - Bulk content generation creates
BlogPosts first and queuesContentgeneration as background jobs.
Generation modes:
quick— LLM-only, single-pass, ~3 min, ~10 creditswith-research— LLM + Reddit discussions + SERP context, ~5 min, ~25 creditsdeep-technical— multi-step plan-then-write with citations, ~12 min, ~50 credits
→ Concept: Content Refiners for the post-generation style/voice layer.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/produce/blog-post | Create + generate one blog post |
| POST | /v1/produce/blog-post/bulk | Bulk create from a keyword list |
| GET | /v1/produce/blog-post | List 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}/regenerate | Re-run a section or the whole thing |
| POST | /v1/produce/blog-post/{id}/refine | Apply a refiner to this post |
| POST | /v1/produce/blog-post/{id}/evaluate | Score this post (see evaluate) |
| POST | /v1/produce/blog-post/{id}/publish | Send 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
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
keywordId | string | yes (one of) | — | Existing keyword in your workspace |
keyword | string | yes (one of) | — | Bare keyword (auto-creates a Keyword row) |
topic | string | yes (one of) | — | Free-form topic (no keyword link) |
pillarSlug | string | no | — | Used 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.targetWordCount | number | no | — | Overrides length |
writing.instructions | string | no | — | Free-form steering; appended to the system prompt |
research.redditUrls | string[] | no | — | Specific Reddit URLs to include as research context |
research.additionalContentUrls | string[] | no | — | Any URLs (Hacker News, blogs, docs) |
model | enum | no | workspace default | claude-sonnet-4-6, gpt-4o, gemini-2.5-pro |
refinerIds | string[] | no | pillar default | Refiners to auto-apply after draft |
approval.required | boolean | no | workspace policy | Pause before refine + publish |
tags | string[] | 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"| Param | Notes |
|---|---|
status | DRAFT, PUBLISHED, ARCHIVED |
pillar | Pillar slug |
keyword | Filter to posts linked to this keyword ID |
tag | Tag slug |
q | Substring on title |
archived | boolean |
limit, cursor | Pagination |
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
}'| Field | Notes |
|---|---|
scope | "all", "section:<id>", "outline", "descriptions". Default "all". |
preserveRefinements | If 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
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | One-of constraint (keywordId / keyword / topic) violated |
| 404 | keyword_not_found | — |
| 404 | pillar_not_found | — |
| 402 | insufficient_credits | — |
| 409 | concurrent_generation | A regenerate is already running on this post |
| 422 | content_too_long | writing.targetWordCount exceeds the model's safe window |
| 503 | model_unavailable | Selected model is upstream-degraded; pass another |
Cost
| Mode | Credits |
|---|---|
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 webhookB. 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.discuss → produce.blog_post → produce.refine → produce.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.submitD. 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.list → produce.blog_post.get → produce.blog_post.regenerate → produce.blog_post.refine → produce.blog_post.evaluate → (conditional) produce.blog_post.publish.
Related
- Concept: Content Refiners
- API: Production · refine
- API: Production · evaluate
- API: Production · publish
- API: Research · keyword
- API: Indexing · gsc index (auto-fires after publish)
- API: Agent · invoke
- Playbook: Generate 100 landing pages overnight (same pattern for blog posts)
- Playbook: Refresh stale content on rank drops
Content gap
Compare your content library against competitor coverage to surface missing topics, keyword gaps, and weaker pages — ranked by lift with suggested briefs ready to queue into bulk content production.
Landing page
REST API for generating SEO landing pages via a four-step pipeline (search intent, SEO metadata, page copy, final metadata). Each step is independently regenerable and governed by workspace-level pillars.