Sanity Webhooks + Next.js ISR Revalidation with HMAC Verification
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
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 a setup that handles all three.
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 your project settings at sanity.io/manage, go to API → Webhooks → Create webhook. 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 in ms>,v1=<base64url HMAC-SHA256>
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('base64url')
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}`, { expire: 0 })
revalidateTag('posts', { expire: 0 })
} 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, profile) invalidates every fetch call that opted into that tag via { next: { tags: ['post:my-slug'] } }. In Next.js 16 the single-argument form is deprecated; for webhooks that need the change visible immediately, the docs recommend revalidateTag(tag, { expire: 0 }), while 'max' gives stale-while-revalidate. 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', { expire: 0 }) 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 (next-sanity or @sanity/client) rather than raw fetch, pass the same options as the third argument: client.fetch(query, params, { next: { tags: ['posts'] } }). The client uses fetch under the hood, so tagged requests participate in revalidateTag the same way.
Handling failed webhooks
Sanity retries a webhook when your endpoint returns a 5xx or 429 status. Per Sanity's webhook docs at the time of writing, it retries twice at 30-second intervals, and requests time out after 30 seconds. 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 (like any 4xx other than 429) is treated as undeliverable and does not trigger Sanity retries.
Clock skew. Sanity embeds a timestamp (t=, in milliseconds) 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() against Number(parts.t) and return 400 if the delta exceeds 300,000 ms.
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: npx localtunnel --port 3000 or ngrok gives you a temporary public URL. Update the webhook URL in Manage, publish a document, and watch the tunnel logs. The webhook's attempts log (in the three-dot menu for the webhook in Manage) shows each delivery and the response your endpoint returned — useful when debugging.
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.
Frequently asked questions
01What format is Sanity's webhook HMAC signature header in?
`sanity-webhook-signature: t=<timestamp>,v1=<signature>` — a Stripe-style format, not a plain digest of the request body. The timestamp is in milliseconds, the signature is a base64url-encoded HMAC-SHA256, and it is computed over `${timestamp}.${rawBody}`, so verification code that just hashes the raw body and compares it directly to the header value will always fail against real Sanity webhooks. See the route handler below for the correct parsing.
02Should I use revalidatePath or revalidateTag for Sanity webhook-triggered ISR?
revalidateTag when a document type appears on multiple routes (a post shows up on its own page, a listing page, and maybe a homepage 'featured' section) — one tag invalidates every fetch that opted in. revalidatePath when a document maps 1:1 to a single route, like a page type with a custom slug.