Help & Guides

Step-by-step guides for every tool, recipe, and setting

Publish & Build EvidenceWebhook format — for your developer

Webhook format — for your developer

Who this is for

Give this page to whoever writes the receiver on your site. It is the whole contract: what we send when a page is published, what we send when it changes, what your endpoint answers, and how to check a message really came from us. A receiver written before 19 September 2026 keeps working as it is. Every field and header it relied on is still sent. What was added - a signature, an event type, our id for the post, and updates - is yours to take up as your code is changed, in the order at the end of this page.

A new page: POST

POST <your endpoint>
Content-Type: application/json
webhook-id: msg_2b8f0c4e9a1d4c7e8f3a5b6c7d8e9f01
webhook-timestamp: 1790000000
webhook-signature: v1,<base64 HMAC-SHA256>
Authorization: Bearer <your secret>
X-Webhook-Secret: <your secret>

{
  "type": "post.created",
  "title": "Emergency roof repair in Bath",
  "html": "<h1>Emergency roof repair in Bath</h1><p>...</p>",
  "excerpt": "One-paragraph summary, at most 155 characters.",
  "imageUrl": "https://therankingfactory.com/blog-images/9f2c.jpg",
  "videoUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "externalId": "gap-4f1c0d2e8b9a4c6d9e0f1a2b3c4d5e6f",
  "publishedAt": "2026-09-19T08:00:00Z",
  "source": "TheRankingFactory"
}

• html is the whole article, starting with its own h1 title. A page the engine writes ends with a script block of type application/ld+json that records its dates and sources - publish it with the page; it is what search and AI engines read about where the page came from. • excerpt may be null. • imageUrl is always an absolute address or null. videoUrl is always a YouTube watch address or null. • externalId is our id for this post, the same every time the same post is sent. It is null only for a page you publish by hand without one. • publishedAt is when we sent it. • webhook-id and webhook-timestamp come with every POST and PUT, and webhook-signature whenever the connection has a secret. The secret is also sent in Authorization and X-Webhook-Secret for now, plus any extra header named on the connection.

What your endpoint answers

200  {"url": "https://yoursite.com/blog/emergency-roof-repair-in-bath", "slug": "emergency-roof-repair-in-bath"}

• Published: reply 2xx with the page's full address in url. Without it we cannot submit the page for indexing, re-measure it or show it, so a 2xx with no url is recorded as accepted but not published. url, link, permalink, postUrl and post_url are all read, at any depth. • Held for review: reply 2xx with no url and a status of draft, pending, review or awaiting-approval, and the slug you stored it under. It shows as waiting for approval, not as a failure. • Refused: any 4xx or 5xx. We record the status and the first 300 characters of what you replied, so a short, plain message helps whoever reads it. • Answer within 30 seconds, or the attempt is recorded as failed. • The same externalId again: answer with the post you already have - the same reply as the first time - rather than creating another. The connection's Test button always sends the same externalId, so a receiver that does this makes every Test after the first come back with the same post.

A change to a page you already have: PUT

Sent only once the connection's Accepts updates box is ticked (Connections, then Publishing, then Manage on the webhook). Until then a Re-align you approve, or a page rebuilt to answer its question, waits on its card with Copy the HTML and Send again - Send again tries once more after you tick the box. Smaller updates made on their own, such as a late piece added to a page, are simply not sent to a site that does not take updates.

PUT <your endpoint>
(the same headers as above)

{
  "type": "post.updated",
  "slug": "emergency-roof-repair-in-bath",
  "url": "https://yoursite.com/blog/emergency-roof-repair-in-bath",
  "title": "Emergency roof repair in Bath",
  "html": "<h1>Emergency roof repair in Bath</h1><p>...</p>",
  "excerpt": "One-paragraph summary.",
  "updatedAt": "2026-09-19T09:30:00Z",
  "source": "TheRankingFactory"
}

• Find the post by url - the address you returned when it was created - or by slug, its last part. • Replace its title, body and excerpt. Keep its address, its publication date and whether it is published. • A post still held for review is updated the same way. • Reply 200 with {"url": "...", "slug": "...", "updated": true}. • A post you do not have is a 404 - never a new post. An update that quietly created a second page is the duplicate updates exist to avoid.

