Type something to search...
How to Use Sanity Webhooks

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.

SettingWhat it controlsExample
URLWhere the request goeshttps://example.com/api/sanity-hook
DatasetWhich dataset to watch, or * for allproduction
TriggerCreate, update, delete, or any mixCreate and delete
FilterWhich documents fire the webhook_type == "post"
ProjectionThe JSON body of the request{_id, _type, "slug": slug.current}
StatusEnabled or disabledEnabled
HTTP methodPOST, PUT, PATCH, DELETE or GETPOST
HeadersExtra headers, such as an auth tokenAuthorization: Bearer ...
API versionThe GROQ version used for filter and projectionv2025-02-19
Drafts and versionsWhether drafts. and versions. documents countOff
SecretUsed to sign each requestA 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 to false. 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-19 or 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() and delta::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:

HeaderContains
sanity-project-idThe project ID
sanity-datasetThe dataset name
sanity-document-idThe ID of the changed document
sanity-operationcreate, update or delete
sanity-transaction-idThe transaction that caused the change
sanity-transaction-timeWhen the transaction happened
sanity-webhook-idThe webhook that sent the request
idempotency-keyA unique key for this delivery
sanity-webhook-signatureThe 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:

  1. 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.
  2. Confirm the dataset and status: a webhook on staging won't fire for production, and disabling a webhook cancels pending requests.
  3. Test the filter in Vision: if the document doesn't match *[<your filter>], the webhook won't fire.
  4. Remember drafts are ignored: saving a draft doesn't fire a default webhook. Publish to test.
  5. Inspect a real payload: point a temporary webhook at a request inspector such as webhook.site to see the exact headers and body.
  6. Test locally through a tunnel: Sanity can't reach localhost, so expose your dev server with a tunnel such as ngrok http 3000 or cloudflared and use that URL.
  7. 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 defineLive in next-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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper