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:
{
"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
- In your space, open Settings > Webhooks and select + New Webhook.
- Enter the endpoint URL.
- On a paid plan, add a webhook secret. Storyblok suggests generating one with
openssl rand -hex 20. Store it server-side, for example asSTORYBLOK_WEBHOOK_SECRET. - 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.
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:
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 verifies the
webhook-signatureheader, 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
standardwebhooksorsvix. - 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_valuesin 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
202or200straight 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_slugin 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.
Acknowledge fast and process asynchronously
See why to process webhooks asynchronously.
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 sits in front of your receiver, checks every Storyblok signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Storyblok webhooks reliably in minutes.