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.
Table of Contents
Quick Start
Create an API key
Account-wide: Settings > API Key & MCP, key starts with lc_. Project-bound: in the project under the Content Autopilot tool > CMS connections > "Contentpilot API key (Pull)", key starts with cp_. Both are shown in plain text only once, copy it immediately.
Configure a webhook
Enter your webhook URL and a shared HMAC secret of your own choosing in the same CMS connection (it is not generated automatically, you set it yourself and keep it identical on both sides). Select the events you want to receive.
Fetch the article
Receive webhooks, verify the HMAC signature, fetch the article via the Pull API and confirm publication.
https://app.visibly-ai.com. The paths /api/v1/articles... stay exactly as documented below.
Integration Flow
The integration follows a push and 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://app.visibly-ai.com/api/v1/articles/42",
"timestamp": "2026-02-20T10:00:00Z"
}
|
v
Step 3: Your handler verifies the HMAC-SHA256 signature
Compare X-Webhook-Signature against HMAC(secret, raw_body)
Reject with 401 if invalid
|
v
Step 4: Your handler calls the Pull API to fetch full content
GET /api/v1/articles/42?include_markdown=true
Authorization: Bearer cp_your_project_key
|
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 /api/v1/articles/42/confirm
Authorization: Bearer cp_your_project_key
Body: {"published_url": "https://your-cms.com/blog/seo-guide-2026"}
|
v
Done. The article status changes to "published" in Visibly.
Authentication
All API requests require a Bearer token in the Authorization header:
Authorization: Bearer cp_your_project_key
Two key types on the same gate
| Prefix | Scope | Create it at |
|---|---|---|
lc_ |
Account-wide. Access to all of your own articles, across projects. A client-side project_id filter works normally here. |
Settings > API Key & MCP |
cp_ |
Project-bound. Sees exclusively this one project, the scope is hard-enforced on every query. A submitted project_id filter is overridden by the scope. Exactly one active key per project, creating a new one rotates the old one out. |
In the project under Content Autopilot > CMS connections > "Contentpilot API key (Pull)" |
Article Lifecycle
Every article passes through the following status phases:
| Status | Description | Webhook |
|---|---|---|
queued |
Article is queued for generation | - |
generating |
Article is currently being generated by the AI | - |
draft |
Draft is available, can be edited | - |
approved |
Approved for publication | article.approved |
published |
Was published externally (via the confirm endpoint) | article.published |
rejected |
Was rejected | - |
archived |
Was manually archived, no longer an active status, not a substitute for "rejected" | - |
failed |
Generation failed | article.failed |
article.failed is reserved as an event type but is not yet fired by visibly today, neither from the generation workflow nor as a selectable checkbox in the CMS connection settings (only article.approved and article.published are offered there). For failure monitoring, filter the Pull API by status=failed instead.
The typical flow for CMS integrations: you filter the Pull API by status=approved or receive the article.approved webhook, fetch the article and then confirm with the confirm endpoint.
Pull API Endpoints
Base URL: https://app.visibly-ai.com
GET/api/v1/articles
Lists articles with optional filters. Results are sorted by creation date descending.
| Parameter | Type | Default | Description |
|---|---|---|---|
status |
string | (all) | Filter by status: queued, generating, draft, approved, rejected, archived, published, failed |
project_id |
int | (all) | Filter by project ID. With a project-bound cp_ key this parameter is ignored, the key's scope always wins. |
since |
string | (none) | Only articles from this date onwards (format: YYYY-MM-DD) |
limit |
int | 20 | Max results per page. Values below 1 are raised to 1, values above 100 are capped at 100 (no error). |
offset |
int | 0 | Pagination offset |
# List all approved articles for project 5
curl -H "Authorization: Bearer cp_your_project_key" \
"https://app.visibly-ai.com/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,
"plan_id": 12,
"recommended_page_type": "guide",
"url_prefix": "/guides/seo/",
"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.
| Parameter | Type | Default | Description |
|---|---|---|---|
id (path) |
int | required | Article ID |
include_markdown |
bool | false | Also return Markdown source in the content_markdown field |
# Fetch article 42 with full markdown content
curl -H "Authorization: Bearer cp_your_project_key" \
"https://app.visibly-ai.com/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,
"plan_id": 12,
"recommended_page_type": "guide",
"url_prefix": "/guides/seo/",
"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"
}
}
content_markdown is only returned when include_markdown=true is set. Without this parameter it is absent from the response to reduce response size. plan_id, recommended_page_type and url_prefix are routing signals for your CMS, for example for its own path prefixes. Full description in the Field Reference further below.
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.
| Field | Type | Required | Description |
|---|---|---|---|
id (path) |
int | Yes | Article ID |
published_url (body) |
string | No | The public URL of the published article. Must be a reachable http(s) URL with a public host, localhost and internal hosts are rejected. |
# Confirm article published with the live URL
curl -X POST -H "Authorization: Bearer cp_your_project_key" \
-H "Content-Type: application/json" \
-d '{"published_url": "https://myblog.com/seo-guide-2026"}' \
"https://app.visibly-ai.com/api/v1/articles/42/confirm"
// Response (200 OK) - first confirmation
{
"success": true,
"article_id": 42,
"message": null
}
// Response (200 OK) - already published (idempotent)
{
"success": true,
"article_id": 42,
"message": "Article already confirmed as published"
}
Field Reference
Complete listing of all fields in API responses.
Article Fields (List and Single Fetch)
| Field | Type | Nullable | Description |
|---|---|---|---|
id |
int | No | Unique article ID |
title |
string | No | Article title |
slug |
string | No | URL-friendly slug (e.g. "seo-guide-2026") |
status |
string | No | Current status (see Article Lifecycle) |
word_count |
int | No | Word count of the article (0 if not generated) |
seo_score |
int | No | SEO optimization score from 0-100 (0 if not measured) |
meta_description |
string | No | SEO meta description (max. 160 characters) |
project_id |
int | Yes | ID of the associated project |
plan_id |
int | Yes | ID of the associated content plan (cluster), if the article came from a content strategy. null if generated without a plan. |
recommended_page_type |
string | No | Recommended page type for the target CMS, e.g. guide, blog, product, collection, landing_page, local_service. Empty string if not set. |
url_prefix |
string | Yes | Path prefix of the cluster, e.g. "/guides/seo/". A blueprint for your CMS, which still builds the final URL itself. null = no cluster path set. |
published_url |
string | No | Public URL after publication (empty if not published) |
scheduled_date |
string | Yes | Scheduled publication date in ISO 8601 format (e.g. "2026-03-01T09:00:00"). null = no date set. |
created_at |
string | No | Creation date in ISO 8601 format |
updated_at |
string | No | Last update in ISO 8601 format |
Additional Fields (single fetch only GET /articles/{id})
| Field | Type | Nullable | Description |
|---|---|---|---|
content_html |
string | No | Full article content as HTML. Always included. |
content_markdown |
string | No | Article content as Markdown source. Only included when include_markdown=true. |
keywords |
array | No | List of target keywords as a string array, e.g. ["seo", "keyword research"] |
cms_published_at |
string | Yes | Time of CMS publication in ISO 8601 format. null if not yet published. |
Pagination Fields (list only GET /articles)
| Field | Type | Description |
|---|---|---|
total |
int | Total number of articles (without limit/offset) |
limit |
int | Applied limit |
offset |
int | Applied offset |
Webhook Events
Webhooks are triggered on status changes. You configure them per CMS connection in the project under the Content Autopilot tool > CMS connections. There you enter your webhook URL and a shared HMAC secret of your own choosing (it is not generated automatically, you set the same secret on both sides) and select the events you want to receive.
| Event | Triggered when |
|---|---|
article.approved |
Article was approved. This is the primary event for CMS integrations: article is ready for publication. Selectable as a checkbox in the CMS connection settings. |
article.published |
Article was confirmed as published via the confirm endpoint. Selectable as a checkbox in the CMS connection settings. |
article.failed |
Reserved for article generation failed. Not fired yet and not selectable as a checkbox either, see the note in the Article Lifecycle section. |
Webhook Payload
Every webhook sends the following JSON as the POST body:
| Field | Type | Description |
|---|---|---|
event |
string | Event type (e.g. "article.approved") |
article_id |
int | ID of the affected article |
title |
string | Article title |
slug |
string | URL slug of the article |
project_id |
int | ID of the project |
scheduled_date |
string|null | Scheduled date in ISO 8601 format, or null if not set |
pull_url |
string | Full URL for fetching the article via the Pull API |
timestamp |
string | Time 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://app.visibly-ai.com/api/v1/articles/42",
"timestamp": "2026-02-20T10:00:00Z"
}
Webhook HTTP Headers
| Header | Description |
|---|---|
Content-Type |
application/json |
X-Webhook-Signature |
HMAC-SHA256 signature: sha256=<hex_digest> |
X-Webhook-Event |
Event type (e.g. article.approved) |
User-Agent |
VisiblyAI-Webhook/1.0 |
Webhook Delivery
Details on webhook delivery:
| Property | Value |
|---|---|
| HTTP method | POST |
| Timeout | 10 seconds. Your endpoint must respond within 10 seconds with a 2xx status. |
| Retries | 3 attempts with increasing delay: 1s, 5s, 25s |
| Success | HTTP 200-299 counts as successful delivery |
| Redirects | HTTP 3xx are blocked (SSRF protection). The webhook is considered failed. |
| Expected response | Your endpoint should return JSON with {"success": true}, but any 2xx status is accepted. |
HMAC-SHA256 Verification
Every webhook is sent with an HMAC-SHA256 signature in the X-Webhook-Signature header. Verify the signature to make sure the webhook originates from Visibly and has not been tampered with.
Algorithm:
- Read the raw request body as bytes, not as a string
- Compute
HMAC-SHA256(webhook_secret, raw_body) - Compare the result as
sha256=<hex_digest>with the header value - 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...
Rate Limits
Guideline values: these limits describe the intended usage; hard enforcement may happen at the infrastructure level and can change.
| Endpoint | Limit |
|---|---|
| GET/api/v1/articles | 120 requests / minute |
| GET/api/v1/articles/{id} | 60 requests / minute |
| POST/api/v1/articles/{id}/confirm | 30 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 with a detail field:
{
"detail": "Human-readable error description"
}
| Code | detail | Cause |
|---|---|---|
| 400 | Invalid status. Valid values: ... |
status parameter not in the allowed list |
| 400 | Invalid date format. Use YYYY-MM-DD |
since does not match the format YYYY-MM-DD |
| 400 | Invalid published_url |
published_url is not a reachable http(s) URL with a public host |
| 401 | Invalid API key |
Missing, unknown, malformed or disabled API key |
| 404 | Article not found |
Article not found, belongs to another user, or is outside the scope of a cp_ key |
| 429 | Rate limit exceeded |
Rate limit exceeded |
| 500 | Internal server error |
Internal server error |
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-connection-settings'
API_KEY = 'cp_your_project_key'
BASE_URL = 'https://app.visibly-ai.com'
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://app.visibly-ai.com';
// 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.
base_url in this package points to a different installation. Always set base_url explicitly to https://app.visibly-ai.com when working with Visibly AI, both in configure_visibly(...) and in VisiblyClient(...).
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='cp_your_project_key',
base_url='https://app.visibly-ai.com',
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='cp_your_project_key',
base_url='https://app.visibly-ai.com',
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)
API Reference
| Function / Class | Description |
|---|---|
configure_visibly(webhook_secret, api_key, base_url, on_article_received) |
Configures the Blueprint with credentials and a 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_bp |
Flask Blueprint. Register with app.register_blueprint(). Endpoint: POST /webhooks/visibly |
default_flask_blog_handler(article) |
Default handler: saves the article as JSON in blog_translations/html/ |