Agentic Mode

API Documentation

Connect your AI agent to Distribb. You write the content — we handle SEO optimization, backlinks, publishing, and analytics.

Base URL  https://distribb.io/api/v1

Quick Start

Fastest setup (Cursor, Claude Code, Codex, and 45+ agents)

npx skills add Bomx/distribb-skill
  1. Sign up at distribb.io
  2. Find your API key in Settings
  3. Set it as an environment variable: export DISTRIBB_API_KEY=your_key
  4. Install the skill: npx skills add Bomx/distribb-skill
  5. Or use curl directly: curl -s -H "Authorization: Bearer $DISTRIBB_API_KEY" https://distribb.io/api/v1/projects | jq .

Authentication

All API requests require a Bearer token in the Authorization header. Your API key is available in your account settings.

Authorization: Bearer your_api_key_here
GET /projects List all your projects
30 req/min

Returns all active projects linked to your account. Use the project ID in subsequent API calls.

Response
{ "projects": [ { "ID": 1, "BusinessName": "Acme Corp", "WebsiteUrl": "https://acme.com", "BusinessDescription": "...", "Language": "English (US)", "Status": "Active", "BacklinkCredits": 10, "ArticlesPerDay": 1 } ] }

New-client flow (agencies)

Spinning up a client from scratch is a deliberate 3-call sequence. Creating the project does NOT spend keyword-research credits, so you can create and configure freely, then ask the user before starting research.

  1. POST /projects with the client's website_url (plus any settings) to create and configure the project.
  2. POST /projects/:id/onboarding to start keyword research and the first articles. Ask the user first; this one spends credits.
  3. POST /projects/:id/wordpress to connect the client's CMS so articles can publish.
POST /projects Create + configure a project
10 req/min

Create a new project for the authenticated account. Only website_url is required; you can pass any writable field from PUT /projects/:id in the same body to configure the project on create. Creating a project does NOT start keyword research (that spends credits), so after creating, ask the user, then call POST /projects/:id/onboarding.

JSON Body

FieldTypeRequiredDescription
website_urlstringYesClient site URL (normalized and validated)
business_namestringNoBusiness name
business_descriptionstringNoWhat the business does
target_audiencestring[]NoAudience segments
(any writable field)anyNoAny key from the PUT writable set (same validation)
Response (201)
{ "project_id": 42, "website_url": "https://client.com", "backlink_credits": 100, "project_slots": { "used": 3, "total": 5 }, "message": "Project created. Keyword research has NOT started yet.", "next_step": "Ask the user if they want to start keyword research now...", "settings_applied": ["tone", "language"] }
Response (200, idempotent reuse)

Posting the same website_url again returns the existing active project instead of creating a duplicate.

{ "project_id": 42, "website_url": "https://client.com", "already_existed": true, "message": "A project already exists for this website; returning it." }
Response (402, project limit reached)

Project slots are gated by the account's paid quantity. At the limit, this returns a machine-readable body with a purchase_url the human can click to buy another slot; retry the same body once they confirm.

HTTP/1.1 402 Payment Required { "error": "project_limit_reached", "active_projects": 5, "project_quantity": 5, "purchase_url": "https://distribb.io/dashboard?add_project=1", "instructions_for_agent": "Tell the user they've hit their project limit and share purchase_url..." }
GET /projects/:id Read one project's full settings
30 req/min

Returns the project plus a settings object that mirrors the writable keys of PUT /projects/:id. GET it, tweak keys, and PUT the same shape back (read-modify-write).

Response
{ "project": { "ID": 42, "BusinessName": "Acme Corp", "WebsiteUrl": "https://acme.com", ... }, "settings": { "tone": "Informative", "language": "English (US)", "keyword_region": "United States", "internal_links_per_article": 5, "competitors": ["https://competitor.com"], "duplicate_content_protection": true, "articles_per_day": 1 } }
PUT /projects/:id Update project settings
10 req/min

Accepts the same field set as the Settings UI. Send only the fields you want to change; article-quality and image preferences are MERGED, so a partial update never resets the others. GET this project to see the settings object; every key there is writable here.

