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

Displaying Sanity content in Shopify

Next

Cookie consent integrations with Sanity

Was this page helpful?

On this page

  • When to use a custom sync handler
  • How custom sync handlers work
  • Example custom sync handler function
Developer guidesLast updated January 9, 2026

Custom sync handlers for Sanity Connect

A custom sync handler allows you to provide an endpoint which receives updates from Shopify and passes data into your content lake. Typically, this will be a serverless function where you can reshape the data from Shopify and apply business logic before it is passed to your content lake.

When to use a custom sync handler

There are a number of scenarios where you may choose to implement a custom sync handler - common examples include:

  • Where you need to apply additional logic to the data - for example, querying additional APIs to retrieve more data (e.g. the Shopify API to get additional metafields)
  • You may want to reduce your document usage on Sanity by only syncing selected products, or syncing variants as an object on product documents rather than variant documents.
  • Where you want to amend the default manner in which Sanity Connect handles a product being deleted on Shopify - by setting isDeleted to true - to fully delete the document from your content lake.

How custom sync handlers work

When enabled, the custom sync handler will send a payload on every update from Shopify as a POST request. You can write your custom business logic in your endpoint and update your content lake accordingly in the function, or respond with a set of documents which Sanity Connect will update for you.

Sanity Connect expects a response header with content-type: application/json and will regard a 200 status code as a success. Any other status code will be considered a failure.

You can find the shape of the payload your handler will receive in our Sanity Connect reference.

Gotcha

The request has a 10s timeout and your handler needs to reply before that. Any failed requests will be retried up to 10 times.

If your handler needs more time to complete updates (for example if it calls a third party API), a common pattern would be to store the payload in a queue for background processing, and respond 200 OK immediately to acknowledge receipt of the payload.

Gotcha

This operation will be batched when manually syncing, especially when dealing with larger catalogs.

Gotcha

Changes in product inventory (through sales) will also trigger updates to your custom handler.

Make sure to tailor your custom handler to account for how our API CDN invalidates cache on writes to non-draft documents, especially if operating on a high traffic stores with fast moving content.

Example custom sync handler function

Below is an example of a barebones custom function that will:

  • Create/update/delete products (including drafts) in the Content Lake on Shopify product operations
  • Only deal with products (variants are included as objects within products)
  • Manual sync will create and update products on your dataset, but will not delete products that have since been removed.

For a more complete example, refer to this gist.

import {createClient} from "@sanity/client";

// Document type for all incoming synced Shopify products
const SHOPIFY_PRODUCT_DOCUMENT_TYPE = "shopify.product";

// Prefix added to all Sanity product document ids
const SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX = "product-";

// Enter your Sanity studio details here.
// You will also need to provide an API token with write access in order for this
// handler to be able to create documents on your behalf.
// Read more on auth, tokens and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = createClient({
  apiVersion: "2021-10-21",
  dataset: process.env.SANITY_DATASET,
  projectId: process.env.SANITY_PROJECT_ID,
  token: process.env.SANITY_ADMIN_AUTH_TOKEN,
  useCdn: false,
});

/**
 * Sanity Connect sends POST requests and expects both:
 * - a 200 status code
 * - a response header with `content-type: application/json`
 * 
 * Remember that this may be run in batches when manually syncing.
 */
export default async function handler(req, res) {
  // Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
  // so manually parsing with `JSON.parse` is unnecessary.
  const { body, method } = req;

  // Ignore non-POST requests
  if (method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const transaction = sanityClient.transaction();
    switch (body.action) {
      case "create":
      case "update":
      case "sync":
        await createOrUpdateProducts(transaction, body.products);
        break;
      case "delete":
        const documentIds = body.productIds.map((id) =>
          getDocumentProductId(id)
        );
        await deleteProducts(transaction, documentIds);
        break;
    }
    await transaction.commit();
  } catch (err) {
    console.error("Transaction failed: ", err.message);
  }

  res.status(200).json({ message: "OK" });
}

/**
 * Creates (or updates if already existing) Sanity documents of type `shopify.product`.
 * Patches existing drafts too, if present.
 *
 * All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
 */
