Skip to content

Open API Overview

The BlogShoot Open API lets automation partners (white-label sites, multi-tenant SaaS, agency tooling) drive the full lifecycle programmatically — create workspaces, configure onboarding, discover keywords, generate articles, and receive articles via webhook — without ever touching the web UI.

https://api.blogshoot.com

All Open API routes live under the /open/ prefix.

All Open API requests require a BlogShoot API key (bsk_* prefix).

  1. Log in to app.blogshoot.com
  2. Go to Settings → API Keys
  3. Click “Create Key”
  4. Select scopes (see Scopes below)
  5. Copy the bsk_* key once — it’s only shown once

Two equivalent ways to authenticate:

Terminal window
# Option A: X-BlogShoot-Key header (recommended)
curl -H "X-BlogShoot-Key: bsk_your_key" \
https://api.blogshoot.com/open/workspaces
# Option B: Bearer token
curl -H "Authorization: Bearer bsk_your_key" \
https://api.blogshoot.com/open/workspaces

Most endpoints respect an optional X-Workspace-Id header that overrides the key owner’s default workspace for the request:

Terminal window
curl -H "X-BlogShoot-Key: bsk_..." \
-H "X-Workspace-Id: 6a21a0d88cdc5df997c026c6" \
https://api.blogshoot.com/open/articles

This is how multi-tenant integrations route traffic to the correct customer’s workspace.

Each API key is restricted to a set of scopes. Without the right scope, the endpoint returns 403 INSUFFICIENT_SCOPE.

ScopeWhat it allows
workspace:readList + read workspace details
workspace:writeCreate, PATCH (partial-update), delete workspaces (each create/delete triggers Stripe quantity ±1)
keywords:readList discovered keywords
keywords:writeTrigger keyword discovery
articles:readList + read articles + progress
articles:writeGenerate articles, retry failed generation
planner:readRead content plan
planner:writeRefill calendar / trigger autopilot
integrations:readRead CMS connection state
integrations:writePush articles to WordPress / Shopify / webhook

Select scopes when creating the key. Adding scopes later means rotating the key.

MethodPathScopeNotes
GET/open/workspacesworkspace:readList all workspaces owned by the key
POST/open/workspacesworkspace:writeCreate workspace + Stripe quantity++ (charges $99/month per seat)
PATCH/open/workspaces/:workspace_idworkspace:writePartial update — fill onboarding fields after creation
DELETE/open/workspaces/:workspace_idworkspace:writeDelete workspace + Stripe quantity—
GET/open/workspace/currentworkspace:readRead the key’s currently-selected workspace
POST/open/workspaces/:workspace_id/auto-fill-businessworkspace:writeTrigger site analysis to crawl the workspace’s website and auto-extract business_info
GET/open/workspaces/:workspace_id/auto-fill-businessworkspace:readCheck auto-fill result / status
GET/open/quotasworkspace:readAccount-wide quota snapshot — article credits + per-workspace per-scope discovery remaining

Creates a workspace immediately and bumps the Stripe subscription quantity by 1 (per-seat prorated charge). All onboarding fields are optional at create time; fill them later with PATCH.

Terminal window
curl -X POST https://api.blogshoot.com/open/workspaces \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{
"website_url": "https://strand-example.com",
"site_name": "Strand Example",
"sitemap_url": "https://strand-example.com/sitemap.xml",
"blog_base_path": "/article/",
"business_info": {
"company_name": "Strand",
"industry": "Cross-border jewelry",
"target_market": "United Arab Emirates",
"content_language": "Arabic"
},
"webhook_url": "https://your-cms.com/webhooks/blogshoot/strand",
"webhook_secret": "whsec_..."
}'

Response:

{
"success": true,
"workspace": {
"id": "6a...",
"website_url": "https://strand-example.com",
"site_name": "Strand Example",
"sitemap_url": "https://strand-example.com/sitemap.xml",
"sitemap_validation": { "status": "pending", "blog_count": 0, "error": null },
"blog_base_path": "/article/",
"business_info": { /* ... */ },
"business_info_auto_filled": true,
"agent_config": { "team_instructions": "", "agents": {} }
},
"subscription_quantity": 2,
"webhook": {
"id": "...",
"webhook_url": "...",
"webhook_secret": "whsec_..."
}
}

Notes:

  • If business_info is provided at create time, business_info_auto_filled is set to true so the BlogShoot UI shows this is programmatic, not user-typed.
  • webhook_secret is returned once; store it server-side.

