Webhook / REST
Send each finished article as a JSON payload to any HTTPS endpoint you control — for custom systems, static sites, or any CMS RankPine doesn't natively support.
The webhook integration is the most flexible option: when an article is ready, RankPine sends it as a JSON payload via HTTP POST to an endpoint you control. Use it to push articles into a custom CMS, trigger a static-site rebuild, drop files in a repo, or anything else you can script.
This is the developer-oriented integration. If your blog runs on WordPress, Ghost, Webflow, or Notion, the dedicated guides for those are simpler. Reach for the webhook when nothing else fits.
What you'll need
- A public HTTPS endpoint that accepts
POSTrequests and responds with a2xxstatus
Connect in RankPine
Go to Integrations, click Connect on Webhook, and fill in the dialog:

| Field | What to enter |
|---|---|
| Endpoint URL | A public HTTPS URL, e.g. https://api.yoursite.com/hooks/rankpine — required |
| Signing secret | (optional) Used to HMAC-sign each request so you can verify it came from RankPine |
| Auth header | (optional) A custom header name to send, e.g. Authorization |
| Header value | (optional) The value for that header, e.g. Bearer your-token |
| Include Markdown | On by default — include the article body as Markdown |
| Include HTML | On by default — include the article body as HTML |
When you connect, RankPine sends a verification ping to your endpoint. It must
respond 2xx or the connection won't save.
Verification ping
{
"event": "verification",
"sentAt": "2026-06-29T09:00:00.000Z"
}Respond with any 2xx status to confirm the endpoint is reachable.
The publish payload
When an article publishes, RankPine sends a POST with this body:
{
"event": "article.published",
"publishedAt": "2026-06-29T09:00:00.000Z",
"site": {
"name": "Acme",
"url": "https://acme.com",
"language": "en"
},
"article": {
"title": "How to choose a CRM",
"slug": "how-to-choose-a-crm",
"metaDescription": "A practical guide to picking the right CRM…",
"language": "en",
"translationGroupId": null,
"wordCount": 1840,
"seoScore": 86,
"sources": [{ "url": "https://example.com/study", "title": "Industry study" }],
"markdown": "# How to choose a CRM\n\n…",
"html": "<h1>How to choose a CRM</h1>…"
},
"images": [{ "url": "https://cdn.rankpine.com/…/cover.png", "alt": "CRM dashboard" }]
}Notes:
article.markdownis included only if Include Markdown is on;article.htmlonly if Include HTML is on.imagesare hosted on RankPine's CDN — the first is the intended featured image.- Inline images inside
html/markdownalready point at those same CDN URLs. article.languageis the language this article is written in.site.languageis your site's main language — on a multi-language site they differ.article.translationGroupIdisnullunless the article has translations. See below.
Multi-language sites
If you've set additional languages, each version of an
article is published separately — one POST per language, each with its own
slug, its own language, and its own id/url in your response. There is no
single request containing all of them.
Every version of the same article shares one translationGroupId, so you can tell
which posts belong together and wire up hreflang yourself:
// three separate POSTs
{ "article": { "slug": "how-to-choose-a-crm", "language": "en", "translationGroupId": "a1b2c3…" } }
{ "article": { "slug": "crm-auswahl", "language": "de", "translationGroupId": "a1b2c3…" } }
{ "article": { "slug": "como-elegir-un-crm", "language": "es", "translationGroupId": "a1b2c3…" } }Storing translationGroupId pays off later
Keep it alongside each post. It's the only thing tying the versions together — the slugs differ by design, and translations can arrive minutes apart, so you can't rely on timing to group them.
The group id is the original article's own id, so the original arrives with
translationGroupId equal to its own identity, and every translation carries the
same value. A site with no additional languages always sends null.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json |
X-RankPine-Event | article.published |
X-RankPine-Signature | sha256=<hex> — present only if you set a signing secret |
| your custom header | the auth header name/value you configured, if any |
Your response
Respond 2xx to acknowledge. Optionally return JSON to tell RankPine where the
article landed:
{ "id": "post_123", "url": "https://acme.com/blog/how-to-choose-a-crm" }If you return an id and url, RankPine records them against the article. If you
don't, that's fine — a 2xx is all that's required.
Verifying the signature
If you set a signing secret, every request includes an X-RankPine-Signature
header. It's an HMAC-SHA256 of the raw request body, hex-encoded, prefixed
with sha256=. Recompute it with your secret and compare — reject the request if
it doesn't match.
import crypto from "node:crypto";
function isValid(rawBody, signatureHeader, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// Compare the raw bytes you received, before JSON parsing.
return signatureHeader === expected;
}Hash the raw body
Compute the HMAC over the exact bytes you received, before any JSON parsing or re-serialisation — re-encoding can change the bytes and break the comparison.