Free AI visibility report

Get my report

Docs · Publishing

Webhook publishing

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

Endpoint requirements

  • Your endpoint must be a public HTTP or HTTPS URL reachable by RankBull.
  • Private, localhost, link-local, and internal hostnames are rejected.
  • Redirects are treated as failures. Configure the final URL directly.
  • Return a 2xx status after you accept and persist the event.

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-rankbull-signature. Once your receiver trusts the new secret, stop accepting the old one.

Headers

Every x-rankbull-* header is also sent duplicated under the legacy x-seoitis-* prefix with an identical value — receivers built before the RankBull rename keep working unchanged. New receivers should read the x-rankbull-* names.

HeaderMeaning
x-rankbull-eventEvent name, for example article.publish_requested.
x-rankbull-request-idPublish request id reused across retries and manual redelivery.
x-rankbull-idempotency-keyStable key for de-duplicating initial delivery and manual redelivery.
x-rankbull-delivery-attemptOne-based HTTP attempt number for this delivery run.
x-rankbull-delivery-max-attemptsMaximum attempts RankBull will make for this delivery run.
x-rankbull-spec-versionOutbound webhook contract version.
x-rankbull-timestampUnix timestamp in seconds.
x-rankbull-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

  • RankBull retries transient failures using your configured retry count.
  • Retry attempts are identified by the delivery-attempt headers.
  • The idempotency key stays stable for the original publish and manual redelivery.
  • Use x-rankbull-idempotency-key to avoid creating duplicate posts.

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-rankbull-timestamp") ?? "";
  const signature = req.headers.get("x-rankbull-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,
  });
}