Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change Site Theme
Sanity logo

Documentation

    • Overview
    • Platform introduction
    • Next.js quickstart
    • Nuxt.js quickstart
    • Astro quickstart
    • React Router quickstart
    • Studio quickstart
    • Build with AI
    • Content Lake
    • Functions
    • APIs and SDKs
    • Agent Actions
    • Visual Editing
    • Blueprints
    • Platform management
    • Dashboard
    • Studio
    • Canvas
    • Media Library
    • App SDK
    • Content Agent
    • HTTP API
    • CLI
    • Libraries
    • Specifications
    • Changelog
    • User guides
    • Developer guides
    • Courses and certifications
    • Join the community
    • Templates
Developer guides
Overview

  • Develop with AI

    Best practices

  • Query optimization

    Paginating with GROQ
    High performance GROQ

  • Roles and authentication

    Setting up Single Sign-On with SAML
    Third-Party Login (Deprecated)
    OAuth2
    Set up SSO authentication with SAML and Azure/Entra ID
    Set up SSO authentication with SAML and PingIdentity
    Set up SSO authentication with SAML and JumpCloud
    Reconcile users against internal systems
    Restrict Access to Specific Documents
    Setting up a Default Relay State for IdP Initiated - SAML Logins

  • Structured content

    Scalable navigation patterns
    An opinionated guide to Sanity Studio
    Browsing Content How You Want with Structure Builder
    Deciding on fields and relationships
    Create richer array item previews
    Dynamic folder structure using the currentUser and workflow states
    Create a time duration object field
    Level up Your Edit Modal with Next/Previous Navigation Buttons for Array Items
    Create a “coupon generator” string field input
    Managing redirects with Sanity
    Create a document form progress component
    Create an array input field with selectable templates
    Creating a Parent/Child Taxonomy
    Create interactive array items for featured elements
    Create a visual string selector field input
    Create a survey rating number field input
    How to use structured content for page building
    Create a recycling bin for deleted documents via Sanity Functions

  • Frontend integration

    Add live content to your application
    Forms with Sanity
    Vercel Integration
    Build your blog with Astro and Sanity
    How to implement front-end search with Sanity

  • Ecommerce

    Displaying Sanity content in Shopify
    Sanity Connect for Shopify
    Custom sync handlers for Sanity Connect

  • Integrating with other services

    A/B testing with Sanity and Growthbook
    Cookie consent integrations with Sanity
    Integrating external data sources with Sanity
    Klaviyo (email campaigns)
    Developing with Next.js on GitHub Codespaces

  • Adopting Sanity

    How to pitch Sanity.io to your team
    Convincing your clients to go with Sanity.io, rather than a traditional CMS
    Not-profit plan
    Agencies: Navigating the Spring 2025 Organization Changes
    How to generate massive amounts of demo content for Sanity
    How to implement Multi-tenancy with Sanity

  • GROQ

    GROQ-Powered Webhooks – Intro to Filters
    GROQ-Powered Webhooks – Intro to Projections

  • Portable Text

    Presenting Portable Text
    Add Inline blocks for the Portable Text Editor
    Beginners guide to Portable Text
    How to add custom YouTube blocks to Portable Text
    Converting Inline Styles to Sanity Block Decorators
    Add things to Portable Text
    Change the height of the PTE

  • Community and ecosystem

    Create your own Sanity template
    Community guides
    Community Code of Conduct
    Contribute to the ecosystem

  • Plugin development

    Migrating plugins to support Content Releases

On this page

Previous

GROQ-Powered Webhooks – Intro to Filters

Next

Presenting Portable Text

Was this page helpful?

On this page

  • Sending eloquent webhook payloads with projections
  • Have it your way
  • Delta-GROQ
  • In conclusion
Developer guidesLast updated September 24, 2025

GROQ-Powered Webhooks – Intro to Projections

A thorough intro to using GROQ-projections in a webhook contest

This developer guide was contributed by Knut Melvær (Head of Developer Community and Education) and Martin Jacobsen (Technical Writer at Sanity.io).

GROQ-powered webhooks in Sanity enable you to shape the payload of your outgoing requests to your exact specifications. In this article, we'll take a closer look at projections and how they can help you communicate clearly with whatever endpoint your webhook is talking to.

Sending eloquent webhook payloads with projections

The two GROQ super-powers webhooks have are filters and projections. The former lets you get real nitty-gritty about when your webhooks should trigger, and is discussed in an article of its own. In this article, we'll be discussing the latter.

GROQ is Sanity's open-source query language. Check out the docs, or watch a video tutorial to get started! When you're good to go, come back and check out the rest of this article!

Have it your way

GROQ projections let you build a JSON data structure using the document which triggered the webhook, and its values – from before and after the change – as building blocks. In your projections, you have access to the document returned from the filter in its entirety including both the original and updated values of every field. With the additional capability to join references multiple levels deep, you have the tools you need to design your outgoing requests to your specifications.

For those already familiar with GROQ; the projection is the bit that usually comes after the filter.

*[/* Filter goes here */] {
  // Projection goes here
  title,
  tags,
}

When we're configuring webhooks, the filter goes into a box of its own, and we are left with only the stuff in curly braces. An example of a GROQ projection with a simple join might look something like this:

{ 
  // returns the values of fields 'title' and 'description'
  // and follows the reference to another document of type
  // 'author' and assigns its 'name' value to 'authorName'
  title,
  description,
  "authorName": author->name 
}

The projection above would result in the JSON being sent as the payload of your webhook request being shaped something like this:

{
  "authorName": "Sinjoro Ajnulo",
  "description": "Lorem ipsum dolor sit amet",
  "title": "Hello, World!"
}

You can use string concatenation to shape your values into the format you need.

{
  // creates a string value assigned to 'status'
  "status": "The price of " + name + " was updated to " + price + "!"
}

Projecting array values works as expected. As does employing the ellipsis operator to retrieve all fields.

{
  // retrieves all fields
  ...,
  // returns an array of asset urls i.e 
  // ["http://cdn.sanity.io/…", "http://cdn.sanity.io/…"]
  "imageUrls": images[].asset->url
}

Gotcha! Projections in webhooks do not 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] }

Delta-GROQ

Projections also support the before() and after() functions. These are part of the extension to the GROQ language called Delta-GROQ which lets you reason about changes made to documents. You might set a filter to trigger whenever the price field is updated and add the following projection (in this example, price is presumed to be a number):

{ 
  _id,
  "status": 
    "Price of " 
      + name 
      + " was changed from " 
      + string(before().price)
      + " to " 
      + string(after().price)
      // ⬆Tip: GROQ happily ignores line-breaks and comments, so feel
      // free to make your filters and projections a bit more readable
}

The returned values of the before() and after()-functions can take projections as well! In fact they contain the document in its entirety in its pre-updated and updated versions, respectively.

{
  "beforeValues": before(){ name, price, description },
  "afterValues": after(){ name, price, description },
}

If you plan to receive webhooks from multiple projects and datasets, you can use the sanity::-namespace in GROQ to include information about the origins of the request:

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

In conclusion

In this article we've looked at how GROQ-projections allow the precise construction of webhook payloads. This enables you to think of any API endpoint as a potential recipient for your webhooks, as you can shape the request to fit the format of the recipient. Things that previously would have needed some middleman cloud function to reformat your data to fit the need of the service in question can now be done entirely with projections. For more inspiration on GROQ, have a look over here!

*[/* Filter goes here */] {
  // Projection goes here
  title,
  tags,
}
{ 
  // returns the values of fields 'title' and 'description'
  // and follows the reference to another document of type
  // 'author' and assigns its 'name' value to 'authorName'
  title,
  description,
  "authorName": author->name 
}
{
  "authorName": "Sinjoro Ajnulo",
  "description": "Lorem ipsum dolor sit amet",
  "title": "Hello, World!"
}
{
  // creates a string value assigned to 'status'
  "status": "The price of " + name + " was updated to " + price + "!"
}
{
  // retrieves all fields
  ...,
  // returns an array of asset urls i.e 
  // ["http://cdn.sanity.io/…", "http://cdn.sanity.io/…"]
  "imageUrls": images[].asset->url
}
{ 
  _id,
  "status": 
    "Price of " 
      + name 
      + " was changed from " 
      + string(before().price)
      + " to " 
      + string(after().price)
      // ⬆Tip: GROQ happily ignores line-breaks and comments, so feel
      // free to make your filters and projections a bit more readable
}
{
  "beforeValues": before(){ name, price, description },
  "afterValues": after(){ name, price, description },
}
{ 
  _id, 
  _type, 
  "projectId": sanity::projectId(), 
  "dataset": sanity::dataset() 
}