Custom CMS webhook
If your website runs on a custom-built CMS, or on a platform we have no direct connection for, SupaTraffic can still publish to it. Your developer adds one HTTPS endpoint to your site. We send every article to it as a signed JSON request, and your code stores and publishes it however your site works.
This page is the complete contract. Everything we send is described here, and we never send anything else to your site — we do not read your pages and we do not call any other address.
Connecting
- Generate a random signing secret of at least 16 characters, for example with
openssl rand -hex 32, and store it on your server. - Build the endpoint: verify the signature, then handle the four events below. It must be reachable over HTTPS on a public address.
- In SupaTraffic open Connections, choose Custom CMS, paste the endpoint URL and the secret, and press Check connection. Then pick your endpoint under “Where should articles go?”.
Before anything is saved we check three things, in this order:
- a signed
pingis answered with{ "ok": true }; - a
pingsigned with the wrong secret is refused with401— so your endpoint cannot be used by anyone else; - an
article.lookupfor an article you cannot have is answered with{ "found": false }.
You can test the first two yourself before connecting:
SECRET='your-signing-secret'
URL='https://your-site.example/webhooks/supatraffic'
BODY='{"event":"ping","sentAt":"2026-10-15T12:00:00.000Z"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i "$URL" -H 'Content-Type: application/json' \
-H 'X-Webhook-Event: ping' -H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: v1=$SIG" --data "$BODY"
# expect: 200 {"ok":true}. Change one character of SECRET and expect 401.Every request
Always a POST to your endpoint URL with a JSON body and these headers:
| Content-Type | application/json; charset=utf-8 |
| X-Webhook-Event | ping, article.publish, article.update or article.lookup |
| X-Webhook-Timestamp | Unix time in seconds, as a string |
| X-Webhook-Delivery | A unique id for this attempt — useful in your logs |
| X-Webhook-Signature | v1= followed by the hex HMAC-SHA256 described below |
- Answer within 30 seconds. Do anything slow after you have responded.
- We never follow redirects. Connect the final address of the endpoint — including its trailing slash, if your framework adds one.
- Reply with a JSON object and a 2xx status when it worked. An empty body, a 204 or a plain
OKis not enough — for an article we need itsidback.
Checking the signature
The signature is an HMAC-SHA256, keyed with your secret, over the timestamp header, a full stop, and the raw request body: timestamp + "." + rawBody, hex-encoded and prefixed with v1=. Reject a request whose signature does not match with a 401, and reject a timestamp more than five minutes away from your clock so an old request cannot be replayed.
The most common mistake is checking against a body your framework has already parsed and re-serialised. Read the raw bytes first.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the request body exactly as received — not JSON.parse'd and re-stringified.
export function verify(rawBody, headers, secret) {
const timestamp = headers["x-webhook-timestamp"];
const received = headers["x-webhook-signature"] ?? "";
if (!/^\d+$/.test(timestamp ?? "") || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
const expected = "v1=" + createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}<?php
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300;
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, (string) $secret);
if (!$secret || !$fresh || !hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);Events
ping — sent when you connect and whenever you press Check connection. Verify the signature and answer ok: true. site is optional; its name is what the connection is called in SupaTraffic.
// request
{ "event": "ping", "sentAt": "2026-10-15T12:00:00.000Z" }
// your response — 200
{ "ok": true, "site": { "name": "Acme Pest Control", "url": "https://acme.example" } }article.publish — create and publish the post now. Scheduled articles are held on our side and sent at their publish time, so publish what you receive straight away; publishAt is the date to show on the post. Fields that are not set arrive as null. Use html or markdown, whichever your CMS stores. The cover image is a public URL — download it if you want a copy on your own server.
// request
{
"event": "article.publish",
"sentAt": "2026-10-15T12:00:00.000Z",
"articleId": "6f1c2a7e-3b0d-4c1e-9a55-2d8e0f7b1c42",
"article": {
"slug": "signs-of-mice-in-your-attic",
"title": "7 Signs You Have Mice in Your Attic",
"html": "<h2>Droppings near the insulation</h2><p>…</p>",
"markdown": "## Droppings near the insulation\n\n…",
"excerpt": "How to tell before the damage is done.",
"seoTitle": "7 Signs of Mice in the Attic (and What to Do)",
"seoDescription": "Scratching at night, droppings, chewed wires: the signs to look for.",
"tags": ["Rodents", "Home"],
"coverImage": { "url": "https://…/cover.webp", "alt": "Attic insulation with droppings" },
"publishAt": "2026-10-15T12:00:00.000Z",
"language": "en",
"focusKeyword": "signs of mice in attic"
}
}
// your response — 200 or 201
{ "id": "1042", "url": "https://acme.example/blog/signs-of-mice-in-your-attic" }language is the BCP 47 tag of the language the article is written in — usually two letters (en), sometimes with a region (pt-BR). focusKeyword is the search term it targets — use it for your SEO plugin’s focus keyphrase if you have one. Either may be null.
Return your own id for the post — it is required — and its public url, which is optional but lets your team click through to it from SupaTraffic. A 2xx answer without an id is treated as “we do not know whether it was stored”: we ask article.lookup and decide from your answer.
article.update — the article was edited after it was published. Replace the post with this id. Keep its existing URL even if the slug in the article changed: links that already found the post should keep working. Answer 404 if the post no longer exists.
// request — same article object, plus the id you returned when it was published
{ "event": "article.update", "sentAt": "…", "articleId": "6f1c2a7e-…", "id": "1042", "article": { … } }
// your response — 200
{ "id": "1042", "url": "https://acme.example/blog/signs-of-mice-in-your-attic" }article.lookup — we sent an article and never saw your answer (a timeout, a dropped connection). Before sending it again we ask whether it arrived. Look the post up by articleId — the slug is only there for your logs, and matching on it can find a different article that once used the same address. Include createdAt — when your system stored the record, not the date shown on the post, as ISO 8601 with a time zone (Z or +02:00). A record that already existed before our request went out is never treated as ours. Answer with a 200 either way; a missing article is { "found": false }, not a 404.
// request
{ "event": "article.lookup", "sentAt": "…", "articleId": "6f1c2a7e-…", "slug": "signs-of-mice-in-your-attic" }
// your response — 200, either
{ "found": false }
// or
{ "found": true, "id": "1042", "url": "https://…", "createdAt": "2026-10-15T12:00:01Z" }What is inside the article body
- Title and cover travel separately. Render
titleas your page heading andcoverImagewhere your template shows a featured image. Our articles open with a paragraph and useh2and below for sections; if your template already prints the title, it is safe to drop a leadingh1should one ever appear. - Clean, unstyled HTML. Headings, paragraphs, line breaks and rules, lists, quotes, code blocks, bold, italic and struck-through text, links, images, figures and tables. No classes, inline styles or scripts — your own stylesheet decides how it looks, so give tables a style.
- Links. Links carry
rel="noopener"and may carrytitleortarget. Onlyhttps:andmailto:addresses survive our cleaning. Almost all are fullhttps://addresses, most of them pointing to pages on your own site that we found when we read it, so they work wherever the post is published. Treat any address without a scheme as relative to your site. If an SEO plugin addsnofollowto every link, exclude your own domain from it. - Images are public URLs on our CDN. The cover is a PNG of roughly 2000 px wide and 200–300 KB, and any image inside the body is an absolute URL too. They can be shown as they are, but we recommend downloading them into your own media storage when you receive the article — your pages then do not depend on a server you do not control.
- Size. A typical request is 40–60 KB of JSON. Make sure your body size limit is at least 1 MB.
Duplicates and retries
articleIdis the article’s identity — store it next to the post. It never changes, even when the title or slug is edited. Ifarticle.publisharrives for anarticleIdyou already have, update that post with the content you received and return its id with a 200 instead of creating a second one.- If a different post already uses the slug, answer
409. We stop and ask your team to change the slug. - When we cannot tell whether a request arrived, we always ask
article.lookupbefore sending again. If lookup fails with anything other than a temporary error, we never resend — we mark the article for your team to check instead.
How we read your status codes
| 2xx | Done — if the answer carries what that event asks for. |
| 3xx | Not followed. We stop and ask your team to connect the final address. |
| 400 / 422 | Rejected. We show your JSON error or message field to your team and do not retry. |
| 401 | Signature rejected. We stop publishing and ask your team to reconnect with the right secret. |
| 403 | Usually a firewall in front of your endpoint. We stop that article; the connection stays. |
| 404 | On publish: nothing at that address. On update: the post was deleted on your side. |
| 409 | Slug already taken by another post. |
| 413 | Too large. Raise your request size limit; we do not retry. |
| 429 / 503 | We back off — honouring Retry-After — and try again. |
| Other 5xx, timeouts | We retry a few times, asking article.lookup first. |
Your secret
We encrypt the signing secret before storing it and use it only to sign requests to your endpoint. Disconnecting deletes our copy. To be certain it can no longer be used, change the secret on your server — requests signed with the old one then fail your check.
Questions
Write to [email protected]. If a publish failed, tell us the article and we can see exactly what your endpoint sent back.