PATCH /open/workspaces/:workspace_id — Partial update onboarding fields

Section titled “PATCH /open/workspaces/:workspace_id — Partial update onboarding fields”

Solves the typical automation flow: you create a workspace before the customer has finished onboarding (because you wanted to lock in pricing or grab a slot), then sync the onboarding data back later.

Body — all fields optional:

{
"site_name": "Strand UAE",
"sitemap_url": "https://strand-example.com/sitemap-index.xml",
"blog_base_path": "/ar/blog/",
"example_blog_url": "https://strand-example.com/ar/blog/sample/",
"business_info": {
"company_name": "Strand",
"industry": "Cross-border jewelry retail",
"target_audience": "Middle Eastern women 25-45 interested in modern fine jewelry",
"brand_voice": "Elegant, modern, with subtle cultural sensitivity",
"key_products": "18k gold rings, diamond necklaces, custom bridal sets",
"unique_selling_points": "Custom-made within 14 days; lifetime resizing",
"target_market": "United Arab Emirates",
"content_language": "Arabic",
"company_story": "Founded in 2024 by ...",
"product_catalog": [
{ "name": "Eternity Ring 18k", "description": "...", "url": "https://..." }
],
"service_areas": ["UAE", "Saudi Arabia", "Kuwait"]
},
"agent_config": {
"team_instructions": "Always write with cultural sensitivity for GCC audiences",
"agents": {
"content_writer_listicle": "Prefer numbered lists with 7-10 items..."
}
}
}

Merge strategies (via ?strategy=):

  • ?strategy=merge (default) — Each field passed overrides the same field; fields not passed are preserved. Good for incremental sync.
  • ?strategy=replacebusiness_info / agent_config are reset to schema defaults plus your new fields. Use when you want to forcibly clear stale fields.
Terminal window
# Incremental: just bump the target_market without touching anything else
curl -X PATCH "https://api.blogshoot.com/open/workspaces/6a.../" \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{ "business_info": { "target_market": "Saudi Arabia" } }'
# Full reset of business_info to the exact object you supply
curl -X PATCH "https://api.blogshoot.com/open/workspaces/6a...?strategy=replace" \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{ "business_info": { /* the whole new object */ } }'

Response:

{
"success": true,
"changed": true,
"strategy": "merge",
"changes": ["business_info", "sitemap_url"],
"workspace": { /* full serialized workspace, same shape as create response */ }
}

Side effects:

  • Changing sitemap_url resets sitemap_validation.status = "pending" and sitemap_auto_detected = false. The next validation pass (manual or scheduled) re-checks.
  • Any business_info PATCH sets business_info_auto_filled = true.

Deletes the workspace and decrements Stripe quantity by 1. Won’t decrement below 1 — to fully cancel the subscription, cancel via billing portal instead. Refuses if subscription_quantity already equals 1.

B2B vs B2C — Different keyword strategies

Section titled “B2B vs B2C — Different keyword strategies”

workspace.business_info.business_model can be B2C (default — consumer-facing, optimize for traffic) or B2B (wholesale / OEM / supplier — optimize for inquiry generation). The discovery and ranking pipelines branch on this flag:

StepB2C flowB2B flow
Seed extractionFlat list of 5-10 broad seeds from business_info4 grouped sets along the buyer journey: Awareness / Comparison / Specification / Sourcing
Search-data expansion1 pass with all seeds4 passes (one per stage), each tagged with buyer_journey_stage
PAA expansionSame (always runs)Same (always runs); PAA-sourced keywords additionally tagged source: "paa"
Intent filteringStandard intent rankingB2B-specific scoring: +20 boost for buyer-action verbs (wholesale, OEM, MOQ, supplier, factory, quote, …), commercial/transactional intent ×3 weight, long-tail prioritized over head terms
Top-N target30 keywords40 keywords (more headroom across 4 stages)
Discovery budgetLowHigher, because every buyer-journey stage is expanded separately

Set the model at workspace creation or via PATCH:

Terminal window
curl -X PATCH "https://api.blogshoot.com/open/workspaces/:id" \
-H "X-BlogShoot-Key: bsk_..." \
-d '{"business_info":{"business_model":"B2B"}}'

For B2B sites like wholesale jewelry suppliers, OEM factories, or cross-border trade platforms, this dramatically changes the keyword pool — instead of getting head terms like “best silver jewelry”, you get inquiry-driving keywords like “925 silver wholesale supplier Hangzhou MOQ 50”.

Each keyword.buyer_journey_stage for B2B workspaces:

StagePatternExample seedsInquiry value
Awareness”how to find”, “what is”, “MOQ meaning”how to find reliable jewelry supplierEducational — top-of-funnel
Comparison”X vs Y”, “best … for …”Alibaba vs direct factory jewelryDecision support — middle
Specificationtechnical/quality details925 silver vs gold plated durabilityQuality verification — middle/bottom
Sourcing“wholesale … from …”, “OEM … factory”wholesale jewelry from HangzhouHighest inquiry intent — bottom

Filter to Sourcing-stage keywords when picking what to write articles about:

Terminal window
curl "https://api.blogshoot.com/open/keywords/list" \
-H "X-BlogShoot-Key: bsk_..." \
| jq '.keywords | map(select(.buyer_journey_stage == "Sourcing"))'

When you (as a partner) manage workspaces for end-customers, filling business_info manually is the bottleneck. Most customers fill only company_name + industry and skip the rest, which kills keyword quality.

The auto-fill endpoint solves this:

Terminal window
# Step 1: Trigger site analysis to crawl the customer's site and extract business_info
curl -X POST "https://api.blogshoot.com/open/workspaces/<workspace_id>/auto-fill-business" \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{ "website_url": "https://strandjewelry.com" }'
# Response: { "success": true, "job_id": "...", "message": "AI analysis started" }
# Step 2: Poll until done (~30-60 seconds)
curl "https://api.blogshoot.com/open/workspaces/<workspace_id>/auto-fill-business" \
-H "X-BlogShoot-Key: bsk_..."
# Response when complete:
# {
# "success": true,
# "auto_filled": true,
# "business_info": {
# "company_name": "Strand Jewelry",
# "industry": "Cross-border wholesale jewelry (China → US/EU/UK/MENA)",
# "target_audience": "Overseas retailers and resellers...",
# "key_products": "Silver, brass, gold-plated chains, earrings, statement pieces. Custom OEM available.",
# "unique_selling_points": "Vetted Hangzhou workshops, one accountable contact, multilingual support, MOQ 12-100 pcs...",
# ...
# }
# }
# Step 3: Show the result to your customer, let them review, then PATCH the final version
curl -X PATCH "https://api.blogshoot.com/open/workspaces/<workspace_id>" \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{ "business_info": { ...reviewed business_info... } }'

Implementation notes:

  • The crawler reads ~8 pages from the homepage and feeds them into the site-analysis pipeline
  • Cost is charged against the account’s article credits (one call ≈ trivial compared to article generation)
  • Unlike the web UI’s auto-fill (which has a “one-time per account” lockout), the Open API version can be called once per workspace — you can re-run it on the same workspace if the site significantly changed
  • business_info.business_model is also auto-detected from on-site signals (wholesale terms, MOQ mentions, quote forms vs cart) — verify in the response, override via PATCH if wrong
MethodPathScopeNotes
POST/open/keywords/discoverkeywords:writeTrigger discovery; accepts target_market + content_language for multilingual workspaces. B2B workspaces auto-run buyer-journey grouped expansion.
GET/open/keywords/listkeywords:readList discovered keywords; supports ?language=, ?target_market=, ?buyer_journey_stage= filters

See Multilingual integration for the multi-language flow.

POST /open/keywords/discover — Idempotency / merge semantics

Section titled “POST /open/keywords/discover — Idempotency / merge semantics”

This endpoint is additive, not idempotent:

  • The workspace has one Keyword document. Calling discover again re-uses that document; it does not create a new one.
  • Each call appends new keywords to the existing keywords[] array — old keywords (including already-used ones) are preserved.
  • Dedup key is (lower(keyword), language). The same literal string can coexist as separate entries across languages (e.g. an English keyword and a Chinese keyword that happens to share characters both stay).
  • Each discovery_history entry is tagged with language + target_market, so the weekly limit (5 / week / (workspace, language, target_market)) is independent per scope. Running 5 English discoveries does not lock out Arabic discoveries.
  • The response includes remaining_this_week_for_scope so you don’t need a second round-trip to read the limit state — schedule the next discovery accordingly.
Terminal window
# Day 1 — discover EN/US
curl -X POST https://api.blogshoot.com/open/keywords/discover \
-H "X-BlogShoot-Key: bsk_..." \
-d '{"target_market":"United States","content_language":"English"}'
# Response: { keyword_id, remaining_this_week_for_scope: 4, ... }
# Day 2 — same scope, appends more keywords (deduped against day 1's keywords)
curl -X POST https://api.blogshoot.com/open/keywords/discover \
-H "X-BlogShoot-Key: bsk_..." \
-d '{"target_market":"United States","content_language":"English"}'
# Response: { keyword_id (same as day 1), remaining_this_week_for_scope: 3 }

GET /open/keywords/list — Available fields per keyword

Section titled “GET /open/keywords/list — Available fields per keyword”

Each item in keywords[] (and keywords_by_category[*][*]) exposes all the signals needed to pick top-N programmatically — no need to fetch a separate detail endpoint:

FieldTypeNotes
keywordstringThe keyword phrase
languagestringSearch language name (e.g. "English", "Chinese (Simplified)")
target_marketstringSearch market name (e.g. "United States", "China")
search_volumenumberEstimated monthly searches
keyword_difficultynumber0-100, lower = easier to rank
cpcnumberAvg cost per click (USD) — proxy for commercial value
competitionnumber0-1, paid competition signal
relevance_scorenumber0-100, model-judged relevance to your business
opportunity_scorenumber≈ volume × relevance / (difficulty + 1) — best single field to sort by. Not normalized — typical range is thousands to millions (use for relative ranking only, never compare against a fixed threshold)
buyer_journey_stagestring | nullB2B workspaces only: Awareness / Comparison / Specification / Sourcing. null for B2C workspaces and for any keyword stored before the workspace was switched to B2B — re-run discovery to populate
is_quick_winboolKD < 20 && volume > 100 shortcut
search_intentstringinformational / commercial / transactional / navigational
intent_categorystringModel-bucketed category (How-to & Guides, Best-of & Listicles, …)
is_usedboolTrue once an article has been generated from this keyword

Suggested usage:

// Sort by combined opportunity, pick top 5 unused
const picks = keywords
.filter(k => !k.is_used)
.sort((a, b) => b.opportunity_score - a.opportunity_score)
.slice(0, 5);
MethodPathScopeNotes
POST/open/articles/generatearticles:writeGenerate article. keyword is the only required field — see below.
GET/open/articlesarticles:readList articles. Supports ?language= and ?target_market= filters.
GET/open/articles/:idarticles:readArticle detail
GET/open/articles/:id/progressarticles:readPoll generation progress
POST/open/articles/:id/retryarticles:writeRetry failed generation
POST/open/articles/research-topicarticles:writePre-flight topic research

POST /open/articles/generate — Request body

Section titled “POST /open/articles/generate — Request body”
FieldRequiredTypeNotes
keywordyesstringTarget keyword. The only field a partner needs to pick — BlogShoot derives the rest.
titlenostringInitial placeholder title. Defaults to keyword. The pipeline’s SEO step rewrites article.title with an optimized headline before Ready, so this rarely matters in practice.
typenostringOne of Listicle / Step-by-Step Guide / Explainer. Defaults are derived from the keyword’s buyer_journey_stage: Sourcing / ComparisonListicle; Awareness / SpecificationExplainer; unknown → Explainer.
languagenostringOverrides the article’s language. Resolution order: this field → keyword’s language tag → workspace.business_info.content_language"English".
target_marketnostringSame resolution chain as language.
skip_overlap_checknoboolEscape hatch. By default, BlogShoot runs a semantic-overlap check against existing articles on the site; if a topic is >threshold similar to existing content, the call returns success:false, code:"TOPIC_OVERLAP" with the offending matches so you can decide. Set skip_overlap_check: true to force generation regardless (use when partner has already made the call).
generation_paramsnoobjectFree-form params forwarded to the AI agents (e.g. custom best_article_url reference).

Minimal partner request — just pick a keyword from /open/keywords/list and hand it over:

Terminal window
curl -X POST "https://api.blogshoot.com/open/articles/generate" \
-H "X-BlogShoot-Key: bsk_..." \
-H "X-Workspace-Id: <workspace_id>" \
-H "Content-Type: application/json" \
-d '{ "keyword": "wholesale silver jewelry MOQ 50" }'
# Response: { "success": true, "article_id": "...", "status": "Draft" }

The article goes through the async pipeline; subscribe to webhooks (see Article delivery) or poll GET /open/articles/:id/progress until status: "Ready".

When an article reaches status: "Ready", BlogShoot auto-fires the webhook to every active connection on the workspace. You do not need to poll-and-push. See Webhook Integration.

MethodPathScopeNotes
GET/open/planner/content-planplanner:read
POST/open/planner/refill-calendarplanner:writeSmart Refill
POST/open/wordpress/article/pushintegrations:writePush article to a WordPress connection
POST/open/shopify/article/pushintegrations:writePush article to a Shopify connection
POST/open/webhook/article/pushintegrations:writeManually fire a webhook (auto-dispatch usually makes this unnecessary)

GET /open/quotas — Schedule-aware quota snapshot

Section titled “GET /open/quotas — Schedule-aware quota snapshot”

Designed for partner-style integrations that drive BlogShoot from their own cron and need to schedule without “blind throwing”. Returns the full quota state in one call:

{
"success": true,
"subscription": {
"status": "active",
"tier": "pro",
"quantity": 2,
"unit_price": 199,
"total_monthly_price": 398,
"next_billing_date": "2026-07-05T00:00:00Z"
},
"article_credits": {
"used": 80,
"total": 400,
"remaining": 320,
"articles_used": 40,
"articles_total": 200,
"articles_remaining": 160,
"period_started_at": "2026-06-05T00:00:00Z",
"period_ends_at": "2026-07-05T00:00:00Z"
},
"keyword_discovery": {
"limit_per_week_per_scope": 5,
"week_window": {
"starts_at": "2026-05-31T00:00:00Z",
"ends_at": "2026-06-07T00:00:00Z"
},
"by_workspace": [
{
"workspace_id": "6a...",
"site_name": "Link4a",
"website_url": "https://link4a.com",
"autopilot_enabled": false,
"by_scope": [
{ "language": "English", "target_market": "United States", "used_this_week": 3, "remaining_this_week": 2 },
{ "language": "Chinese (Simplified)", "target_market": "China", "used_this_week": 1, "remaining_this_week": 4 }
]
}
]
}
}

Notes:

  • article_credits is account-level (all workspaces share one pool). One article costs 2 credits → articles_remaining = remaining / 2.
  • keyword_discovery.by_workspace[*].by_scope only lists (language, target_market) combinations that have been used this week. Combinations not listed have full quota (5 / 5).
  • autopilot_enabled reflects the workspace setting (default true). When false, BlogShoot’s internal AutoPilot will not auto-generate articles on this workspace — see the AutoPilot conflict protection section.

If your integration drives article generation from your own cron, you must disable BlogShoot’s built-in AutoPilot on each workspace you manage, otherwise both pipelines will compete for the same keywords and credits.

Terminal window
curl -X PATCH "https://api.blogshoot.com/open/workspaces/<workspace_id>" \
-H "X-BlogShoot-Key: bsk_..." \
-H "Content-Type: application/json" \
-d '{ "autopilot_enabled": false }'

Effects of autopilot_enabled: false:

  • The daily AutoPilot cron skips this workspace (autopilot_disabled_on_workspace is logged)
  • POST /open/planner/refill-calendar returns { "success": false, "code": "AUTOPILOT_DISABLED" }
  • All other endpoints (/open/articles/generate, /open/keywords/discover, …) keep working — only internal automation is paused

Re-enable later (or never — partner-managed workspaces typically stay disabled forever):

Terminal window
curl -X PATCH "https://api.blogshoot.com/open/workspaces/<workspace_id>" \
-d '{ "autopilot_enabled": true }'
1. POST /open/workspaces → create + Stripe quantity++
2. PATCH /open/workspaces/:id → fill onboarding incrementally
3. PATCH /open/workspaces/:id → set { autopilot_enabled: false }
4. POST /open/keywords/discover → with target_market + content_language
response carries remaining_this_week_for_scope
5. GET /open/keywords/list → pick top-N by opportunity_score
6. GET /open/quotas (optional) → check article credits before bulk-scheduling
7. POST /open/articles/generate → for each picked keyword
8. (auto) → article reaches Ready → BlogShoot fires webhook
9. (your side) → webhook receiver routes by article.language
HTTPError codeMeaning
401MISSING_API_KEYNo X-BlogShoot-Key / Authorization: Bearer bsk_* header
401INVALID_API_KEYKey doesn’t exist or wrong format
401API_KEY_REVOKEDKey was revoked
403INSUFFICIENT_SCOPEKey lacks the scope this endpoint requires
402NO_ACTIVE_SUBSCRIPTIONNeed an active Stripe subscription to call write endpoints
402BILLING_FAILEDStripe charge failed (e.g., card declined on workspace add)
404WORKSPACE_NOT_FOUNDWorkspace doesn’t belong to the key owner
409QTY_CHANGE_IN_PROGRESSAnother mutation is currently running; retry shortly

Always include the response body when reporting issues — error field identifies the failure precisely.