Webhook publishing

Last reviewed: 2026-06-11. This is the outbound publishing webhook contract for receiving generated RankBull articles in your own CMS or app.

Endpoint requirements

Authentication

You can choose no auth, bearer token, basic auth, or signed requests. For signed requests, RankBull generates a server-side whsec_ signing secret and shows it once after the connection test succeeds. Store it in your receiver.

When you rotate a signed webhook, RankBull stores the old secret as signing_secret_previous. While that value is configured, RankBullincludes both current and previous sha256=... signatures in x-seoitis-signature. Once your receiver trusts the new secret, stop accepting the old one.

Headers

HeaderMeaning
x-seoitis-eventEvent name, for example article.publish_requested.
x-seoitis-request-idPublish request id reused across retries and manual redelivery.
x-seoitis-idempotency-keyStable key for de-duplicating initial delivery and manual redelivery.
x-seoitis-delivery-attemptOne-based HTTP attempt number for this delivery run.
x-seoitis-delivery-max-attemptsMaximum attempts RankBull will make for this delivery run.
x-seoitis-spec-versionOutbound webhook contract version.
x-seoitis-timestampUnix timestamp in seconds.
x-seoitis-signatureHMAC SHA-256 signature when signed requests are enabled.

Verify signed requests

Verify the signature against ${timestamp}.${rawBody}. Use the raw body string exactly as received, before JSON parsing. Reject stale timestamps; five minutes is a practical tolerance.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyRankBullWebhook(params: {
  rawBody: string;
  timestamp: string;
  signatureHeader: string;
  signingSecret: string;
}) {
  const timestampSeconds = Number(params.timestamp);
  if (!Number.isSafeInteger(timestampSeconds)) return false;
  if (Math.abs(Date.now() / 1000 - timestampSeconds) > 300) return false;

  const signingSecret = params.signingSecret.trim();
  if (!signingSecret) return false;

  const expected = createHmac("sha256", signingSecret)
    .update(params.timestamp + "." + params.rawBody)
    .digest("hex");

  return params.signatureHeader.split(",").some((part) => {
    const match = /^sha256=([a-f0-9]{64})$/i.exec(part.trim());
    if (!match) return false;
    return timingSafeEqual(Buffer.from(match[1], "hex"), Buffer.from(expected, "hex"));
  });
}

Delivery and redelivery

Response contract

A 2xx status means the delivery succeeded. If you return JSON, RankBull reads optional status, external_id, external_url, andpublished_at fields to link back to the created post.

{
  "ok": true,
  "status": "published",
  "external_id": "post_123",
  "external_url": "https://example.com/blog/my-post"
}

Next.js receiver example

export async function POST(req: Request) {
  const rawBody = await req.text();
  const timestamp = req.headers.get("x-seoitis-timestamp") ?? "";
  const signature = req.headers.get("x-seoitis-signature") ?? "";

  if (!verifyRankBullWebhook({
    rawBody,
    timestamp,
    signatureHeader: signature,
    signingSecret: process.env.RANKBULL_WEBHOOK_SECRET!,
  })) {
    return Response.json({ ok: false }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  // Store payload.article.content_html in your CMS or database.

  return Response.json({
    ok: true,
    status: "published",
    external_id: "your-post-id",
    external_url: process.env.SITE_URL + "/blog/" + payload.article.slug,
  });
}