---
title: Publishing webhooks
description: Receive your published articles as a signed JSON webhook so they land in your CMS or site automatically — payload shape, authentication, retries, and a verification example.
---

# Publishing webhooks

When you publish an AI-generated article in QuickSEO, we send it to your own endpoint as a JSON `POST` so it can land in your CMS or website automatically. Point the webhook at a small receiver on your side, verify the request, and create the post — no copy-pasting.

## Introduction

A publishing webhook is configured **per site**. Once it's set up and enabled, clicking **Publish** on an article fires an `article.published` event to your URL with the full article body as both HTML and Markdown. Your endpoint creates (or updates) the post and returns a `2xx` — at which point QuickSEO marks the article as Published.

Webhook publishing is available on paid plans.

## Set up a webhook

1. Open your site, then go to **Settings → Webhook Publishing**.
2. Enter your **Webhook URL** (the endpoint that will receive the `POST`) and click **Save**.
3. We generate a **Bearer token** for the site. It's shown in full only right after you save or regenerate it — reveal it with the eye icon and copy it somewhere safe. Afterwards only the last 4 characters are displayed.
4. Make sure the **Enabled** switch is on.
5. Click **Send Test Article** to fire a sample payload at your endpoint and confirm it responds with a `2xx`.

You can **Regenerate Token** at any time (this immediately invalidates the old one — update your receiver) or **Remove Webhook** to disable delivery entirely.

## The request

Each delivery is a single HTTPS `POST` to your configured URL:

| | |
|---|---|
| Method | `POST` |
| `Content-Type` | `application/json` |
| `Authorization` | `Bearer <your-signing-token>` |
| `X-QuickSEO-Event` | `article.published` |
| Timeout | 10 seconds per attempt |

The `Authorization` header carries the site's signing token verbatim. Treat it as a shared secret: compare it against your stored copy to confirm the request really came from QuickSEO.

## Payload

The request body is the same shape for real publishes and for the **Send Test Article** button (the test uses an all-zero `id`):

```json
{
  "event": "article.published",
  "timestamp": "2026-06-05T12:34:56.000Z",
  "article": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "title": "How to Rank in ChatGPT",
    "slug": "how-to-rank-in-chatgpt",
    "description": "A practical guide to getting cited by AI assistants.",
    "tags": ["ai-seo", "chatgpt"],
    "cover_image_url": "https://cdn.quickseo.ai/covers/how-to-rank.png",
    "html": "<h1>How to Rank in ChatGPT</h1><p>…</p>",
    "markdown": "# How to Rank in ChatGPT\n\n…",
    "created_at": "2026-06-05T12:30:00.000Z"
  }
}
```

### Fields

| Field | Type | Notes |
|---|---|---|
| `event` | string | Always `article.published` for now. |
| `timestamp` | string | ISO 8601 time the delivery was sent. |
| `article.id` | string | UUID. Use it as your idempotency key. |
| `article.title` | string | Article title. |
| `article.slug` | string | URL-safe slug. |
| `article.description` | string | Meta description / excerpt. |
| `article.tags` | string[] | May be empty. |
| `article.cover_image_url` | string \| null | Absolute URL, or `null` if no cover. |
| `article.html` | string | Rendered HTML body. Use this for most CMSes. |
| `article.markdown` | string | Same body as Markdown, if your CMS prefers it. |
| `article.created_at` | string | ISO 8601 creation time. |

## Verify the request

Reject anything whose `Authorization` Bearer token doesn't match the token you saved when configuring the webhook. Use a constant-time comparison to avoid timing leaks:

```js
import crypto from 'node:crypto'

const QUICKSEO_TOKEN = process.env.QUICKSEO_WEBHOOK_TOKEN // the token you copied from Settings

export function handler(req, res) {
  const sent = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '')
  const expected = QUICKSEO_TOKEN

  const ok =
    sent.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))

  if (!ok) return res.status(401).json({ error: 'invalid token' })

  const { event, article } = req.body
  if (event === 'article.published') {
    // Upsert by article.id so retries don't create duplicates:
    //   create or update the post in your CMS using article.html (or article.markdown),
    //   article.title, article.slug, article.description, article.tags, article.cover_image_url
  }

  // Respond 2xx quickly — see "Responses & retries" below.
  return res.status(200).json({ ok: true })
}
```

## Responses & retries

- **Respond with any `2xx`** to acknowledge delivery. On the first successful `2xx`, QuickSEO marks the article as **Published**.
- **Respond fast.** Each attempt times out after **10 seconds**. If you have heavy work to do (image downloads, re-rendering), enqueue it and return `2xx` immediately.
- **On failure** — any non-`2xx` status, a timeout, or a network error — QuickSEO retries with backoff:

  | Attempt | When |
  |---|---|
  | 1 | immediately on Publish |
  | 2 | ~30 seconds after attempt 1 fails |
  | 3 | ~2 minutes after attempt 2 fails |

  After 3 failed attempts we stop and the article is left unpublished. Every attempt (status code and your response body) is recorded so you can debug from the QuickSEO side.
- **Make your handler idempotent.** Retries re-send the same `article.id`, so upsert by `id` (or `slug`) rather than blindly inserting.

## Troubleshooting

- **Test says it failed with an HTTP status** — your endpoint returned a non-`2xx`. The response body is shown inline in the Webhook Publishing card; check it for the reason.
- **Test says "Connection failed" / timeout** — the URL isn't reachable from the public internet within 10s, or it's blocking the request. Confirm it's a public HTTPS URL and responds quickly.
- **401 from your own receiver** — the stored token and the one you're checking against have drifted. Re-copy the token from Settings, or **Regenerate Token** and update both sides.
- **Duplicate posts** — you're inserting instead of upserting on retries. Key on `article.id`.

---

Need a hand wiring it up? Email [support@quickseo.ai](mailto:support@quickseo.ai?subject=Webhook%20setup%20help).
