Guide
A shared token isn’t a signature
Every Shapio webhook is signed, so your receiver can tell a real request from a forged, changed, or replayed one. Strapi sends a fixed token instead. Here’s the difference, with a real receiver and real attacks.

A webhook is a URL that does something when you hit it. It rebuilds your site, clears a cache, posts in Slack, or emails a customer. Your CMS calls it when content changes. So does anyone else who finds it.
That's why the receiver has to answer two questions before it does anything: did this really come from my CMS, and is it exactly what my CMS sent? Shapio answers both with a signature on every request. Strapi doesn't.
What Strapi sends
Strapi's webhooks carry the event name in X-Strapi-Event and any fixed headers you configure in defaultHeaders, usually an Authorization token you make up and paste into both sides. That's what you get. Strapi's own docs recommend signing payloads with an HMAC, but they only show the checking side, as middleware you write yourself. Nothing in Strapi computes the signature. People asked for signing in 2023.
This isn't a hard problem the industry hasn't solved. Contentful and Sanity both sign their webhooks. They're hosted, though. Strapi, the most-starred open-source headless CMS on GitHub, leaves signing to you.
Why a shared token isn't enough
A fixed token is better than nothing. It still falls short in three ways:
It's the same on every request. Anyone who sees one request, in a log, a proxy, or a receiver that prints its headers, can send their own requests forever.
It doesn't cover the body. Change the payload in transit and the token still matches.
It never expires. Capture a request today and send it again next month.
A signature fixes all three. It's computed from a secret that never travels with the request, it covers every byte of the body, and it includes the time it was made.
What Shapio sends
Here's a real request from a Shapio webhook named "Docs site rebuild", sent when I published a guide:
x-shapio-event: entry.published
x-shapio-delivery: a34216ab-5c9f-4d82-a877-db0375654177
x-shapio-timestamp: 1792417019
x-shapio-signature: v1=43ca95fb50cd4a5c8ccbcd57cbf3b070cfbc5df72111d7e41615250bb9a19042
content-type: application/json{"id":"da436cfa-1d70-4b38-a4a4-bb3d97096d59","type":"entry.published","createdAt":"2026-10-19T13:36:59.277Z","site":{"id":"00000000-0000-4000-b000-000000000001","key":"default"},"data":{"modelId":"138cdac8-b48b-4da6-b72b-3bf79cd9d452","modelKey":"guide","routeKey":"guides","ancestors":[],"entryId":"c87039d7-4faa-4350-a267-f17f6a3f68b2","locale":"en","revisionId":"06274d45-89f0-405f-b201-f2cf60fef6b1","snapshot":6}}The signature is an HMAC-SHA256 of the timestamp, a dot, and the raw body, keyed with the webhook's signing secret:
v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>Each webhook gets its own secret. Shapio shows it once when you create the webhook, stores it encrypted, and never sends it anywhere. If it leaks, Rotate secret issues a new one in one click. The old one stops working right away, so give the new one to your receiver at the same time.
The receiver
This is the receiver I ran for this post, exactly as it ran, with @shapio/client from npm. It checks the signature with verifyWebhookSignature from @shapio/client, drops duplicates by delivery ID, and only then does its work:
import { createServer } from 'node:http';
import { verifyWebhookSignature } from '@shapio/client';
const secret = process.env.SHAPIO_WEBHOOK_SECRET;
const seen = new Set(); // use your database or Redis in production
createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', async () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
const check = await verifyWebhookSignature({
secret,
signature: request.headers['x-shapio-signature'],
timestamp: request.headers['x-shapio-timestamp'],
body: rawBody,
});
if (!check.ok) {
console.log(`rejected: ${check.reason}`);
response.writeHead(401).end(check.reason);
return;
}
const delivery = request.headers['x-shapio-delivery'];
if (seen.has(delivery)) {
console.log(`duplicate delivery ${delivery}, ignored`);
response.writeHead(200).end('duplicate');
return;
}
seen.add(delivery);
const event = JSON.parse(rawBody);
console.log(`verified ${event.type} (delivery ${delivery})`);
// Rebuild the site, purge the cache, post to Slack...
response.writeHead(200).end('ok');
});
}).listen(4476);verifyWebhookSignature uses Web Crypto, so the same code runs outside Node. I checked it in Node 24, Cloudflare's Workers runtime, Deno, and Chrome. In a browser it needs an HTTPS or localhost page, like anything that uses Web Crypto. It compares in constant time and rejects timestamps more than five minutes off. If you'd rather not add a dependency, the check is a few lines of node:crypto:
import { createHmac, timingSafeEqual } from 'node:crypto';
const verify = (secret, rawBody, timestamp, signature) => {
const expected = `v1=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}`;
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return (
fresh &&
typeof signature === 'string' &&
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
};Verify the body exactly as it arrived. The signature covers bytes, not a JavaScript object, so check it before you parse anything, and don't check a version your framework rebuilt for you.
Then I attacked it
With the receiver running, I sent it four requests that weren't Shapio's real ones. These are the actual responses.
A forged event. Someone finds the URL and sends their own entry.published with a made-up signature:
forged: 401 signature mismatchA tampered event. The real request, real headers, real signature, with one value changed in the body ("modelKey":"guide" to "modelKey":"pricing"):
tampered: 401 signature mismatchWith a shared token, both of these would have gone through.
A replay, right away. The real request sent again, byte for byte, 11 seconds later:
replayed right away (11s after it was signed): 200 duplicateThe signature is valid, because it is a real request. That's what the delivery ID is for. It's the same on every retry Shapio makes, so a receiver that remembers it does the work once.
A replay, later. Memory doesn't last. Receivers restart, deploy, and scale out. So I restarted the receiver, emptying its memory, and sent the same request again five minutes after it was signed:
replayed after a restart (311s after it was signed): 401 timestamp outside the allowed windowThe signature still matches, but the timestamp is part of what's signed, so it can't be updated without the secret. Five minutes is the window, and after that the request is dead.
Everything Shapio sends is signed
It isn't just webhooks:
Webhooks, for every event: entries, media, schema, locales, change sets, deployments, and site settings.
Build triggers. A generic build webhook gets the same headers, so your build server can check that a deploy really came from Shapio before it spends ten minutes building.
Build-status callbacks. When your build server reports back ("building", "deployed", or "failed"), Shapio checks its signature too. Here's what it said when I tried:
unsigned: 401 Callback rejected: missing signature or timestamp
wrong secret: 401 Callback rejected: signature mismatch
stale (10 min old): 401 Callback rejected: timestamp outside the allowed window
signed: 200 {"applied":true,"status":"deployed"}Every attempt is in the webhook's delivery log in the admin: the request headers (with the signature redacted), your receiver's response, and how long it took. You can redeliver any of them, or press Send test to check a new receiver before real content flows through it.
Signing should be the default, not a weekend project. It's on for every webhook in Shapio, and there's nothing to switch on.