Content Autopilot API

Integrate the Content Autopilot into your CMS with the Pull API and Webhooks. This page contains everything you need to build a complete integration.

Quick Start

1

API key revoked.

Go to Account > API Keys and create a new API key. The key starts with sk_live_. API key generated. Copy it now - it will only be shown once.

2

Configure Webhook

Go to a project > CMS Settings. Enter your webhook URL and copy the automatically generated secret. Select the events you want to receive.

3

Publish articles

Receive webhooks, verify the HMAC signature, fetch the article via the Pull API and confirm publication.

Integration Flow

The integration follows a push + pull pattern. Webhooks notify your system of new articles, the Pull API delivers the full content.

Step 1: Article approved in Visibly Content Autopilot | v Step 2: Webhook fires to your endpoint POST https://your-cms.com/webhooks/visibly Headers: Content-Type: application/json X-Webhook-Signature: sha256=<hmac_digest> X-Webhook-Event: article.approved User-Agent: VisiblyAI-Webhook/1.0 Body: { "event": "article.approved", "article_id": 42, "title": "SEO Guide 2026", "slug": "seo-guide-2026", "project_id": 5, "scheduled_date": "2026-03-01T09:00:00", "pull_url": "https://www.antonioblago.com/content-autopilot/api/v1/articles/42", "timestamp": "2026-02-20T10:00:00Z" } | v Step 3: Your handler verifies HMAC-SHA256 signature Compare X-Webhook-Signature against HMAC(secret, raw_body) Reject with 401 if invalid | v Step 4: Your handler calls Pull API to fetch full content GET /content-autopilot/api/v1/articles/42?include_markdown=true Authorization: Bearer sk_live_abc123 | v Step 5: Your CMS saves/publishes the article Use scheduled_date for scheduling, content_html for rendering | v Step 6: Your handler confirms publication POST /content-autopilot/api/v1/articles/42/confirm Authorization: Bearer sk_live_abc123 Body: {"published_url": "https://your-cms.com/blog/seo-guide-2026"} | v Done. Article status changes to "published" in Visibly.

Authentication

All API requests require a Bearer token in the Authorization header:

Authorization: Bearer sk_live_abc123def456

You manage your API keys under /account/api-keys.

Inactive or deleted API keys are immediately rejected with HTTP 401. Each API key is tied to a user. You can only access your own articles.

Article Lifecycle

Every article passes through the following status phases:

StatusDescriptionWebhook
queuedArticle queued for generation.-
generatingArticle is being regenerated...-
draftDraft is available, can be edited-
approvedApproved for publicationarticle.approved
publishedWas published externally (via confirm endpoint)article.published
rejectedWas rejected-
failedStatus change failed.article.failed

The typical flow for CMS integrations: you filter the Pull API by status=approved or you receive the article.approved webhook, fetch the article and then confirm with the confirm endpoint.

Pull API Endpoints

Base URL: https://www.antonioblago.com/content-autopilot

GET /api/v1/articles

Lists articles with optional filters. Results are sorted by creation date descending.

ParameterTypStandardDescription
statusstringAllFilter by status: queued, generating, draft, approved, published, rejected, failed
project_idintAllFilter by project ID (positive integer)
sincestring(none)Only articles from this date onwards (format: YYYY-MM-DD)
limitint20Max. results per page (1-100, values above 100 are capped at 100)
offsetint0Pagination Offset
# List all approved articles for project 5 curl -H "Authorization: Bearer sk_live_abc123" \ "https://www.antonioblago.com/content-autopilot/api/v1/articles?status=approved&project_id=5&limit=10"
// Response (200 OK) { "articles": [ { "id": 42, "title": "SEO Guide 2026", "slug": "seo-guide-2026", "status": "approved", "word_count": 1850, "seo_score": 85, "meta_description": "Learn how to optimize your website for search engines in 2026.", "project_id": 5, "published_url": "", "scheduled_date": "2026-03-01T09:00:00", "created_at": "2026-02-20T10:00:00", "updated_at": "2026-02-20T12:00:00" } ], "total": 42, "limit": 10, "offset": 0 }

