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
- 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-seoitis-signature. Once your receiver trusts the new secret, stop accepting the old one.
Headers
| Header | Meaning |
|---|---|
x-seoitis-event | Event name, for example article.publish_requested. |
x-seoitis-request-id | Publish request id reused across retries and manual redelivery. |
x-seoitis-idempotency-key | Stable key for de-duplicating initial delivery and manual redelivery. |
x-seoitis-delivery-attempt | One-based HTTP attempt number for this delivery run. |
x-seoitis-delivery-max-attempts | Maximum attempts RankBull will make for this delivery run. |
x-seoitis-spec-version | Outbound webhook contract version. |
x-seoitis-timestamp | Unix timestamp in seconds. |
x-seoitis-signature | HMAC 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-seoitis-idempotency-keyto 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-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,
});
}