Verifying Sanity Webhook Signatures

This guide, part of Sanity Studio Customization, sets up GROQ-powered webhooks that notify the frontend of content changes securely and efficiently. It covers choosing the filter and projection so webhooks fire only for relevant changes and carry exactly the data the handler needs, configuring a secret, verifying the signature and timestamp in the handler, and turning the payload into cache revalidation.

Sanity’s webhooks are defined with GROQ: a filter decides which document changes trigger the webhook, and a projection shapes the payload. That makes them precise, but also easy to misconfigure: a filter that matches drafts fires on every keystroke of every editor, and a webhook without a secret lets anyone who knows the URL trigger revalidations. Sanity signs requests when a secret is set, with a signature header that includes a timestamp, and the @sanity/webhook package verifies it.

From document change to revalidated pageA published document change matches the webhook's GROQ filter, which excludes drafts; the projection builds a small payload with type, id, slug and referenced ids; Sanity signs it with the secret; the handler verifies signature and timestamp and revalidates tags for the document and its references.DocumentpublishedGROQ filterno draftsProjectiontype, id, slug, refsSignedrequestHandlerverify + revalidatematch
The filter decides when, the projection decides what, the signature decides whether to trust it.

The Problem

A magazine configured a webhook with the filter _type == "article" and no projection or secret. Every autosave of every draft article triggered the webhook, sending the entire document to the frontend, which revalidated the article page and the homepage each time. During busy editing periods, the frontend handled several requests per second, the homepage cache was rebuilt constantly, and the handler logs contained full drafts of unpublished articles. A security review flagged the endpoint as callable by anyone.

How to Configure the Webhook

Filter out drafts. Add !(_id in path("drafts.**")) to the filter, so only published documents trigger the webhook. Publishing, unpublishing and deleting are then the only events that reach the frontend for public caches.

Scope by type. List the types that affect public pages: _type in ["article", "author", "category", "siteSettings"].

Project a small payload. Return _type, _id, the slug and ids of references that influence other pages, such as the article’s categories, plus delta::operation() to know whether it was a create, update or delete.

Set a secret. Configure a secret on the webhook. Sanity then sends a sanity-webhook-signature header with a timestamp and a signature over the timestamp and body.

Verify in the handler. Use isValidSignature from @sanity/webhook on the raw body, and reject stale timestamps.

Webhook settings and their effectFilter, projection, secret, HTTP method and trigger settings for a Sanity webhook, with the recommended value and the problem each prevents.SettingRecommendedPreventsFiltertypes list, no draftsautosave floodsProjectiontype, id, slug, reference idsleaking drafts, large payloadsSecretset, per environmentforged requestsTriggerscreate, update, deletemissed unpublish and deleteDatasetone webhook per datasetstaging revalidating production
A few settings turn a noisy, open endpoint into a quiet, trusted one.

Implementation

The webhook’s filter and projection, entered in the Sanity management UI or defined through the API:

GROQ
// Filter
_type in ["article", "author", "category"] && !(_id in path("drafts.**"))

// Projection
{
  _type,
  _id,
  "slug": slug.current,
  "categoryIds": categories[]._ref,
  "operation": delta::operation()
}

The handler verifies the signature with the official helper, then revalidates tags that the frontend’s fetches use.

TypeScript
// app/api/sanity-webhook/route.ts
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
import { revalidateTag } from "next/cache";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? "";
  if (!(await isValidSignature(body, signature, process.env.SANITY_WEBHOOK_SECRET!))) {
    return new Response("invalid signature", { status: 401 });
  }

  // The signature header carries a timestamp (t=...); reject old requests to limit replays.
  const t = Number(/t=(\d+)/.exec(signature)?.[1]);
  if (!t || Math.abs(Date.now() - t) > 5 * 60 * 1000) return new Response("stale", { status: 401 });

  const p = JSON.parse(body) as { _type: string; _id: string; slug?: string; categoryIds?: string[]; operation: string };
  revalidateTag(`${p._type}:${p._id}`);
  revalidateTag(`${p._type}:list`);
  for (const id of p.categoryIds ?? []) revalidateTag(`category:${id}`);
  return Response.json({ ok: true, type: p._type, operation: p.operation }, { status: 202 });
}

The frontend’s queries tag their cached results accordingly: article pages with article:{id} and the ids of referenced authors and categories, listings with article:list, category pages with category:{id}. With that mapping, a publish revalidates every page showing the changed document.

Timestamp units

