Developer Resources

Webhook CMS Integration

Active

Publish articles from KatanaSEO to any system — your custom CMS, static site generator, headless backend, or internal tool. No plugins, no SDK. Just an HTTPS endpoint.

Overview

The Webhook CMS integration sends article data to your HTTPS endpoint whenever an article changes status in KatanaSEO. Instead of connecting to a specific CMS like WordPress or Ghost, you receive the full article content (Markdown + HTML) and handle publishing however you want.

How it works:

  1. You configure a webhook URL and receive a secure token
  2. When an article is approved, published, or updated, KatanaSEO sends a POST request to your URL
  3. Your server verifies the token, processes the article, and returns a 200 response
  4. If your server is down, we retry with exponential backoff

Setup

  1. Go to Profile Settings → CMS Integration and select Custom Webhook
  2. Enter your endpoint URL (must be HTTPS)
  3. KatanaSEO generates a secure token automatically. Copy it — you will need it to verify incoming requests. The token is only shown once.
  4. Click Test Connection. We send a test event to verify your endpoint is reachable and returns 200.
  5. Done. Articles will be sent to your endpoint based on their status changes.

Tip: If your server is not ready yet, you can use a service like webhook.site or requestbin.com to inspect the payloads KatanaSEO sends.

Security

Every request from KatanaSEO includes three layers of verification. You should check at least the Bearer token. For maximum security, verify all three.

1. Bearer Token

The token generated during setup is sent in the Authorization header:

Authorization: Bearer ktna_wh_7f3a9b2e1d4c6f8a0b5e3d7c9a1f4b6e

Compare this against the token you stored. Reject the request immediately if it does not match.

2. Timestamp Verification

The X-Katana-Timestamp header contains the Unix timestamp (seconds) of when the request was created. Reject requests older than 5 minutes to prevent replay attacks:

X-Katana-Timestamp: 1711267200

// Verify: abs(now - timestamp) < 300 seconds

3. HMAC Signature

The X-Katana-Signature header contains an HMAC-SHA256 signature of the timestamp and request body combined as {timestamp}.{body}, using your token as the secret:

X-Katana-Signature: sha256=a1b2c3d4e5f6...

// To verify:
// 1. Build message: "{timestamp}.{raw_request_body}"
// 2. Compute HMAC-SHA256(your_token, message)
// 3. Compare the hex digest with the signature (after removing "sha256=" prefix)
// 4. Use constant-time comparison to prevent timing attacks

Important: The same token is used for both Bearer authentication and HMAC signing. One credential, two verification methods. The HMAC message is {timestamp}.{raw_body} — always use the raw request body (bytes), not a re-serialized version.

Request Headers Summary

HeaderValuePurpose
AuthorizationBearer <token>Authentication
X-Katana-TimestampUnix timestamp (seconds)Anti-replay
X-Katana-Signaturesha256=<hex>Body integrity
X-Katana-Eventarticle.sync | article.trashEvent type
X-Katana-Delivery-IdUUIDIdempotency key
Content-Typeapplication/jsonAlways JSON

Events & Payloads

article.sync

Sent when an article is created, updated, or changes status (draft → published, etc.). Contains the full article content in both Markdown and HTML.

JSONarticle.sync payload
{
  "event": "article.sync",
  "timestamp": 1711267200,
  "article": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "How to Improve Core Web Vitals in 2026",
    "slug": "improve-core-web-vitals-2026",
    "status": "published",
    "content_markdown": "# How to Improve Core Web Vitals...\n\nFull article content in Markdown.",
    "content_html": "<h1>How to Improve Core Web Vitals...</h1>\n<p>Full article content in HTML.</p>",
    "meta_description": "Learn how to optimize LCP, INP, and CLS for better rankings.",
    "featured_image_url": "https://images.unsplash.com/photo-example",
    "tags": ["seo", "core-web-vitals", "performance"],
    "scheduled_at": null,
    "published_at": "2026-03-25T10:00:00Z"
  },
  "profile": {
    "id": "123",
    "domain": "acme.com"
  }
}

article.trash

Sent when an article is deleted from KatanaSEO. Contains only the article ID and slug so you can remove it from your system.

JSONarticle.trash payload
{
  "event": "article.trash",
  "timestamp": 1711267200,
  "article": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "slug": "improve-core-web-vitals-2026"
  },
  "profile": {
    "id": "123",
    "domain": "acme.com"
  }
}

test

Sent when you click “Test Connection” in the dashboard. Empty payload with just the event name. Your server should return 200.

JSONtest payload
{
  "event": "test",
  "timestamp": 1711267200,
  "article": null,
  "profile": null
}

Article Fields Reference

FieldTypeDescription
idstring (UUID)Unique article identifier
titlestringArticle title
slugstringURL-safe slug
statusstringOne of: draft, review, approved, published, archived
content_markdownstringFull article content in Markdown
content_htmlstringFull article content in HTML
meta_descriptionstringSEO meta description
featured_image_urlstring | nullFeatured image URL
tagsstring[]Article tags/categories
scheduled_atstring | nullISO 8601 datetime if scheduled
published_atstring | nullISO 8601 datetime when published

Your Server Requirements

Your endpoint must:

  • Accept POST requests with JSON body
  • Be accessible over HTTPS (HTTP is rejected)
  • Return 2xx within 10 seconds — anything else triggers a retry
  • Be idempotent — use the X-Katana-Delivery-Id header to deduplicate, since retries may deliver the same event more than once

Response Format

A simple 200 OK is enough. Optionally return JSON with a published_url — we will store it and display it in the dashboard:

JSONMinimal response
{ "ok": true }
JSONResponse with published URL (optional)
{
  "ok": true,
  "published_url": "https://acme.com/blog/improve-core-web-vitals-2026"
}

Retry Policy

If your server returns a non-2xx status or is unreachable, KatanaSEO retries with exponential backoff:

AttemptDelayTotal elapsed
1stImmediate0s
2nd2 seconds2s
3rd5 seconds7s
4th10 seconds17s
5th+Job re-queued (minutes)Handled by scheduler

If all quick retries fail, the job is re-queued with longer intervals by the scheduler. After repeated failures the article is marked with cms_sync_status: error and the error is visible in the dashboard. You can manually retry from there.

Note: Responses with 401 or 403 are not retried — they indicate an invalid token, which won't fix itself.

Testing Your Integration

  1. Test Connection button — Sends a test event. Verifies your endpoint is reachable and returns 200. The dashboard shows the response status.
  2. Draft article — Create a test article and move it through statuses. Each transition triggers an article.sync event. Check that your server receives the content correctly.
  3. Verify signatures locally — Use the code examples below to set up a local endpoint. Tools like ngrok can expose your localhost to the internet for testing.
bashQuick signature test with curl
# Simulate what KatanaSEO sends
TOKEN="your_token_here"
TIMESTAMP=$(date +%s)
BODY='{"event":"test","timestamp":'$TIMESTAMP'}'
SIGNATURE=$(echo -n "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$TOKEN" | cut -d' ' -f2)

curl -X POST https://your-endpoint.com/katanaseo/webhook \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Katana-Timestamp: $TIMESTAMP" \
  -H "X-Katana-Signature: sha256=$SIGNATURE" \
  -d "$BODY"

Code Examples

Complete webhook receivers with all three security checks. Copy the one that matches your stack:

Node.js (Express)

Node.jsserver.js
const crypto = require("crypto");
const express = require("express");
const app = express();

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

const WEBHOOK_TOKEN = process.env.KATANASEO_WEBHOOK_TOKEN;

app.post("/katanaseo/webhook", (req, res) => {
  // 1. Verify Bearer token
  const auth = req.headers["authorization"];
  if (auth !== `Bearer ${WEBHOOK_TOKEN}`) {
    return res.status(401).json({ error: "Invalid token" });
  }

  // 2. Verify timestamp (reject if older than 5 minutes)
  const ts = parseInt(req.headers["x-katana-timestamp"], 10);
  if (Math.abs(Date.now() / 1000 - ts) > 300) {
    return res.status(403).json({ error: "Request expired" });
  }

  // 3. Verify HMAC signature (message = "timestamp.body")
  const message = ts + "." + req.rawBody;
  const expected = crypto
    .createHmac("sha256", WEBHOOK_TOKEN)
    .update(message)
    .digest("hex");
  const signature = (req.headers["x-katana-signature"] || "").replace("sha256=", "");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(403).json({ error: "Invalid signature" });
  }

  // 4. Process the event
  const { event, article, profile } = req.body;

  if (event === "article.sync") {
    console.log(`Publishing "${article.title}" to ${profile.domain}`);
    // Save to your database, trigger a build, etc.
  } else if (event === "article.trash") {
    console.log(`Removing article ${article.slug}`);
    // Delete from your system
  }

  // Return 200 with optional published_url
  res.json({ ok: true, published_url: `https://${profile.domain}/blog/${article.slug}` });
});

app.listen(3000);

Python (Flask)

Pythonapp.py
import hashlib
import hmac
import os
import time

from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_TOKEN = os.environ["KATANASEO_WEBHOOK_TOKEN"]