Watch the exact key names: the tone key is tone (not content_style), the duplicate-content toggle is duplicate_content_protection (not a bare duplicate_content_guard), and articles_per_day is plan-controlled and NOT settable (it is echoed back under ignored, never written).

Writable fields (JSON body)

FieldTypeNotes
ai_instructionsstringCustom writer instructions (null to clear)
business_namestringBusiness name
business_descriptionstringWhat the business does
target_audiencestring[]Audience segments
sitemap_urlstringSitemap URL
blog_root_urlstringBlog root URL
content_pillarsstring[]Pillar page URLs (list or CSV)
internal_links_per_articleint1 to 5 (alias: internal_links)
toneenumInformative, Conversational, Persuasive
languagestringSupported label, e.g. "English (US)", "French"
keyword_regionstringe.g. "United States", "United Kingdom", "Worldwide"
writing_profileenumExperienced practitioner, Simple educational, Balanced SEO
product_positioningenumNeutral operational, Soft mention, Promotional
custom_author_namestringByline author name
social_media_ai_instructionsstringSocial repurposing instructions
publish_timestring24-hour "HH:MM", e.g. "09:00"
timezonestringIANA name, e.g. "America/New_York"
publishing_statusenumPublish Immediately, Save as Drafts, Send as Drafts
social_media_publishing_statusenumPublish Immediately, Save as Drafts
image_hostingenumDistribb, CMS
image_stylestringFree-form style description (null to clear)
brand_colorstringHex, e.g. "#e11d2a"
image_prompt_instructionsstringExtra image-prompt guidance
title_based_featured_imageboolRender the title onto the featured image
cta_intensityenumNone, Soft, Direct
first_person_writingboolWrite in first person
table_of_contentsboolAdd a table of contents
avoid_formulaic_section_endingsboolSuppress formulaic section closers
require_operational_examplesboolRequire concrete operational examples
strict_banned_phrase_guardboolEnforce banned phrases strictly
banned_phrasesstring[]Phrases to avoid (list or newline text)
brand_intelligenceboolBrand intelligence on/off
duplicate_content_protectionboolDuplicate-content guard on/off
videos_enabledboolEmbed YouTube videos on/off
backlinks_networkboolParticipate in the backlink network
competitorsstring[]Competitor domains (list or CSV)

Not settable via API: articles_per_day (plan-controlled) and optimization thresholds (applied at scan time). Passing them is accepted but echoed under ignored. Connect WordPress via POST /projects/:id/wordpress.

Response
{ "project_id": 42, "updated_fields": ["tone", "duplicate_content_protection"], "updated_columns": ["ContentStyle", "DuplicateContentGuardEnabled"], "message": "Project settings updated.", "ignored": ["articles_per_day"], "notes": { "articles_per_day": "Controlled by the plan; not settable via the API." } }
POST /projects/:id/onboarding Start keyword research + first articles
5 req/min

Runs the same discovery pipeline the dashboard runs when onboarding finishes: keyword research, then the first planned articles. Ask the user before calling this; it spends keyword and LLM credits. Idempotent: if the project already has articles it will not re-run. Free and Agentic plans bring their own keywords, so research is skipped and the response says so.

Response (202)
{ "project_id": 42, "status": "started", "message": "Keyword research started. Your content calendar will fill shortly...", "poll": "/api/v1/articles?project_id=42" }
Response (200, skipped)
{ "project_id": 42, "status": "skipped_byok", "message": "Your plan brings its own keywords... Use POST /api/v1/keywords/search." }
POST /projects/:id/wordpress Connect a WordPress site
10 req/min

Connect (or reconnect) a project's WordPress site via the Distribb plugin. Install the Distribb WordPress plugin on the site, copy its Integration Key, and send it here. Credentials are validated before saving (format check plus live probe), exactly like the Settings UI.

JSON Body

FieldTypeRequiredDescription
wordpress_urlstringYesThe WordPress site URL
integration_keystringYesThe Distribb plugin Integration Key
wp_usernamestringNoOptional WordPress username
Response
{ "project_id": 42, "wordpress_url": "https://client.com", "status": "connected", "message": "WordPress connected successfully." }
GET /articles List articles with filters
30 req/min

