# 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)

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/strapi-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/strapi-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/strapi-webhooks/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

```bash
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):

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/strapi-webhooks/references/overview.md) - What Strapi webhooks are, events and payload shapes
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/strapi-webhooks/references/setup.md) - Add a webhook and an auth header in Strapi
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/strapi-webhooks/references/verification.md) - Header check details, limits, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/strapi-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests