Website connector

Your website is always published first, and the URL it returns becomes the canonical link for every cross-post scatterpost makes afterwards. Choose push mode or pull mode when you add the channel; both keep your own site as the canonical.

No website yet? Start with a free blog template, already wired to push mode.

Push mode

scatterpost sends a signed HTTP POST to your endpoint, and your endpoint replies with the URL the post now lives at. Add the channel with:

{
  "platform": "website",
  "credentials": {
    "endpointUrl": "https://your-site.example.com/api/scatterpost",
    "secret": "a-random-string-at-least-32-characters-long",
    "mode": "push"
  }
}

Request contract

scatterpost POSTs to endpointUrl with a JSON body and two headers: X-Scatterpost-Signature and X-Scatterpost-Event: publication.publish. Redirects are never followed. The signature header has the form t=<unix seconds>,v1=<hex hmac_sha256(secret, "<t>.<body>")>, checked against the exact raw body string, with a five-minute replay window either side of t. Your endpoint should reply { "url": "..." } with the public URL of the post it just wrote.

Verifying the signature: Next.js

// 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 });
}

Verifying the signature: WordPress (PHP)

<?php
// wp-content/mu-plugins/scatterpost-connector.php
add_action('rest_api_init', function () {
    register_rest_route('scatterpost/v1', '/publish', [
        'methods' => 'POST',
        'callback' => 'scatterpost_handle_publish',
        'permission_callback' => '__return_true',
    ]);
});

function scatterpost_verify_signature($header, $body, $secret) {
    if (!preg_match('/^t=(\d+),v1=([0-9a-f]+)$/', trim($header), $matches)) {
        return false;
    }
    [$full, $timestamp, $signature] = $matches;
    $timestamp = (int) $timestamp;

    if (abs(time() - $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
    return hash_equals($expected, $signature);
}

function scatterpost_handle_publish(WP_REST_Request $request) {
    $secret = getenv('SCATTERPOST_WEBHOOK_SECRET');
    if (!is_string($secret) || strlen($secret) < 32) {
        return new WP_REST_Response(['error' => 'webhook secret not configured'], 500);
    }
    $body = $request->get_body();
    $header = $request->get_header('x_scatterpost_signature') ?? '';

    if (!scatterpost_verify_signature($header, $body, $secret)) {
        return new WP_REST_Response(['error' => 'invalid signature'], 401);
    }

    $payload = json_decode($body, true);

    $post_id = wp_insert_post([
        'post_title'   => $payload['title'],
        'post_content' => $payload['bodyHtml'],
        'post_status'  => 'publish',
    ]);

    return new WP_REST_Response(['url' => get_permalink($post_id)], 200);
}

Rotating the secret

Generate a new random string of at least 32 characters, update your endpoint's environment variable with it first, then edit the channel in scatterpost with the same new value. Every request signed after that point uses the new secret; a request already in flight with the old secret still verifies until it completes, since the check runs against whichever secret you pass it.

Pull mode

Your site polls scatterpost instead of receiving a webhook. Add the channel with "mode": "pull", then on a schedule of your own:

  1. Call GET /api/v1/publications?channel=website&due=true with your API key. This returns the publications that are due: pending or scheduled, and not scheduled for the future.
  2. Write each post with your own storage.
  3. Report completion with PATCH /api/v1/publications/:id and body { "status": "published", "url": "..." }. The URL becomes the article's canonical, and any other channel that was waiting on it is published straight away rather than on the next cron pass.
// Poll every few minutes.
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 }),
    }
  );
}

Only call PATCH once you have actually written the post: it sets published_at and the canonical URL every time it is called, so a poll loop should mark each publication as handled locally before moving on, rather than relying on the API to reject a repeat call.