# Guide to Strapi Webhooks: Features and Best Practices

Strapi is an open-source headless CMS. Teams model and edit content in Strapi and serve it to websites and apps through REST or GraphQL. Its webhooks tell other systems when that content changes, for example so a static site rebuilds as soon as an editor publishes.

This guide covers the events Strapi sends and their payloads, how to authenticate requests with a static header token, how Strapi's delivery behaves when your endpoint fails, and the practices that keep a receiver reliable in production. It describes Strapi 5.

## What are Strapi webhooks?

A Strapi webhook is an HTTP `POST` with a JSON body, sent to a URL you configure when one of the events you selected happens. Each webhook has a name, a URL, optional headers and a list of events. Every request carries an `X-Strapi-Event` header naming the event, and the body repeats it in an `event` field.

Strapi does not sign webhook requests. To authenticate them, you add a header with a secret token, either on each webhook or once for all webhooks in `config/server`. The Strapi docs include a section on verifying HMAC signatures, but it is advice for a sender you build yourself, for example from a lifecycle hook. Strapi's own webhooks don't send a signature.

## Strapi webhook features

| Feature | Details |
| --- | --- |
| Configuration | Admin panel **Settings > Webhooks**, or programmatically |
| Default headers | `webhooks.defaultHeaders` in `config/server`, overridden by headers set on a webhook |
| Method and format | `POST`, `Content-Type: application/json` |
| Discriminator | `X-Strapi-Event` header and the body's `event` field |
| Authentication | A static header you choose, such as `Authorization: Bearer <token>`. No signature |
| Payload | Entry events send the fully populated entry, except `entry.delete` and `entry.unpublish` |
| Excluded data | Private fields and the User content-type |
| Delivery | 10 second timeout, no retries |
| Testing | **Trigger** button on a webhook sends a `trigger-test` event |

## Events

| Event | Fires when |
| --- | --- |
| `entry.create` | An entry is created |
| `entry.update` | An entry is updated |
| `entry.delete` | An entry is deleted |
| `entry.publish` | An entry is published (Draft & Publish only) |
| `entry.unpublish` | An entry is unpublished (Draft & Publish only) |
| `entry.draft-discard` | The draft version of an entry is discarded (Draft & Publish only). Not in the admin form, so the webhook has to be created programmatically |
| `media.create` | A media file is uploaded |
| `media.update` | A media file is updated |
| `media.delete` | A media file is deleted |
| `review-workflows.updateEntryStage` | Content moves between review stages. Enterprise edition only |
| `releases.publish` | A release is published. Only on some Strapi plans |

Entry events share one envelope: `event`, `createdAt`, `model`, `uid` and `entry`. Here is Strapi's `entry.create` example, with the repeatable component and dynamic zone shortened:

```json
{
  "event": "entry.create",
  "createdAt": "2026-09-09T08:49:26.158Z",
  "model": "address",
  "uid": "api::address.address",
  "entry": {
    "id": 1,
    "documentId": "w7vfs319acmaxnurjk5vaaza",
    "city": "Paris",
    "postalCode": "75001",
    "createdAt": "2026-09-09T08:49:26.150Z",
    "updatedAt": "2026-09-09T08:49:26.150Z",
    "publishedAt": null,
    "openingHours": [
      { "id": 1, "day": "monday", "from": "09:00", "to": "18:00" }
    ],
    "blocks": [
      { "id": 1, "body": "Ring the bell twice.", "__component": "address.note" }
    ]
  }
}

```

A few details matter when you process these:

* `documentId` identifies the document and `id` identifies the version the event is about. A draft and its published version share a `documentId` but have different `id` values, and discarding a draft creates a new version with a new `id`. Key your records on `documentId`.
* For every entry event except `entry.delete` and `entry.unpublish`, Strapi reads the entry again before sending, so it arrives with all its relations, media, components and dynamic zones populated. You can't choose what is populated; the Strapi 4 `webhooks.populateRelations` option was removed in Strapi 5.
* `entry.delete` and `entry.unpublish` send the entry as the request that deleted or unpublished it returned. From the admin panel, `entry.delete` carries only the entry's own fields.
* One editor action can send more than one event. Publishing saves the draft first, so `entry.update` arrives just before `entry.publish`.

Media events use a different envelope, `event`, `createdAt` and `media`, with no `model` or `uid`. The `media` object is the file entry, including the `formats` generated for an image, and doesn't list the entries the file is attached to. `releases.publish` sends `isPublished` and `release`, and `review-workflows.updateEntryStage` sends `model`, `uid`, `entity` and `workflow`.

