---
title: Webhooks
description: Shopify webhook handler that invalidates Next.js cache tags when products, collections, or CMS metaobjects change.
type: guide
---

# Webhooks



Read paths in the template are cached aggressively with `"use cache"` or `"use cache: remote"` and `cacheLife("max")`. Without an invalidation signal, edits in Shopify Admin won't surface until the cache expires. The webhook handler at `/api/webhooks/shopify` closes that loop: Shopify posts a topic, the handler verifies the signature, and `revalidateTag()` invalidates the affected cache tags.

The handler is opt-in through `SHOPIFY_WEBHOOK_SECRET`. When the secret is unset, `POST /api/webhooks/shopify` returns `404` before reading the request body. Set the secret and register webhooks in Shopify Admin to enable the endpoint in any environment.

## How it works

**Fine-grained tags cascade rather than one broad flush per resource type.** The handler never fires the broad `products` or `collections` tags — those wrap *every* read of their type and exist as manual break-glass levers (see below). It relies on fine-grained tags instead, which is sufficient because the reads in `lib/shopify/operations/` stamp each aggregate cache entry with the granular tag of every item it contains — `tagProducts()` adds a `product-{numericId}` tag per product on list/search/collection/recommendation reads, `getCollections`/`getCollectionsListing` add a `collection-{handle}` tag per collection, and sitemap shards add the handle tag for every product or collection they contain:

* **Product topics** fire per-product tags. Invalidating one product cascades to exactly the surfaces that display it, including the sitemap shard carrying its `product-{handle}` tag. The numeric tag is derived from `admin_graphql_api_id` (falling back to the payload `id`) so reads that cache by ID are invalidated alongside handle-based PDP reads. Create and delete additionally fire **`products-index`**, which refreshes sitemap page counts and all product shards because their membership may shift.
* **Collection topics** fire the `collection-{handle}` tag, which busts that collection's PLP reads — `getCollection` (metadata) and `getCollectionProducts` (the product list, including membership and sort changes) — and, because aggregate reads stamp every collection they return with that same tag, the all-collections listing and the affected sitemap shard whenever an existing collection is edited. Create and delete change the *set* of collections (and a brand-new collection has no tag on already-cached entries), so they additionally fire **`collections-index`** — a tag carried by the all-collections reads (`getCollections`, `getCollectionsListing`) and every collections sitemap count or shard. So create/delete refresh the listing, page counts, and shifted shards without touching any individual collection's PLP. Product cards inside a collection list are independently covered by their own `product-{numericId}` tags via the product webhook. Navigation menus aren't busted here; menu titles and links are edited independently in Shopify Navigation, and there is no menu webhook topic.

> **Index and break-glass tags.** `products-index` refreshes product sitemap counts and shards when membership changes. `collections-index` does the same for collection sitemaps and also refreshes the all-collections listing. The broader `collections` tag sits on every collection read, and `products` sits on every product read, but neither broad tag is fired automatically. They're manual purge levers — call `revalidateTag("collections")` (or `"products"`) to flush all data of that type at once, e.g. after a bulk import or a Storefront API change that touches everything. Routine edits should never need them.

> **No `inventory_levels/*` branch.** The inventory webhook payload identifies an `inventory_item_id` and `location_id`, not a product — so it can't be scoped to a per-product tag without an extra lookup, and busting the catalog on every stock tick would destroy cache hit rates. The template intentionally omits this branch; availability propagates at cache expiry (or via any live, uncached reads you add). Registering the webhook anyway is harmless — it hits no branch and returns an empty `tagsInvalidated`.

> **No `pages/*`, `blogs/*`, or `articles/*` branch.** Shopify exposes no webhook topics for Online Store pages, blogs, articles, or store policies — only metaobjects (the CMS) emit content webhooks — so there is nothing for the handler to catch. Those reads rely on cache lifetime instead: pages, blogs, articles, and policies all use `cacheLife("max")`, so they surface edits only via a manual `revalidateTag` purge or a redeploy. See [Content pages](/docs/anatomy/pages/content) for the temporary workaround.

**Metaobjects trade per-type index tags for a cheap broad flush.** Metaobjects are tagged like every other resource: a broad `metaobjects` tag and a `metaobject-{handle}` tag built from the payload `handle` — no `type` inspection. Unlike the catalog, the handler *does* fire the broad `metaobjects` tag (alongside the handle tag) because metaobjects are typically low-cardinality, so refreshing every metaobject read on any change is cheap and spares them from needing per-type index tags — the same trade the `articles` branch makes. The base template has no metaobject reads; these tags matter only if you read metaobjects yourself — tag those reads with `metaobjects` (and `metaobject-{handle}` where a handle is in scope) so this branch invalidates them.