GET /api/v1/articles/{id}

Fetches a single article with full content. Always returns HTML content. Markdown content only on request.

ParameterTypStandardDescription
id (Path)intsuccessful!Article ID
include_markdownboolfalseAlso return Markdown source in the content_markdown field
# Fetch article 42 with full markdown content curl -H "Authorization: Bearer sk_live_abc123" \ "https://www.antonioblago.com/content-autopilot/api/v1/articles/42?include_markdown=true"
// Response (200 OK) { "article": { "id": 42, "title": "SEO Guide 2026", "slug": "seo-guide-2026", "status": "approved", "word_count": 1850, "seo_score": 85, "meta_description": "Learn how to optimize your website for search engines in 2026.", "content_html": "<h1>SEO Guide 2026</h1>\n<p>Search engine optimization is...</p>", "content_markdown": "# SEO Guide 2026\n\nSearch engine optimization is...", "keywords": ["seo", "search engine optimization", "keyword research"], "project_id": 5, "published_url": "", "scheduled_date": "2026-03-01T09:00:00", "cms_published_at": null, "created_at": "2026-02-20T10:00:00", "updated_at": "2026-02-20T12:00:00" } }
The field content_markdown is only returned when include_markdown=true is set. Without this parameter it is absent from the response to reduce response size.

POST /api/v1/articles/{id}/confirm

Confirms that the article was published externally. This endpoint is idempotent: calling it again on an already published article returns success.

FieldTypRequiredDescription
id (Path)intYesArticle ID
published_url (Body)stringNoThe public URL of the published article. Must start with https://.
# Confirm article published with the live URL curl -X POST -H "Authorization: Bearer sk_live_abc123" \ -H "Content-Type: application/json" \ -d '{"published_url": "https://myblog.com/seo-guide-2026"}' \ "https://www.antonioblago.com/content-autopilot/api/v1/articles/42/confirm"
// Response (200 OK) - first confirmation { "success": true, "article_id": 42, "status": "published", "message": "Article marked as published" } // Response (200 OK) - already published (idempotent) { "success": true, "article_id": 42, "status": "published", "message": "Article already published" }

Field Reference

Complete listing of all fields in API responses.

Article Fields (List and Single Fetch)

FieldTypNullableDescription
idintNoUnique article ID
titlestringNoArticles in progress
slugstringNoURL-friendly slug (e.g. "seo-guide-2026")
statusstringNoCurrent status (see Article Lifecycle)
word_countintNoWord count of the article (0 if not generated)
seo_scoreintNoSEO optimization score from 0-100
meta_descriptionstringNoSEO meta description (max. 160 characters)
project_idintYesID of the associated project
published_urlstringNoPublic URL after publication (empty if not published)
scheduled_datestringYesScheduled publication date in ISO 8601 format (e.g. "2026-03-01T09:00:00"). null = no date set.
created_atstringNoCreation date in ISO 8601 format
updated_atstringNoLast update in ISO 8601 format

Additional Fields (single fetch only GET /articles/{id})

FieldTypNullableDescription
content_htmlstringNoFull article content as HTML. Always included.
content_markdownstringNoArticle content as Markdown source. Only included when include_markdown=true.
keywordsarrayNoList of target keywords as a string array, e.g. ["seo", "keyword research"]
cms_published_atstringYesTime of CMS publication in ISO 8601 format. null if not yet published.

Pagination Fields (list only GET /articles)

FieldTypDescription
totalintTotal number of articles (without limit/offset)
limitintApplied limit
offsetintApplied offset

Webhook Events

Webhooks are triggered on status changes. You configure them per CMS connection under Project > CMS Settings. There you select the events and receive the webhook secret.

EventTriggered when
article.approvedArticle was approved. This is the primary event for CMS integrations: article is ready for publication.
article.publishedArticle was confirmed as published via the confirm endpoint.
article.failedArticle generation has failed. Useful for monitoring/alerting.

Webhook Payload

Every webhook sends the following JSON as the POST body:

