How I wire Sanity webhooks to Next.js ISR revalidation with HMAC verification

May 11, 2026 · 6 min read

Sanity webhooks and Next.js ISR revalidation are a natural pair, but the wiring has a few sharp edges: verifying the request is genuinely from Sanity, deciding between revalidatePath and revalidateTag, and making sure a failed revalidation doesn't silently swallow itself. This post walks through the exact setup I use on production projects.

Why on-demand ISR instead of a fixed interval

Time-based revalidation (e.g. revalidate: 60) is fine for low-traffic blogs, but it has two problems. First, an editor publishes a fix and waits up to a minute for it to appear — which generates support messages. Second, every page revalidates on a timer whether content changed or not, burning unnecessary compute on Vercel or your own infra. On-demand revalidation via Sanity webhooks flips this: pages stay cached until Sanity tells Next.js something changed, then only the affected paths regenerate.

Setting up the Sanity webhook

In Sanity Manage (manage.sanity.io), go to API → Webhooks → Create. Fill in:

  • URL: https://your-site.com/api/revalidate
  • Trigger on: Create, Update, Delete
  • Filter: _type == "post" || _type == "page" (scope it, don't fire on everything)
  • Projections: {_type, slug} — send only what you need, not the whole document
  • Secret: generate a random string (32+ chars), copy it into your env as SANITY_WEBHOOK_SECRET

Sanity signs each request with an HMAC-SHA256 signature in the sanity-webhook-signature header. You must verify this before doing anything else.

The route handler

Create the handler at app/api/revalidate/route.ts.

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { createHmac, timingSafeEqual } from 'crypto'
 
const SECRET = process.env.SANITY_WEBHOOK_SECRET ?? ''
 
async function verifySignature(rawBody: string, signatureHeader: string): Promise<boolean> {
  if (!SECRET || !signatureHeader) return false
 
  // Sanity sends: t=<timestamp>,v1=<hex-digest>
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('=')),
  ) as { t: string; v1: string }
 
  const expected = createHmac('sha256', SECRET)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')
 
  try {
    return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  } catch {
    return false
  }
}
 
export async function POST(req: Request) {
  const rawBody = await req.text()
  const headerList = await headers()
  const signature = headerList.get('sanity-webhook-signature') ?? ''
 
  const valid = await verifySignature(rawBody, signature)
  if (!valid) {
    return Response.json({ message: 'Invalid signature' }, { status: 401 })
  }
 
  let payload: { _type?: string; slug?: { current?: string } }
  try {
    payload = JSON.parse(rawBody)
  } catch {
    return Response.json({ message: 'Bad JSON' }, { status: 400 })
  }
 
  const { _type, slug } = payload
 
  try {
    if (_type === 'post' && slug?.current) {
      // Tag-based: revalidate just this post and the listing
      revalidateTag(`post:${slug.current}`)
      revalidateTag('posts')
    } else if (_type === 'page' && slug?.current) {
      revalidatePath(`/${slug.current}`)
    } else {
      // Fallback: nuke the whole site — use sparingly
      revalidatePath('/', 'layout')
    }
  } catch (err) {
    console.error('[revalidate] cache invalidation failed', err)
    // Return 500 so Sanity retries the webhook
    return Response.json({ message: 'Revalidation error' }, { status: 500 })
  }
 
  return Response.json({ revalidated: true, type: _type, slug: slug?.current })
}

A few things worth pointing out in this handler. req.text() comes before any JSON parsing — you need the raw string to recompute the HMAC. Parsing first discards the exact bytes Sanity signed. The timingSafeEqual call prevents timing attacks that could leak whether the secret is partially correct. And the try/catch around the revalidation block returns a 500 on failure, which matters for Sanity's retry logic (covered below).

revalidatePath vs revalidateTag

Both functions invalidate the Next.js Data Cache and the Full Route Cache, but they target different things.

revalidatePath(path, type?) invalidates every fetch call associated with a specific URL path. Use it when a document maps 1:1 to a route — a page type with a custom slug is the clearest case. Passing 'layout' as the second argument also busts nested layouts, which matters if your nav renders from CMS data.

revalidateTag(tag) invalidates every fetch call that opted into that tag via { next: { tags: ['post:my-slug'] } }. Use it for content types that appear on multiple routes — a post shows up at /blog/my-slug but also on /blog (the listing), maybe / (featured posts), and an RSS feed. One revalidateTag('posts') call busts all of them at once.

Tag your fetches at the data layer:

// lib/sanity/queries.ts
export async function getPost(slug: string) {
  return fetch(
    `https://${projectId}.api.sanity.io/v2024-01-01/data/query/${dataset}?query=${encodeURIComponent(postQuery)}&$slug=${JSON.stringify(slug)}`,
    {
      next: {
        tags: [`post:${slug}`, 'posts'],
        revalidate: false, // rely entirely on on-demand revalidation
      },
    },
  ).then((r) => r.json())
}

If you're using the Sanity client rather than raw fetch, wrap it in a Next.js fetch call or use unstable_cache with the same tags. The client's own caching doesn't participate in revalidateTag.

Handling failed webhooks

Sanity retries a webhook when your endpoint returns a non-2xx status. The default schedule is roughly: immediate, 5 s, 30 s, 5 min, 30 min — five attempts total. This means returning 500 on a revalidation error is the right move; Sanity will try again rather than silently dropping the event.

Two failure modes to plan for:

Signature mismatch on legitimate requests. This usually means the secret in your env doesn't match what Sanity stored. Verify by checking the raw header value in your logs and re-generating the secret in Manage if needed. A 401 response does not trigger Sanity retries, which is intentional — you don't want an attacker triggering infinite retries.

Clock skew. Sanity embeds a timestamp (t=) in the signature header. Some implementations reject requests older than 5 minutes. The code above doesn't enforce a time window, but if you want to add it, compare Date.now() / 1000 against parseInt(parts.t) and return 400 if the delta exceeds 300 seconds.

For observability, pipe the console.error in the catch block to whatever you use — Axiom, Better Stack, Sentry. At minimum, log _type, slug, and the error message so you can replay manually from the Sanity webhook delivery log if needed.

Local testing

Sanity can't reach localhost, so use a tunnel. With the Vercel CLI: vercel dev exposes a public URL automatically in some plans. Otherwise, npx localtunnel --port 3000 gives you a temporary URL. Update the webhook URL in Manage, publish a document, and watch the tunnel logs. You can also replay any delivery from the Sanity webhook log under API → Webhooks → recent deliveries — useful when debugging without re-publishing.

Once this is wired up, your editors get near-instant deploys on publish and you stop paying for pages that regenerate on a timer with no content change behind them.

Related posts

All posts →