# Guide to Storyblok Webhooks: Features and Best Practices

Storyblok webhooks tell your systems when content changes: a story is published, unpublished, moved or deleted, an asset is uploaded or replaced, a workflow stage changes, a release is merged, or someone runs a task. They are how Storyblok sites clear caches, trigger static site rebuilds, re-index search and sync content to other systems as soon as editors hit publish.

This guide covers how Storyblok webhooks are set up and signed, why the `webhook-signature` header is not what its name suggests, the triggers and payloads, Storyblok's no-retry policy, and the best practices for running a Storyblok receiver in production.

## What are Storyblok webhooks?

A Storyblok webhook belongs to a space. It has an endpoint URL, a set of triggers and, on paid plans, a webhook secret. When a trigger fires, in the app or through the API, Storyblok sends a JSON payload to the endpoint.

If the webhook has a secret, Storyblok signs each request with an HMAC-SHA1 of the raw body, keyed with the secret, and sends the lowercase hex digest in a `webhook-signature` header.

That header name is also used by the Standard Webhooks specification, but Storyblok doesn't follow it. There are no `webhook-id` or `webhook-timestamp` headers, no `v1,` prefix and no base64. Standard Webhooks libraries such as `standardwebhooks` or `svix` will reject every Storyblok request.

## Storyblok webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per space: **Settings > Webhooks**, or the Management API |
| Format | JSON |
| Common fields | `text`, `action`, `space_id` |
| Signature header | `webhook-signature`, when a secret is set |
| Scheme | HMAC-SHA1, lowercase hex (not Standard Webhooks) |
| Signed content | The raw request body |
| Secret | Chosen by you. Paid plans only |
| Unsigned webhooks | Free plans, and all Tasks app webhooks |
| Replay window | None. No timestamp is signed |
| Required response | A `2xx` with `content-type: application/json` |
| Retries | None. Webhooks don't retry on failure |
| Long-running work | Answer `202 Accepted` at once for anything over 120 seconds |
| Logs | **Settings > Webhooks > View logs**, with status, response and payload per run |

Storyblok publishes static source IPs per region. Allowlist both the primary and fallback address for your space's region:

| Region | Primary | Fallback |
| --- | --- | --- |
| EU | `3.121.25.244` | `35.159.255.4` |
| US | `44.206.118.25` | `3.231.44.46` |
| Canada | `15.157.6.14` | `35.183.66.204` |
| Australia | `13.238.72.230` | `52.63.91.95` |

There are no static IPs for the China region.

## Common events

Storyblok calls its events triggers, and identifies them in the payload's `action` field.

| Area | Actions |
| --- | --- |
| Stories | `published`, `unpublished`, `deleted`, `moved` |
| Assets | `created`, `replaced`, `deleted`, `restored` |
| Workflow | `stage.changed` |
| User management | `added`, `removed`, `roles_updated` |
| Datasources | `entries_updated`, `entries_deleted` |
| Discussions | `created`, `resolved`, `comment_created`, `comment_updated`, `comment_deleted` |
| Experiments | `experiment.created`, `.started`, `.resumed`, `.paused`, `.completed`, `.winner_selected`, `.deleted` |
| Releases | `merged` |
| Pipelines | `deployed` |
| Tasks app | `task_execution` |

Experiments, Releases and Pipelines are only available on paid plans.

`action` doesn't say what kind of thing changed. `deleted` is sent for both stories and assets, and `created` for both assets and discussions. Check which ID field is present, such as `story_id` or `asset_id`, before you act.

Here is Storyblok's story `published` example:

```json
{
  "text": "The user joy.bringer@storyblok.com published the Story Home 2 (marketing-articles/home-2)\nhttps://app.storyblok.com/#/me/spaces/184738/stories/0/0/166888799188279",
  "action": "published",
  "space_id": 184738,
  "story_id": 166888799188279,
  "full_slug": "marketing-articles/home-2"
}

```

`text` is a human-readable sentence that includes the user's email address and a link. The other fields depend on the trigger: `story_id` and `full_slug` for stories (plus `old_full_slug` on `moved`), `asset_id` for assets, `workflow_name` and `workflow_stage_name` for `stage.changed`, `user_id` for user management, and `datasource_slug` for datasources. Payloads carry IDs, not content, so fetch the story or asset from the API if you need it.

In the `task_execution` example, `space_id` is a string rather than a number, and the payload adds `task` and `dialog_values`.

## Setting up Storyblok webhooks

1. In your space, open **Settings > Webhooks** and select **+ New Webhook**.
2. Enter the endpoint URL.
3. On a paid plan, add a webhook secret. Storyblok suggests generating one with `openssl rand -hex 20`. Store it server-side, for example as `STORYBLOK_WEBHOOK_SECRET`.
4. Select the triggers and save.

If you use Hookdeck, create a Storyblok source, set the same secret on it, and use the Hookdeck source URL as the endpoint. Webhook secrets need a paid Storyblok plan, and Tasks app webhooks are never signed. On a free plan, or for a task webhook, leave verification off on the Hookdeck source, and use a separate source if you also have signed webhooks.

For task webhooks, Storyblok suggests passing a secret value in the task's **User dialog** field and checking it in the body (`dialog_values`).

## Securing Storyblok webhooks