FieldTypDescription
eventstringEvent type (e.g. "article.approved")
article_idintID of the affected article
titlestringArticles in progress
slugstringURL slug of the article
project_idintError loading the project.
scheduled_datestring|nullScheduled date in ISO 8601 format, or null if not set
pull_urlstringFull URL for fetching the article via the Pull API
timestampstringTime of webhook trigger in ISO 8601 UTC format
{ "event": "article.approved", "article_id": 42, "title": "SEO Guide 2026", "slug": "seo-guide-2026", "project_id": 5, "scheduled_date": "2026-03-01T09:00:00", "pull_url": "https://www.antonioblago.com/content-autopilot/api/v1/articles/42", "timestamp": "2026-02-20T10:00:00Z" }

Webhook HTTP Headers

CountriesDescription
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256 signature: sha256=<hex_digest>
X-Webhook-EventEvent type (e.g. article.approved)
User-AgentVisiblyAI-Webhook/1.0

Status change failed.

Details on webhook delivery:

PropertyValue
HTTP methodPOST
Timeout10 seconds. Your endpoint must respond within 10 seconds with a 2xx status.
Retries3 attempts with increasing delay: 1s, 5s, 25s
successful!HTTP 200-299 counts as successful delivery
RedirectsHTTP 3xx are blocked (SSRF protection). The webhook is considered failed.
Expected ResponseYour endpoint should return JSON with {"success": true} but any 2xx status is accepted.
Important: Process the webhook quickly or asynchronously. If your endpoint takes longer than 10 seconds, the webhook is considered failed and will be redelivered. Recommendation: save webhook data immediately and process in the background.

HMAC-SHA256 Verification

Every webhook is sent with an HMAC-SHA256 signature in the header X-Webhook-Signature sent. Verify the signature to ensure the webhook originates from Visibly and has not been tampered with.

Algorithm:

  1. Read the raw request body as bytes (not as a string!)
  2. Compute HMAC-SHA256(webhook_secret, raw_body)
  3. Compare the result as sha256=<hex_digest> with the header value
  4. Use a timing-safe comparison (e.g. hmac.compare_digest) to prevent timing attacks

Python

import hmac, hashlib def verify_signature(payload_bytes, secret, signature): """Verify HMAC-SHA256 webhook signature. Args: payload_bytes: Raw request body as bytes secret: Your webhook secret (string) signature: Value of X-Webhook-Signature header Returns: True if signature is valid """ if not signature or not signature.startswith('sha256='): return False expected = f"sha256={hmac.new(secret.encode('utf-8'), payload_bytes, hashlib.sha256).hexdigest()}" return hmac.compare_digest(expected, signature) # In your Flask route: payload = request.get_data() # raw bytes, NOT request.json sig = request.headers.get('X-Webhook-Signature', '') if not verify_signature(payload, WEBHOOK_SECRET, sig): return jsonify({'error': 'Invalid signature'}), 401

Node.js

const crypto = require('crypto'); function verifySignature(payload, secret, signature) { if (!signature || !signature.startsWith('sha256=')) return false; const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signature) ); } // Express with raw body parser: // app.use('/webhooks', express.raw({ type: 'application/json' })) app.post('/webhooks/visibly', (req, res) => { const sig = req.headers['x-webhook-signature'] || ''; if (!verifySignature(req.body, process.env.WEBHOOK_SECRET, sig)) { return res.status(401).json({ error: 'Invalid signature' }); } const data = JSON.parse(req.body); const { article_id, scheduled_date, pull_url } = data; // Fetch full article from pull_url... res.json({ success: true }); });

PHP

function verifySignature($payload, $secret, $signature) { if (!$signature || strpos($signature, 'sha256=') !== 0) return false; $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); return hash_equals($expected, $signature); } $payload = file_get_contents('php://input'); $sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? ''; if (!verifySignature($payload, $webhookSecret, $sig)) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } $data = json_decode($payload, true); $articleId = $data['article_id']; $scheduledDate = $data['scheduled_date'] ?? null; // Fetch full article via Pull API...

Created on

EndpointLimit
GET /api/v1/articles120 requests / minute
GET /api/v1/articles/{id}60 requests / minute
POST /api/v1/articles/{id}/confirm30 requests / minute

