# Storyblok Webhooks

## When to Use This Skill

* How do I receive Storyblok webhooks?
* How do I verify the Storyblok `webhook-signature` header?
* Why does my Storyblok webhook signature verification fail?
* How do I handle story `published`, `unpublished`, `deleted`, or `moved` events to rebuild a site or purge a cache?
* How do I tell a story event from an asset or datasource event when they share an `action` value?

## Verification (core)

Storyblok signs the **raw request body** with **HMAC-SHA1** keyed on the Webhook secret you typed into the webhook settings, and sends the lowercase **hex** digest in the `webhook-signature` header. There is no prefix, timestamp, or message ID.

> **This is not Standard Webhooks.** Despite the header name, there are no `webhook-id` / `webhook-timestamp` headers and no `v1,` base64 signature. Do **not** use the `standardwebhooks` or `svix` libraries. Storyblok has no webhook verification SDK, so verification is manual.

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

function verifyStoryblokWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha1', secret).update(rawBody).digest('hex');
  const received = signatureHeader.trim().toLowerCase();
  if (received.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

```

```python
import hashlib, hmac

def verify_storyblok_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha1).hexdigest()
    return hmac.compare_digest(signature_header.strip().lower().encode(), expected.encode())

```

The secret is a plain string (use it as-is; no base64 decoding). Read the body as raw bytes before any JSON parsing. In Express use `express.raw({ type: '*/*' })` so every content type reaches the verifier as a `Buffer`.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storyblok-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storyblok-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storyblok-webhooks/examples/fastapi/).

## Plan and signing caveats

* **Webhook secrets need a paid plan.** On plans without one, webhooks are delivered unsigned and there is nothing to verify; restrict by Storyblok's static IPs instead (see [setup](https://github.com/hookdeck/webhook-skills/blob/main/skills/storyblok-webhooks/references/setup.md)).
* **Task (Tasks app) webhooks are unsigned.** They never carry a `webhook-signature`. Put your own secret in the task's **User dialog** field and check `dialog_values` in your endpoint. Use a separate route so you don't weaken the signed one.
* Experiments, Releases, and Pipelines triggers are also paid-plan features.

## Payload and Event Types

Every payload has `text`, `action`, and `space_id`, plus entity fields. There is **no event-type header**: the event is the `action` value, and `action` alone is ambiguous (`created`, `deleted` appear for several entities), so also look at the entity field (`story_id`, `asset_id`, `datasource_slug`, ...).

| Entity | `action` values | Entity fields |
| --- | --- | --- |
| Story | `published`, `unpublished`, `deleted`, `moved` | `story_id`, `full_slug` (`moved` adds `old_full_slug`) |
| Asset | `created`, `replaced`, `deleted`, `restored` | `asset_id` |
| Workflow | `stage.changed` | `story_id`, `workflow_name`, `workflow_stage_name` |
| User | `added`, `removed`, `roles_updated` | `user_id` |
| Datasource | `entries_updated`, `entries_deleted` | `datasource_slug` |
| Discussion | `created`, `resolved`, `comment_created`, `comment_deleted`, `comment_updated` | `id`, `story_id`, `block_uid`, `component`, `fieldname` |
| Experiment | `experiment.created`, `experiment.started`, `experiment.resumed`, `experiment.paused`, `experiment.completed`, `experiment.winner_selected`, `experiment.deleted` | `experiment_id`, `status` |
| Release | `merged` | `release_id` |
| Pipeline | `deployed` | `branch_id` |
| Task | `task_execution` (unsigned) | `task`, `dialog_values` |

Example (story published):

```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"
}

```

> **For full payloads**, see [Storyblok webhooks documentation](https://www.storyblok.com/docs/concepts/webhooks).

## Delivery Behavior

| Item | Behavior |
| --- | --- |
| Response | Must be `2xx` with `content-type: application/json` |
| Retries | None. Failed deliveries are not retried |
| Timeout | Respond within 120 seconds; for slow work return `202 Accepted` immediately |
| Source IPs | Static per region (EU, US, CA, AP); allow both primary and fallback |
| Logs | **Settings → Webhooks → View logs** shows status, response, and payload |

Because there are no retries, acknowledge fast and process asynchronously.

## Important Headers

| Header | Description |
| --- | --- |
| `webhook-signature` | Lowercase hex HMAC-SHA1 of the raw body (only when a Webhook secret is set) |

## Environment Variables

```bash
STORYBLOK_WEBHOOK_SECRET=your_webhook_secret   # The "Webhook secret" you entered in Settings → Webhooks

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 storyblok --path /webhooks/storyblok

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storyblok-webhooks/references/overview.md) - Webhook concepts, all event types and payloads
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storyblok-webhooks/references/setup.md) - Configure a webhook and secret in Storyblok
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storyblok-webhooks/references/verification.md) - Signature verification details and gotchas