ai-content-autopilot
Python SDK for the Visibly Content Autopilot API.
Visibly Content Autopilot is an AI-powered content generation platform that creates SEO-optimized articles for your projects. It handles keyword research, content planning, and article generation — then delivers finished articles to your CMS via webhooks or a Pull API.
This SDK gives you everything you need to integrate Content Autopilot into any Python/Flask application:
- Pull API Client — fetch, list, and confirm articles programmatically
- Webhook Receiver — a ready-made Flask Blueprint that verifies HMAC signatures, fetches full article content, and calls your handler
- HMAC-SHA256 Verification — standalone signature verification for custom webhook implementations
How It Works
1. Content Autopilot generates & approves an article
|
2. Webhook fires to your endpoint (POST /webhooks/visibly)
with HMAC-SHA256 signature for security
|
3. Your app verifies the signature, then calls the Pull API
to fetch the full article (HTML, Markdown, keywords, SEO score)
|
4. Your app saves/publishes the article in your CMS
|
5. Your app confirms publication back to Visibly
(article status changes to "published")
Installation
pip install ai-content-autopilot
Requirements: Python 3.8+, Flask 2.3+, Requests 2.25+
Getting Started
1. Get your credentials
- API Key: Go to Account > API Keys and create a new key (starts with
sk_live_) - Webhook Secret: Go to your Project > CMS Settings and configure a webhook endpoint. The secret is generated automatically.
Full API documentation: Developer Docs
2. Choose your integration style
Option A: Full Flask Blueprint (recommended)
The easiest way to integrate. Register the blueprint and provide a handler function — the SDK handles signature verification, article fetching, and error responses automatically.
from flask import Flask
from ai_content_autopilot import configure_visibly, contentpilot_webhook_bp
app = Flask(__name__)
def my_handler(article):
"""Called when a webhook delivers an article.
article dict contains:
id, title, slug, content_html, content_markdown,
keywords, meta_description, seo_score, word_count,
_webhook_event, _webhook_timestamp, _scheduled_date
"""
# Save to your database, CMS, filesystem, etc.
db.session.add(Post(
title=article['title'],
body=article['content_html'],
slug=article['slug'],
publish_at=article.get('_scheduled_date'),
))
db.session.commit()
return True # Return False to reject (422 response)
configure_visibly(
webhook_secret='your-webhook-secret',
api_key='sk_live_your_api_key',
on_article_received=my_handler,
)
app.register_blueprint(contentpilot_webhook_bp)
# POST /webhooks/visibly is now active and handles:
# 1. HMAC-SHA256 signature verification
# 2. Full article fetch via Pull API
# 3. Calls my_handler(article)
# 4. Returns {"success": true} or appropriate error
Option B: Standalone Pull API Client
Use the client directly to poll for articles or integrate into non-Flask applications.
from ai_content_autopilot import VisiblyClient
client = VisiblyClient(api_key='sk_live_your_api_key')
# List approved articles ready for publishing
articles = client.list_articles(status='approved', project_id=5, limit=20)
for a in articles:
print(a['id'], a['title'], a.get('scheduled_date'))
# Fetch a single article with full content
article = client.fetch_article(42, include_markdown=True)
print(article['content_html'])
print(article['keywords']) # e.g. ["seo", "keyword research"]
print(article['seo_score']) # 0-100
# Confirm publication (updates status to "published" in Visibly)
client.confirm_published(42, 'https://myblog.com/seo-guide-2026')
Option C: HMAC verification only
For custom webhook implementations in any framework.
from ai_content_autopilot import verify_webhook_signature
# In your webhook endpoint:
payload_bytes = request.get_data() # raw bytes, NOT request.json
signature = request.headers.get('X-Webhook-Signature', '')
if not verify_webhook_signature(payload_bytes, 'your-secret', signature):
return {'error': 'Invalid signature'}, 401
Article Lifecycle
| Status | Description | Webhook Event |
|---|---|---|
queued |
Waiting to be generated | - |
generating |
AI is writing the article | - |
draft |
Draft ready for review | - |
approved |
Ready for publishing | article.approved |
published |
Confirmed published via API | article.published |
rejected |
Rejected by user | - |
failed |
Generation failed | article.failed |
An already published article that is edited in Visibly and pushed back fires
article.updated. The payload carries published_url and revision so you
can find the existing post by URL and skip a state you already have.
API Reference
| Function / Class | Description |
|---|---|
configure_visibly(webhook_secret, api_key, base_url, on_article_received, background) |
Configure the Blueprint. background=True (default) acknowledges with 202 and works in a thread |
verify_webhook_signature(payload_bytes, secret, signature_header) |
Verify HMAC-SHA256 signature. Returns True/False |
VisiblyClient(api_key, base_url, timeout) |
Pull API client for fetching, listing, and confirming articles |
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 article as JSON to ./content_output/ |
Webhook Payload
When a webhook fires, the POST /webhooks/visibly endpoint receives a JSON body with these fields:
{
"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"
}
| Field | Type | Description |
|---|---|---|
event |
string | article.approved, article.updated, article.published, or article.failed |
article_id |
int | Database ID of the article |
title |
string | Article title |
slug |
string | URL-safe slug |
project_id |
int | Owning project ID |
scheduled_date |
string/null | ISO 8601 scheduled publish date, or null |
pull_url |
string | Full URL to fetch article content via Pull API |
timestamp |
string | ISO 8601 UTC timestamp of the event |
published_url |
string | Live URL of the existing post (article.updated only) |
revision |
int | Counter that increases on every write in Visibly (article.updated only) |
The Blueprint fetches the full article via the Pull API and injects webhook metadata (_webhook_event, _webhook_timestamp, _scheduled_date, _published_url, _revision) into the article dict before calling your handler.
Article Response Fields
When fetching an article via VisiblyClient.fetch_article(), the returned dict contains:
| Field | Type | Description |
|---|---|---|
id |
int | Unique article ID |
title |
string | Article title |
slug |
string | URL-friendly slug |
status |
string | Current status (see Article Lifecycle) |
content_html |
string | Full article as HTML |
content_markdown |
string | Article as Markdown (only if include_markdown=True) |
keywords |
list | Target keywords, e.g. ["seo", "keyword research"] |
meta_description |
string | SEO meta description (max 160 chars) |
seo_score |
int | SEO optimization score (0-100) |
word_count |
int | Article word count |
project_id |
int | Owning project ID |
plan_id |
int/null | Content cluster the article belongs to |
url_prefix |
string/null | Path prefix of the cluster, e.g. /glossary/ |
content_language |
string/null | Language of the cluster, e.g. de, en |
target_country |
string/null | Target country of the cluster, e.g. DE |
recommended_page_type |
string | Page template hint, e.g. guide, blog |
revision |
int | Increases on every write in Visibly |
content_format |
string | html or markdown |
published_url |
string | Public URL after publication (empty if unpublished) |
scheduled_date |
string/null | Planned publish date (ISO 8601) |
created_at |
string | Creation timestamp |
updated_at |
string | Last update timestamp |
Delivery Contract
A webhook is a signal, not a job with a return value. Visibly waits 10 seconds for your response, and this is what each outcome means to it:
| Your response | How Visibly reads it |
|---|---|
202 |
Accepted, you are working on it. Success. |
200 with a body naming blog_post_id, post_id or id |
Processed, done. |
200 with a JSON body naming none of those |
Acknowledged, but nothing happened. Logged as a failure with your message. |
4xx |
Rejected. Not retried, a second attempt would fail the same way. |
5xx, 429 |
Transient. Retried after 1s, 5s, 25s. |
| No response within 10s (read timeout) | Delivered, outcome unknown. Not retried. |
That last row is the one that matters. Up to v1.0.2 this SDK fetched the article and ran your handler inside the request. If your handler was slow, and translating an article into several languages is slow, Visibly gave up waiting and delivered again. Measured in production on 2026-09-14: three delivery attempts for one article turned into three LLM translation runs on the receiving CMS.
Both sides are fixed now. Visibly no longer retries on a read timeout, because
the request had already reached you. And this SDK acknowledges with 202
before doing any work:
configure_visibly(
webhook_secret='...',
api_key='...',
on_article_received=my_handler,
background=True, # the default: acknowledge first, work afterwards
)
The background path also keeps a process-local register of articles currently being processed, so a repeated delivery is skipped instead of running your handler twice. With multiple web workers that register does not span processes. If double processing would be expensive for you, add a claim column in your own database.
Set background=False only if your handler reliably finishes inside those 10
seconds. You then get the synchronous response codes (200 / 422 / 500 / 502).
Multilingual Sites and hreflang
Visibly writes one article in one language. There is no translation endpoint, and no article carries several language variants. Going multilingual is a structural decision, not a post-processing step, and you have two ways to make it.
Option A: your CMS translates
You receive the source article and produce the other languages yourself. This is what TMPilot.ai does: one German article arrives, the CMS translates it and stores all languages under one post.
- You own the translation cost, the glossary, and the consistency between languages.
- Visibly sees one article. A later
article.updatedoverwrites your source language, and your CMS re-derives the rest. - Slow by nature, which is exactly why your handler must not run inside the webhook request. See Delivery Contract above.
Option B: one cluster per language
A content cluster in Visibly is a plan with its own path prefix, language and target country. Create one per language, and Visibly writes each article natively in that language instead of translating it:
| Cluster | content_language |
target_country |
url_prefix |
|---|---|---|---|
| Glossary DE | de |
DE |
/glossar/ |
| Glossary EN | en |
US |
/en/glossary/ |
| Glossary FR | fr |
FR |
/fr/glossaire/ |
Every article then arrives with those three fields, and you build the URL:
def my_handler(article):
prefix = article.get('url_prefix') or '/'
url = f"https://example.com{prefix}{article['slug']}"
lang = article.get('content_language') # 'de', 'en', 'fr' - or None
...
- Native phrasing per language, not a translation of German sentence structure. Keywords are researched per market.
- Costs one article per language against your monthly quota.
How the hreflang structure comes about
Visibly does not build URLs and does not emit hreflang tags. It delivers
slug, url_prefix and content_language as the blueprint; the finished URL
is yours, and you report it back with confirm_published(). The tags are
therefore yours to render:
<link rel="alternate" hreflang="de" href="https://example.com/glossar/nizza-klasse/">
<link rel="alternate" hreflang="en" href="https://example.com/en/glossary/nice-class/">
<link rel="alternate" hreflang="fr" href="https://example.com/fr/glossaire/classe-de-nice/">
<link rel="alternate" hreflang="x-default" href="https://example.com/en/glossary/nice-class/">
Three rules that generators get wrong more often than not:
- Every variant links to every variant, including itself. A page that omits its own hreflang is treated as an incomplete set and ignored.
x-defaultpoints at the version for visitors none of the others fit, usually the English one. It is not "the site's default language".- Only link pages that exist and are indexable. An hreflang pointing at a
noindexpage or a 404 invalidates the whole cluster.
What Visibly does not tell you yet: which articles are translations of one
another. Two clusters deliver two independent articles, and there is no group
identifier linking them. Keep that mapping on your side, using the cluster
pair plus your own topic key, or derive it from plan_id and the order in
which topics were queued.
Webhook Security
Every webhook request includes an X-Webhook-Signature header with an HMAC-SHA256 signature:
X-Webhook-Signature: sha256=<hex_digest>
The SDK verifies this automatically when using the Blueprint. The verification uses hmac.compare_digest for timing-safe comparison to prevent timing attacks.
Error Handling
The VisiblyClient methods handle errors gracefully:
fetch_article()returnsNoneon any error (404, network failure, timeout)list_articles()returns[]on any errorconfirm_published()returnsFalseon any error
All errors are logged via Python's logging module at WARNING or ERROR level.
When using the Blueprint with background=True (the default), HTTP responses are:
| Code | Meaning |
|---|---|
| 202 | Accepted — fetching and processing in the background |
202 already_processing |
This article is already being processed, duplicate skipped |
| 400 | Invalid JSON in webhook payload |
| 401 | HMAC signature verification failed |
| 500 | Webhook secret or API key not configured |
A failure inside your handler does not become a 5xx: the delivery succeeded, the work did not. A 5xx would invite a retry that reproduces the same error. Failures are logged, so watch your logs rather than the HTTP status.
With background=False the response reflects the outcome of the work:
| Code | Meaning |
|---|---|
| 200 | Success — article processed |
| 422 | Handler returned False (article rejected) |
| 500 | Webhook not configured, or handler raised an exception |
| 502 | Failed to fetch article from Pull API |
Rate Limits
The Visibly API enforces the following rate limits:
| Endpoint | Limit |
|---|---|
GET /api/v1/articles |
120 requests/min |
GET /api/v1/articles/{id} |
60 requests/min |
POST /api/v1/articles/{id}/confirm |
30 requests/min |
When rate-limited, the API returns HTTP 429 with a Retry-After header.
Links
- Visibly AI — the platform
- API Keys — manage your API keys
- GitHub Repository
- PyPI Package
Development
git clone https://github.com/AntonioBlago/ai-content-autopilot.git
cd ai-content-autopilot
pip install -e ".[dev]"
pytest tests/ -v
License
MIT - see LICENSE for details.
Metadata
Release files for ai-content-autopilot 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| ai_content_autopilot-1.1.0.tar.gz | 24.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ai_content_autopilot-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 39.2 kB
Release files / ai_content_autopilot-1.1.0.tar.gz
| Download URL | ai_content_autopilot-1.1.0.tar.gz |
|---|---|
| Size | 24.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eab77ccfb309d8aae5f2bf3c2e0d110c06f2cd6513e8fa0d5a9b3a4021b4a6d9
|
|
BLAKE2b-256 checksum How to use checksums |
a35aea01dec66114bb9bad9e5566cf437b3304fd6a99ca2bd7aa319726da2104
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|
Release files / ai_content_autopilot-1.1.0-py3-none-any.whl
| Download URL | ai_content_autopilot-1.1.0-py3-none-any.whl |
|---|---|
| Size | 15.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ae2105fe7c9abaf7912d0eed286f58ee2517fa4393815d34c460fa6f8902bf86
|
|
BLAKE2b-256 checksum How to use checksums |
abaeb215cbdc3e951084dc2070426b0f2fd060ddefde464aed86d1a86343ebdd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|
Security for ai-content-autopilot 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.