> **Inspect Strapi webhooks as they arrive.** Set a [Hookdeck Inspect](https://inspect.hookdeck.com) URL on a webhook and click **Trigger** to see the exact headers and body Strapi sends, with no account or setup required.

## Setting up Strapi webhooks

Generate a long random token first, for example with `openssl rand -hex 32`.

1. In the admin panel, go to **Settings > Webhooks** and click **Add new webhook**.
2. Enter a name and your endpoint's HTTPS URL.
3. Under **Headers**, add `Authorization` with the value `Bearer <your token>`, or a custom header such as `X-Strapi-Webhook-Token`.
4. Select the events you want.
5. Save, then click **Trigger** to send a `trigger-test` event and check the result.

To add the same header to every webhook, set it once in `config/server` and keep the token in an environment variable:

```javascript
// config/server.js
module.exports = ({ env }) => ({
  // ...your existing server settings
  webhooks: {
    defaultHeaders: {
      Authorization: `Bearer ${env('WEBHOOK_TOKEN')}`,
    },
  },
});

```

A header set on a webhook overrides the default header with the same name.

To put Hookdeck in front of your service, create a Hookdeck source with the **Strapi** type and use its URL as the webhook URL. The source asks for a **Header Name** and a **Header Value**: enter the header name you used and its whole value. Header names match case-insensitively, but the value must match exactly, so include `Bearer ` if your value has it. Hookdeck still answers with a `200`, but a request with a missing or wrong header is recorded as failed verification and never reaches your destination. The source accepts `POST`, which is the only method Strapi uses.

## Securing Strapi webhooks

Read the header you chose and compare its whole value with your stored token in constant time. Reject the request if the header is missing or the token isn't configured.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = process.env.STRAPI_WEBHOOK_HEADER || 'authorization';
// The whole expected value, such as "Bearer <token>".
const SECRET = process.env.STRAPI_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/strapi', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  switch (payload.event) {
    case 'entry.publish':
    case 'entry.unpublish':
    case 'entry.delete':
      // Rebuild or purge using payload.uid and payload.entry.documentId
      break;
    case 'media.create':
    case 'media.update':
    case 'media.delete':
      break;
    default:
      // Log and acknowledge anything else, including trigger-test.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("STRAPI_WEBHOOK_HEADER", "Authorization")
SECRET = os.environ.get("STRAPI_WEBHOOK_SECRET", "")  # e.g. "Bearer <token>"

def verify_strapi_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header proves the sender knows the token. It does not cover the body and carries no timestamp, so a modified or replayed request still passes. Serve the endpoint over HTTPS, use a long random token, keep it out of source control with an environment variable, and rotate it if it might have leaked. If you need a signature, send the request yourself from a lifecycle hook and sign it there.

> **Make Strapi webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your token header, stores every content event, and retries delivery to your service, which Strapi doesn't do.

## Strapi webhook limitations and pain points

### Failed deliveries are not retried

**The Problem:** A publish event that hits your endpoint during a deploy or outage is lost, and the site or index it should have updated stays stale.

**Why It Happens:** Strapi's webhook runner makes one attempt per event, with a 10 second timeout. A network error, a timeout or an error response ends that attempt, and the event is not sent again.

**Workarounds:**

* Keep your endpoint fast and highly available, and respond before doing slow work such as a site rebuild.
* Run a periodic reconciliation that reads recently updated entries from the Strapi API.

**How Hookdeck Can Help:** Hookdeck accepts and stores each event as it arrives, then delivers it to your service with [retries](/docs/retries) you configure. You can replay events after an outage, and [issues](/docs/issues) alert you when deliveries fail.

### A static header is all you get

**The Problem:** Anyone who learns the token can send requests your endpoint accepts.

**Why It Happens:** Strapi sends the headers you configure but doesn't sign the body or add a timestamp.

**Workarounds:**

* Use a long random token over HTTPS and rotate it on a schedule.
* Treat each webhook as a signal and read the current entry from the Strapi API before acting on anything sensitive.

**How Hookdeck Can Help:** Hookdeck checks the header on every request and keeps those that fail, so you can see unauthenticated traffic without it reaching your service.

### One editor action, several events

**The Problem:** Publishing one entry rebuilds your site twice.

**Why It Happens:** Publishing saves the draft first, so `entry.update` is sent just before `entry.publish`.

**Workarounds:**

* Subscribe only to the events you act on. A static site usually needs `entry.publish`, `entry.unpublish` and `entry.delete`, not `entry.update`.
* Debounce rebuilds so several events within a few seconds trigger one build.

**How Hookdeck Can Help:** [Filters](/docs/filters) can drop events you don't need, and a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) on the destination stops a burst of edits from flooding your build system.

### Large payloads

**The Problem:** Entries with many relations, components or media produce large bodies, and processing them slowly risks the 10 second timeout.

**Why It Happens:** Strapi populates the whole entry, and you can't limit what is populated.

**Workarounds:**

* Raise your body size limit, acknowledge first, and process from a queue.
* Use only `uid` and `documentId` from the payload and fetch exactly the fields you need from the API.

**How Hookdeck Can Help:** Hookdeck acknowledges Strapi straight away and [transformations](/docs/transformations) can trim the payload before it reaches your service.

## Best practices

### Key on `documentId`, not `id`

`id` changes between draft and published versions and when a draft is discarded. Store and look up content by `uid` plus `documentId`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle each envelope

Entry, media, release and review workflow events have different shapes. Route on `event` first, then read `entry`, `media`, `release` or `entity` accordingly, and ignore `trigger-test`.

### Don't send secrets in content

Private fields aren't sent and the User content-type never triggers webhooks, but everything else in an entry is. Mark sensitive attributes private in the content-type schema.

### Acknowledge fast

Return `200` once the request is authenticated and process it from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Strapi sends a JSON `POST` for content, media, release and review workflow changes, with the event in the `X-Strapi-Event` header and the body. It doesn't sign requests or retry them. Authenticate with a long random header token over HTTPS, compare it in constant time, key content on `documentId`, and respond within the 10 second timeout.

[Hookdeck Event Gateway](https://hookdeck.com) checks the token header, stores every event, and retries delivery so an outage doesn't leave your site or index out of date.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Strapi webhooks reliably in minutes.