async function createOrUpdateProducts(transaction, products) {
  // Extract draft document IDs from current update
  const draftDocumentIds = products.map((product) => {
    const productId = extractIdFromGid(product.id);
    return `drafts.${getDocumentProductId(productId)}`;
  });

  // Determine if drafts exist for any updated products
  const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
    ids: draftDocumentIds,
  });

  products.forEach((product) => {
    // Build Sanity product document
    const document = buildProductDocument(product);
    const draftId = `drafts.${document._id}`;

    // Create (or update) existing published document
    transaction
      .createIfNotExists(document)
      .patch(document._id, (patch) => patch.set(document));

    // Check if this product has a corresponding draft and if so, update that too.
    if (existingDrafts.includes(draftId)) {
      transaction.patch(draftId, (patch) =>
        patch.set({
          ...document,
          _id: draftId,
        })
      );
    }
  });
}

/**
 * Delete corresponding Sanity documents of type `shopify.product`.
 * Published and draft documents will be deleted.
 */
async function deleteProducts(transaction, documentIds) {
  documentIds.forEach((id) => {
    transaction.delete(id).delete(`drafts.${id}`);
  });
}

/**
 * Build Sanity document from product payload
 */
function buildProductDocument(product) {
  const {
    featuredImage,
    id,
    options,
    productType,
    priceRange,
    status,
    title,
    variants,
  } = product;
  const productId = extractIdFromGid(id);
  return {
    _id: getDocumentProductId(productId),
    _type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
    image: featuredImage?.src,
    options: options?.map((option, index) => ({
      _key: String(index),
      name: option.name,
      position: option.position,
      values: option.values,
    })),
    priceRange,
    productType,
    status,
    title,
    variants: variants?.map((variant, index) => {
      const variantId = extractIdFromGid(variant.id);
      return {
        _key: String(index),
        compareAtPrice: Number(variant.compareAtPrice || 0),
        id: variantId,
        inStock: !!variant.inventoryManagement
          ? variant.inventoryPolicy === "continue" ||
            variant.inventoryQuantity > 0
          : true,
        inventoryManagement: variant.inventoryManagement,
        inventoryPolicy: variant.inventoryPolicy,
        option1: variant?.selectedOptions?.[0]?.value,
        option2: variant?.selectedOptions?.[1]?.value,
        option3: variant?.selectedOptions?.[2]?.value,
        price: Number(variant.price || 0),
        sku: variant.sku,
        title: variant.title,
      };
    }),
  };
}

/**
 * Extract ID from Shopify GID string (all values after the last slash)
 * e.g. gid://shopify/Product/12345 => 12345
 */
function extractIdFromGid(gid) {
  return gid?.match(/[^\/]+$/i)[0];
}

/**
 * Map Shopify product ID number to a corresponding Sanity document ID string
 * e.g. 12345 => product-12345
 */
function getDocumentProductId(productId) {
  return `${SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX}${productId}`;
}
import {createClient} from "@sanity/client";

// Document type for all incoming synced Shopify products
const SHOPIFY_PRODUCT_DOCUMENT_TYPE = "shopify.product";

// Prefix added to all Sanity product document ids
const SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX = "product-";

// Enter your Sanity studio details here.
// You will also need to provide an API token with write access in order for this
// handler to be able to create documents on your behalf.
// Read more on auth, tokens and securing them: https://www.sanity.io/docs/http-auth
const sanityClient = createClient({
  apiVersion: "2021-10-21",
  dataset: process.env.SANITY_DATASET,
  projectId: process.env.SANITY_PROJECT_ID,
  token: process.env.SANITY_ADMIN_AUTH_TOKEN,
  useCdn: false,
});

/**
 * Sanity Connect sends POST requests and expects both:
 * - a 200 status code
 * - a response header with `content-type: application/json`
 * 
 * Remember that this may be run in batches when manually syncing.
 */