Query Parameters

NameTypeRequiredDescription
project_idintNoFilter by project
statusstringNoDraft, Planned, Published, Generating
limitintNoMax results (default 50, max 200)
offsetintNoPagination offset
Response
{ "articles": [ { "ID": 42, "Title": "Best CRM for Startups", "MainKeyword": "best crm for startups", "Status": "Draft", "ScheduledDate": "2026-03-25T09:00:00", "ProjectID": 1, "Slug": "best-crm-for-startups", "ArticleStyle": "Informative" } ], "count": 1 }
POST /articles Submit a new article
10 req/min

Submit AI-generated content. Distribb handles backlink credit processing, stores the article, and prepares it for publishing.

JSON Body

FieldTypeRequiredDescription
project_idintYesTarget project ID
keywordstringYesMain keyword / topic
titlestringNoArticle title (defaults to keyword)
contentstringNoFull HTML content
meta_descriptionstringNoSEO meta description
scheduled_datestringNoISO 8601 date (e.g. 2026-03-25T09:00:00Z)
article_stylestringNoInformative, Listicle, How-To, etc.
statusstringNoDraft (default) or Planned
Response (201)
{ "article_id": 123, "status": "Draft", "keyword": "best crm for startups", "slug": "best-crm-for-startups", "message": "Article created as Draft.", "backlinks_processed": 2 }
PUT /articles/:id Update an existing article
10 req/min

Update an article's content, title, meta description, status, or scheduled date. Useful for revising articles to add backlink targets after receiving a backlinks_warning. Cannot update published articles.

Body Parameters
contentstringUpdated HTML content
titlestringUpdated title
meta_descriptionstringUpdated meta description
statusstring"Draft" or "Planned"
scheduled_datestringISO 8601 date (e.g. 2026-04-01T09:00:00Z)

Send only the fields you want to update. If content is updated and the project participates in the backlink network, backlinks are re-scanned.

Response
{ "article_id": 123, "updated_fields": ["Content", "IsPreGenerated"], "message": "Article updated successfully.", "backlinks_processed": 2 }
GET /articles/:id Get a single article
30 req/min

Retrieve full article details including content. The article must belong to your account.

Response
{ "ID": 123, "Title": "Best CRM for Startups", "MainKeyword": "best crm for startups", "Content": "<h1>Best CRM for...</h1>...", "MetaDescription": "Compare the top CRM...", "Status": "Draft", "Slug": "best-crm-for-startups", "ScheduledDate": "2026-03-25T09:00:00", "ProjectID": 1, "ArticleStyle": "Informative" }
DELETE /articles/:id Delete a draft or planned article
10 req/min

Delete an article that belongs to your account. Draft and Planned articles are removed. Published articles are blocked (400); unschedule them, or unpublish or hide them from the dashboard or your CMS first.

Response
{ "article_id": 123, "deleted": true, "message": "Article deleted." }
POST /keywords/search Search keywords with volume data
5 req/min

Search for keyword ideas with search volume and difficulty data. Paid Agentic Mode uses Distribb's keyword data; the Free Agentic plan uses your own DataForSEO or Ahrefs keys (see BYO Keys below). Also available at the alias POST /keywords/research (identical behavior).

JSON Body

FieldTypeRequiredDescription
keywordstringYesSeed keyword to research
project_idintNoProject for context and ownership
Response
{ "keywords": [ { "keyword": "crm software for small business", "search_volume": 2400, "keyword_difficulty": 38 } ] }
Bring-Your-Own-Keys (Free Agentic plan)

If the calling user is on the Free Agentic plan and has not yet saved a DataForSEO or Ahrefs API key, this endpoint returns HTTP 402 Payment Required with a machine-readable body so your agent knows what to do. Paid plans never see this response.

HTTP/1.1 402 Payment Required { "error": "byo_keys_required", "message": "Keyword research requires your own DataForSEO or Ahrefs API key.", "plan": "Agentic Free", "required": { "any_of": ["dataforseo", "ahrefs"] }, "setup_url": "https://distribb.io/settings#seo-keys", "docs_url": "https://distribb.io/api-docs#byo-keys", "instructions_for_agent": "Tell the user to add their DataForSEO Login + API Key (or Ahrefs API Key) at distribb.io/settings, then re-run keyword research." }

Agent contract: on receiving 402 with error = "byo_keys_required", halt the keyword-research step and surface instructions_for_agent verbatim to the human user. Do not retry until setup_url has been visited and credentials saved.

Provider precedence when both keys are saved: DataForSEO is used first (full keyword expansion); if only Ahrefs is saved, the response is sourced from Ahrefs Keywords Explorer ("source": "byo_ahrefs"). All other Distribb endpoints (articles, integrations, backlinks) work normally without BYO keys.

GET /integrations List connected CMS and social platforms
30 req/min

Lists active CMS and social integrations. Each row returns Platform (the platform/CMS type, e.g. WordPress, Shopify, Google Search Console, a social type), a friendly IntegrationName label, and Status.

Query Parameters

NameTypeRequiredDescription
project_idintNoFilter to a specific project
Response
{ "integrations": [ { "ID": 1, "Platform": "WordPress", "IntegrationName": "WordPress", "Status": "Active", "ProjectID": 1, "BusinessName": "Acme Corp" } ] }
POST /articles/generate Expand your content into a full article (Pro only)
5 req/min

Submit your own content (notes, drafts, talking points) and Distribb's AI will expand it into a full SEO-optimized article with YouTube videos, images, quotes, backlinks, and internal links. Requires the Pro plan and costs 1 article credit. Not available on the Agentic plan (use POST /articles instead to submit your own AI-generated content).

JSON Body

FieldTypeRequiredDescription
project_idintYesTarget project ID
keywordstringYesTarget keyword / topic for SEO
source_contentstringYesYour content to expand (notes, draft, talking points, etc.)
instructionsstringNoAdditional guidance (e.g. "add YouTube videos", "focus on beginners")
titlestringNoArticle title (defaults to keyword)
article_stylestringNoInformative, Listicle, How-To, etc. (default: Informative)
Response (202)
{ "article_id": 456, "status": "generating", "keyword": "link building strategies", "slug": "link-building-strategies", "message": "Article generation started. Distribb will expand your content...", "article_credits_remaining": 29 }
POST /articles/:id/publish Publish to connected CMS
5 req/min

Triggers CMS publishing for an article. Distribb handles the integration (WordPress, Webflow, Shopify, Ghost, Wix, Notion, Framer, or Webhook) based on the project's connected platform.

Response (200)
{ "status": "published", "article_id": 123 }
Response (202 - Queued for Retry)
{ "error": "Publishing failed. The article has been queued and will be retried.", "article_id": 123 }
GET /business-context Get project business details
30 req/min

Returns project-specific context needed for high-quality content: business name, description, competitors, custom AI instructions, and language. Use this to ground your AI writer in the user's brand voice.

Query Parameters

NameTypeRequiredDescription
project_idintYesYour project ID
Response
{ "business_name": "Acme Corp", "website_url": "https://acme.com", "description": "CRM platform for startups...", "competitors": ["https://competitor1.com", "https://competitor2.com"], "ai_instructions": "Use a friendly tone, focus on SaaS...", "language": "English (US)", "target_audience": "SaaS founders, startup CTOs", "internal_links_per_article": 5 }
GET /search-console GSC search performance by query and page
10 req/min

Google Search Console performance for a project: top queries, top pages, and site totals (clicks, impressions, CTR, average position) over the last N days. Requires the user to have connected GSC (Integrations, Google Search Console). If GSC is not connected, returns HTTP 200 with connected: false plus an instructions_for_agent string telling the user to connect it.

Query Parameters

NameTypeRequiredDescription
project_idintYesYour project ID
daysintNoLookback window (default 28, max 90)
limitintNoRows per dimension (default 25, max 1000)
start_rowintNoPagination offset; see the pagination block
compareboolNoAdd period-over-period deltas (see below)

