> ## Documentation Index
> Fetch the complete documentation index at: https://docs.seosorted.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook payload reference

> The exact JSON body, headers and events sent by the webhook connector.

## 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.deleted`   | A published article is deleted.                             |
| `test`              | You click **Send test** on the Integrations tab.            |

Every payload has the same shape, so one handler covers all four — branch on `event`.

## Headers

```http theme={null}
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](/connectors/webhook#verifying-the-signature).

## Body

A real delivery, with the body fields truncated:

```json theme={null}
{
  "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

<ResponseField name="event" type="string" required>
  One of `article.published`, `article.updated`, `article.deleted`, `test`.
</ResponseField>

<ResponseField name="deliveredAt" type="string" required>
  ISO 8601 timestamp of when we built the payload.
</ResponseField>

<ResponseField name="deliveryId" type="string" required>
  Matches the `X-SeoSorted-Delivery` header.
</ResponseField>

<ResponseField name="workspace" type="object" required>
  <Expandable title="properties">
    <ResponseField name="id" type="string">Workspace id.</ResponseField>
    <ResponseField name="name" type="string">Workspace name, usually your domain.</ResponseField>
    <ResponseField name="websiteUrl" type="string">The site this workspace tracks.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="article" type="object" required>
  <Expandable title="properties">
    <ResponseField name="id" type="string">Stable article id — use it to upsert on your side.</ResponseField>
    <ResponseField name="title" type="string">Article title.</ResponseField>
    <ResponseField name="slug" type="string | null">URL slug we generated.</ResponseField>
    <ResponseField name="contentType" type="string">`BLOG_POST`, `LISTICLE`, `GUIDE`, and so on.</ResponseField>
    <ResponseField name="metaTitle" type="string | null">SEO title.</ResponseField>
    <ResponseField name="metaDescription" type="string | null">SEO description.</ResponseField>
    <ResponseField name="focusKeyword" type="string | null">Primary keyword.</ResponseField>
    <ResponseField name="featuredImageUrl" type="string | null">Hosted image URL — download it if you need a local copy.</ResponseField>
    <ResponseField name="targetLength" type="number">Word count the article was written to.</ResponseField>
    <ResponseField name="wordCount" type="number">Actual word count.</ResponseField>
    <ResponseField name="seoScore" type="number | null">0–100 internal SEO score.</ResponseField>
    <ResponseField name="publishedAt" type="string | null">ISO timestamp of the first publish, if any.</ResponseField>
    <ResponseField name="publishedUrl" type="string | null">Public URL, when a previous destination returned one. `null` for webhook-only articles.</ResponseField>
    <ResponseField name="body.markdown" type="string | null">Article body as Markdown.</ResponseField>
    <ResponseField name="body.html" type="string | null">The same body rendered to HTML.</ResponseField>
  </Expandable>
</ResponseField>

<Tip>
  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.
</Tip>

## A minimal handler

```ts Express theme={null}
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);
});
```