When exceeded you receive HTTP 429 with a Retry-After header. Wait the indicated number of seconds before retrying the request.

Error Responses

All errors are returned as JSON:

{ "error": "Human-readable error description" }
enErrorCause
400invalid_statusstatus parameter not in the allowed list
400invalid_limitlimit is not a positive integer
400invalid_sincesince does not match the format YYYY-MM-DD
400invalid_urlpublished_url is not a valid https:// URL
401unauthorizedMissing, invalid or disabled API key
404not_foundCrawl not found or access denied.
429rate_limitedRate limit exceeded
500internal_errorInternal server error
Security note: Both "not found" and "belongs to another user" return 404. This prevents revealing whether articles of other users exist.

Full Integration Example

This code shows a production-ready integration. Copy it as a starting point for your CMS.

Flask (Python)

import hmac, hashlib, json, logging, requests from flask import Flask, request, jsonify app = Flask(__name__) logger = logging.getLogger(__name__) # Configuration - replace with your actual credentials WEBHOOK_SECRET = 'your-webhook-secret-from-cms-settings' API_KEY = 'sk_live_your_api_key' BASE_URL = 'https://www.antonioblago.com/content-autopilot' def verify_signature(payload_bytes, secret, signature): """Verify HMAC-SHA256 webhook signature.""" if not signature or not signature.startswith('sha256='): return False expected = f"sha256={hmac.new(secret.encode('utf-8'), payload_bytes, hashlib.sha256).hexdigest()}" return hmac.compare_digest(expected, signature) def fetch_article(article_id): """Fetch full article from Pull API.""" url = f'{BASE_URL}/api/v1/articles/{article_id}?include_markdown=true' resp = requests.get(url, headers={'Authorization': f'Bearer {API_KEY}'}, timeout=30) resp.raise_for_status() return resp.json()['article'] def confirm_published(article_id, published_url): """Confirm article was published.""" url = f'{BASE_URL}/api/v1/articles/{article_id}/confirm' resp = requests.post(url, headers={'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json'}, json={'published_url': published_url}, timeout=30) return resp.status_code == 200 def save_to_cms(article, scheduled_date): """Save article to your CMS. Replace with your actual CMS logic.""" post = { 'title': article['title'], 'slug': article['slug'], 'body_html': article['content_html'], 'body_markdown': article.get('content_markdown', ''), 'meta_description': article['meta_description'], 'keywords': article['keywords'], 'publish_at': scheduled_date, # null = publish immediately } # db.session.add(Post(**post)) # db.session.commit() logger.info(f"Saved article: {article['title']}") return f"https://myblog.com/blog/{article['slug']}" @app.route('/webhooks/visibly', methods=['POST']) def handle_webhook(): # Step 1: Verify HMAC signature payload = request.get_data() sig = request.headers.get('X-Webhook-Signature', '') if not verify_signature(payload, WEBHOOK_SECRET, sig): return jsonify({'error': 'Invalid signature'}), 401 # Step 2: Parse webhook payload data = json.loads(payload) event = data['event'] article_id = data['article_id'] scheduled_date = data.get('scheduled_date') # ISO string or null # Step 3: Only process article.approved events if event != 'article.approved': return jsonify({'success': True, 'message': 'Event ignored'}) # Step 4: Fetch full article via Pull API article = fetch_article(article_id) # Step 5: Save to CMS published_url = save_to_cms(article, scheduled_date) # Step 6: Confirm publication confirm_published(article_id, published_url) return jsonify({'success': True, 'article_id': article_id})

Node.js (Express)