Each query/page row carries is_brand (matches the business name) and striking_distance (impressions ≥ 10 and average position 4 to 20). Paginate with start_row; the response returns a pagination block whose next_start_row is null on the last page. With compare=true, each row adds delta_clicks, delta_impressions, delta_position, and is_new versus the immediately preceding window, and a top-level comparison block adds previous_totals and delta_totals.

Response
{ "connected": true, "project_id": 1, "property": "https://acme.com/", "date_range": { "start_date": "2026-06-07", "end_date": "2026-07-05", "days": 28 }, "totals": { "clicks": 1240, "impressions": 88200, "ctr": 0.0141, "avg_position": 18.4 }, "top_queries": [ { "query": "best crm for startups", "clicks": 92, "impressions": 4100, "ctr": 0.0224, "position": 7.2, "is_brand": false, "striking_distance": true } ], "top_pages": [ "..." ], "pagination": { "limit": 25, "start_row": 0, "next_start_row": 25 } }
With ?compare=true (adds)
{ "comparison": { "previous_date_range": { "start_date": "2026-05-10", "end_date": "2026-06-06", "days": 28 }, "previous_totals": { "clicks": 1010, "impressions": 80100, "ctr": 0.0126, "avg_position": 19.1 }, "delta_totals": { "clicks": 230, "impressions": 8100, "ctr": 0.0015, "avg_position": -0.7 } } }
GET /rankings  ·  /analytics Aliases of /search-console
10 req/min

Documented aliases of /search-console, for agents that look for /rankings or /analytics. Same handler, same parameters, same response. Note: this is Search Console search performance (clicks, impressions, CTR, position by query and page), NOT web-session analytics.

GET /ai-visibility AI-visibility (AEO) data by view
30 req/min

Read AI-visibility data for a project: how often it is cited across AI answer engines, its share of voice, and tracked prompts. API-key mirror of the dashboard AI-visibility pane.

Query Parameters

NameTypeRequiredDescription
project_idintYesYour project ID
viewstringNosummary (default), prompts, competitors, cited_pages
page, per_pageintNoPagination for view=prompts
Response (view=summary)
{ "visibility_score": 42, "share_of_voice": 0.18, "engines": [ { "engine": "perplexity", "cited": true } ], "manual_scans_used": 1, "manual_scans_limit": 2, "can_scan": true }
POST /ai-visibility/scan Trigger an on-demand scan
6 req/min

Trigger an on-demand AI-visibility scan (the "Scan now" action). Enforces a per-project daily manual-scan cap that is SHARED with the dashboard button and the Distribb Agent, so heavy API scanning draws from the same budget. Returns 202 when queued, 429 when the cap is hit. project_id in the body or query.

Response (202, queued)
{ "status": "queued", "project_id": 1, "manual_scans_used": 2, "manual_scans_limit": 2, "message": "Scan queued. Poll GET /api/v1/ai-visibility?view=summary until it completes." }
Response (429, rate limited)
HTTP/1.1 429 Too Many Requests { "status": "rate_limited", "manual_scans_used": 2, "manual_scans_limit": 2, "error": "Daily scan limit reached (2/2). Resets at midnight UTC." }
POST /ai-visibility/prompts Add or remove a tracked prompt
20 req/min

Add (POST) or remove (DELETE) a tracked AI-visibility prompt. Added prompts are scanned on the next scan; removal is a soft-delete (past results are kept).

JSON Body

FieldTypeRequiredDescription
project_idintYesYour project ID
promptstringYesThe prompt to track (or remove)
Response
{ "status": "added", "prompt": "best crm for startups" }
GET /suggestions List content-optimization suggestions
30 req/min

List a project's content-optimization suggestions (top queries and pages where a rewrite could lift rankings). Returns per-status counts, the project's suggestion settings, and whether GSC is connected. Suggestions are largely GSC-driven, so a disconnected project will usually have none.

Query Parameters