Compute an HMAC-SHA1 of the raw request body with the webhook secret, encode it as hex, and compare it with `webhook-signature` in constant time. Storyblok sends the body as raw text, and its tutorial warns that frameworks which parse JSON automatically break verification.

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

function verifyStoryblokSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;

  const expected = crypto.createHmac('sha1', secret).update(rawBody).digest('hex');

  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/storyblok', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.STORYBLOK_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyStoryblokSignature(req.body, req.get('webhook-signature'), secret)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.action plus the ID field present (story_id, asset_id, ...).
  // Storyblok expects a 2xx with a JSON content type.
  res.status(200).json({ received: true });
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_storyblok_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha1).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_storyblok_signature(raw_body, request.headers.get("webhook-signature"),
#                            os.environ["STORYBLOK_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `storyblok-test-secret` and this exact body:

```
{"text":"The user joy.bringer@storyblok.com published the Story Home","action":"published","space_id":184738,"story_id":166888799188279,"full_slug":"home"}

```

The signature should be:

```
8d62da749dd3a46d662844be5aa6821005489b85

```

> **Make Storyblok webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `webhook-signature` header, accepts every publish and asset event as soon as it arrives, and retries delivery to your service, which Storyblok itself never does.

## Storyblok webhook limitations and pain points

### No retries

**The Problem:** If your endpoint is down or errors when an editor publishes, the rebuild or cache purge never happens.

**Why It Happens:** Storyblok doesn't retry failed webhooks. A deploy, a cold start or a brief outage drops the event.

**Workarounds:**

* Check **View logs** for failed runs, and re-trigger important ones by republishing.
* Schedule a periodic rebuild or cache refresh as a safety net.
* Keep the receiver small and always available.

**How Hookdeck Can Help:** Hookdeck accepts each request as soon as it arrives and stores it, then retries delivery to your service on a schedule you control and lets you replay any event, adding the retries Storyblok doesn't have.

### The header name suggests Standard Webhooks

**The Problem:** A Standard Webhooks library rejects every request, or code looks for `webhook-id` and `webhook-timestamp` headers that never arrive.

**Why It Happens:** Storyblok uses the header name `webhook-signature`, but signs with plain HMAC-SHA1 hex over the body.

**Workarounds:**

* Verify manually, as above, and don't use `standardwebhooks` or `svix`.
* If you auto-detect webhook formats, don't treat the header name alone as a sign of Standard Webhooks.

**How Hookdeck Can Help:** Hookdeck's Storyblok source verifies the header as HMAC-SHA1 hex, so you don't have to special-case it.

### Signing needs a paid plan, and tasks are never signed

**The Problem:** On a free plan, or for task webhooks, there's nothing to verify.

**Why It Happens:** Webhook secrets are a paid-plan feature, and the Tasks app doesn't support secrets at all.

**Workarounds:**

* Allowlist Storyblok's static IPs for your region.
* For tasks, put a secret in the **User dialog** field and check `dialog_values` in your receiver.
* Use a long, unguessable endpoint path.

**How Hookdeck Can Help:** Hookdeck logs every request with its headers and body, so unexpected traffic to an unsigned source is visible, and Hookdeck filters can drop requests that don't carry the value you expect in `dialog_values`.

### Slow handlers time out

**The Problem:** A webhook that starts a long build fails, even though the build runs.

**Why It Happens:** Storyblok times out requests that take too long, and recommends answering `202 Accepted` immediately for tasks that run longer than 120 seconds.

**Workarounds:**

* Return `202` or `200` straight away, and run builds from a queue.
* Use your build provider's deploy hook as the destination, rather than doing work in the receiver.

**How Hookdeck Can Help:** Hookdeck answers Storyblok immediately and delivers to your build hook or service with its own timeouts and retries.

### Bursts during bulk publishing

**The Problem:** Publishing many stories in a short time triggers a rebuild for each one.

**Why It Happens:** Each story action is its own trigger with its own payload, identified by one `story_id`, and Storyblok documents no batching, so busy publishing produces a burst of `published` events.

**Workarounds:**

* Debounce rebuilds, for example by queueing one build and dropping duplicates for a minute.
* For incremental updates, purge only the `full_slug` in each event.

**How Hookdeck Can Help:** Hookdeck's destination rate limits and filters can smooth bursts and drop events you don't need before they reach your build system.

## Best practices

### Verify against the raw body

The signature covers the exact text Storyblok sent. In Express, mount `express.raw()` on the webhook route and parse after verification. In a Next.js route handler, read `await request.text()`. In FastAPI, read `await request.body()`.

### Route on `action` and the ID field

Build a key from what changed and how, for example `story.deleted` when `story_id` is present and `asset.deleted` when `asset_id` is present.

### Respond with JSON

Storyblok expects a `2xx` with `content-type: application/json`. Send a small JSON body rather than plain text or an empty response.

### Make handlers idempotent

There's no event ID, so repeated deliveries, including your own replays, need to be safe. Purging a cache or rebuilding twice should be harmless. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Storyblok signs webhooks with a secret on paid plans, as a hex HMAC-SHA1 of the raw body in `webhook-signature`, which is not Standard Webhooks despite the name. Verify against the raw text, route on `action` plus the ID field present, answer with a JSON `2xx` quickly, and plan for failures yourself, because Storyblok never retries.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Storyblok signature, and holds each event until your service has processed it.

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