Gareth Wilson Gareth Wilson

Guide to Sanity Webhooks: Features and Best Practices

Published


Sanity webhooks fire when documents change in your Content Lake: a post is published, a product's price is updated, a document is deleted. What makes them different from most webhook systems is that you define exactly when they fire and exactly what they send, using GROQ, Sanity's query language. That flexibility is powerful, and it means there's no fixed catalog of event names to learn.

This guide covers how Sanity webhooks work, how to define triggers and payloads, how to verify signatures with the official helper, the delivery behavior worth planning around, and the best practices that keep a Content Lake integration reliable in production.

What are Sanity webhooks?

Sanity's GROQ-powered webhooks send an HTTP POST when a document is created, updated, or deleted in the Content Lake. Rather than emitting predefined events, each webhook is configured with a GROQ filter (which documents and changes trigger it) and a GROQ projection (what the payload looks like). The default payload is the full document, but you can project exactly the fields you want.

Sanity signs deliveries with a Stripe-style scheme in the sanity-webhook-signature header, and provides an official helper library so you don't have to implement the verification by hand.

Sanity webhook features

FeatureDetails
Configurationsanity.io/manage (per-project webhooks)
TriggersDocument create, update, delete
TargetingGROQ filter, including delta::changedAny / changedOnly, before() / after()
PayloadGROQ projection (defaults to the full document)
Signature headersanity-webhook-signature, format t=<timestamp>,v1=<signature>
Signature schemebase64url HMAC-SHA256 over <timestamp>.<rawBody>
Verification helper@sanity/webhook (isValidSignature, assertValidSignature)
Delivery1 concurrent request, 2 retries at 30s intervals, 30s timeout
Deduplicationidempotency-key header
DefaultsDraft and version events ignored unless you opt in

Defining triggers and payloads

There are no event names to subscribe to. Instead you define, per webhook:

  • A trigger: create, update, or delete.
  • A GROQ filter: which documents count. For example, _type == "post" limits the webhook to posts. Delta functions let you fire only on meaningful changes, such as delta::changedAny(title) to fire only when the title changes.
  • A GROQ projection: the shape of the payload. Omit it to send the full document, or project just the fields your handler needs.

Two representative configurations:

TriggerGROQ filterSends
Create_type == "post"A webhook when a new post document is created
Updatedelta::changedAny(title)A webhook only when a document's title changes

By default Sanity ignores draft and version events, so you get published-content changes unless you configure otherwise.

Setting up Sanity webhooks

Create webhooks at sanity.io/manage under your project. For each one you set the URL, the trigger, the GROQ filter and projection, and a secret used to sign deliveries. Set the secret; without it there's no signature to verify.

Securing Sanity webhooks

Each delivery carries a sanity-webhook-signature header in the form t=<timestamp>,v1=<signature>, where the signature is a base64url HMAC-SHA256 over <timestamp>.<rawBody>. The cleanest way to verify is the official @sanity/webhook helper, which parses the header, recomputes the HMAC over the raw body, and checks it for you.

import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";

const SECRET = process.env.SANITY_WEBHOOK_SECRET;

// Read the body as raw text so the signature matches byte-for-byte
app.post("/webhook", express.text({ type: "application/json" }), async (req, res) => {
  const signature = req.headers[SIGNATURE_HEADER_NAME]; // "sanity-webhook-signature"

  if (!(await isValidSignature(req.body, signature, SECRET))) {
    return res.status(401).send("Invalid signature");
  }

  const payload = JSON.parse(req.body);
  res.status(200).send("OK"); // acknowledge fast
  processQueue.add(payload); // process asynchronously
});

The helper also exposes assertValidSignature (which throws on failure) and a requireSignedRequest middleware. Verifying against the raw body is essential: parse it first and the recomputed HMAC won't match. There's no official Python package, so outside Node reproduce the scheme directly (HMAC-SHA256 over <timestamp>.<rawBody>, base64url), parsing the t= and v1= values out of the header, and compare in constant time.

Sanity webhook limitations and pain points

No delivery guarantee

