---
title: "Publish through a webhook"
description: "Receive each article as a signed JSON POST at your own endpoint, and answer with the URL you published it to."
---

> Documentation Index
> Fetch the complete documentation index at: https://rctrl.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Publish through a webhook

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](/docs/nextjs).

## 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:

```json
{
  "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.

```js
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:

```json
{ "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**.

> **Note**
>
> Deliveries arrive from one fixed IP address. If a firewall guards your
> endpoint, one rule lets RankControl through: see [the crawler page](/docs/bot).

## 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.

```js
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:

```bash
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.

Source: https://rctrl.com/docs/webhook/index.mdx
