
How to Use Sanity Webhooks
Sanity webhooks are HTTP requests that Sanity sends to a URL of your choice whenever a document is created, updated or deleted. They're called GROQ-powered webhooks because a GROQ filter decides which changes fire the webhook, and a GROQ projection decides what the JSON payload contains. You create them in Manage or through the CLI, protect them with a secret, and verify each request's signature on your server with @sanity/webhook or next-sanity/webhook.
Webhooks are how the rest of your stack finds out that content changed. They revalidate pages, trigger builds, sync search indexes, post to Slack and call any API that accepts HTTP. This guide covers creating a webhook, choosing triggers, writing filters and projections, using before(), after() and delta:: functions, verifying signatures, handling retries and duplicates, and debugging deliveries that fail.
How Sanity Webhooks Work
When a transaction changes a document in your dataset, Sanity checks every enabled webhook in the project. If the change matches the webhook's dataset, trigger and filter, Sanity evaluates the projection against the document and sends the result to your URL.
| Setting | What it controls | Example |
|---|---|---|
| URL | Where the request goes | https://example.com/api/sanity-hook |
| Dataset | Which dataset to watch, or * for all | production |
| Trigger | Create, update, delete, or any mix | Create and delete |
| Filter | Which documents fire the webhook | _type == "post" |
| Projection | The JSON body of the request | {_id, _type, "slug": slug.current} |
| Status | Enabled or disabled | Enabled |
| HTTP method | POST, PUT, PATCH, DELETE or GET | POST |
| Headers | Extra headers, such as an auth token | Authorization: Bearer ... |
| API version | The GROQ version used for filter and projection | v2025-02-19 |
| Drafts and versions | Whether drafts. and versions. documents count | Off |
| Secret | Used to sign each request | A long random string |
By default, webhooks only fire for published documents. Drafts and release versions are ignored unless you turn those settings on, which is what you want for most integrations: your site doesn't care about every keystroke an editor makes.
Create a Webhook in Manage
Open sanity.io/manage, select your project and go to API > Webhooks, then choose Create webhook. Fill in the name, URL, dataset, triggers, filter and projection, add a secret and save.
You can also share a webhook as a template link. Manage creates a URL that pre-fills the form, which is handy for documentation or for setting up the same webhook across several projects. Remove custom headers before you share, because they're included in the link.
Manage Webhooks From the CLI
The Sanity CLI has a hook command group for working with webhooks from your Studio folder:
# Open Manage to create a new webhook for this project
npx sanity hook create
# List the project's webhooks
npx sanity hook list
# Show recent delivery attempts for a webhook
npx sanity hook logs
# Show the details of one attempt
npx sanity hook attempt <attempt-id>
# Delete a webhook
npx sanity hook delete
hook logs is the quickest way to see whether a webhook fired and what your endpoint answered.
Choose the Right Triggers
Each webhook fires on create, update, delete, or a combination:
- Create: a new published document appears, including the first time a draft is published.
- Update: an existing published document changes, which in practice means it was published again.
- Delete: a published document disappears. Unpublishing counts as a delete, because the published document is removed.
If you enable drafts, unpublishing also fires a create for the new draft, and every edit in the Studio fires an update on that draft. That can mean a request every second or two while someone types, so only enable drafts when the integration genuinely needs them, such as a review notification.
Write a Filter
The filter is the part of a GROQ query that goes between *[ and ]. An empty filter matches every document, which is rarely what you want:
_type == "post" && defined(slug.current)
Narrow it to the types and conditions your endpoint cares about:
_type in ["post", "page", "author"] && !(_id in path("secrets.**"))
A few rules are specific to webhooks:
- No sub-queries: expressions like
count(*[references(^._id)])and cross-dataset references evaluate tofalse. The filter only sees the changed document. - Use the webhook's API version: newer GROQ features need a recent version, and version support requires
v2025-02-19or later. - Test the filter first: paste it into a query such as
*[_type == "post" && defined(slug.current)][0...5]in Vision to check it matches the documents you expect.
The general filter syntax is covered in filtering and sorting with GROQ.
Fire Only on Meaningful Changes
Webhooks give you three extra tools for comparing a document before and after the change:
before()returns the document as it was before the transaction.after()returns the document after it.delta::changedAny()anddelta::changedOnly()check which fields changed.delta::operation()returns"create","update"or"delete".
This filter fires only when a post's title or slug changes, not when someone fixes a typo in the body:
_type == "post" && delta::changedAny((title, slug))
This one fires once, when a product goes out of stock:
_type == "product" && before().stock > 0 && after().stock == 0
And this one fires when a document's status field moves into review, which is the pattern behind review notifications in editorial workflows:
_type == "post" && status == "inReview" && before().status != "inReview"
On a delete, after() is null, and on a create, before() is null. Write conditions that cope with both, or limit the webhook to the triggers where they make sense.
Shape the Payload With a Projection
The projection defines the JSON body. Leave it empty and Sanity sends the whole document after the change, which is wasteful and can leak fields you didn't mean to send. A focused projection is better:
{
_id,
_type,
"slug": slug.current,
"operation": delta::operation(),
"previousSlug": before().slug.current,
title
}
{
"_id": "post-123",
"_type": "post",
"slug": "sanity-webhooks",
"operation": "update",
"previousSlug": "using-sanity-webhooks",
"title": "How to Use Sanity Webhooks"
}
previousSlug lets a handler clear the cache for the old URL as well as the new one. On a delete, project from before(), since the document no longer exists:
{
_id,
_type,
"slug": coalesce(after().slug.current, before().slug.current)
}
Projections can rename fields, follow references with -> and use GROQ functions, but sub-queries aren't supported. The GROQ projections guide covers the syntax.
Send Payloads Third-Party APIs Expect
Because the projection controls the body, many services can receive webhooks directly, without a handler of your own. A Slack incoming webhook expects a text field:
{
"text": "Published: " + title + " https://example.com/blog/" + slug.current
}
Add any headers the service requires, such as an API key, in the webhook's headers section.
Headers Sanity Sends
Every request includes headers that identify the change, which are useful for logging and routing:
| Header | Contains |
|---|---|
sanity-project-id | The project ID |
sanity-dataset | The dataset name |
sanity-document-id | The ID of the changed document |
sanity-operation | create, update or delete |
sanity-transaction-id | The transaction that caused the change |
sanity-transaction-time | When the transaction happened |
sanity-webhook-id | The webhook that sent the request |
idempotency-key | A unique key for this delivery |
sanity-webhook-signature | The signature, when a secret is set |
Verify the Signature
Anyone who finds your endpoint's URL can send it a request. Set a secret on the webhook, and Sanity signs each request with an HMAC-SHA256 of the timestamp and body, sent in the sanity-webhook-signature header. Your handler recomputes the signature and rejects anything that doesn't match.
Install the toolkit:
npm install @sanity/webhook
The important detail is to verify the raw request body. If you parse the JSON and stringify it again, whitespace or key order can change and the signature won't match. In a Next.js Route Handler, or any framework built on the Fetch API's Request, read the body as text:
// src/app/api/sanity-hook/route.ts
import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook'
const secret = process.env.SANITY_WEBHOOK_SECRET
type Payload = { _id: string; _type: string; slug?: string; operation?: string }
export async function POST(req: Request) {
if (!secret) return new Response('Missing secret', { status: 500 })
const signature = req.headers.get(SIGNATURE_HEADER_NAME) ?? ''
const rawBody = await req.text()
if (!(await isValidSignature(rawBody, signature, secret))) {
return new Response('Invalid signature', { status: 401 })
}
const payload = JSON.parse(rawBody) as Payload
await handleChange(payload)
return Response.json({ ok: true })
}
async function handleChange(payload: Payload) {
console.log(`${payload.operation} on ${payload._type} ${payload._id}`)
}
isValidSignature returns a Promise in version 4 and later, so remember the await. The signature includes a timestamp, which also helps protect against replayed requests.
If you're in Next.js, parseBody from next-sanity/webhook wraps the same check and returns the parsed body. It's the usual choice for revalidation handlers, and triggering site rebuilds with Sanity webhooks uses it for revalidateTag.
Express and Node Servers
For Express, register a text body parser on the route so the raw body is available, then use the middleware:
import express from 'express'
import bodyParser from 'body-parser'
import { requireSignedRequest } from '@sanity/webhook'
const app = express()
app.post(
'/sanity-hook',
bodyParser.text({ type: 'application/json' }),
requireSignedRequest({ secret: process.env.SANITY_WEBHOOK_SECRET!, parseBody: true }),
(req, res) => {
// req.body is now the parsed payload
res.json({ ok: true })
},
)
app.listen(3000)
Store the secret in an environment variable, never in your repository, and rotate it if it leaks. More hardening advice is in securing your Sanity project.
Retries, Timeouts and Idempotency
Webhook delivery has a few rules that shape how you write handlers:
- Respond with a 2xx status to mark a delivery as successful.
- 429 and 5xx responses are retried: Sanity retries a failed request twice, 30 seconds apart.
- Other 4xx responses are not retried: they're treated as undeliverable. Return 400 or 401 for bad input, and 500 or 503 only when trying again might help.
- Requests time out after 30 seconds: do slow work in the background and answer quickly.
- One request at a time per webhook: a slow endpoint holds up the queue behind it.
Delivery is at least once, so the same event can arrive twice, for example when your handler finished the work but the response timed out. The idempotency-key header is unique per delivery, so store the keys you've processed and skip repeats:
const key = req.headers.get('idempotency-key')
if (key && (await alreadyProcessed(key))) {
return Response.json({ ok: true, duplicate: true })
}
await handleChange(payload)
if (key) await markProcessed(key, { ttlSeconds: 60 * 60 * 24 })
alreadyProcessed and markProcessed can be a Redis set, a database table with a unique constraint or a key-value store with expiry. Better still, make the work itself idempotent. Revalidating a tag twice or upserting a search record twice is harmless, whereas sending an email twice isn't.
Don't Write Back to the Same Document Carelessly
A handler that updates the document which triggered it causes another update event, which calls the handler again. If you need to write back, for example to store a generated summary, filter the webhook with delta::changedAny() on the fields editors change, so your own writes don't match. Writing data is covered in how to use Sanity's Mutation API.
Debugging Webhooks
When nothing seems to happen, work through these checks:
- Check the attempts log: in Manage, open the webhook's menu and view attempts, or run
npx sanity hook logs. Each attempt shows the status code and response body your endpoint returned. - Confirm the dataset and status: a webhook on
stagingwon't fire forproduction, and disabling a webhook cancels pending requests. - Test the filter in Vision: if the document doesn't match
*[<your filter>], the webhook won't fire. - Remember drafts are ignored: saving a draft doesn't fire a default webhook. Publish to test.
- Inspect a real payload: point a temporary webhook at a request inspector such as webhook.site to see the exact headers and body.
- Test locally through a tunnel: Sanity can't reach
localhost, so expose your dev server with a tunnel such asngrok http 3000orcloudflaredand use that URL. - Log signature failures: a sudden run of 401s usually means the secret differs between Manage and your environment, or something parsed the body before verification.
The message log at https://<projectId>.api.sanity.io/v2021-10-04/hooks/<webhookId>/messages shows whether requests are queued. Messages that stay queued mean your endpoint is too slow to keep up.
Webhooks or Something Else?
Webhooks aren't the only way to react to content changes:
- Webhooks: best for notifying your own servers and third-party services. You host the code.
- Sanity Functions: run your code on Sanity's infrastructure in response to document events, with no endpoint to host or secure.
- Listeners: the listen API streams changes over a long-lived connection, which suits back-end scripts more than websites. The Sanity HTTP API post explains it.
- The Live Content API: keeps pages fresh without webhooks, through
defineLiveinnext-sanity.
FAQ: Sanity Webhooks
Not by default. Webhooks only fire for published documents unless you enable the drafts setting. With drafts enabled, every Studio edit fires an update, so keep a tight filter if you turn it on.
Add a secret to the webhook, then use isValidSignature from @sanity/webhook, or parseBody from next-sanity/webhook, to check the sanity-webhook-signature header against the raw request body. Reject requests that fail with a 401.
Yes, for 429 and 5xx responses. Sanity retries twice with 30 seconds between attempts. Other 4xx responses aren't retried, and requests time out after 30 seconds, so respond quickly.
Yes. Projections can follow references with ->, so you can include an author's name or a category slug. Sub-queries aren't supported, so you can't search for other documents that reference the changed one.
Delivery is at least once, so duplicates happen, for example after a timeout. Use the idempotency-key header to skip deliveries you've already processed, or make the handler's work safe to repeat.
Use delta::changedAny() in the filter, for example _type == "post" && delta::changedAny((title, slug)). You can also compare before() and after() to catch a value moving from one state to another.
Conclusion
Sanity webhooks connect your content to everything else. A filter decides which changes matter, a projection sends exactly the data the receiver needs, and before(), after() and delta:: functions let you fire only when something meaningful happens. Creating one takes a minute in Manage, and sanity hook logs shows what happened afterwards.
On the receiving end, always verify the signature against the raw body, answer quickly with the right status code, and expect the occasional duplicate. With those habits in place, the same pattern handles cache revalidation, search syncing, notifications and builds. For the most common use, keeping a website up to date, continue with triggering site rebuilds with Sanity webhooks.


