Verifying Storyblok Webhooks and Cache Versions

This guide belongs to Storyblok Visual Editor Integration and covers the two mechanisms that keep a Storyblok frontend fresh: webhooks, which tell the frontend that content was published, and the cache version, which tells Storyblok’s CDN that its cached responses are stale. It shows how to verify webhook signatures, map events to cache tags, handle the cache version in custom clients, and cover missed deliveries.

Storyblok sends a webhook for story events such as publish, unpublish, delete and move, as well as for assets, datasources, workflow changes and releases. When a secret is configured for the webhook, each request carries a webhook-signature header: an HMAC-SHA1 of the raw request body, keyed with the secret. Verifying it on the frontend proves that the request came from Storyblok and was not altered. Separately, each publish increases the space’s cache version. Content Delivery API responses are cached per full URL, including the cv parameter, so fetching with the new version returns fresh content.

From publish to fresh contentAn editor publishes a story; Storyblok increases the cache version and sends a signed webhook with the action and full slug; the handler verifies the HMAC over the raw body and revalidates tags for the story and its lists; the next render reads the current cache version and fetches the story with it, receiving fresh content.StoryblokWebhook handlerFrontend renderDelivery APIpublished, full_slug (signed)HMAC-SHA1 raw bodyrevalidate tagsGET spaces/me (current cv)GET story ?cv=currentfresh story
The signature proves the event; the cache version guarantees the content behind it is fresh.

The Problem

A news site’s revalidation endpoint accepted any request with a story_id, and its custom HTTP client fetched stories without a cache version to keep URLs “clean”. Two things went wrong. First, a load test someone had pointed at the wrong host hammered the endpoint, revalidating the homepage hundreds of times per minute. Second, after publishes, pages sometimes stayed stale for minutes although revalidation ran, because the client fetched from the API’s CDN without a cache version and received the previously cached response. Editors learned to publish twice, which made the problem harder to diagnose.

How Webhooks and Cache Versions Work

Secret and signature. Set a secret on each webhook in the space settings. Storyblok then computes an HMAC-SHA1 over the raw request body with that secret and sends it, hex-encoded, in webhook-signature.

Verify on the raw body. Read the body as text, compute the HMAC with the same secret, compare in constant time, and only then parse the JSON. Parsing and re-serializing first changes the bytes and breaks verification.

Payloads. Story events carry the action, such as published or unpublished, the space id, the story id and the story’s full slug. That is enough to revalidate tags for the story and the lists it appears in.

The cache version. The official clients read the current cache version from the space endpoint and add it as cv to requests. Custom clients must do the same: fetch the space’s version when it may have changed, such as after a webhook, and add it to every published request.

Backstop. Keep a scheduled job that revalidates recently published stories, so a failed delivery does not leave a page stale indefinitely.

Two caches, two invalidation mechanismsThe frontend's own cache and Storyblok's API CDN compared by what invalidates them, what triggers it, and what happens if it is missing.CacheInvalidated byMissing it meansFrontend data cachewebhook, revalidateTagstale page until TTLStoryblok API CDNnew cv parameterstale API response after revalidationYour CDN in front of pagespurge or short TTL + SWRstale HTML at the edge
Both caches must be refreshed, or one of them serves the old content.

Implementation

The webhook handler verifies the signature on the raw body, then maps the event to tags:

TypeScript
// app/api/storyblok/webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";

type SbEvent = { action: string; text?: string; space_id: number; story_id?: number; full_slug?: string };

function verify(raw: string, signature: string | null) {
  const expected = createHmac("sha1", process.env.STORYBLOK_WEBHOOK_SECRET!).update(raw).digest("hex");
  const got = Buffer.from(signature ?? "");
  return got.length === expected.length && timingSafeEqual(got, Buffer.from(expected));
}

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verify(raw, req.headers.get("webhook-signature"))) return new Response("invalid signature", { status: 401 });

  const e = JSON.parse(raw) as SbEvent;
  if (String(e.space_id) !== process.env.STORYBLOK_SPACE_ID) return new Response("wrong space", { status: 400 });

  const tags = new Set<string>(["storyblok:cv"]);           // forces a fresh cache version lookup
  if (e.full_slug) {
    tags.add(`story:${e.full_slug}`);
    const folder = e.full_slug.split("/").slice(0, -1).join("/");
    tags.add(`list:${folder || "root"}`);
  }
  if (["published", "unpublished", "deleted", "moved"].includes(e.action)) tags.add("navigation");
  for (const t of tags) revalidateTag(t);

  console.log(JSON.stringify({ kind: "storyblok_webhook", action: e.action, slug: e.full_slug, tags: [...tags] }));
  return Response.json({ ok: true });
}

A custom client caches the current cache version under its own tag, so the webhook refreshes it along with the content:

