Agent skill

Storyblok Webhooks Skill

Receive and verify Storyblok webhooks. Use when setting up Storyblok webhook handlers, debugging webhook-signature verification (HMAC-SHA1 of the raw body, not Standard Webhooks), or handling CMS events like published, unpublished, deleted, moved, entries_updated, and stage.changed.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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.

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));
}
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/, examples/nextjs/, 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).
  • 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, ...).

Entityaction valuesEntity fields
Storypublished, unpublished, deleted, movedstory_id, full_slug (moved adds old_full_slug)
Assetcreated, replaced, deleted, restoredasset_id
Workflowstage.changedstory_id, workflow_name, workflow_stage_name
Useradded, removed, roles_updateduser_id
Datasourceentries_updated, entries_deleteddatasource_slug
Discussioncreated, resolved, comment_created, comment_deleted, comment_updatedid, story_id, block_uid, component, fieldname
Experimentexperiment.created, experiment.started, experiment.resumed, experiment.paused, experiment.completed, experiment.winner_selected, experiment.deletedexperiment_id, status
Releasemergedrelease_id
Pipelinedeployedbranch_id
Tasktask_execution (unsigned)task, dialog_values

Example (story published):

{
  "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.

Delivery Behavior

ItemBehavior
ResponseMust be 2xx with content-type: application/json
RetriesNone. Failed deliveries are not retried
TimeoutRespond within 120 seconds; for slow work return 202 Accepted immediately
Source IPsStatic per region (EU, US, CA, AP); allow both primary and fallback
LogsSettings → Webhooks → View logs shows status, response, and payload

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

Important Headers

HeaderDescription
webhook-signatureLowercase hex HMAC-SHA1 of the raw body (only when a Webhook secret is set)

Environment Variables

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

Local Development

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

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 4, 2026

View on GitHub →