# Guide to Sanity Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | sanity.io/manage (per-project webhooks) |
| Triggers | Document create, update, delete |
| Targeting | GROQ filter, including `delta::changedAny` / `changedOnly`, `before()` / `after()` |
| Payload | GROQ projection (defaults to the full document) |
| Signature header | `sanity-webhook-signature`, format `t=<timestamp>,v1=<signature>` |
| Signature scheme | base64url HMAC-SHA256 over `<timestamp>.<rawBody>` |
| Verification helper | `@sanity/webhook` (`isValidSignature`, `assertValidSignature`) |
| Delivery | 1 concurrent request, 2 retries at 30s intervals, 30s timeout |
| Deduplication | `idempotency-key` header |
| Defaults | Draft 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:

| Trigger | GROQ filter | Sends |
| --- | --- | --- |
| Create | `_type == "post"` | A webhook when a new post document is created |
| Update | `delta::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](https://www.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.

```javascript
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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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.

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Sanity webhooks reliably in minutes.