The Problem: Sanity is explicit that webhooks come with no strict delivery guarantee. A delivery can fail after its retries and simply not arrive, so an integration that treats webhooks as a complete record of every change will drift out of sync.

Why It Happens: Webhooks are a best-effort notification mechanism, not a durable event log.

Workarounds:

  • Reconcile periodically by querying the Content Lake for the current state rather than trusting you saw every change.
  • Log every delivery on receipt so you have your own record.
  • Treat webhooks as a prompt to re-fetch, not as the source of truth.

How Hookdeck Can Help: Hookdeck durably queues every delivery it accepts and retries to your downstream on a schedule you control, turning best-effort delivery into something you can actually rely on, with a full record of what arrived.

Single-concurrency delivery with limited retries

The Problem: Sanity sends one request at a time per webhook, retries only twice at 30-second intervals, and times out after 30 seconds. A slow handler both burns the retry budget and holds up the next delivery, because there's no parallelism.

Why It Happens: Serialized delivery keeps ordering predictable, but it makes your handler's latency everyone's problem.

Workarounds:

  • Acknowledge fast (return 200 the moment the signature verifies) and process asynchronously so you never approach the 30-second timeout.
  • Keep the synchronous handler to verification and enqueue only.

How Hookdeck Can Help: Hookdeck accepts deliveries immediately and forwards to your endpoint on its own schedule and concurrency, so a slow downstream no longer stalls Sanity's single-concurrency queue.

Verifying against a parsed body silently fails

The Problem: The signature is computed over the raw body. Frameworks that auto-parse JSON discard the exact bytes, so the recomputed HMAC never matches and every delivery looks forged.

Why It Happens: Most middleware parses request bodies by default.

Workarounds:

  • Read the body as raw text (express.text) and verify before parsing.
  • Use the @sanity/webhook helper, which expects the stringified body.

How Hookdeck Can Help: Hookdeck verifies the sanity-webhook-signature at the edge against the bytes as received, so your app gets pre-verified payloads without raw-body plumbing.

Duplicate deliveries

The Problem: Retries mean the same change can arrive more than once. Acting on a duplicate can double-write or double-trigger downstream work.

Why It Happens: A retry fires when Sanity doesn't get a timely success, even if your handler actually processed the first attempt.

Workarounds:

  • Dedupe using the idempotency-key header Sanity includes for exactly this purpose.
  • Make side effects idempotent at the destination.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge with configurable rules, so retries don't reach your handler twice. See our guide to webhook idempotency.

Best practices

Use @sanity/webhook and verify the raw body

The helper handles the t=/v1= parsing and the HMAC over <timestamp>.<rawBody>. Feed it the raw, unparsed body string.

Scope filters tightly with GROQ

Use _type filters and delta functions like delta::changedAny(field) so a webhook fires only on the changes you care about, and project just the fields your handler needs.

Acknowledge fast, process asynchronously

Return 200 as soon as the signature verifies and defer real work to a queue, well inside the 30-second timeout. See why to process webhooks asynchronously.

Dedupe on idempotency-key

Use the idempotency-key header so retries are no-ops, and keep side effects idempotent.

Reconcile, don't rely

With no delivery guarantee, periodically reconcile against the Content Lake so a dropped delivery doesn't leave you permanently out of sync.

Make Sanity webhooks production-ready

Hookdeck verifies signatures, deduplicates, and durably queues every Content Lake change

Conclusion

Sanity webhooks trade a fixed event catalog for GROQ-defined triggers and projections, which lets you fire precisely on the changes you care about and send exactly the payload you want. The verification scheme is clean and well-supported by the @sanity/webhook helper, as long as you verify against the raw body. Where you have to be careful is delivery: single concurrency, only two retries, a 30-second timeout, and no strict delivery guarantee.

That puts reconciliation, fast acknowledgement, deduplication, and raw-body handling on your plate. Hookdeck verifies signatures, deduplicates, and durably queues every Content Lake change at the edge, so your integration gets reliable delivery on top of Sanity's best-effort webhooks and only ever processes verified, unique events.

Get started with Hookdeck for free and handle Sanity webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.