# 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

```json title="POST /api/content-webhook"
{
  "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.

:::tabs
@tab Next.js
```ts title="app/api/content-webhook/route.ts"
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");
}
```
@tab Express
```js title="server.js"
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");
});
```
@tab Python
```python title="app.py"
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"
```
:::

> [!WARNING] Verify the raw body
> If your framework parses the JSON first and you hash the re-serialized result, the signature won't match.

## Test it

Send yourself a signed request with the same secret before you connect it:

```bash
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 `200` as 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](/docs/publishing#is-it-really-live).
