@shopkit/webhooks
v0.5.1
Published
Webhook utilities for Shopify storefront applications
Readme
@shopkit/webhooks
Webhook utilities for Shopify storefront applications. Handles webhook verification, cache invalidation, and Next.js page revalidation.
Installation
bun add @shopkit/webhooksQuick Start
import { handleShopifyWebhook } from "@shopkit/webhooks";
// In your Next.js API route
export async function POST(request: Request) {
const response = await handleShopifyWebhook(request, {
secret: process.env.SHOPIFY_WEBHOOK_SECRET!,
});
return Response.json(response, {
status: response.success ? 200 : 400,
});
}Features
- HMAC Verification: Validates Shopify webhook signatures (SHA-256)
- Cache Invalidation: Automatically determines which caches to invalidate based on entity type
- Page Revalidation: Triggers Next.js ISR revalidation for affected pages
- Entity Support: Products, collections, and inventory updates
Supported Webhooks
| Entity | Events | Revalidation Path |
|--------|--------|-------------------|
| Product | create, update, delete | /products/{handle} |
| Collection | create, update, delete | /collections/{handle} |
| Inventory | update | Requires product context |
Cache-tag taxonomy — who emits, who purges
On-demand invalidation is a two-sided contract: a purge only reaches a page if the
read that built the page attached the same tag. Both sides import the strings from
@shopkit/core/cache-tags, and both sides are covered by an invariant test, so the
table below cannot silently drift:
@shopkit/data-layer→custom-cache-tag-coverage.test.tsforces every client method to be classified as a tagged read or as untagged-by-design.@shopkit/webhooks→cache-tag-coverage.test.tsfails if any tag in the taxonomy has no event that purges it, or if the planner invents a tag outside the taxonomy.
| Tag | Emitted by (custom adapter read) | Purged by |
|---|---|---|
| product:{handle} | getProduct, getProductsByHandles | product.*, inventory.updated, pricing.updated, products.bulk_updated |
| collection:{handle} | getCollection, getCollectionsByHandles, getCollectionFilters | collection.*, product.* (via the payload's collections), collections.bulk_updated |
| collections | getCollection, getCollections, getCollectionsByHandles | collection.created/.deleted/rename, collections.bulk_updated |
| products | getProducts (lists, sitemaps, search endpoints) | product.created/.deleted/rename, products.bulk_updated |
| search | nothing — see below | nothing, deliberately |
| nav-menu | @shopkit/navigation menu fetch | navigation.updated |
| seo | @shopkit/seo remote-config fetch | seo.updated |
Two rules explain every row:
- Entity events purge entity tags; set-changing events also purge the broad tag. A product being created, deleted or renamed changes which URLs exist, and list surfaces (sitemap, search, PLP rails) carry only the broad tags. An ordinary edit does not: its entity tag already reaches every surface that rendered it.
- High-frequency events stay entity-scoped.
inventory.updatedandpricing.updatedarrive on a schedule, so they never fire broad tags — that would keep every list page rebuilding. Bulk events do fire them, because the handles a bulk delivery reports are not a guarantee of everything that moved.
cache.clear_all appears in no row: it purges by layout scope
(revalidatePath("/", "layout")), which reaches more than any tag list could. Next
derives an implicit _N_T_/layout tag for every page, and a cache entry is validated
against its own tags union the reading request's implicit tags — so rendered pages and
the fetch entries behind them both go, without submitting a single entity tag.
CacheTags.search() is in the taxonomy but unused on both sides: search results are
read through getProducts, which already carries products, so a search purge could
never reach anything products does not. Purging a tag no read attaches is the failure
that got the search.reindex event removed — it returns 200 having invalidated
nothing, and the "purged nothing" guard cannot see it because a tag was submitted.
The coverage test records it as unproduced rather than wiring it.
Per-shopper reads (cart, orders, customers) and every mutation carry no tags by design: a tag implies a shared cache entry, and sharing a cart between shoppers is a data leak rather than a stale page.
Not covered: the Shopify (direct Storefront API) adapter emits no tags at all. Its
requests go through @shopify/storefront-api-client, which owns the fetch call and
accepts no Next.js cache options, so tagging it needs a customFetchApi shim first.
Every storefront in production runs the custom adapter (NEXT_PUBLIC_PLATFORM=custom).
Configuration
handleShopifyWebhook(request, {
secret: process.env.SHOPIFY_WEBHOOK_SECRET!,
// Additional options as needed
});Environment Variables
| Variable | Description |
|----------|-------------|
| SHOPIFY_WEBHOOK_SECRET | Shopify webhook HMAC secret for signature verification |
Peer Dependencies
next>= 14.0.0
Architecture
See ARCHITECTURE.md for detailed system design.