Has a held page gone live? (only if you hold pages for review)

GET <your endpoint>/status?slug=emergency-roof-repair-in-bath&title=Emergency%20roof%20repair%20in%20Bath
Authorization: Bearer <your secret>
X-Webhook-Secret: <your secret>

200  {"published": true,  "url": "https://yoursite.com/blog/emergency-roof-repair-in-bath", "status": "published"}
200  {"published": false, "url": null, "status": "draft"}
200  {"published": false, "url": null, "status": "unknown"}

We ask this about a page that was held, until it goes live - when the connection has a secret. Answer published with its url once it is live, draft while it is held, and unknown only when you have no such post at all - unknown is read as "it is gone", and the page stops being waited for. Without this endpoint we look for the published page ourselves, which works less well. This request has no body, so it carries the secret headers rather than a signature.

Checking the signature

1. Take the raw request body exactly as it arrived - before any JSON parsing, which can change it. 2. Make an HMAC-SHA256 of webhook-id, a full stop, webhook-timestamp, a full stop, and the body, keyed with your secret. A secret that starts whsec_ is base64 after those six characters - decode it; any other secret is used as its UTF-8 text, so the secret you already set works. 3. Compare v1, followed by the base64 result, with webhook-signature, in constant time. The header can hold several signatures separated by spaces; any one that matches will do. 4. Refuse a webhook-timestamp more than five minutes from your own clock. That is what stops an old message being sent again. This is the Standard Webhooks scheme (standardwebhooks.com), and its libraries for most languages do all four steps. Once you check it, you no longer need the secret headers.

// Node.js with Express. Read the body raw: parsing it first changes the bytes the signature covers.
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.RANKING_FACTORY_SECRET;

function verified(req) {
  const id = req.get('webhook-id');
  const ts = req.get('webhook-timestamp');
  const header = req.get('webhook-signature') || '';
  if (!id || !ts || !Number.isInteger(Number(ts)) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = SECRET.startsWith('whsec_') ? Buffer.from(SECRET.slice(6), 'base64') : Buffer.from(SECRET, 'utf8');
  const signed = id + '.' + ts + '.' + req.body.toString('utf8');
  const expected = 'v1,' + crypto.createHmac('sha256', key).update(signed).digest('base64');
  return header.split(' ').some(s => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

const app = express();
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verified(req)) return res.status(401).end();
  const post = JSON.parse(req.body.toString('utf8'));
  // Create the post - or, if you already hold post.externalId, answer with that one.
  res.json({ url: pageUrl, slug: pageSlug });
});
<?php
// PHP. Read the body raw from php://input, before json_decode.
// In a WordPress REST route: $request->get_body() and $request->get_header('webhook-signature').
function rf_verified(string $secret, string $id, string $ts, string $header, string $body): bool {
    if ($id === '' || !ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
    $key = strpos($secret, 'whsec_') === 0 ? base64_decode(substr($secret, 6)) : $secret;
    $expected = 'v1,' . base64_encode(hash_hmac('sha256', $id . '.' . $ts . '.' . $body, $key, true));
    foreach (explode(' ', $header) as $sig) {
        if (hash_equals($expected, $sig)) return true;
    }
    return false;
}

$body = file_get_contents('php://input');
if (!rf_verified(getenv('RANKING_FACTORY_SECRET'), $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
        $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $body)) {
    http_response_code(401);
    exit;
}
$post = json_decode($body, true);
// Create the post - or, if you already hold $post['externalId'], answer with that one.
header('Content-Type: application/json');
echo json_encode(['url' => $pageUrl, 'slug' => $pageSlug]);

Bringing a receiver written before 19 September up to date

1. Nothing is required: it keeps working as it is. 2. Store externalId with each post, and answer a post.created whose externalId you already hold with that post. This ends duplicates from resends and from Test. 3. Check the signature, then stop relying on the secret headers - they will be retired in a later version, announced first. 4. Take updates: handle the PUT above, then tick Accepts updates on the connection. From then on a rewrite of a live page replaces it in place; any waiting on a page's card can be sent with Send again. 5. If you hold pages for review, answer the status question, so we learn when one goes live.