NameTypeRequiredDescription
project_idintYesYour project ID
statusstringNopending, approved, rewriting, ready, published, rejected, failed, superseded
typestringNoFilter by opportunity_type: cannibalisation, declining_page, striking_distance, ctr_underperform, etc.
limitintNoMax results (default 100, max 500)
Response
{ "project_id": 1, "suggestions": [ { "id": 7, "status": "pending", "article_title": "Best CRM for Startups" } ], "counts": { "pending": 3, "ready": 1, "published": 5 }, "gsc_connected": true }
POST /suggestions/run Trigger an on-demand suggestion scan
3 req/min

Pull GSC, score articles, and insert new pending suggestions (deduped against existing open ones). Mirrors the weekly Monday cron. Returns the count created. project_id in the body or query.

Response
{ "project_id": 1, "created": 4, "message": "4 new suggestion(s) created. List them with GET /api/v1/suggestions?..." }
POST /suggestions/:id/… Read + act on a single suggestion
5-30 req/min

The full review-and-publish loop for one suggestion. The typical path is approve (starts a background rewrite), poll the single-suggestion GET until status is ready, review the diff, then publish.

Endpoints

Method + PathDescription
GET /suggestions/:idGet one suggestion, including proposed_diff once a rewrite is ready
GET /suggestions/:id/diffBefore/after rewrite plus the GSC trigger_snapshot (null until ready)
POST /suggestions/:id/approveApprove a pending suggestion; kicks off scrape + LLM rewrite. Poll until ready
POST /suggestions/:id/rejectReject so it stops showing as actionable. Optional body { "reason": "..." }
POST /suggestions/:id/publishPublish a ready rewrite to the connected CMS (409 if the article changed since staging)
POST /suggestions/:id/regenerateRe-run the rewrite with optional { "feedback": "..." }. Paid plans only (402 on free)
Response (approve)
{ "suggestion": { "id": 7, "status": "approved" }, "message": "Approved. A background rewrite has started; poll until status is 'ready', then publish it." }
GET /microworkers/campaigns List registered Microworkers campaigns
30 req/min

List project-scoped Microworkers campaigns registered through Distribb.

Query Parameters

NameTypeRequiredDescription
project_idintNoFilter to a specific project
limitintNoMax results (default 50, max 100)
offsetintNoPagination offset
Response
{ "campaigns": [ { "ID": 3, "ProjectID": 1, "MicroworkersCampaignID": "abc123", "Platform": "linkedin", "Title": "Share our article", "Status": "running", "AvailablePositions": 50, "PaymentPerTask": 0.15 } ], "count": 1 }
POST /microworkers/campaigns Create + manage Microworkers campaigns
5-30 req/min

Create a Microworkers Basic Campaign and register it to a project, or register/inspect an existing one. Creating a campaign also builds its Microworkers template.

Endpoints

Method + PathDescription
POST /microworkers/campaignsCreate + register. Body: project_id, title, description, template_html (plus optional available_positions, payment_per_task ≥ 0.15, etc.)
POST /microworkers/campaigns/registerRegister an existing campaign by campaign_id so this key can manage it
GET /microworkers/campaigns/:idGet a registered campaign plus its live Microworkers status
GET /microworkers/campaigns/:id/slotsList slots/submissions (query: page, pageSize, status)
POST /microworkers/slots/:id/rateRate a slot. Body: campaign_id, rating (OK, NOK, REVISE), optional comment
Response (201, create)
{ "campaign_id": "abc123", "template_id": "tpl_456", "project_id": 1, "status": "created", "external_url": "https://ttv.microworkers.com/campaign/abc123", "message": "Microworkers campaign created and registered to the project." }
GET /outreach/entitlement Check Outreach Pro entitlement
30 req/min

Returns whether the authenticated account is entitled to Outreach Pro (the flag is set and the account is active). Returns 403 when the Outreach Pro beta is closed for the account.

Response
{ "entitled": true, "plan": "Outreach Pro", "status": "active" }
POST /outreach/sync Mirror the Outreach skill DB
10 req/min

Idempotent upsert of the local Outreach skill database into MySQL mirror tables, keyed by the authenticated account. Body: { since, persons[], identities[], leads[], actions[], suppressions[] }. The client re-pushes boundary rows; the upsert makes it idempotent. Returns per-table counts. 403 if not entitled.

