CMS Webhook
Receive generated articles at your own HTTPS endpoint. Use this for custom CMSes, headless setups, or platforms we don't integrate natively yet.
When this fires
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 Content → 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:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event": "created",
"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:
idstringStable article ID — your upsert key. Stays the same across re-sends, so upsert by it to replace in place instead of duplicating.eventstring"created" on first publish, "updated" when re-sending an existing article (e.g. an optimized version). Advisory hint — id-upsert handles replacement either way.titlestringThe 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", "Comparison", "Buying guide" or "Cost guide".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
Node.js / Express:
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
app.post("/webhook/seo-ladders", express.json(), async (req, res) => {
// 1. Verify the request is from SEOLadders
if (req.headers["x-webhook-secret"] !== WEBHOOK_SECRET) {
return res.status(401).json({ error: "Invalid webhook secret" });
}
const article = req.body;
// 2. Upsert by "id" — this is the key. The same article always keeps the
// same "id" across re-sends (an optimized version arrives with event
// "updated" and the SAME id), so upserting replaces the post in place
// instead of creating a duplicate.
const existing = await db.posts.findOne({ seo_ladders_id: article.id });
if (existing) {
await db.posts.update(
{ seo_ladders_id: article.id },
{
title: article.title,
slug: article.slug,
content_markdown: article.content_markdown,
meta_description: article.meta_description,
featured_image: article.featured_image,
},
);
} else {
await db.posts.insert({
seo_ladders_id: article.id, // store it so future updates match
title: article.title,
slug: article.slug,
content_markdown: article.content_markdown,
meta_description: article.meta_description,
featured_image: article.featured_image,
});
}
// Tell SEOLadders where the post lives and that it is live, so the
// dashboard marks it Published and links to it. Send "draft" instead if
// your endpoint saves posts unpublished.
return res.status(200).json({
ok: true,
url: "https://yoursite.com/blog/" + article.slug,
status: "published",
});
});
app.listen(3000);Python / Flask:
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 SEOLadders
if request.headers.get("X-Webhook-Secret") != WEBHOOK_SECRET:
return jsonify({"error": "Invalid webhook secret"}), 401
article = request.json
# 2. Upsert by "id" — this is the key. The same article always keeps the
# same "id" across re-sends (an optimized version arrives with event
# "updated" and the SAME id), so upserting replaces the post in place
# instead of creating a duplicate.
fields = {
"title": article["title"],
"slug": article["slug"],
"content_markdown": article["content_markdown"],
"meta_description": article["meta_description"],
"featured_image": article.get("featured_image"),
}
existing = db.posts.find_one(seo_ladders_id=article["id"])
if existing:
db.posts.update(seo_ladders_id=article["id"], **fields)
else:
# store the id so future updates match
db.posts.insert(seo_ladders_id=article["id"], **fields)
# Tell SEOLadders where the post lives and that it is live, so the
# dashboard marks it Published and links to it. Send "draft" instead if
# your endpoint saves posts unpublished.
return jsonify({
"ok": True,
"url": "https://yoursite.com/blog/" + article["slug"],
"status": "published",
}), 200
if __name__ == "__main__":
app.run(port=3000)What to reply
Any 2xx response counts as delivered. To have the article shown as Published in your dashboard, with a link to it, include these in the JSON you reply with:
url(orlink,permalink): the post's live address.status:"published"when the post is publicly visible, or"draft"when your endpoint saves it unpublished.id(optional): your own post id.
Without a status, the article stays at Ready: delivered, but not confirmed live. That is deliberate, so a receiver that saves drafts is never shown as published.
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.
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");
}Site fixes (seo_fix)
When the site audit finds a fixable issue on a page and you press Send to your webhook on it, SEOLadders sends one seo_fix event to the same URL, with the same X-Webhook-Secret. It is sent only when you press the button, never on a schedule. The body has no title, slug or content fields, so check event first.
action:"apply", or"revert"when you press Undo (the samefixis sent back: restore what you had).fix.kindis one ofseo_title(title),meta_description(description),social_tags(ogTitle,ogDescription),canonical(canonical),robots_meta(noindex: false),image_alt(images[]ofsrc,alt,decorative),structured_data(jsonLd),site_file(file,content) orredirect(from,to).- Reply 2xx once you've accepted it. The next audit, or Check again in the audit, checks the live page.
{
"event": "seo_fix",
"action": "apply",
"page_url": "https://example.com/pricing",
"fix": {
"kind": "meta_description",
"description": "Plans for teams of every size, billed monthly or yearly. Compare what each plan includes and pick the one that fits.",
"reason": "The page had no meta description."
},
"sent_at": "2026-11-01T09:00:00.000Z"
}
// Route on "event" before treating a body as an article:
app.post("/api/seoladders", (req, res) => {
if (req.body.event === "seo_fix") {
applySeoFix(req.body); // "apply" or "revert" the same fix
return res.status(200).json({ ok: true });
}
upsertArticle(req.body);
res.status(200).json({ ok: true });
});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 & updates — the
idfield is a stable UUID that stays the same across re-sends. Upsert by it so retries don't duplicate, and so an optimized version (which arrives withevent: "updated"and the sameid) replaces the existing post in place rather than creating a second one.
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.