TypeScript
// lib/storyblok-fetch.ts
const HOST = process.env.STORYBLOK_REGION === "us" ? "https://api-us.storyblok.com" : "https://api.storyblok.com";

async function currentCv(): Promise<number> {
  const res = await fetch(`${HOST}/v2/cdn/spaces/me?token=${process.env.STORYBLOK_PUBLIC_TOKEN}`, { next: { tags: ["storyblok:cv"] } });
  const { space } = await res.json();
  return space.version;
}

export async function getPublishedStory(slug: string) {
  const cv = await currentCv();
  const url = `${HOST}/v2/cdn/stories/${slug}?version=published&cv=${cv}&token=${process.env.STORYBLOK_PUBLIC_TOKEN}`;
  const res = await fetch(url, { next: { tags: [`story:${slug}`] } });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Storyblok ${res.status} for ${slug}`);
  return (await res.json()).story;
}

With the official client, the cache version is handled for you; make sure its in-memory cache is cleared or the client recreated after a webhook in long-running servers, so it picks up the new version.

Moves and renames

When editors move or rename a story, its full slug changes. The webhook carries the new slug; the old one is not included. Keep a small map from story id to last known slug, updated on each webhook, so the handler can revalidate the old path, and create a redirect from the old path if the page was public. Without that, the old URL may serve the cached page until it expires.

Several webhooks

Configure separate webhooks for production and staging deployments, each with its own secret, and check the space id in the handler. Asset and datasource events deserve their own handling: a datasource change, such as a list of countries, usually affects many pages and maps to a shared tag.

Configuration Reference

Item Recommendation Why
Secret set on every webhook, per environment Enables signatures.
Verification HMAC-SHA1 of the raw body, constant-time Proves origin and integrity.
Space check compare space_id Staging events never touch production.
Tags story, folder list, navigation Precise revalidation.
Cache version refreshed after each webhook API CDN returns fresh content.
Moves id-to-slug map, redirects Old paths do not linger.
Backstop scheduled revalidation of recent publishes Missed deliveries recover.

Gotchas & Edge Cases

  • Body parsers. Middleware that parses JSON before the handler breaks verification; read the raw text.
  • Stale cv in memory. A long-running process that cached the cache version at startup keeps requesting old versions; tie the version to the same revalidation as the content.
  • Unpublish and delete. Revalidate lists and navigation too, or links to removed pages remain.
  • Releases. Merging a release publishes many stories at once and sends many events; tag revalidation handles bursts well, build hooks need debouncing.
  • Timeouts. Answer quickly and do slow work after responding, or deliveries time out.

Worked Example

The news site added secrets to its webhooks, verified signatures on the raw body and checked the space id, which rejected the misdirected load test before it could revalidate anything. Its custom client started sending the current cache version, refreshed through the same tag the webhook revalidated. Stale pages after publishing disappeared, and editors stopped publishing twice. The team measured time from publish to fresh page across a week of publishes and found the slowest case dropped from several minutes to a few seconds.

Slowest time from publish to fresh page in a weekMaximum observed seconds between publishing a story and the public page showing the change, before and after adding cache version handling.Without cv handling420 secondsWith cv refreshed by webhook6 seconds
Refreshing the cache version removed the long tail of stale pages.

Monitoring Freshness

Freshness problems are easier to catch with a direct measurement than with complaints from editors. Add a synthetic check that, every few minutes, reads a small “heartbeat” story’s published timestamp through the public frontend and compares it with the API. A scheduled job updates and publishes the heartbeat story regularly through the Management API, so the check always has something to measure. If the difference exceeds a threshold, alert. Combine that with metrics from the webhook handler, such as deliveries per hour and signature failures, and a dashboard shows at a glance whether content is flowing. A sudden drop in deliveries usually means a changed secret or a disabled webhook, both of which are otherwise noticed only when an editor reports a stale page.

Rollout Checklist

  • Set a secret on every webhook and verify HMAC-SHA1 on the raw body.
  • Check the space id and use separate webhooks per environment.
  • Map events to story, list and navigation tags.
  • Send the current cache version with every published request.
  • Handle moves with an id-to-slug map and redirects.
  • Add a backstop job and a freshness check.

Frequently Asked Questions

Why SHA-1?

It is what Storyblok uses for webhook signatures. As an HMAC, it remains suitable for authenticating messages.

Do I need the cache version with the official client?

No, the client handles it. Make sure long-running processes do not keep an outdated version in memory.

Does draft content use the cache version?

Draft requests are not served from the CDN cache, so the cache version matters for published content only.

What if a webhook fails?

Storyblok’s webhook log in the space settings shows failed deliveries. The backstop job revalidates recent publishes regardless.

Can I test signatures locally?

Yes. Compute the HMAC of a fixture body with a test secret in your tests, and send it with the fixture to the handler to cover valid, invalid and missing signatures.