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 Sep 19, 2026 keeps working as it is, on the connection it was set up with: a connection made before Oct 9, 2026 still sends the secret itself in Authorization and X-Webhook-Secret until you switch that off in Manage. A connection made since then sends only the signature, so its receiver must check the signature (below). 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>

{
  "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",
  "section": "guides",
  "category": "Roof repair"
}

• 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. • section and category (since Sep 29, 2026) say where the page belongs, as hints you may use or ignore: section is "guides", "compare", "glossary" or "use-cases", from the kind of page it is; category is the subject's parent topic. Either may be null. • webhook-id and webhook-timestamp come with every POST and PUT, and webhook-signature whenever the connection has a secret. The secret itself is never sent - except on a connection made before Oct 9, 2026 that has not been switched off it, which also sends it in Authorization and X-Webhook-Secret, 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 other than 408 and 429. We take it as your answer, record the status and the first 300 characters of what you replied, and do not send that post again - so a short, plain message helps whoever reads it. • Busy or broken: 408, 429 or any 5xx, no answer within 30 seconds, or no connection at all. A post queued on the Drip Schedule is tried again - see Retries below; any other 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.

Retries

A post queued on the Drip Schedule that met a timeout, a refused connection, 408, 429 or a 5xx is sent again: five attempts in all, about 1 minute, 5 minutes, 30 minutes and 2 hours after the one before, each wait varied by up to 10% either way. A Retry-After header asking for longer than that is honoured, up to 6 hours. After the fifth attempt we stop, and the Drip Schedule says so - for example "Gave up after 5 attempts: HTTP 503". While it is still trying it shows "Retrying (attempt 2 of 5, next at ...)". Every attempt is the same message. webhook-id stays the same (msg_ followed by our id for the post) and so does externalId; webhook-timestamp and webhook-signature are made fresh each time, so a retry still passes your timestamp check. A receiver that answers a repeated externalId with the post it already has - as asked above - never makes a copy from a retry.

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
webhook-id: msg_7c1d9e2f3a4b5c6d7e8f9a0b1c2d3e4f
webhook-timestamp: 1790000000
webhook-signature: v1,<base64 HMAC-SHA256 over an empty body>

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 its signature is made over an empty body - check it exactly as below, with the body empty. A connection made before Oct 9, 2026 that still sends the secret itself sends it here too.

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 Sep 19 up to date

1. Nothing is required: it keeps working as it is, on the connection it was set up with. A NEW connection made since Oct 9, 2026 sends only the signature, so a receiver that checks only the secret headers must take step 3 before it is connected again. 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 untick "Also send the secret itself" in Manage on the connection, so the secret stops travelling with every request. 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.