Publishing API

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.

01

Create a public HTTPS POST route

02

Verify HMAC on the raw body

03

Save by article.id and return 2xx

Coming very soon

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.

Use webhook today

Events

EventWhenRequired action
connection.testA user tests a saved endpointVerify and return 2xx; do not create a post
article.publishedA scheduled article is readyCreate or update using article.id

Request headers

X-FSS-Eventconnection.test or article.published
X-FSS-DeliveryStable UUID for idempotency across retries
X-FSS-TimestampUnix timestamp in seconds
X-FSS-Signaturesha256=<hex HMAC>
Content-Typeapplication/json

Verify 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.

FieldTypeRequiredDescription
schemaVersionintegerYesPayload schema version. Currently always 1.
eventstringYesconnection.test or article.published.
deliveryIdUUIDYesStable identifier reused across retries.
createdAtISO 8601YesTime FullStackSEO created the delivery.
articleobjectYesTest 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

FieldTypeNullableDescription
idUUID or testNoPermanent upsert key.
titlestringNoArticle headline.
slugstringNoSuggested URL slug; it may change.
htmlstringNoGenerated HTML. Apply your normal CMS rendering policy.
metaTitlestringYesSEO title.
metaDescriptionstringYesSEO description.
excerptstringNoShort article summary.
featuredImageUrlURL stringYesPublic featured-image URL.
featuredImageAltstringYesFeatured-image alternative text.
seoobjectNoTarget keyword, intent, FAQs, internal links, and research provenance.
structuredDataobject[]NoReady-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 fieldTypeRequiredDescription
statuspublished / draftFor confirmed publicationActual destination state. Published requires a valid URL.
externalId or postIdstring / numberNoReceiver-side post identifier stored with the receipt.
urlHTTP(S) URLNoPublic post URL used in the audit trail and notification.
Your responseMeaningPublish behavior
2xx without a publication receiptDelivery acceptedTest passes; article is not confirmed published.
2xx + published status + URLPublication confirmed by destinationCounts as published and shows the website link.
2xx + draft statusDraft on websiteDelivered but does not count as published.
400Invalid body or unsupported eventFails; a scheduled publish retries.
401 / 403Invalid signature or secretFails; a scheduled publish retries.
404 / 405Wrong URL or methodFails; a scheduled publish retries.
408 / 429Unavailable or rate limitedFails; a scheduled publish retries.
Any 5xxReceiver errorFails; a scheduled publish retries.
No response in 25sRequest timeoutFails; 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.