const express = require('express'); const crypto = require('crypto'); const axios = require('axios'); const app = express(); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; const API_KEY = process.env.VISIBLY_API_KEY; const BASE_URL = 'https://www.antonioblago.com/content-autopilot'; // IMPORTANT: Use raw body parser for HMAC verification app.use('/webhooks', express.raw({ type: 'application/json' })); function verifySignature(payload, secret, signature) { if (!signature || !signature.startsWith('sha256=')) return false; const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); } catch { return false; } } app.post('/webhooks/visibly', async (req, res) => { // Step 1: Verify signature const sig = req.headers['x-webhook-signature'] || ''; if (!verifySignature(req.body, WEBHOOK_SECRET, sig)) { return res.status(401).json({ error: 'Invalid signature' }); } // Step 2: Parse payload const data = JSON.parse(req.body); const { event, article_id, scheduled_date } = data; if (event !== 'article.approved') { return res.json({ success: true, message: 'Event ignored' }); } // Step 3: Fetch full article const { data: articleResp } = await axios.get( `${BASE_URL}/api/v1/articles/${article_id}?include_markdown=true`, { headers: { Authorization: `Bearer ${API_KEY}` } } ); const article = articleResp.article; // Step 4: Save to your CMS const publishedUrl = await saveToCms(article, scheduled_date); // Step 5: Confirm publication await axios.post( `${BASE_URL}/api/v1/articles/${article_id}/confirm`, { published_url: publishedUrl }, { headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' } } ); res.json({ success: true, article_id }); }); app.listen(3000);

Python SDK (ContentPilot Integration)

We provide a ready-made Python module that you can integrate directly into your Flask application. It includes a Pull API client, HMAC verification and a Flask Blueprint as a webhook receiver.

Usage

# pip install ai-content-autopilot from ai_content_autopilot import ( contentpilot_webhook_bp, configure_visibly, VisiblyClient, verify_webhook_signature, ) # --- Option A: Full Blueprint (webhook receiver + auto-fetch) --- def my_article_handler(article): """Called when a webhook delivers an article. Args: article: dict with all article fields + webhook metadata: - id, title, slug, content_html, content_markdown - keywords, meta_description, seo_score, word_count - scheduled_date, cms_published_at - _webhook_event (e.g. "article.approved") - _webhook_timestamp - _scheduled_date (from webhook payload) Returns: True if successfully processed """ # Save to your database, CMS, filesystem, etc. db.session.add(Post( title=article['title'], body=article['content_html'], publish_at=article.get('_scheduled_date'), )) db.session.commit() return True configure_visibly( webhook_secret='your-webhook-secret', api_key='sk_live_your_api_key', on_article_received=my_article_handler, ) app.register_blueprint(contentpilot_webhook_bp) # Now POST /webhooks/visibly will automatically: # 1. Verify HMAC signature # 2. Fetch full article via Pull API # 3. Call my_article_handler(article) # 4. Return {"success": true} or error # --- Option B: Standalone Pull API Client --- client = VisiblyClient( api_key='sk_live_your_api_key', base_url='https://www.antonioblago.com/content-autopilot', timeout=30, ) # List approved articles articles = client.list_articles(status='approved', project_id=5, limit=20) for a in articles: print(a['id'], a['title'], a['scheduled_date']) # Fetch single article with full content article = client.fetch_article(42, include_markdown=True) print(article['content_html']) print(article['keywords']) # Confirm publication success = client.confirm_published(42, 'https://myblog.com/seo-guide-2026') # --- Option C: Just HMAC verification --- payload_bytes = request.get_data() signature = request.headers.get('X-Webhook-Signature', '') is_valid = verify_webhook_signature(payload_bytes, 'your-secret', signature)

Copy

Function / ClassDescription
configure_visibly(webhook_secret, api_key, base_url, on_article_received)Configures the Blueprint with credentials and callback function
verify_webhook_signature(payload_bytes, secret, signature_header)Verifies HMAC-SHA256 signature. Returns True/False.
VisiblyClient(api_key, base_url, timeout)Pull API client for fetching, listing articles and confirming publication
client.fetch_article(article_id, include_markdown)Returns article dict or None on error
client.list_articles(status, project_id, limit, offset)Returns list of article dicts
client.confirm_published(article_id, published_url)Confirms publication. Returns True/False.
contentpilot_webhook_bpFlask Blueprint. Register with app.register_blueprint(). Endpoint: POST /webhooks/visibly
default_flask_blog_handler(article)Default handler: Saves article as JSON in blog_translations/html/
Cookie-Settings