Use scatterpost with the site you already have

If you already publish somewhere, whether that is WordPress, Webflow, Ghost or your own Next.js app, scatterpost can publish there first and use its URL as the canonical for every cross-post. If you do not have a site yet, a free blog template is faster than connecting one.

When to use a connector versus a template

Connect your existing site when you already have readers, SEO history or a domain you do not want to move away from. Reach for a blog template instead when you have nothing to connect yet: it ships with the connector wiring and the checklist below already in place.

Choosing a connector: push or pull

Push mode: scatterpost sends a signed HTTP POST to a route handler you add to your site. Pull mode: your site polls scatterpost on a schedule of its own instead of receiving anything. Both keep your site's own URL as the canonical for every cross-post scatterpost makes afterwards.

Push: verify the signature

scatterpost POSTs a JSON body with the header X-Scatterpost-Signature, shaped t=<unix seconds>,v1=<hex hmac_sha256(secret, "<t>.<body>")>, checked against the exact raw body string with a five-minute window either side of t. Compare the signature with a timing-safe equality check, never ===. Your route replies { "url": "..." } with the public URL of the post it just wrote.

Next.js route handler

// app/api/scatterpost/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.SCATTERPOST_WEBHOOK_SECRET ?? "";
// Refuse to start without a real secret: an empty key would accept forged signatures.
if (SECRET.length < 32) throw new Error("SCATTERPOST_WEBHOOK_SECRET must be at least 32 characters.");
const TOLERANCE_SECONDS = 300;

function verifySignature(header: string, body: string): boolean {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header.trim());
  if (!match) return false;
  const [, timestampRaw, signature] = match;
  const timestamp = Number(timestampRaw);
  if (!Number.isFinite(timestamp)) return false;

  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");
  const expectedBuffer = Buffer.from(expected, "hex");
  const actualBuffer = Buffer.from(signature ?? "", "hex");
  if (expectedBuffer.length !== actualBuffer.length) return false;
  return timingSafeEqual(expectedBuffer, actualBuffer);
}

export async function POST(request: Request) {
  const rawBody = await request.text();
  const header = request.headers.get("X-Scatterpost-Signature") ?? "";

  if (!verifySignature(header, rawBody)) {
    return new Response("invalid signature", { status: 401 });
  }

  const payload = JSON.parse(rawBody) as { title: string; bodyHtml: string };
  // Write the post with your own storage, then return the URL it now
  // lives at. scatterpost stores this as the article's canonical URL
  // before any cross-post job runs.
  const url = await savePost(payload);

  return Response.json({ url });
}

Plain Node.js

// Plain Node.js, any framework
const { createHmac, timingSafeEqual } = require("node:crypto");

const SECRET = process.env.SCATTERPOST_WEBHOOK_SECRET ?? "";
// Refuse to start without a real secret: an empty key would accept forged signatures.
if (SECRET.length < 32) throw new Error("SCATTERPOST_WEBHOOK_SECRET must be at least 32 characters.");
const TOLERANCE_SECONDS = 300;

function verifySignature(header, body) {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header.trim());
  if (!match) return false;
  const [, timestampRaw, signature] = match;
  const timestamp = Number(timestampRaw);
  if (!Number.isFinite(timestamp)) return false;

  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");
  const expectedBuffer = Buffer.from(expected, "hex");
  const actualBuffer = Buffer.from(signature || "", "hex");
  if (expectedBuffer.length !== actualBuffer.length) return false;
  return timingSafeEqual(expectedBuffer, actualBuffer);
}

// rawBody must be the exact bytes scatterpost sent, before any JSON.parse.
app.post("/api/scatterpost", (req, res) => {
  const header = req.get("X-Scatterpost-Signature") || "";
  if (!verifySignature(header, req.rawBody)) {
    return res.status(401).send("invalid signature");
  }
  const payload = JSON.parse(req.rawBody);
  savePost(payload).then((url) => res.json({ url }));
});

Pull: poll and report

Call GET /api/v1/publications?channel=website&due=true with your API key on a schedule of your own, write each post with your own storage, then report completion with PATCH /api/v1/publications/:id and body { "status": "published", "url": "..." }. The URL becomes the article's canonical.

// Poll every few minutes, on a schedule of your own.
const response = await fetch(
  "https://app.scatterpost.io/api/v1/publications?channel=website&due=true",
  { headers: { Authorization: `Bearer ${process.env.SCATTERPOST_API_KEY}` } }
);
const { data } = await response.json();

for (const publication of data) {
  const url = await savePost(publication);

  await fetch(
    `https://app.scatterpost.io/api/v1/publications/${publication.id}`,
    {
      method: "PATCH",
      headers: {
        Authorization: `Bearer ${process.env.SCATTERPOST_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ status: "published", url }),
    }
  );
}

WordPress, Webflow, Ghost

None of these have a scatterpost plugin today, and no plugin exists yet for any of them. Pull mode is the practical route: a small script or plugin on your side polls the endpoint above and creates the post with that platform's own API (the WordPress REST API, Webflow's CMS API, or Ghost's Admin API), then reports the published URL back with the same PATCH call.

BlogPosting JSON-LD

Add this to each post page, filled in with that post's own fields:

{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Your post title",
  "description": "One or two sentences for search and AI results.",
  "url": "https://your-site.example.com/blog/your-post",
  "mainEntityOfPage": "https://your-site.example.com/blog/your-post",
  "datePublished": "2026-09-27T09:00:00.000Z",
  "dateModified": "2026-09-27T09:00:00.000Z",
  "image": "https://your-site.example.com/og/your-post.png",
  "author": { "@type": "Person", "name": "Your name" },
  "publisher": { "@type": "Organization", "name": "Your company" }
}

llms.txt template

Serve this at /llms.txt, a plain Markdown file listing what an AI agent or crawler reading your site should know:

# Your Company

Your company builds [one sentence of what you do].

## Articles

- [Your post title](https://your-site.example.com/blog/your-post): one or two sentences this article is about.

## Policies

- [Privacy](https://your-site.example.com/privacy)
- [Terms](https://your-site.example.com/terms)

Checklist

  • <link rel="canonical"> on every post page, pointing to itself.
  • A sitemap that lists every post with a lastmod date.
  • An RSS feed listing your posts.
  • robots.txt allowing search engines and AI crawlers to fetch your pages.
  • Open Graph tags (og:title, og:description, og:url, og:image) on every post.
  • Exactly one h1 per page.

None of this guarantees a ranking or an AI citation; it only removes the reasons a crawler or an agent would skip or misread the page.