Publishing integrations

Webhook

The escape hatch for every platform we do not speak natively. We POST the finished article to a URL you control; you decide what happens to it.

Setting it up#

  1. In the app, open Integrations → New integration → Webhook.
  2. Give it a name (2–30 characters — it is how the connection is labelled in the publishing screen).
  3. Give it your endpoint URL. It must be http(s) and reachable from the public internet. A URL behind a VPN cannot be delivered to, and the connection test will say so.
  4. Set an access token, a signing secret, or both. At least one is required: an endpoint with neither is a URL anyone on the internet could post to, which is not a connection. Both are shown once — copy them then.
  5. Save. We immediately POST a connection_test event; any 2xx marks the connection verified.

On Vercel and similar platforms: the receiving route must be publicly accessible. Deployment protection on a preview URL turns every delivery into a 401, and the connection test fails with exactly that status.

Authenticating a delivery#

Two independent schemes, and every delivery carries whichever ones the connection has credentials for. Check either. You do not need both.

HeaderPresent whenWhat it proves
Authorization: Bearer …The connection has an access token.The sender knows the token. Simple, and enough behind a gateway that checks headers.
X-Rankli-TimestampThe connection has a signing secret.Unix seconds. Part of the signed string, so it cannot be changed independently.
X-Rankli-SignatureThe connection has a signing secret.v1=<hex HMAC-SHA256>. Survives a leaked URL, and lets you reject replays.

The signature is computed over "<timestamp>.<raw request body>". The timestamp is inside the signed string precisely so a captured request cannot be replayed with a fresh clock — which is what makes rejecting old deliveries a real defence rather than a gesture.

Events#

event_typeSent whenCarries
connection_testYou create or re-test the connection.An empty data. Answer 2xx.
publish_articlesA scheduled article reaches its publish time.data.articles — an array holding one article.
update_articleAn article you already received is rewritten or improved.data.article — one object, no created_at.

Match an update by slug, or by the id you returned when the article first arrived. Every delivery is one article; the array on publish exists so a future batch does not change the shape.

The payload#

POST, Content-Type: application/json. This is a public contract: it only ever changes additively, so a field you read today will still be there.

publish_articles
{
  "event_type": "publish_articles",
  "timestamp": "2026-09-06T07:14:22.104Z",
  "data": {
    "articles": [
      {
        "id": "art_9f31c2",
        "title": "How to plan a month of SEO content",
        "content_markdown": "## Start with what you sell\n…",
        "content_html": "<h2>Start with what you sell</h2>…",
        "meta_description": "A month of content in one afternoon: how to pick the keywords…",
        "created_at": "2026-09-06T07:14:22.104Z",
        "image_url": "https://…/hero.png",
        "slug": "how-to-plan-a-month-of-seo-content",
        "tags": ["seo", "content planning"]
      }
    ]
  },

  // The first version of this contract, still sent beside the new keys so that
  // no receiver written against it breaks. Ignore these unless you already use them.
  "event": "article.published",
  "sentAt": "2026-09-06T07:14:22.104Z",
  "article": { "id": "art_9f31c2", "title": "…", "slug": "…", "metaDescription": "…",
               "html": "…", "assets": [{ "url", "alt", "kind", "position" }] }
}

update_article is the same body with data.article as a single object rather than an array, and no created_at. connection_test carries an empty data and no article at all.

Copy the images. image_url — and the URLs in the legacy assets list — point at our storage and are not permanent. Download them into your own media library as part of handling the event, exactly as our WordPress and Webflow adapters do.

The doubled envelope is deliberate. A receiver written for the tool most people are migrating from reads event_type and data; one written against our first release reads event and article. Sending both costs a few hundred bytes and means neither has to be rewritten.

Verifying a delivery#

Express
const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.RANKLI_WEBHOOK_SECRET;
const TOKEN  = process.env.RANKLI_WEBHOOK_TOKEN;

// The raw bytes, not the parsed object: re-serialising JSON changes whitespace
// and key order, and the signature is over exactly what we sent.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8');

  // Either check is enough. This does the token first because it is cheap.
  const bearer = (req.header('authorization') ?? '').replace(/^Bearer /, '');
  let ok = TOKEN !== undefined && bearer === TOKEN;

  if (!ok && SECRET) {
    const timestamp = req.header('X-Rankli-Timestamp') ?? '';
    const signature = req.header('X-Rankli-Signature') ?? '';
    // Reject anything older than five minutes: a captured delivery is valid forever otherwise.
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (timestamp && age <= 300) {
      const expected = 'v1=' + crypto
        .createHmac('sha256', SECRET)
        .update(timestamp + '.' + body)
        .digest('hex');
      const a = Buffer.from(expected);
      const b = Buffer.from(signature);
      ok = a.length === b.length && crypto.timingSafeEqual(a, b);
    }
  }
  if (!ok) return res.status(401).send('unauthorised');

  const event = JSON.parse(body);
  if (event.event_type === 'connection_test') return res.status(200).json({});

  const article = event.event_type === 'publish_articles'
    ? event.data.articles[0]
    : event.data.article;

  const post = upsertBySlug(article);           // match updates on article.slug
  res.status(200).json({ id: post.id, url: post.url });
});

Verify against the raw bytes. The single most common cause of a signature that never matches is a JSON body parser upstream re-serialising the payload before your handler sees it.

Responding#

Any 2xx acknowledges the article. The body is optional; when it is JSON with these fields we use them:

FieldWhat we do with it
idStored as the remote id and sent back on later updates, so you can match without a slug lookup.
urlShown in the app as where the article landed, and linked from the article row.

Return no id and we key later updates on the article’s slug, which is why an upsert by slug is the safest receiver to write.

A non-2xx is a failure. 429 and 5xx are retried with backoff; a 4xx is not, because repeating a request your server has already rejected is how an integration gets an IP banned by a security plugin. Failed publishes are shown in the app with the status and body you returned, and can be retried by hand or with POST /articles/{id}/retry-publish.