export default async function handler(req, res) {
  // Next.js will automatically parse `req.body` with requests of `content-type: application/json`,
  // so manually parsing with `JSON.parse` is unnecessary.
  const { body, method } = req;

  // Ignore non-POST requests
  if (method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }

  try {
    const transaction = sanityClient.transaction();
    switch (body.action) {
      case "create":
      case "update":
      case "sync":
        await createOrUpdateProducts(transaction, body.products);
        break;
      case "delete":
        const documentIds = body.productIds.map((id) =>
          getDocumentProductId(id)
        );
        await deleteProducts(transaction, documentIds);
        break;
    }
    await transaction.commit();
  } catch (err) {
    console.error("Transaction failed: ", err.message);
  }

  res.status(200).json({ message: "OK" });
}

/**
 * Creates (or updates if already existing) Sanity documents of type `shopify.product`.
 * Patches existing drafts too, if present.
 *
 * All products will be created with a deterministic _id in the format `product-${SHOPIFY_ID}`
 */
async function createOrUpdateProducts(transaction, products) {
  // Extract draft document IDs from current update
  const draftDocumentIds = products.map((product) => {
    const productId = extractIdFromGid(product.id);
    return `drafts.${getDocumentProductId(productId)}`;
  });

  // Determine if drafts exist for any updated products
  const existingDrafts = await sanityClient.fetch(`*[_id in $ids]._id`, {
    ids: draftDocumentIds,
  });

  products.forEach((product) => {
    // Build Sanity product document
    const document = buildProductDocument(product);
    const draftId = `drafts.${document._id}`;

    // Create (or update) existing published document
    transaction
      .createIfNotExists(document)
      .patch(document._id, (patch) => patch.set(document));

    // Check if this product has a corresponding draft and if so, update that too.
    if (existingDrafts.includes(draftId)) {
      transaction.patch(draftId, (patch) =>
        patch.set({
          ...document,
          _id: draftId,
        })
      );
    }
  });
}

/**
 * Delete corresponding Sanity documents of type `shopify.product`.
 * Published and draft documents will be deleted.
 */
async function deleteProducts(transaction, documentIds) {
  documentIds.forEach((id) => {
    transaction.delete(id).delete(`drafts.${id}`);
  });
}

/**
 * Build Sanity document from product payload
 */
function buildProductDocument(product) {
  const {
    featuredImage,
    id,
    options,
    productType,
    priceRange,
    status,
    title,
    variants,
  } = product;
  const productId = extractIdFromGid(id);
  return {
    _id: getDocumentProductId(productId),
    _type: SHOPIFY_PRODUCT_DOCUMENT_TYPE,
    image: featuredImage?.src,
    options: options?.map((option, index) => ({
      _key: String(index),
      name: option.name,
      position: option.position,
      values: option.values,
    })),
    priceRange,
    productType,
    status,
    title,
    variants: variants?.map((variant, index) => {
      const variantId = extractIdFromGid(variant.id);
      return {
        _key: String(index),
        compareAtPrice: Number(variant.compareAtPrice || 0),
        id: variantId,
        inStock: !!variant.inventoryManagement
          ? variant.inventoryPolicy === "continue" ||
            variant.inventoryQuantity > 0
          : true,
        inventoryManagement: variant.inventoryManagement,
        inventoryPolicy: variant.inventoryPolicy,
        option1: variant?.selectedOptions?.[0]?.value,
        option2: variant?.selectedOptions?.[1]?.value,
        option3: variant?.selectedOptions?.[2]?.value,
        price: Number(variant.price || 0),
        sku: variant.sku,
        title: variant.title,
      };
    }),
  };
}

/**
 * Extract ID from Shopify GID string (all values after the last slash)
 * e.g. gid://shopify/Product/12345 => 12345
 */
function extractIdFromGid(gid) {
  return gid?.match(/[^\/]+$/i)[0];
}

/**
 * Map Shopify product ID number to a corresponding Sanity document ID string
 * e.g. 12345 => product-12345
 */
function getDocumentProductId(productId) {
  return `${SHOPIFY_PRODUCT_DOCUMENT_ID_PREFIX}${productId}`;
}