Publishing articles to any CMS through a signed webhook
Verify webhooks by recomputing HMAC-SHA256 over the raw body, comparing in constant time, rejecting stale timestamps and deduplicating IDs. 30 lines.
LLaunchScaler·Published ·8 min read
Webhook signature verification means recomputing the sender's HMAC-SHA256 over the exact raw request body with a secret you both hold, then comparing your result with the signature header in constant time. Add two more checks and a receiver is safe to let publish articles: reject timestamps outside a few minutes, and ignore any webhook ID you have already processed, so a retry never creates a second post.
Below is a receiver in about 30 lines of Node that does all four, based on the Standard Webhooks specification, then how to plug it into any CMS.
Why publish through a signed webhook at all?
A webhook moves the publishing decision into your own code. The sender posts the finished article to a URL you run, and your receiver decides what to do with it: create a post in a CMS with no built-in integration, write a Markdown file to a Git repository, or queue it for review. The signature is what makes that URL safe to expose.
Without a signature, anyone who learns the URL can post content to your site. With one, only a request carrying a valid HMAC for that exact body, made with your secret, gets through. The HMAC also proves the body was not changed in transit, because changing a single byte changes the result.
The Standard Webhooks specification, an open specification for webhook formats, defines three headers for this:
Header
What it holds
webhook-id
A unique ID for the event, the same on every retry of that event
webhook-timestamp
The send time as an integer Unix timestamp in seconds
Questions, answered
What people ask about this
01
How does webhook signature verification work?
The sender computes an HMAC-SHA256 of the request body (often with an ID and timestamp) using a secret you share, and sends it in a header. Your receiver recomputes the HMAC over the exact raw body with the same secret and accepts the request only if the two match.
02
Why must webhook signatures be compared in constant time?
One or more signatures, space-separated, each as v1, followed by a base64 HMAC
The signed content is the ID, the timestamp and the body joined by full stops: msg_id.timestamp.payload. The specification's symmetric scheme is HMAC-SHA256 with a random secret of 24 to 64 bytes, serialized as base64 with a whsec_ prefix. The signature header is a list so the sender can rotate secrets without downtime, sending signatures from the old and new secrets side by side.
How do you verify an HMAC webhook signature?
Read the raw body before any parser touches it, rebuild the signed string, compute HMAC-SHA256 with the shared secret and compare the result with each signature in the header using a constant-time function. Accept the request if any one matches. Reject it with a 401 if none do.
The order of operations matters:
Read the raw request body as bytes. Do not parse it yet.
Read webhook-id, webhook-timestamp and webhook-signature. Reject the request if any is missing.
Check the timestamp is within your tolerance (the replay section below).
Build the signed content: ${id}.${timestamp}.${rawBody}.
Compute HMAC-SHA256 of that string with the decoded secret.
For each v1,<signature> entry in the header, base64-decode the signature and compare it with yours in constant time.
Only after a match, parse the JSON and act on it.
Step 1 is the one people get wrong. The Standard Webhooks spec calls it "a very common failure mode": consumers "parse the body as json, and then serialize it again," which changes whitespace or key order and breaks the signature. Stripe's documentation lists the same trap: frameworks that add or remove whitespace, reorder key-value pairs or change the encoding, and "all of these cases lead to a failed signature verification."
Why compare signatures in constant time?
An ordinary equality check returns as soon as it finds a byte that differs, so a request with a mostly correct signature takes slightly longer to reject than a wrong one. Measured over many requests, that difference can leak the signature a byte at a time. A constant-time comparison takes the same time whatever the input.
GitHub's webhook documentation is direct about it: "Never use a plain == operator," and use a method like crypto.timingSafeEqual, which performs a "constant time" comparison. The Standard Webhooks spec warns that skipping it "can expose consumers to timing-attacks and turn them into signing oracles."
Node's documentation adds one detail to design around: crypto.timingSafeEqual throws "if a and b have different byte lengths." Check the lengths first and treat a mismatch as a failed verification, as the receiver below does, so a malformed header returns a 401 instead of crashing the handler.
How do you stop replay attacks?
Put the timestamp inside the signed content and reject requests whose timestamp is too far from your own clock. An attacker who captures a valid request cannot change its timestamp without breaking the signature, so an old capture becomes useless once it falls outside your tolerance window.
Stripe's documentation explains the same mechanism for its own header and gives a concrete default: its libraries use "a default tolerance of 5 minutes between the timestamp and the current time." It adds two warnings worth copying. Do not set the tolerance to 0, because that "disables the recency check entirely," and keep your server clock accurate with NTP, since a drifting clock rejects good requests.
Check the absolute difference, not only the age, so a timestamp far in the future is rejected too. Five minutes is a sensible window for publishing, where a delivery is either prompt or a retry that will carry a fresh timestamp.
How do you stop a retry from publishing twice?
Use the webhook ID as an idempotency key. The Standard Webhooks spec says the ID "remains the same no matter how many times a webhook that has failed is retried" and recommends using it "to prevent accidentally processing the same webhook more than once." Store each processed ID, and answer a repeat with a 2xx without doing the work again.
Retries are normal, not an edge case. The spec treats any non-2xx response, a timeout or a reset connection as a failed delivery, and recommends senders retry over several days with exponential backoff. If your CMS call succeeds but your response times out, the sender will send the same article again, and without an idempotency check you get a duplicate post.
For publishing, store the ID with the ID of the post you created. Then a repeat can return the existing post's URL, and your database, not memory, holds the record, so it survives a restart. Make the webhook ID column unique, so two simultaneous deliveries of the same event cannot both insert.
What does a complete webhook receiver look like?
This receiver runs on Node 18 or later with no dependencies. It reads the raw body, checks the timestamp, verifies every v1 signature in constant time, skips IDs it has seen and hands the article to a createPost function you write for your CMS.
import http from "node:http";
import crypto from "node:crypto";
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const TOLERANCE = 5 * 60; // seconds
const processed = new Map(); // use a table with a unique webhook_id column in production
function verify(id, ts, body, header) {
if (!id || !ts || !header || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${id}.${ts}.${body}`).digest();
return header.split(" ").some((entry) => {
const [version, sig] = entry.split(",");
const given = Buffer.from(sig ?? "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString("utf8");
const id = req.headers["webhook-id"];
if (!verify(id, req.headers["webhook-timestamp"], body, req.headers["webhook-signature"])) return res.writeHead(401).end();
if (processed.has(id)) return res.writeHead(200).end(JSON.stringify({ url: processed.get(id) }));
const url = await createPost(JSON.parse(body)); // your CMS call, returns the new post's URL
processed.set(id, url);
res.writeHead(201).end(JSON.stringify({ url }));
}).listen(3000);
Run it behind HTTPS, since the secret protects integrity but not privacy, and an article in transit is readable without TLS. Return quickly: if createPost can be slow, record the ID, queue the work and respond, rather than holding the connection open until the sender's timeout triggers a retry.
How do you create the post in your CMS?
createPost is the only part that changes between platforms. It takes the verified article (title, HTML body, slug and whatever else the sender includes) and calls your CMS's own API, then returns the new post's URL. Create a draft first while testing, so a mistake never reaches readers.
Destination
What createPost calls
Guide
WordPress
POST /wp-json/wp/v2/posts with an application password
Keep the CMS credentials in the receiver's environment, never in the webhook payload. The sender needs only the webhook secret, and the receiver alone holds the keys that can write to your site. If the sender is ever compromised, the attacker can still only send articles through the path you control.
What other signature formats will you meet?
Not every sender follows Standard Webhooks, but most use the same ingredients: an HMAC-SHA256, a header carrying it, and sometimes a timestamp. The differences are in header names, encoding and what exactly is signed, so read your sender's documentation before adapting the receiver.
Sender style
Signature header
Format
Timestamp signed
Standard Webhooks
webhook-signature
v1, plus base64, space-separated list
Yes, in webhook-timestamp
GitHub
X-Hub-Signature-256
sha256= plus a hex HMAC of the body
No
Stripe
Stripe-Signature
t= timestamp and v1= signature, comma-separated
Yes
GitHub's format signs only the body, so it gives you integrity but carries no signed timestamp to reject old requests with; deduplicating on an ID is your only guard against a replay. GitHub also notes that its older X-Hub-Signature header uses HMAC-SHA1 and exists only for legacy purposes, so verify the -256 header.
Receive finished articles through a signed webhook
A signed webhook is one of the destinations LaunchScaler's content engine publishes to, alongside WordPress, Webflow, Ghost, Shopify, Notion and Medium, so a receiver built on the pattern above can take its articles into any system you run. Match the header names to the ones it sends before you deploy. The engine plans a month of articles from your own Search Console queries, writes one a day (thirty a month) from what your product does, in your voice, and by default holds every draft in your workspace for your review, to rewrite, trim or scrap, before anything is sent.
It costs $99/mo per website, with 3 days free before the first charge. Start the engine and your first article is ready on day one.
A normal string comparison stops at the first differing byte, so response times can leak how much of a guessed signature is right. GitHub's docs say never to use a plain == and to use a function like crypto.timingSafeEqual instead.
03
How do you stop webhook replay attacks?
Include a timestamp in the signed content and reject requests whose timestamp is too far from your server's clock. Stripe's libraries use a default tolerance of 5 minutes and warn that a tolerance of 0 disables the check.
04
Why does webhook signature verification fail when the secret is right?
Usually the body was parsed and re-serialized before verifying. Both Stripe and the Standard Webhooks spec warn that frameworks which parse JSON or change whitespace break the signature, so verify against the raw bytes.
05
How do you stop a webhook retry from publishing a post twice?
Use the webhook's unique ID as an idempotency key. Record it when you process the event, and if the same ID arrives again, return a 2xx response without creating another post.
Scaled content abuse is Google's policy against many pages made mainly to rank, by any method. What it covers, and how to publish daily and stay clear.
Turn Search Console queries into a content plan: export them, group variants by intent, use Query groups if you have them, and map each group to a page.