**The handler validates before it trusts anything in the payload.** On each request:

1. Returns `404` immediately if `SHOPIFY_WEBHOOK_SECRET` is unset
2. Reads the raw body as text — required for stable HMAC verification
3. Computes the HMAC-SHA256 of the body and compares it to `x-shopify-hmac-sha256` using `crypto.timingSafeEqual`. A missing, malformed, or mismatched signature returns `401`.
4. Reads `x-shopify-topic`. Missing topic returns `400`.
5. Dispatches on the topic prefix (`products/`, `collections/`, or `metaobjects/`) and builds a tag list from the payload
6. Calls `revalidateTag()` for each affected tag
7. Returns `{ success, topic, tagsInvalidated }` for log inspection

Payload parsing is wrapped in `try`/`catch`. Metaobject topics seed the broad `metaobjects` tag before parsing, so it still fires on a parse failure. Product and collection create/delete topics likewise seed their narrow index tag before parsing, ensuring sitemap membership is refreshed even if the resource identifiers cannot be read. Update topics need a parseable `handle` or product ID to scope invalidation; malformed update payloads log and invalidate nothing rather than busting the whole catalog. Real Shopify webhooks carry parseable payloads, so this distinction mainly matters for malformed test posts.

> **Security note:** The secret is the enablement gate, not only a verification option. An unconfigured deployment exposes no active webhook processor; a configured deployment accepts only valid Shopify-signed requests.

## Out of the box

A single route (`POST /api/webhooks/shopify`) handles every supported topic. Shopify sets the topic on each request via the `x-shopify-topic` header, and the handler dispatches to the appropriate tag set:

| Webhook topic        | Tags invalidated                                                                        |
| -------------------- | --------------------------------------------------------------------------------------- |
| `products/create`    | `products-index`, `product-{handle}`, `product-{numericId}`, `recommendations-{handle}` |
| `products/update`    | `product-{handle}`, `product-{numericId}`, `recommendations-{handle}`                   |
| `products/delete`    | `products-index`, `product-{handle}`, `product-{numericId}`, `recommendations-{handle}` |
| `collections/create` | `collections-index`, `collection-{handle}`                                              |
| `collections/update` | `collection-{handle}`                                                                   |
| `collections/delete` | `collections-index`, `collection-{handle}`                                              |
| `metaobjects/*`      | `metaobjects`, `metaobject-{handle}`                                                    |

**Setting up webhooks in Shopify:**

1. Open **Shopify Admin → Settings → Notifications → Webhooks**
2. Set the URL to `https://your-domain.com/api/webhooks/shopify`
3. Choose **JSON** as the format
4. Create a webhook for each topic in the table above
5. Copy the webhook signing secret and set it as `SHOPIFY_WEBHOOK_SECRET` in your environment

You can register only the topics you care about. Skipping `collections/*`, for example, just means collection edits propagate at cache expiry instead of immediately. The handler has no `inventory_levels/*` branch (see above), so there's no need to register that topic.

**Environment variables:**

| Variable                 | When to set                                                                                                                                              |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SHOPIFY_WEBHOOK_SECRET` | Set in every environment where Shopify posts to `/api/webhooks/shopify`. Without it, the endpoint is disabled and returns `404` before reading the body. |

See [Environment Variables](/docs/reference/env-vars) for the full reference.

**Verifying it works:** with `SHOPIFY_WEBHOOK_SECRET` unset, confirm the endpoint is disabled without sending a body:

```bash
curl -i -X POST http://localhost:3000/api/webhooks/shopify
```

The response should be `404`. After setting the secret, an unsigned request should return `401`. To verify successful processing with a valid signature, use the **Send test notification** button on each webhook in Shopify Admin and watch your function logs for the received topic and invalidated tags.

## Common customizations

**Adding a new topic:**

1. Register the webhook in Shopify Admin pointing at `/api/webhooks/shopify`
2. Add a branch in `app/api/webhooks/shopify/route.ts` that matches the topic prefix and pushes the cache tags you want to invalidate
3. Confirm the tags you're invalidating actually wrap the reads you care about — `cacheTag("...")` calls inside `lib/shopify/operations/` and `lib/cms/`

If the new topic touches data not currently cached by tag, add a `cacheTag()` to the operation that reads it before the webhook will have any effect.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)