Publish every finished article to your website.
FullStackSEO sends one signed JSON POST to your endpoint. Verify it, upsert the article in your CMS, and return any 2xx response.
Create a public HTTPS POST route
Verify HMAC on the raw body
Save by article.id and return 2xx
Direct WordPress publishing is on the way.
Plugin downloads and setup will appear here when the integration is ready. Custom websites and CMS platforms can use the signed publishing webhook today.
Events
| Event | When | Required action |
|---|---|---|
| connection.test | A user tests a saved endpoint | Verify and return 2xx; do not create a post |
| article.published | A scheduled article is ready | Create or update using article.id |
Request headers
X-FSS-Eventconnection.test or article.publishedX-FSS-DeliveryStable UUID for idempotency across retriesX-FSS-TimestampUnix timestamp in secondsX-FSS-Signaturesha256=<hex HMAC>Content-Typeapplication/jsonVerify the exact raw body
Compute HMAC-SHA256 over timestamp.rawBody. Reject requests older than five minutes. Parse JSON only after verification.
import crypto from "node:crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
export const config = { api: { bodyParser: false } };
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).end();
const rawBody = (await getRawBody(req)).toString("utf8");
const timestamp = String(req.headers["x-fss-timestamp"] ?? "");
const supplied = String(req.headers["x-fss-signature"] ?? "").replace(/^sha256=/, "");
const expected = crypto
.createHmac("sha256", process.env.FULLSTACKSEO_WEBHOOK_SECRET)
.update(timestamp + "." + rawBody)
.digest("hex");
const valid = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
&& /^[a-f0-9]{64}$/i.test(supplied)
&& crypto.timingSafeEqual(Buffer.from(supplied, "hex"), Buffer.from(expected, "hex"));
if (!valid) return res.status(401).json({ error: "invalid_signature" });
const event = JSON.parse(rawBody);
if (await deliveryAlreadyHandled(event.deliveryId)) return res.status(204).end();
if (event.event === "connection.test") return res.status(204).end();
await createOrUpdatePost(event.article);
await rememberDelivery(event.deliveryId);
return res.status(204).end();
}Request bodies
Every event uses the same envelope. The event header and body field always match.
| Field | Type | Required | Description |
|---|---|---|---|
schemaVersion | integer | Yes | Payload schema version. Currently always 1. |
event | string | Yes | connection.test or article.published. |
deliveryId | UUID | Yes | Stable identifier reused across retries. |
createdAt | ISO 8601 | Yes | Time FullStackSEO created the delivery. |
article | object | Yes | Test fixture or the finished article. |
connection.test request
Verify the signature and return 2xx. Do not create or update a post.
{
"schemaVersion": 1,
"event": "connection.test",
"deliveryId": "02da0c7f-65a1-4e11-9b85-3cc8088f0fd5",
"createdAt": "2026-09-12T07:30:00.000Z",
"article": {
"id": "test",
"title": "FullStackSEO connection test",
"slug": "fullstackseo-connection-test",
"html": "<p>Your signed publishing webhook is connected.</p>",
"metaTitle": "FullStackSEO connection test",
"metaDescription": "A test delivery from FullStackSEO.com.",
"excerpt": "A test delivery from FullStackSEO.com.",
"featuredImageUrl": null,
"featuredImageAlt": null,
"seo": {
"targetKeyword": "fullstackseo publishing webhook",
"searchIntent": "informational",
"primaryLocale": "en",
"articleType": "connection-test",
"imageStyle": "none",
"faqs": [],
"internalLinks": [],
"researchSources": []
},
"structuredData": []
}
}article.published request
Create or update the post using article.id as the permanent key. A featured image URL is a permanent public media URL.
{
"schemaVersion": 1,
"event": "article.published",
"deliveryId": "40b35d0d-9b6f-4c3f-bcb1-ecc8a234f55d",
"createdAt": "2026-09-11T12:30:00.000Z",
"article": {
"id": "9a8e311f-6d3a-49a1-8cc7-84297f55c0d0",
"title": "Technical SEO audit checklist",
"slug": "technical-seo-audit-checklist",
"html": "<p>Finished article HTML...</p>",
"metaTitle": "Technical SEO Audit Checklist",
"metaDescription": "A practical technical SEO checklist.",
"excerpt": "A practical technical SEO checklist.",
"featuredImageUrl": "https://media.example.com/articles/image.webp",
"featuredImageAlt": "Technical SEO audit workflow",
"seo": {
"targetKeyword": "technical seo audit checklist",
"searchIntent": "informational",
"primaryLocale": "en",
"articleType": "how-to",
"imageStyle": "editorial",
"faqs": [{
"question": "How often should you run a technical SEO audit?",
"answer": "Run a focused audit monthly and after major site changes."
}],
"internalLinks": [{
"title": "Crawlability guide",
"url": "https://example.com/crawlability",
"anchor": "technical crawlability"
}],
"researchSources": [{
"title": "Search documentation",
"url": "https://example.com/search-docs",
"publishedDate": null
}]
},
"structuredData": [{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": []
}]
}
}Article fields
| Field | Type | Nullable | Description |
|---|---|---|---|
id | UUID or test | No | Permanent upsert key. |
title | string | No | Article headline. |
slug | string | No | Suggested URL slug; it may change. |
html | string | No | Generated HTML. Apply your normal CMS rendering policy. |
metaTitle | string | Yes | SEO title. |
metaDescription | string | Yes | SEO description. |
excerpt | string | No | Short article summary. |
featuredImageUrl | URL string | Yes | Public featured-image URL. |
featuredImageAlt | string | Yes | Featured-image alternative text. |
seo | object | No | Target keyword, intent, FAQs, internal links, and research provenance. |
structuredData | object[] | No | Ready-to-serialize JSON-LD blocks. |
Responses
Any 2xx confirms delivery acceptance, not public visibility. Return the actual post status and URL after saving. Drafts and empty legacy responses do not count as published articles.
| Success field | Type | Required | Description |
|---|---|---|---|
status | published / draft | For confirmed publication | Actual destination state. Published requires a valid URL. |
externalId or postId | string / number | No | Receiver-side post identifier stored with the receipt. |
url | HTTP(S) URL | No | Public post URL used in the audit trail and notification. |
| Your response | Meaning | Publish behavior |
|---|---|---|
2xx without a publication receipt | Delivery accepted | Test passes; article is not confirmed published. |
2xx + published status + URL | Publication confirmed by destination | Counts as published and shows the website link. |
2xx + draft status | Draft on website | Delivered but does not count as published. |
400 | Invalid body or unsupported event | Fails; a scheduled publish retries. |
401 / 403 | Invalid signature or secret | Fails; a scheduled publish retries. |
404 / 405 | Wrong URL or method | Fails; a scheduled publish retries. |
408 / 429 | Unavailable or rate limited | Fails; a scheduled publish retries. |
Any 5xx | Receiver error | Fails; a scheduled publish retries. |
No response in 25s | Request timeout | Fails; a scheduled publish retries. |
Recommended success response
HTTP/1.1 200 OK
Content-Type: application/json
{"ok":true,"status":"published","externalId":"42","url":"https://example.com/my-post"}Recommended authentication failure
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error":"invalid_signature"}The interactive connection test makes one request and reports an error immediately. Scheduled article.published deliveries use up to five total attempts with exponential backoff beginning at five seconds.
Retries and idempotency
Return a 2xx within 25 seconds. Queue slow work on your side.
Store deliveryId with a unique constraint. The same ID is reused when we retry.
Return 2xx for a delivery you already handled; never create the post twice.
Use article.id as the permanent upsert key even when title or slug changes.
Test before publishing
Open Connections, enter the HTTPS endpoint and a secret of at least 16 characters, then select “Save and send test.” The connection unlocks only after your endpoint verifies the request and returns 2xx.