Response
{ "synced": { "persons": 12, "identities": 3, "leads": 40, "actions": 88, "suppressions": 5 } }
POST your_webhook_url Payload Distribb sends to your endpoint

If you connect a custom Webhook integration (instead of WordPress / Webflow / Shopify / Ghost / Wix / Notion / Framer), Distribb posts each finished article to your URL. The payload is wire-compatible with Outrank’s webhook spec, so any receiver written for Outrank works against Distribb with zero changes. Your endpoint must accept this JSON shape, validate the bearer token, and respond with 200 within 30 seconds.

Headers

NameValue
Content-Typeapplication/json
AuthorizationBearer <your access token> — the token you set in Settings → Integrations → Webhook
X-API-Key<your access token> — same value, sent for receivers that read this header (AWS API Gateway, generic SaaS)
x-make-apikey<your access token> — same value, sent for Make.com Custom Webhook "API Key restriction"
User-AgentDistribb-Publisher/1.0

Distribb sends your access token under three header names so the request authenticates against any common webhook receiver without extra configuration. Your endpoint only needs to validate one of them.

Body

FieldTypeDescription
event_typestring"publish_articles" on the first send for a given slug; "update_articles" when the user clicks Sync to CMS on an article that is already live. Route on this field to decide CREATE vs UPDATE on your side.
timestampstringWhen the webhook was sent (ISO 8601, UTC)
data.articles[].idstringDistribb article ID — stable across publish and subsequent sync events. Safe to use as your idempotency key.
data.articles[].titlestringArticle title
data.articles[].slugstringURL slug, e.g. lawn-care-toronto. Treat as the canonical identifier on your CMS so subsequent update_articles events resolve to the same post.
data.articles[].content_htmlstringFull article body as HTML (with <h2>, <p>, <ul>, <figure>, etc.). Render this if your CMS displays HTML directly.
data.articles[].content_markdownstringSame body converted to real Markdown. Render this if your CMS expects Markdown. Pick one of the two — never both.
data.articles[].meta_descriptionstringSEO meta description (~155 chars)
data.articles[].created_atstringWhen the article was created (ISO 8601, UTC)
data.articles[].image_urlstringPublic URL of the feature image (already hosted on our CDN). Use as-is or re-upload to your storage.
data.articles[].alt_textstringAlt text for the feature image
data.articles[].tagsstring[]SEO tags / keywords
data.articles[].authorstringAuthor display name
data.articles[].statusstring"Published" or "Draft" — based on the publishing preference set on your project
data.articles[].is_updatebooleantrue on sync events, false on first publish. Redundant with event_type but handy if your router checks the article object directly.
Example payload — initial publish
{ "event_type": "publish_articles", "timestamp": "2026-04-18T15:21:00Z", "data": { "articles": [ { "id": "63452", "title": "Essential Lawn Maintenance Toronto Guide 2026", "slug": "essential-lawn-maintenance-toronto-2026", "content_html": "<h2>Why a Healthy Lawn Matters</h2><p>Toronto lawns face...</p>...", "content_markdown": "## Why a Healthy Lawn Matters\n\nToronto lawns face...", "meta_description": "A practical guide to year-round lawn care in Toronto...", "created_at": "2026-04-15T12:23:16Z", "image_url": "https://rebelgrowth.s3.us-east-1.amazonaws.com/blog-images/lawn-1.jpg", "alt_text": "lawn maintenance Toronto", "tags": ["lawn care", "toronto", "seasonal"], "author": "Trim Gym Lawn Care", "status": "Published", "is_update": false } ] } }
Example payload — sync (user re-edited in Distribb, clicked “Sync to CMS”)
{ "event_type": "update_articles", "timestamp": "2026-04-19T09:12:33Z", "data": { "articles": [ { "id": "63452", "slug": "essential-lawn-maintenance-toronto-2026", "title": "Essential Lawn Maintenance Toronto Guide 2026 (Updated)", "content_html": "...revised body...", "status": "Published", "is_update": true // remaining fields identical shape; only changed values differ } ] } }
Expected response (200)
{ "status": "ok", "published_url": "https://yourdomain.com/blog/essential-lawn-maintenance-toronto-2026" }

If you return a published_url in the response body, Distribb will store it and link to it from your dashboard. Any non-2xx response triggers up to 3 retries with exponential backoff.

Handling updates (recommended)

When the user clicks Sync to CMS on an article that has already been published, Distribb sends the same payload shape but with event_type: "update_articles" and is_update: true. Implement upsert on your side — resolve the existing post by slug (or by storing Distribb’s id on first publish), then update its title, body, meta, image, and status in place rather than creating a new post.

if (payload.event_type === "update_articles") { // find the existing post by slug or by stored Distribb id const existing = await db.posts.findOne({ slug: article.slug }); if (existing) { await db.posts.update(existing.id, article); } else { await db.posts.create(article); // fall back to create if not found } } else { await db.posts.create(article); }

If your receiver ignores event_type and always inserts, sync events will create duplicate posts. Always branch on event_type or store Distribb’s id on first publish.

Common pitfalls
  • Wall-of-text output → you're rendering content_html through a Markdown parser (or content_markdown through an HTML renderer). Pick the field that matches your renderer.
  • Missing images → your CMS strips remote <img> tags. Either allow our S3 domain or re-upload image_url to your own storage before saving.
  • 401 from your endpoint → the bearer token in Settings doesn't match what your endpoint expects. Update one of them.
  • Duplicate posts after Sync → your handler always inserts and ignores event_type. Branch on "update_articles" to upsert by slug.

MCP Server Model Context Protocol

Instead of writing curl commands, connect the Distribb MCP server to Claude or Cursor and call every endpoint as a native tool — no CLI flags, no shell scripts. The agent calls list_projects, create_article, publish_article directly — same workflow, zero boilerplate.

1
Download the server

Clone the distribb-skill repo or download distribb_mcp_server.py directly.

git clone https://github.com/Bomx/distribb-skill # or just download distribb_mcp_server.py
2
Install dependencies
pip install mcp requests python-dotenv
3
Add to your MCP config

Cursor: ~/.cursor/mcp.json  ·  Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json

{ "mcpServers": { "distribb": { "command": "python3", "args": ["/path/to/distribb_mcp_server.py"], "env": { "DISTRIBB_API_KEY": "your_api_key_here" } } } }
4
Restart your client

Claude or Cursor will launch the server automatically. The tools appear immediately in the agent's tool list.


Available tools
list_projects Get all projects and their IDs
get_business_context Brand voice, competitors, AI instructions
search_keywords Keyword research with volume + difficulty
get_internal_links Existing pages to cross-link in new articles
get_backlink_targets Partner URLs to earn backlink credits
get_backlinks_status Credit balance and network status
list_articles Content calendar, filterable by status
get_article Full article details including content
create_article Submit a new SEO article
update_article Revise draft or planned articles
publish_article Push to CMS + trigger social repurposing
list_integrations See connected CMS and social platforms

Typical Workflow

Here is how an AI agent typically uses the API to create and publish an SEO article:

1. GET /projects # Get your project ID 2. GET /business-context?project_id=1 # Fetch brand voice + competitors 3. POST /keywords/search # Find target keywords 4. GET /internal-links?project_id=1&keyword=... # Get pages to link to 5. GET /backlink-targets?project_id=1&keyword=... # Get network URLs to cite 6. # Your AI writes the article using context from steps 2-5 7. POST /articles # Submit the article 8. POST /articles/123/publish # Publish to CMS

Errors

All error responses follow the same format:

{ "error": "Description of what went wrong" }
Status CodeMeaning
400Bad request — missing or invalid parameters
401Unauthorized — invalid or missing API key
402Payment required. project_limit_reached (POST /projects; body carries a purchase_url) or byo_keys_required (keyword search on the Free Agentic plan). Both return a machine-readable body with next-step instructions.
403Forbidden. Resource does not belong to your account, or a beta feature is closed
404Not found — resource does not exist
409Conflict. The target changed since it was staged (e.g. publishing a suggestion whose article moved)
429Rate limited. Too many requests, or a daily cap (e.g. AI-visibility scans) was hit; wait and retry
500Server error — something went wrong on our end