@app.post("/katanaseo/webhook")
def handle_webhook():
    # 1. Verify Bearer token
    auth = request.headers.get("Authorization", "")
    if auth != f"Bearer {WEBHOOK_TOKEN}":
        return jsonify(error="Invalid token"), 401

    # 2. Verify timestamp
    ts = int(request.headers.get("X-Katana-Timestamp", "0"))
    if abs(time.time() - ts) > 300:
        return jsonify(error="Request expired"), 403

    # 3. Verify HMAC signature (message = "timestamp.body")
    message = f"{ts}.".encode() + request.get_data()
    expected = hmac.new(
        WEBHOOK_TOKEN.encode(), message, hashlib.sha256
    ).hexdigest()
    signature = request.headers.get("X-Katana-Signature", "").removeprefix("sha256=")
    if not hmac.compare_digest(expected, signature):
        return jsonify(error="Invalid signature"), 403

    # 4. Process event
    data = request.get_json()
    event = data["event"]
    article = data["article"]
    profile = data["profile"]

    if event == "article.sync":
        # Save article to your CMS/database
        save_article(article, profile)
    elif event == "article.trash":
        # Remove article
        delete_article(article["id"])

    return jsonify(ok=True, published_url=f"https://{profile['domain']}/blog/{article['slug']}")

PHP

PHPwebhook.php
<?php
$token = getenv('KATANASEO_WEBHOOK_TOKEN');
$body  = file_get_contents('php://input');

// 1. Verify Bearer token
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($auth !== "Bearer $token") {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid token']));
}

// 2. Verify timestamp
$ts = intval($_SERVER['HTTP_X_KATANA_TIMESTAMP'] ?? 0);
if (abs(time() - $ts) > 300) {
    http_response_code(403);
    exit(json_encode(['error' => 'Request expired']));
}

// 3. Verify HMAC signature (message = "timestamp.body")
$expected  = hash_hmac('sha256', $ts . '.' . $body, $token);
$signature = str_replace('sha256=', '', $_SERVER['HTTP_X_KATANA_SIGNATURE'] ?? '');
if (!hash_equals($expected, $signature)) {
    http_response_code(403);
    exit(json_encode(['error' => 'Invalid signature']));
}

// 4. Process event
$data    = json_decode($body, true);
$event   = $data['event'];
$article = $data['article'];

if ($event === 'article.sync') {
    // Insert/update in your database
} elseif ($event === 'article.trash') {
    // Delete from your database
}

echo json_encode(['ok' => true]);

Go

Gomain.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var token = os.Getenv("KATANASEO_WEBHOOK_TOKEN")

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)

	// 1. Verify Bearer token
	if r.Header.Get("Authorization") != "Bearer "+token {
		http.Error(w, `{"error":"Invalid token"}`, 401)
		return
	}

	// 2. Verify timestamp
	ts, _ := strconv.ParseInt(r.Header.Get("X-Katana-Timestamp"), 10, 64)
	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		http.Error(w, `{"error":"Request expired"}`, 403)
		return
	}

	// 3. Verify HMAC signature (message = "timestamp.body")
	mac := hmac.New(sha256.New, []byte(token))
	mac.Write([]byte(r.Header.Get("X-Katana-Timestamp") + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	signature := strings.TrimPrefix(r.Header.Get("X-Katana-Signature"), "sha256=")
	if !hmac.Equal([]byte(expected), []byte(signature)) {
		http.Error(w, `{"error":"Invalid signature"}`, 403)
		return
	}

	// 4. Process event
	var payload map[string]interface{}
	json.Unmarshal(body, &payload)
	// ... handle event ...

	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"ok":true}`))
}

func main() {
	http.HandleFunc("/katanaseo/webhook", webhookHandler)
	http.ListenAndServe(":8080", nil)
}

Troubleshooting

“Test Connection” fails with timeout

Your endpoint must respond within 10 seconds. Check that the URL is correct, the server is running, and there are no firewall rules blocking incoming requests from external IPs.

Getting 401 errors

The Bearer token in your server does not match the one generated by KatanaSEO. Regenerate the token from Profile Settings → CMS Integration and update your server's environment variable.

Signature verification fails

Make sure you are computing the HMAC over the raw request body (bytes), not a parsed-and-reserialized JSON string. Whitespace and key order matter. Also ensure you are comparing the hex digest, not the raw binary.

Receiving duplicate events

Retries can deliver the same event multiple times. Store the X-Katana-Delivery-Id header value and skip processing if you have already seen it.

Articles stuck in “pending” sync status

This means the webhook job is queued but has not been processed yet. Wait a few minutes. If it persists, check the job status in your dashboard under the article's detail page.

Ready to integrate?

Set up your webhook in under 2 minutes. No SDK required.