Webhook reference
The signed POST Seovyn sends to your own site when an article is approved or updated: the events, headers and payload, how to verify the signature in Next.js, Express or Python, retries, and how to test it.
If your site isn't built on a platform Seovyn has an integration for, a webhook lets you receive each article and publish it your own way: into a database, a static build or a custom CMS.
Set it up
On Publishing, choose Webhook under For developers and enter the address Seovyn should POST to, for example https://yoursite.com/api/content-webhook. When you save, Seovyn shows a signing secret once. Copy it into your site's environment then; to get a new one, set the webhook up again.
The request
Seovyn sends a POST with a JSON body and two headers:
| Header | Value |
|---|---|
content-type | application/json |
x-contentautopilot-signature | Hex-encoded HMAC-SHA256 of the raw request body, keyed with your signing secret |
Answer with a 2xx status within 8 seconds. Anything else, or no answer in time, is a failed delivery.
Events
event | When |
|---|---|
article.approved | An article is approved for the first time |
article.updated | An existing article is republished at the same address: after a refresh, a new search snippet, or new links to other articles |
Both carry the same shape. Upsert by slug: it identifies the page either way.
The payload
{
"event": "article.approved",
"data": {
"itemId": "…",
"title": "…",
"pageType": "blog",
"slug": "how-to-…",
"body": "# Markdown…",
"summary": "…",
"metaTitle": "…",
"metaDescription": "…",
"focusKeyword": "…",
"tags": ["…"],
"sourceUrl": "https://…",
"readingMinutes": 7,
"sources": [{ "title": "…", "url": "https://…" }],
"image": { "url": "https://…", "alt": "…", "width": 1200, "height": 630 },
"author": "Jane Doe",
"approvedAt": "2026-10-01T09:30:00.000Z",
"updatedAt": null,
"jsonLd": { "@context": "https://schema.org", "…": "…" }
}
}
| Field | Notes |
|---|---|
pageType | blog, comparison or landing. Map them to /blog/…, /compare/… and /features/… |
body | The article in Markdown |
metaTitle, metaDescription | The <title> and meta description |
sources | The references the writer drew on. Render them as a "Sources" section |
image | A 1200 × 630 cover image, or null. Use it as the hero and as og:image |
author, updatedAt | Show the byline, and use updatedAt as the page's modified date |
jsonLd | A ready-made schema.org graph: put it in a <script type="application/ld+json"> |
Verify the signature
Always verify before you trust a request. Compute the HMAC of the exact bytes you received and compare it in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text(); // the raw body, not re-serialized JSON
const sent = request.headers.get("x-contentautopilot-signature") ?? "";
const expected = createHmac("sha256", process.env.SEOVYN_WEBHOOK_SECRET!).update(raw).digest("hex");
const a = Buffer.from(sent);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("Bad signature", { status: 401 });
}
const { event, data } = JSON.parse(raw);
// Save or update the page identified by data.slug.
return new Response("ok");
}
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// express.raw keeps the exact bytes: the signature covers the body as sent.
app.post("/api/content-webhook", express.raw({ type: "application/json" }), (req, res) => {
const sent = req.get("x-contentautopilot-signature") ?? "";
const expected = createHmac("sha256", process.env.SEOVYN_WEBHOOK_SECRET).update(req.body).digest("hex");
const a = Buffer.from(sent);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).send("Bad signature");
const { event, data } = JSON.parse(req.body.toString("utf8"));
// Save or update the page identified by data.slug.
res.send("ok");
});
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SEOVYN_WEBHOOK_SECRET"].encode()
@app.post("/api/content-webhook")
def content_webhook():
raw = request.get_data() # the raw body, before any JSON parsing
sent = request.headers.get("x-contentautopilot-signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected):
abort(401)
payload = json.loads(raw)
# Save or update the page identified by payload["data"]["slug"].
return "ok"
Test it
Send yourself a signed request with the same secret before you connect it:
body='{"event":"article.approved","data":{"slug":"hello-seovyn","title":"Hello, Seovyn"}}'
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$SEOVYN_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i https://yoursite.com/api/content-webhook \
-H "content-type: application/json" \
-H "x-contentautopilot-signature: $sig" \
--data "$body"
A 200 means your handler accepted it; a 401 means the signature check failed.
Retries
A delivery that fails with a server error, a timeout, a rate limit or a network problem is tried again after 10 minutes, 30 minutes, 2, 6 and 12 hours: six attempts over about a day. A 4xx answer (a bad signature, an unknown route, unauthorized) fails the same way every time, so it isn't retried. Failed deliveries appear under Needs attention on the Publishing page with the reason.
Good practice
- Answer fast. Return
200as soon as the request is verified and stored; do slow work (rebuilding a site, resizing images) afterwards. - Make it idempotent. The same event can arrive more than once. Upsert by
slug. - Keep the secret out of source control. Read it from the environment.
- Confirm the page is reachable. After delivery, Seovyn loads the article's address to check it's live, at
https://yoursite.com/<section>/<slug>. A page that doesn't load is flagged. See Publishing.