Webhook CMS Integration
ActivePublish 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:
- You configure a webhook URL and receive a secure token
- When an article is approved, published, or updated, KatanaSEO sends a POST request to your URL
- Your server verifies the token, processes the article, and returns a 200 response
- If your server is down, we retry with exponential backoff
Setup
- Go to Profile Settings → CMS Integration and select Custom Webhook
- Enter your endpoint URL (must be HTTPS)
- KatanaSEO generates a secure token automatically. Copy it — you will need it to verify incoming requests. The token is only shown once.
- Click Test Connection. We send a
testevent to verify your endpoint is reachable and returns 200. - 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_7f3a9b2e1d4c6f8a0b5e3d7c9a1f4b6eCompare 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 seconds3. 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 attacksImportant: 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
| Header | Value | Purpose |
|---|---|---|
| Authorization | Bearer <token> | Authentication |
| X-Katana-Timestamp | Unix timestamp (seconds) | Anti-replay |
| X-Katana-Signature | sha256=<hex> | Body integrity |
| X-Katana-Event | article.sync | article.trash | Event type |
| X-Katana-Delivery-Id | UUID | Idempotency key |
| Content-Type | application/json | Always 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.
{
"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.
{
"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.
{
"event": "test",
"timestamp": 1711267200,
"article": null,
"profile": null
}Article Fields Reference
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique article identifier |
| title | string | Article title |
| slug | string | URL-safe slug |
| status | string | One of: draft, review, approved, published, archived |
| content_markdown | string | Full article content in Markdown |
| content_html | string | Full article content in HTML |
| meta_description | string | SEO meta description |
| featured_image_url | string | null | Featured image URL |
| tags | string[] | Article tags/categories |
| scheduled_at | string | null | ISO 8601 datetime if scheduled |
| published_at | string | null | ISO 8601 datetime when published |
Your Server Requirements
Your endpoint must:
- Accept POST requests with JSON body
- Be accessible over HTTPS (HTTP is rejected)
- Return
2xxwithin 10 seconds — anything else triggers a retry - Be idempotent — use the
X-Katana-Delivery-Idheader 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:
{ "ok": true }{
"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:
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1st | Immediate | 0s |
| 2nd | 2 seconds | 2s |
| 3rd | 5 seconds | 7s |
| 4th | 10 seconds | 17s |
| 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
- Test Connection button — Sends a
testevent. Verifies your endpoint is reachable and returns 200. The dashboard shows the response status. - Draft article — Create a test article and move it through statuses. Each transition triggers an
article.syncevent. Check that your server receives the content correctly. - Verify signatures locally — Use the code examples below to set up a local endpoint. Tools like
ngrokcan expose your localhost to the internet for testing.
# 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)
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)
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
<?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
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.