Agent skill
Strapi Webhooks Skill
Receive and authenticate Strapi webhooks. Use when setting up a Strapi webhook endpoint, checking the auth header you configured in Strapi, debugging 401s, or handling content events like entry.create, entry.update, entry.publish, entry.unpublish, media.create and releases.publish.
Install this skill
npx skills add hookdeck/webhook-skills --skill strapi-webhooks
Strapi (the headless CMS) sends an HTTP POST with a JSON body when content changes: an entry is created, updated, deleted, published or unpublished, a media file is uploaded, a release goes out. Strapi does not sign webhook requests. There is no signature header to verify. (The "Verifying signatures" section in Strapi's docs, with X-Webhook-Signature over timestamp.body, is guidance for a sender you build yourself. Strapi doesn't send those headers.)
Instead, you add a secret header to the webhook, either per webhook in the admin panel or for all webhooks via webhooks.defaultHeaders in config/server, and your endpoint checks it.
When to Use This Skill
- How do I receive Strapi webhooks?
- Does Strapi sign webhook payloads? (No: use a secret header you configure)
- How do I authenticate a Strapi webhook with an
Authorization: Bearer ...header? - How do I handle
entry.publish,entry.updateormedia.createevents? - Why does my Strapi webhook endpoint return 401?
Verification (core)
const crypto = require('crypto');
// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function verifyStrapiRequest(headers, secret = process.env.STRAPI_WEBHOOK_SECRET,
headerName = process.env.STRAPI_WEBHOOK_HEADER || 'Authorization') {
if (!secret) return false; // never accept when unconfigured
const received = headers[headerName.toLowerCase()]; // Node lowercases header names
return Boolean(received) && safeEqual(received, secret); // compare the WHOLE value
}
- The header name is whatever you chose in Strapi (
STRAPI_WEBHOOK_HEADER, defaultAuthorization). Look it up case-insensitively. STRAPI_WEBHOOK_SECRETis the exact, whole header value, including anyBearerprefix you typed in Strapi. Don't strip or parse the prefix; compare the whole string.- Return
401for a missing header or a mismatch,400for a body that isn't a JSON object,200once handled. - Read the body raw (
express.raw({ type: '*/*' })) and parse JSON only after the check. - Python:
hmac.compare_digeston bytes (encode both sides; passingstrraises on non-ASCII).
What this does and doesn't give you: a static shared credential authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Anyone who captures one request can replay it. Use HTTPS, and rotate the credential if it leaks.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Event Types
Each request has an X-Strapi-Event header with the same value as the body's event.
| Event | Sent When |
|---|---|
entry.create | A Content Type entry is created |
entry.update | An entry is updated (publishing saves the draft first, so entry.update precedes entry.publish) |
entry.delete | An entry is deleted |
entry.publish | An entry is published (Draft & Publish enabled) |
entry.unpublish | An entry is unpublished (Draft & Publish enabled) |
entry.draft-discard | A draft is discarded; not listed in the admin form, create the webhook programmatically |
media.create | A file is uploaded |
media.update | A file is replaced or its metadata changes |
media.delete | A file is deleted in the media interface |
review-workflows.updateEntryStage | Content moves between review stages (Enterprise) |
releases.publish | A release is published (Growth and Enterprise) |
Entry events carry event, createdAt, model, uid and entry. Media events carry media instead (no model). review-workflows.updateEntryStage carries entity and workflow; releases.publish carries release. The User content-type never triggers webhooks.
Environment Variables
STRAPI_WEBHOOK_HEADER=Authorization # header you added in Strapi (default)
STRAPI_WEBHOOK_SECRET=Bearer my-very-secured-token # exact, whole header value
Local Development
Strapi can't reach localhost from a hosted environment. Tunnel with the Hookdeck CLI (no account required):
npx hookdeck-cli listen 3000 strapi --path /webhooks/strapi
Use the printed URL as the webhook URL in Strapi, together with your auth header.
Resources
- references/overview.md - What Strapi webhooks are, events and payload shapes
- references/setup.md - Add a webhook and an auth header in Strapi
- references/verification.md - Header check details, limits, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests