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
- Go to Brand Control → Publishing and pick the Webhook card.
- Enter a name and the Endpoint URL that receives articles. It must be
an
https://address. - 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. - 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
- Deploy your route.
- On the destination row, click Send test event. The result shows under the button with the status code your endpoint returned.
- 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.