Every finished article, signed and POSTed to you.
The escape hatch for every CMS we do not speak natively, and a first-class integration rather than a consolation prize. You get the title, the slug, the meta description, the body HTML and the assets, signed so you can prove the request came from us. The payload and the signing scheme are a public contract — we change them additively or not at all.
One JSON POST per article, and this is all of it.
Content-Type: application/json, with a rankli-publisher user agent. There is no envelope beyond what you see: three top-level keys, and everything about the article underneath the third.
article.published
Values are illustrative; every field name and its type is exact.
{
"event": "article.published",
"sentAt": "2026-09-04T09:12:44.310Z",
"article": {
"id": "6f1c1a2e-77b0-4a3e-9f0b-2a1d5c8e4b71",
"title": "What Field Service Software Actually Costs",
"slug": "what-field-service-software-actually-costs",
"metaDescription": "Published pricing for eight field service platforms, what each tier includes, and the costs that are not on the pricing page.",
"html": "<h1>What Field Service Software Actually Costs</h1>\n<p>…</p>",
"assets": [
{
"url": "https://cdn.example-storage.com/rankli/6f1c1a2e/hero.png",
"alt": "A dispatch board with eight jobs across three technicians",
"kind": "image",
"position": 0
}
]
}
}On article.updated the article object carries one extra key, remoteId — the id your endpoint returned the first time. On connection.verify there is no article key at all.
What each field is.
- article.id — our id for the article. Stable across retries and across every later update. This is the key to store if you store one.
- title, slug, metaDescription — plain strings, already the final values.
- html — the whole article body as sanitised HTML, converted from markdown. Headings, paragraphs, lists, links and images.
- assets — every image, in document order, each with url, alt, kind and position.
Three events.
- connection.verify
- you press Test connection. No article — it exists so a broken endpoint is found before an article depends on it.
- article.published
- an article passes review and this connection is its destination.
- article.updated
- the same article is published again, carrying the id your endpoint returned the first time.
All three are signed the same way and all three expect a 2xx. Verification is a real delivery precisely so a broken endpoint is discovered before an article depends on it.
HMAC-SHA256 over the timestamp and the raw body.
The scheme is Stripe’s, because it is well understood and most people reading this have implemented it once already. Two headers ride on every request:
| Header | Value |
|---|---|
| X-Rankli-Timestamp | Unix seconds at the moment of sending. It is inside the signed string, so it cannot be altered independently of the body — which is what makes it usable as a replay defence. |
| X-Rankli-Signature | v1=<hex hmac_sha256(secret, timestamp + "." + rawBody)>. The v1= prefix is part of the value: it is there so a future scheme can be added beside it rather than replacing it. |
A receiver, in full
Express; the shape is the same in any framework.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
// The RAW bytes are what was signed. Parsing the JSON and re-serialising it will
// not reproduce them, and the signature will never match.
app.post('/hooks/rankli', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Rankli-Timestamp') ?? '';
const signature = req.get('X-Rankli-Signature') ?? '';
const body = req.body.toString('utf8');
// Reject anything older than five minutes: the timestamp is inside the signed
// string, so it cannot be moved without invalidating the signature.
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(400);
}
const expected =
'v1=' + createHmac('sha256', process.env.RANKLI_WEBHOOK_SECRET)
.update(`${ts}.${body}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);
const { event, article } = JSON.parse(body);
if (event === 'connection.verify') return res.sendStatus(200);
const id = upsertPost(article); // yours; key on article.id
res.json({ id, url: `https://example.com/blog/${article.slug}` });
});Three things this snippet is careful about, and all three are ways a real implementation goes wrong: the HMAC is over the raw bytes, not a re-serialised object; the comparison is constant-time; and a delivery older than about five minutes is rejected outright. We sign with the same function we would verify with, so the scheme has exactly one definition on our side.
Any 2xx is enough. Two optional fields make it better.
The response.
Any 2xx acknowledges the article and the publish is recorded as succeeded. An empty body is fine; a non-JSON body is fine.
If you answer with JSON, two keys are read. id is stored and comes back as article.remoteId on every later update of that article, so your receiver can find its own row without keeping a map. url is shown to the customer as where the article was published. Return neither and we key later updates on our own article.id.
Delivery, retries and duplicates.
- 30 seconds to answer, then the attempt is abandoned.
- At most 3 attempts per publish, on a network failure, a 429 or a 5xx only, with exponential backoff and full jitter. A Retry-After header is honoured.
- A 4xx is never retried. It means we are configured wrongly, and hammering your endpoint would not fix that.
- Assume at-least-once. A timeout on a request your server actually handled will be retried, so the same article.id can arrive twice. Upsert on it.
Two decisions this integration hands you rather than making.
On the other three platforms we can answer these, because their APIs have an opinion. Here there is nobody to ask but you, and pretending otherwise would put a field in the payload that means nothing.
Draft or live.
The payload carries no status field. The connection’s draft-or-live setting decides how a WordPress post or a Shopify article arrives; here your receiver decides, because only it knows what a draft means in your system.
If you want the same review step the other integrations give you, create whatever your CMS calls an unpublished post and stop there.
The images.
Asset URLs are handed over as-is and nothing is uploaded anywhere. Copy them to your own storage — ours are not permanent, and an article that hotlinks them is an article that breaks when we rotate buckets.
One shape to handle: with no image provider configured, an asset url is an inline data:image/svg+xml URL rather than an https one, and the same bytes are already inside the html. Read the scheme before you fetch.
This is the route for the platforms we have no adapter for.
We publish natively to WordPress, Webflow and Shopify. These come up often and there is no adapter for any of them:
- Ghost
- Wix
- Framer
- Notion
- Squarespace
Each has an API you can post to, and the receiver above is most of the work. That is a real answer and it is still more work than clicking a button — if you want a one-click integration for one of these today, we are not your product yet.
Four rules that do not depend on which platform you use.
The contract above is the webhook’s. These four are the pipeline’s, and they run before your endpoint ever hears from us.
Nothing publishes until the credentials verify.
A connection is saved unverified and is not used. Verification is a real call to your platform — it reports who we authenticated as and, where the platform will say, whether that identity may publish at all. Change the URL or any credential and the connection goes back to unverified until it is tested again.
Draft or live is your choice, per connection.
Draft is the default: articles arrive unpublished and nothing is public until someone presses publish on your side. Choosing live is a deliberate switch, and the screen says in plain words that nobody on your team will see the article first.
A publish that fails is reported as failed.
The failure is stored against the article with the reason the platform gave, and the job is marked failed rather than done. A 4xx is treated as misconfiguration and is not retried — retrying bad credentials against your site is how one wrong password becomes an IP ban.
An article that fails a hard SEO rule never reaches this stage.
The publish step is only queued for a draft that passed review and that a model actually wrote. An article held back says which rule held it. That gate sits before every adapter, so it applies to all four equally.
Straight answers.
Can I use this to fan an article out to several places?
Yes, and it is the only way to. A project publishes through one connection, so everything downstream of that is your endpoint’s business — post to two CMSes, push to a queue, open a pull request against a static site. We hand you the article and record what you answered.
What happens if my endpoint is down?
A 5xx or a network failure is retried with backoff, up to 3 attempts. After that the publish is recorded as failed, with the status your endpoint returned, against that article — not marked done. The article itself is unharmed and can be published again once you are back.
Is the secret ever sent in the request?
No. Only the signature derived from it, which is why the scheme is worth using at all. On our side the secret is stored write-only — replaceable from the form, never displayed back — and scrubbed out of any error message before that message is stored or shown to anyone.
Do you send anything other than articles?
No. Three events, all listed above, all about one article each — or, for connection.verify, about nothing. There is no analytics traffic, no heartbeat and no batching.
Can I test it without signing up?
Not against our servers, no — but everything you need is on this page. The payload above is what the adapter sends, checked against it in our test suite, and the snippet is a working receiver. You can have the endpoint finished before the connection exists.
Write the receiver, then point a connection at it.
$1 once buys 3 finished articles, delivered to your endpoint signed. Nothing renews.