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-Secret header on every request. Save it as an env var on your receiver.
  • Include HTML — toggle to include both content_html and content_markdown in 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 article
titlestringThe article title
slugstringURL-friendly version of the title
meta_descriptionstringSEO meta description
content_markdownstringArticle content in Markdown format
content_htmlstringArticle content in HTML format (when "include HTML" is on)
keywordstringTarget SEO keyword
article_typestringType — "Explainer", "Guide: How-to", "Listicle", etc.
word_countnumberTotal word count
reading_timenumberEstimated reading time in minutes
featured_imageobjectFeatured image data: { url, alt }
json_ldobjectStructured data — Article + FAQPage + BreadcrumbList
created_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 id field 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 uses request.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 100kb cap.