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

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.

2

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.

3

Fetch the article

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

Base URL for every endpoint on this page: 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)"
A missing, unknown, malformed or disabled API key is rejected uniformly with HTTP 401, there is no way to infer whether a key ever existed. Every key is tied to a user or a project, you can only access your own articles.

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"
  }
}
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. 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.
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 it in the background.

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:

  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...

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
Security note: both "not found" and "belongs to another user" or "outside scope" 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-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.

Important: the default value of 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/