Check the unit of the timestamp in the signature header of your Sanity version and adjust the comparison; the goal is to reject requests that are more than a few minutes old. The signature itself already covers the timestamp, so it cannot be altered without invalidating the request.

Several datasets

Create one webhook per dataset, each with its own secret and endpoint path or a dataset marker in the payload, and route staging events to staging deployments only, as described in routing webhooks by environment.

Choosing what the projection sends

The projection is a small API contract between Sanity and the handler, so design it like one. Include what the handler needs to decide which caches to revalidate: the type, the id, the slug for path-based revalidation, and ids of documents whose pages display this one, such as categories for articles. Exclude body content and anything personal, since webhook payloads end up in logs and monitoring tools. Keep it stable: when the frontend’s tagging changes, extend the projection rather than repurposing fields, and version it with a small field such as "v": 2 if the handler must handle both shapes during a transition. For deletes, remember that current values are gone and use before() for anything the handler needs, as shown above. A well-designed projection keeps the handler small and free of extra API calls.

Configuration Reference

Item Recommendation Why
Filter public types, drafts excluded Only relevant events.
Projection minimal ids and slugs Small payloads, no drafts in logs.
Secret per webhook and dataset Isolation, easy rotation.
Verification isValidSignature over the raw body Official, correct canonical form.
Timestamp reject older than a few minutes Limits replay.
Response 202 quickly, work idempotent No retries from timeouts.

Gotchas & Edge Cases

  • Parsed bodies. Verification must use the raw body string; frameworks that parse JSON first break signatures.
  • Delete events. Deleted documents have no current values; project from before() if the handler needs the old slug, for example "slug": coalesce(slug.current, before().slug.current).
  • Reference changes. When an author changes, pages that display the author must be revalidated. Tag pages with referenced ids, or add the author type to the webhook with its own tag.
  • Retries. Sanity retries failed deliveries; handlers must be idempotent, which tag revalidation naturally is.

Worked Example

The magazine changed its webhook filter to exclude drafts, projected only type, id, slug and category ids, set a secret per dataset and verified signatures in the handler. Requests to the endpoint fell from several per second during editing to one per publish, the homepage cache stopped churning, and drafts disappeared from logs. The security review’s finding was closed after a test showed that unsigned and replayed requests were rejected. The team later added a second webhook for the staging dataset with its own secret, and staging edits have never touched production caches since.

Webhook requests per hour during editingRequests from Sanity to the frontend's webhook endpoint per hour during busy editing, with a filter matching drafts and with drafts excluded.Drafts included4300 requests per hourDrafts excluded24 requests per hour
Excluding drafts reduced traffic to the number of real publishes.

Testing Webhooks Locally and in CI

Webhook handlers are easy to test once the signature step is reproducible. The @sanity/webhook package can also create signatures, so tests can sign fixture payloads with a test secret and send them to the handler: a valid request must revalidate the expected tags, an unsigned or wrongly signed one must return 401, and a correctly signed but old one must be rejected as stale. For end-to-end checks, point a webhook on a development dataset at a tunnel to a local server, publish a test document and confirm that the handler receives the projected payload. Keep fixture payloads for each document type and for create, update and delete operations, so changes to the projection are covered by tests before they reach production.

Rollout Checklist

  • Filter webhooks to public types and exclude drafts.
  • Project a minimal payload with ids, slugs and relevant reference ids.
  • Set a secret per webhook and dataset.
  • Verify signatures on the raw body and reject stale timestamps.
  • Revalidate tags for the document, its type list and referencing pages.
  • Test with signed fixtures and monitor delivery failures.

Frequently Asked Questions

Why not send the whole document?

It exposes content in logs and couples the handler to the schema. Revalidation only needs ids; the frontend fetches fresh data anyway when it re-renders the affected pages, so a full document in the payload adds risk without adding anything the handler uses.

Does excluding drafts miss unpublishing?

No. Unpublishing deletes the published document, which triggers the webhook for its published id, so the handler can remove the page from caches right away.

Can webhooks trigger builds for static sites?

Yes. Call a build hook from the handler, with debouncing so a burst of publishes becomes one build, and only after verification has succeeded for that request, never before.

What happens when the handler is down?

Sanity retries failed deliveries for a while. Monitor delivery failures in the project’s webhook logs, and keep a scheduled revalidation of recently changed documents as a backstop, so an outage delays updates rather than losing them.

How do we rotate the secret?

Configure the new secret on the webhook and accept both secrets in the handler briefly, then remove the old one once deliveries with the new secret have succeeded.