CMS Webhook
Receive generated articles at your own HTTPS endpoint. Use this for custom CMSes, headless setups, or platforms we don't integrate natively (Webflow, Ghost, Framer, Notion).
When this fires
The webhook fires when an article finishes generating AND your
blog_settings.auto_publish is set to true AND cms_type = "webhook". Otherwise the article lands as a draft for manual review. Same trigger whether the article came from the Article Writer, a batch run, or AutoBlog.Configuration
In the dashboard under Settings → CMS Integration, choose Webhook and supply:
- Webhook URL — your public HTTPS endpoint (POST). Must be reachable from the public internet.
- HTTP method — POST (default) or PUT.
- Secret token — auto-generated. Sent as the
X-Webhook-Secretheader on every request. Save it as an env var on your receiver. - Include HTML — toggle to include both
content_htmlandcontent_markdownin the payload. On by default.
Payload schema
The webhook delivers a JSON body shaped like this:
json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "How to Implement Webhooks",
"meta_title": "How to Implement Webhooks",
"slug": "how-to-implement-webhooks",
"meta_description": "Learn how to implement webhooks in your application with this comprehensive guide.",
"content_markdown": "# How to Implement Webhooks\n\nWebhooks are powerful...",
"content_html": "<h1>How to Implement Webhooks</h1><p>Webhooks are powerful...</p>",
"keyword": "implement webhooks",
"article_type": "Guide: How-to",
"word_count": 1847,
"reading_time": 9,
"featured_image": {
"url": "https://storage.example.com/images/webhook-guide.jpg",
"alt": "How to Implement Webhooks"
},
"faq": [
{ "question": "What is a webhook?", "answer": "An HTTP callback fired when an event occurs." }
],
"json_ld": {
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to Implement Webhooks"
},
"created_at": "2026-01-31T12:00:00.000Z",
"generated_at": "2026-01-31T14:30:00.000Z"
}Field reference:
FieldTypeDescription
idstringUnique identifier for the articletitlestringThe article titleslugstringURL-friendly version of the titlemeta_descriptionstringSEO meta descriptioncontent_markdownstringArticle content in Markdown formatcontent_htmlstringArticle content in HTML format (when "include HTML" is on)keywordstringTarget SEO keywordarticle_typestringType — "Explainer", "Guide: How-to", "Listicle", etc.word_countnumberTotal word countreading_timenumberEstimated reading time in minutesfeatured_imageobjectFeatured image data: { url, alt }json_ldobjectStructured data — Article + FAQPage + BreadcrumbListcreated_atstringCreation timestamp (ISO 8601)generated_atstringGeneration timestamp (ISO 8601)Verifying the request
Every request includes an X-Webhook-Secret header carrying the secret token shown in your CMS settings. Compare it against the value you stored on your receiver — reject anything that doesn't match.
Always verify, always HTTPS
Without verification, any caller who guesses your URL can post fake articles into your CMS. Reject requests where the header is missing or wrong, and only ever expose the endpoint over HTTPS so the secret isn't leaked over the wire.
Node.js / Express:
javascript
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
app.post("/webhook/seo-ladders", express.json(), (req, res) => {
// 1. Verify the request is from SEO Ladders
if (req.headers["x-webhook-secret"] !== WEBHOOK_SECRET) {
return res.status(401).json({ error: "Invalid webhook secret" });
}
// 2. Process the article payload
const { title, slug, content_markdown, meta_description, featured_image } = req.body;
// 3. Save to your DB / trigger build / push to CMS
// ...
return res.status(200).json({ ok: true });
});
app.listen(3000);Python / Flask:
python
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET")
@app.route("/webhook/seo-ladders", methods=["POST"])
def handle_webhook():
# 1. Verify the request is from SEO Ladders
if request.headers.get("X-Webhook-Secret") != WEBHOOK_SECRET:
return jsonify({"error": "Invalid webhook secret"}), 401
# 2. Process the article payload
data = request.json
# 3. Save to your DB / trigger build / push to CMS
# ...
return jsonify({"ok": True}), 200
if __name__ == "__main__":
app.run(port=3000)SEO field usage
The payload ships everything you need to render an SEO-clean article. Where each field belongs in your output:
featured_image— top of the article body. Improves engagement, social sharing previews, and image-search rankings.json_ld— inject as a<script type="application/ld+json">in<head>or end of<body>. Eligible for rich snippets — star ratings, FAQ dropdowns, article info — which can lift CTR meaningfully.meta_description— set as<meta name="description">. Controls the SERP snippet under your title.slug— use as the URL path. Clean keyword-rich URLs are easier to read and contribute to ranking.
javascript
function buildArticleHtml(payload) {
let html = "";
// 1. Featured image at the top — improves engagement and SEO
if (payload.featured_image?.url) {
html += '<figure class="featured-image">' +
'<img src="' + payload.featured_image.url + '" ' +
'alt="' + (payload.featured_image.alt || payload.title) + '" />' +
"</figure>\n\n";
}
// 2. Article body
html += payload.content_html;
// 3. JSON-LD structured data — eligible for rich snippets
if (payload.json_ld) {
html += '\n\n<script type="application/ld+json">\n' +
JSON.stringify(payload.json_ld, null, 2) +
"\n</script>";
}
return html;
}
// In your <head>:
function buildMetaTags(payload) {
return [
`<meta name="description" content="${payload.meta_description}" />`,
`<meta property="og:title" content="${payload.title}" />`,
`<meta property="og:description" content="${payload.meta_description}" />`,
`<meta property="og:image" content="${payload.featured_image?.url ?? ""}" />`,
].join("\n");
}Retry & timeout
- Timeout — your endpoint must respond within 30 seconds. Long-running CMS sync should ack with a 2xx and process the payload async.
- Retry on failure — non-2xx responses retry automatically with exponential backoff. After the final retry, the publish step is marked failed in your dashboard and you can re-trigger with one click.
- Idempotency — the
idfield is a stable UUID. Use it as a dedupe key so retries don't create duplicate posts.
Troubleshooting
- “Webhook endpoint not receiving requests” — verify the URL is correct, publicly accessible, and uses HTTPS in production. Some platforms (Vercel, Netlify) front-end auth or edge protections — make sure your webhook route is publicly reachable.
- “Secret validation failing” — the header is
X-Webhook-Secret(case-insensitive). Make sure no extra whitespace was pasted into your env var, and that the value matches what's in your CMS settings exactly. - “500 errors from receiver” — check your server logs for parsing errors. Most are JSON body parsing or schema mismatches. Express needs
express.json()middleware; Flask usesrequest.json. - “Payload size too large” — articles with many images and full HTML can be 200-500KB. Increase your body-parser limit if you're hitting the default
100kbcap.