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
- Open your site, then go to Settings → Webhook Publishing.
- Enter your Webhook URL (the endpoint that will receive the
POST) and click Save. - 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.
- Make sure the Enabled switch is on.
- 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):
{
"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:
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
2xxto acknowledge delivery. On the first successful2xx, 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
2xximmediately. -
On failure — any non-
2xxstatus, 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 byid(orslug) 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.