Events
| Event | Sent when |
|---|---|
article.published | An article is published to this webhook for the first time. |
article.updated | An already-published article is published again. |
article.unpublished | A published article is taken down — from Unpublish, or by moving it off Published. |
article.deleted | A published article is deleted. |
test | You click Send test on the Integrations tab. |
event.
article.unpublished is the only event whose result we wait for. Answer 2xx once the post is
off your site; on anything else the article stays Published in seosorted, the dashboard shows
the error, and nothing is marked as taken down. The payload still carries the publishedAt and
publishedUrl of the post being removed, so you can match it against what you stored at publish
time.Headers
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-SeoSorted-Signature: t=1786526112,v1=99ac6341579c16973ca9d1ecda22aa95cfabae850ab510c08a8c0fad4ae9f469
X-SeoSorted-Event: article.published
X-SeoSorted-Delivery: cmspviqsp0003ura62z7x7oj3
X-SeoSorted-Delivery is unique per attempt — use it as an idempotency key.
See verifying the signature.
Body
A real delivery, with the body fields truncated:{
"event": "article.published",
"deliveredAt": "2026-08-12T09:39:43.035Z",
"deliveryId": "cmspwe9fg0001urtydkeyx4k8",
"workspace": {
"id": "cmspuvypx0001urym6octbz30",
"name": "seosorted.ai",
"websiteUrl": "https://seosorted.ai"
},
"article": {
"id": "cmspuvyq60005urymrk9q2mgk",
"title": "How to Test a CMS Connector End to End",
"slug": "how-to-test-a-cms-connector-end-to-end",
"contentType": "BLOG_POST",
"metaTitle": "How to Test a CMS Connector End to End",
"metaDescription": "A practical walkthrough of verifying your publishing connector…",
"focusKeyword": "cms connector",
"featuredImageUrl": null,
"targetLength": 1200,
"wordCount": 125,
"seoScore": 82,
"publishedAt": "2026-08-12T09:00:32.276Z",
"publishedUrl": null,
"body": {
"markdown": "## Why connector testing matters\n\nPublishing is the last mile…",
"html": "<h2>Why connector testing matters</h2>\n<p>Publishing is the last mile…"
}
}
}
Fields
string
required
One of
article.published, article.updated, article.deleted, test.string
required
ISO 8601 timestamp of when we built the payload.
string
required
Matches the
X-SeoSorted-Delivery header.object
required
object
required
Show properties
Show properties
string
Stable article id — use it to upsert on your side.
string
Article title.
string | null
URL slug we generated.
string
BLOG_POST, LISTICLE, GUIDE, and so on.string | null
SEO title.
string | null
SEO description.
string | null
Primary keyword.
string | null
Hosted image URL — download it if you need a local copy.
number
Word count the article was written to.
number
Actual word count.
number | null
0–100 internal SEO score.
string | null
ISO timestamp of the first publish, if any. On
article.unpublished this is still set — it describes the post you are being asked to remove.string | null
Public URL, when a destination returned one — including on
article.unpublished. null for webhook-only articles whose receiver never answered with a URL.string | null
Article body as Markdown.
string | null
The same body rendered to HTML.
Take
body.markdown if you store Markdown, body.html if you render directly. They are the same
content — the HTML is rendered from the Markdown at delivery time.A minimal handler
Express
import express from "express";
import { verify } from "./verify"; // from the webhook page
const app = express();
// Raw body — the signature covers the exact bytes we sent.
app.post("/seosorted/articles", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("X-SeoSorted-Signature") ?? "";
if (!verify(process.env.SEOSORTED_WEBHOOK_SECRET!, req.body.toString("utf8"), signature)) {
return res.status(401).json({ error: "bad signature" });
}
const payload = JSON.parse(req.body.toString("utf8"));
// Acknowledge first, work afterwards — we time out after 15 seconds.
res.status(200).json({ received: payload.deliveryId });
queue.add("import-article", payload);
});

