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.update or media.create events?
  • 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, default Authorization). Look it up case-insensitively.
  • STRAPI_WEBHOOK_SECRET is the exact, whole header value, including any Bearer prefix you typed in Strapi. Don't strip or parse the prefix; compare the whole string.
  • Return 401 for a missing header or a mismatch, 400 for a body that isn't a JSON object, 200 once handled.
  • Read the body raw (express.raw({ type: '*/*' })) and parse JSON only after the check.
  • Python: hmac.compare_digest on bytes (encode both sides; passing str raises 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.

EventSent When
entry.createA Content Type entry is created
entry.updateAn entry is updated (publishing saves the draft first, so entry.update precedes entry.publish)
entry.deleteAn entry is deleted
entry.publishAn entry is published (Draft & Publish enabled)
entry.unpublishAn entry is unpublished (Draft & Publish enabled)
entry.draft-discardA draft is discarded; not listed in the admin form, create the webhook programmatically
media.createA file is uploaded
media.updateA file is replaced or its metadata changes
media.deleteA file is deleted in the media interface
review-workflows.updateEntryStageContent moves between review stages (Enterprise)
releases.publishA 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →