Strapi's webhooks are simple: an entry changes, Strapi fires a POST request at a URL you configured. What Strapi's docs don't tell you is what happens next — because "next" is entirely your problem. Strapi retries failed deliveries, which means your receiver can get the same event twice. Your downstream sync call can time out. And if you don't plan for either, you end up with duplicated content in your search index, double-charged CRM records, or silently dropped updates nobody notices until a customer complains.
This tutorial builds a webhook receiver that handles all three failure modes with working code you can run locally in under ten minutes: idempotency keys so duplicate deliveries are no-ops, exponential backoff so transient downstream failures don't lose events, and a dead-letter table so permanent failures are visible instead of silent.
What we're building
A small Express service that:
- Authenticates incoming Strapi webhooks with a shared secret header
- Computes a stable idempotency key per event and rejects duplicates before any processing happens
- Acknowledges Strapi immediately, then processes the event asynchronously with retries
- Writes permanently-failed events to a
dead_letterstable instead of dropping them
Step 1: Configure the webhook in Strapi
In your Strapi admin panel, go to Settings → Webhooks → Create new webhook. Set:
-
URL:
https://your-receiver.example.com/webhooks/strapi -
Headers: add
x-webhook-secret: <a long random string>— Strapi doesn't sign payloads out of the box, so this header is your only authentication layer. Treat it like a password. -
Events: select
entry.create,entry.update,entry.publish(whatever your use case needs)
Strapi will retry a webhook delivery if your endpoint doesn't respond within its timeout or returns a non-2xx status. That retry is the whole reason this tutorial exists — assume every event can arrive more than once.
Step 2: Project setup
mkdir strapi-webhook-receiver && cd strapi-webhook-receiver
npm init -y
npm install express better-sqlite3
You'll need Node 18+ for built-in fetch. Set your shared secret:
export STRAPI_WEBHOOK_SECRET="a-long-random-string-matching-strapi-config"
Step 3: The idempotency key
Strapi's payload doesn't include a delivery ID, so you have to derive a stable fingerprint from the event itself. The combination of event type, model, entry id, and the entry's updatedAt timestamp is unique per real change — a genuine retry of the same delivery will produce the identical fingerprint, while a real second edit changes updatedAt and gets its own key.
// idempotency.js
import crypto from 'crypto';
export function makeIdempotencyKey(body) {
const { event, model, entry } = body;
const fingerprint = `${event}:${model}:${entry?.id}:${entry?.updatedAt}`;
return crypto.createHash('sha256').update(fingerprint).digest('hex');
}
Step 4: The receiver
The key design decision: check-and-record the idempotency key as one atomic step, using the database's unique constraint as the deduplication mechanism. Don't check-then-insert in two steps — that has a race window if Strapi (or your load balancer) delivers two copies of the same event concurrently.
// server.js
import express from 'express';
import Database from 'better-sqlite3';
import { makeIdempotencyKey } from './idempotency.js';
const app = express();
app.use(express.json());
const db = new Database('webhooks.db');
db.exec(`
CREATE TABLE IF NOT EXISTS processed_webhooks (
idempotency_key TEXT PRIMARY KEY,
received_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dead_letters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
idempotency_key TEXT NOT NULL,
payload TEXT NOT NULL,
error TEXT NOT NULL,
failed_at TEXT NOT NULL
);
`);
const WEBHOOK_SECRET = process.env.STRAPI_WEBHOOK_SECRET;
const MAX_ATTEMPTS = 4;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function syncToDownstream(entry) {
// stand-in for a real call: search index, CDN purge, CRM sync, etc.
const res = await fetch(process.env.DOWNSTREAM_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(entry),
});
if (!res.ok) throw new Error(`downstream responded ${res.status}`);
}
async function processWithRetry(idempotencyKey, body) {
let attempt = 0;
while (attempt < MAX_ATTEMPTS) {
try {
await syncToDownstream(body.entry);
return;
} catch (err) {
attempt += 1;
if (attempt >= MAX_ATTEMPTS) {
db.prepare(
`INSERT INTO dead_letters (idempotency_key, payload, error, failed_at) VALUES (?, ?, ?, ?)`
).run(idempotencyKey, JSON.stringify(body), err.message, new Date().toISOString());
return;
}
await sleep(2 ** attempt * 250); // 500ms, 1s, 2s
}
}
}
app.post('/webhooks/strapi', (req, res) => {
if (req.get('x-webhook-secret') !== WEBHOOK_SECRET) {
return res.status(401).send('unauthorized');
}
const idempotencyKey = makeIdempotencyKey(req.body);
try {
db.prepare(
`INSERT INTO processed_webhooks (idempotency_key, received_at) VALUES (?, ?)`
).run(idempotencyKey, new Date().toISOString());
} catch {
// UNIQUE constraint failed -> this is a duplicate delivery, already handled
return res.status(200).send('duplicate, already processed');
}
// Ack Strapi immediately so it doesn't time out and retry while we work
res.status(202).send('accepted');
processWithRetry(idempotencyKey, req.body);
});
app.listen(3000, () => console.log('Webhook receiver listening on :3000'));
Two details matter here. First, we respond 202 before processWithRetry finishes — Strapi's own retry timeout is outside your control, so the fastest way to stop unwanted retries is to acknowledge receipt the instant you've durably recorded the idempotency key, then do the slow work afterward. Second, the INSERT into processed_webhooks happens before any downstream call, so even if your process crashes mid-retry, a redelivered event is still recognized as a duplicate and won't double-process — you'd only lose the pending sync, which the dead-letter table (or a periodic sweep of processed_webhooks without a matching success log) can catch.
Step 5: Test it against real failure modes
Run the server:
DOWNSTREAM_URL="https://httpbin.org/status/500" node server.js
Send a webhook payload shaped like Strapi's:
curl -X POST localhost:3000/webhooks/strapi \
-H "content-type: application/json" \
-H "x-webhook-secret: $STRAPI_WEBHOOK_SECRET" \
-d '{"event":"entry.update","model":"article","entry":{"id":42,"updatedAt":"2026-08-25T10:00:00.000Z"}}'
Send the exact same payload again — you'll get back duplicate, already processed instead of a second downstream call. Then point DOWNSTREAM_URL at a real 500-returning endpoint and watch the server log four attempts with growing delays before the event lands in dead_letters:
sqlite3 webhooks.db "SELECT idempotency_key, error, failed_at FROM dead_letters;"
Production notes
Swap better-sqlite3 for Postgres or Redis once you're running more than one instance of the receiver — SQLite's file lock works for a single process but won't coordinate across replicas. Add a cron job or admin endpoint that replays rows from dead_letters after you've fixed whatever caused the downstream failure. And expire old rows from processed_webhooks on a schedule (e.g., after 30 days) so the table doesn't grow unbounded — you only need to remember an event long enough to catch a redelivery, not forever.
The pattern here isn't Strapi-specific — any webhook consumer needs the same three pieces: an idempotency key derived from the event's content, an ack-then-process split so retries don't pile up, and a place for permanent failures to land where a human can see them. Strapi just happens to be a common enough source of webhooks that getting this wrong is easy to do by accident.
Top comments (0)