Skip to content
rctrl Docsrctrl Docs

Publish through a webhook

Receive each article as a signed JSON POST at your own endpoint, and answer with the URL you published it to.

RankControl publishes to any system through a webhook. Each approved article arrives at an endpoint you own as a signed JSON POST. Your code stores it, renders it, and answers with the URL where the article now lives. From that URL, RankControl checks that the article renders and links to it from your dashboard.

Use this path when your site runs on a CMS RankControl does not connect to directly, or on code of your own. A Next.js or static site needs no endpoint: see the Content API page.

Connect the destination

  1. Go to Brand Control → Publishing and pick the Webhook card.
  2. Enter a name and the Endpoint URL that receives articles. It must be an https:// address.
  3. Enter the Site address where readers find your articles, for example https://yoursite.com. With it, this webhook can be the destination that serves your site. Without it, the webhook is a feed beside another destination. You can add the address later on the destination row.
  4. Click Create webhook.

The destination row now shows your Signing secret and an Agent prompt. The prompt holds the receiver contract from this page with your secret filled in. Paste it into Cursor, Claude Code, or any coding agent working in the repository that serves your endpoint.

What arrives

Every delivery is a POST with a JSON body and three headers.

Header Value
Authorization Bearer <signing secret>
X-RankControl-Signature sha256=<hex>: the HMAC-SHA256 of the raw request body, keyed with the signing secret
X-RankControl-Event article.published, article.updated, or test

The body:

{
  "event": "article.published",
  "timestamp": 1757462400000,
  "article": { "id": "...", "title": "...", "slug": "...", "html": "..." }
}
Event When
article.published An article is published for the first time
article.updated A published article is published again after an edit. Same article.id, so update the existing post
test You clicked Send test event. Respond 200 and stop. The sample article is safe to discard

The article object

Field Type Notes
id string Stable per article. Match updates to the original post with it
title string
slug string The suggested slug. You may change it
html string The full rendered body. Render this field
markdown string, optional The same content as Markdown
metaTitle, metaDescription string, optional
keywords string[], optional
featuredImageUrl string, optional The hero image, an absolute URL on cdn.imgcloud.org
images array, optional { url, altText?, placement? } for each image in the body
faq array, optional { question, answer } pairs
schemaJsonLd string, optional JSON-LD, ready for a <script type="application/ld+json"> tag in the page head
canonicalUrl string, optional
language string, optional BCP-47 code. Absent means English
author object, optional { name, bio?, linkedinUrl? }
categoryName string, optional Map it to a category or tag
publishedAt number Epoch milliseconds
status "publish" or "draft" Set by Publish as on the destination row. Store a draft without publishing it when your system has drafts

Verify the signature

Store the signing secret in an environment variable and verify every request. Compute the HMAC over the raw body bytes, before any JSON parsing. Reject the request when the signature does not match.

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

function verifySignature(rawBody, header) {
  const expected =
    "sha256=" +
    createHmac("sha256", process.env.RANKCONTROL_WEBHOOK_SECRET)
      .update(rawBody)
      .digest("hex");
  const given = header || "";
  return (
    given.length === expected.length &&
    timingSafeEqual(Buffer.from(given), Buffer.from(expected))
  );
}

The Authorization header carries the same secret as a Bearer token. A receiver that cannot read the raw body can compare that instead.

Answer with the URL

Respond with a 2xx status within 30 seconds, and put the URL you published the article to in the JSON response:

{ "ok": true, "url": "https://yoursite.com/the-path-you-created" }

{ "article": { "url": "..." } } and { "data": { "url": "..." } } work too. The URL must be absolute and on the same site as your endpoint. api.yoursite.com and yoursite.com count as the same site.

Answer first, then do the slow work. Store the article and build the page after you have responded. A delivery that waits on that work passes 30 seconds and counts as failed. The destination row then shows “Your endpoint did not answer within 30 seconds”.

Your endpoint is the only thing that knows the final address, because the slug is yours to change. The URL gives the article a link on your destination, a live check, and eligibility for auto-write. Leave it out and the delivery still succeeds, but the article has no address on your account. The destination row then shows Delivered, no address, and auto-write stays off until your endpoint returns URLs.

A non-2xx response records the delivery as failed on the destination row, with the status code your endpoint returned. Fix the endpoint and publish the article again.

Test and publish

  1. Deploy your route.
  2. On the destination row, click Send test event. The result shows under the button with the status code your endpoint returned.
  3. Open Content, pick an article, and publish it. About two minutes later RankControl loads the URL your endpoint returned to confirm the article renders. Once one article is confirmed live, auto-write becomes available under Settings → Pipeline.

Count AI crawler visits

AI crawlers fetch your HTML and stop. They run no JavaScript, so only the code that serves your pages can count them. In that code, report requests from known AI crawlers to RankControl, keyed with the same signing secret. Express shown; any framework works the same way.

const AI_BOTS =
  /GPTBot|ChatGPT-User|PerplexityBot|ClaudeBot|Claude-Web|anthropic-ai|Google-Extended|Bingbot|Bytespider|CCBot|cohere-ai|Amazonbot|Meta-ExternalAgent|Applebot-Extended|Googlebot/;

app.use((req, res, next) => {
  const ua = req.get("user-agent") || "";
  if (AI_BOTS.test(ua)) {
    fetch("https://YOUR-RANKCONTROL-API/api/site/crawl", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-rankcontrol-key": process.env.RANKCONTROL_WEBHOOK_SECRET,
      },
      body: JSON.stringify({ user_agent: ua, path: req.path }),
    }).catch(() => {});
  }
  next();
});

The agent prompt on your destination carries this as step 7 with the API address and the current crawler list filled in. Only matched crawlers are reported, and the call never blocks a response. RankControl matches each visit to the article at that path, including paths your receiver renamed.

Verify the install with one request. It stores nothing:

curl -X POST "https://YOUR-RANKCONTROL-API/api/site/crawl" \
  -H "x-rankcontrol-key: $RANKCONTROL_WEBHOOK_SECRET" \
  -H "content-type: application/json" \
  -d '{"user_agent":"install-check","path":"/","test":true}'

The answer is {"ok":true,"test":true}. Crawler visits then appear under Analytics → AI Crawling.

Serving destination or feed

One destination serves your site at a time. A webhook with a site address can be that destination: article links across RankControl then use the URLs your endpoint returns, and auto-write can unlock once one article is confirmed live. If another destination is active, click Make active on the webhook row to switch.

A webhook without a site address is a feed. It receives every article beside your active destination, and RankControl never builds article links from it.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close