GROQ-powered webhooks (WIP)

Add webhooks to your project that are triggered when content changes.

Webhooks are a way to integrate applications with automated HTTP requests. Typically you use it to connect services together by creating a special URL that accepts incoming requests. What happens when the request depends on the application or service. Some services only support receiving webhooks, others can both receive and send them. With the Sanity Content Lake you can both set up outgoing webhooks, and you can receive webhooks to any of the API endpoints, as long as you can make them authenticated and have the correct payload.

You can create and manage outgoing webhooks in the API section of your project settings. They can also be managed through the CLI or directly through the project APIs. The Sanity Content Lake has two types of webhooks depending on what triggers them, changes made to specific documents, or from any transaction in a project’s datasets. In most cases, you probably want to set up a GROQ-powered webhook.

Webhooks are typically used for, but not limited to:

  • Setting up notifications to systems like Slack, Discord, or email services
  • Keep external logs and update auditing systems
  • Update content in other services
  • Trigger automation and workflows

With GROQ-powered webhooks, you can use filters to control exactly when a request should happen and projections to define its payload. In addition, you can also control the HTTP request type, as well as adding headers.


The projection will always be returned as JSON. If you for some reason need it to be another content type, you’ll have to pass it through a serverless function or a custom endpoint and do the transformation there.

Using filters and GROQ’s delta-functions

GROQ-filters are the expressions you commonly find within the *[] of a query. When defining webhooks, you only need to add the filter expression, that is, you should omit the square brackets. Let’s say you want a webhook to trigger when a new document of the type “product” has been made, then you’ll check off for “create” and add the following filter expression:

_type == “product”

If you only want the webhook to trigger when a product document has been updated and only when it has as a referenced category of the name “chocolate”, then you can check off for “update” and add a filter that looks like this:

_type == “product” && “chocolate” in categories[]->name

Often you want to get even more fine-grained and only trigger webhooks based on a specific content change. For this, you can use delta-functions (LINK TO COME) to check if fields have been changed during the event. You can also access the fields’ content before and after the transaction has been done. Let’s say you want a webhook to trigger only when the name of a product has been changed:

_type == “product” && delta::changedAny(name)

You can also get more specific and trigger webhooks when a field goes from having a value to another. For example, if we want a webhook to trigger when a title field went from having the string "DRAFT" in it to not. (TK: Find better example):

before().title match "DRAFT*" && !(after().title match "DRAFT*")

Payload projections

GROQ-powered webhooks support custom payloads in the requests. More specifically, the payloads are defined as GROQ projections. You’ll have access to the document that’s returned from the filter and all its content. You can also join references in the documents.

Typically you’ll use GROQ projections to make a JSON data structure from the values of the documents:

{ _id, title }

You can use string concatenation to tailor simple notifications:

'The product ' + name + ' was updated'

Projections support the before() and after() functions. Let’s say you trigger a webhook whenever the price field is updated:

{ _id, title, 'status': 'Price of ' + name + ' was changed from ' + before().price + ' to ' + after().price }

If you plan to receive webhooks from multiple projects and datasets, you can use the sanity::-namespace in GROQ to include information about where the webhook is triggered from:

{ _id, _type, 'projectId': sanity::projectId(), 'dataset': sanity::dataset() }


Projections in webhooks don't support sub-queries. In other words, you can’t access other documents in your dataset unless they are referenced from the document in question. For example, the following webhook projection will not work: { “relatedProducts”: *[^.category._ref in categories] }


The webhook configuration object looks like this through the API:

      type: 'document',
      rule: object,
      apiVersion: string,
      includeDrafts: boolean,
      id: string,
      name: string,
      projectId: string,
      dataset: string,
      url: string,
      createdByUserId: string,
      isDisabled: boolean,
      isDisabledByUser: boolean,
      deletedAt: timestamp,
      description: string,
      httpMethod: string,
      headers: object,
			secret: string

The rule object looks like this:

type Action = 'create' | 'update' | 'delete'

type Hook = {
  on?: Action | Action[] // this defaults to ['create', 'update']
  filter?: string
  projection?: string


// Examples

// All create/update

// When author is created/updated
{"filter":"_type == 'author'"} 

// When a new match is created 
{"on":"create", "filter":"_type == 'match'"}

// When a field is updated
{"on": "update", "filter": "delta::changedAny(role)"}

// When a field is updated — or the document is created
{"filter": "delta::changedAny(role)"}

// When a field is updated (depending on the state)
{"on": "update": "filter": "before().state == 'in-review' && delta::changedAny(title)"}

// On a delete
{"on": "delete"}

// Dynamic projection
{"on":["delete", "update"],"projection":{after() == null => {'action': 'delete'}}}

Debugging webhooks

Sanity will use the HTTP status code to determine if delivery is successful:

  • 200-range will be treated as a success
  • 400-range will be treated as undeliverable, as the server said it was a client error
  • 500-range will be retried using an exponential back-off pattern before giving up after 10 unsuccessful deliveries.

To see the responses from your web server, use the CLI tool: sanity hook logs --detailed


Delta functions

Delta-GROQ is an extension of GROQ which makes it possible to reason about changes done to a document. This involves a few functions:

  • A before() function returns the attributes done before the change.
  • An after() function returns the attributes after the change.
  • delta::changedAny which returns true if certain attributes have changed.
  • delta::changedOnly which returns true if only some attributes have changed.

These functions are only available in delta mode.

before() and after()

The functions before() and after() return the attributes before and after the change. When the change is a create operation then before() is null, and when the change is a delete operation then after() is null.

These allow you to create expressive filters (after().score > before().score will only match when the score increases) and let you refer to both old and new values in projections ('Title changed from ' + before().title + ' to ' + after().title).

Diff functions

There are two new functions available in Delta mode:

delta::changedAny(selector) -> bool
delta::changedOnly(selector) -> bool

// Example: Return true when title has changed

Notice that these functions accept a selector and not a full GROQ expression. See the next section for how they work. delta::changedAny uses the selector to search for values and returns true if any of them have changed. delta::changedOnly uses the selector to search for values and returns true ****if there are no changes anywhere else.

These are very useful for filtering: You can use delta::changedAny(title) to only match changes done to a specific field.

We've also added variants of these functions which are available inside regular GROQ and works on provided objects:

diff::changedAny(before, after, selector) -> bool
diff::changedOnly(before, after, selector) -> bool

// Example: This returns true because last name has changed.
  (firstName, lastName)


Selector is a new concept in GROQ which represents parts of a document. These are the currently supported selectors (shown used with changedAny):

// One field:

// Multipe fields:
delta::changedAny((title, description))

// Nested fields:

// Fields on arrays:

// Nested fields on arrays:
delta::changedAny(authors[].(year, name))

// Filters:
delta::changedAny(authors[year > 1950].name)

Was this article helpful?