A merchant changes a price in the Shopify admin, reloads the headless storefront, and sees the old price. Headless builds cache catalog data so pages render without calling Shopify on every request, and nothing tells that cache when the admin changes.
Shopify webhooks close the gap, and wiring them up takes one route handler, six topics, and two cache tags. This guide covers subscribing so subscriptions survive failures, verifying delivery before anything parses it, and deciding when the work behind a handler justifies a queue.
Key takeaways:
Subscriptions declared in
shopify.app.tomlsurvive delivery failures, while ones created through the GraphQL Admin API are removed if deliveries keep failing.Signature verification only passes if the handler reads the raw body before any parser touches it, because re-serialized JSON no longer matches what Shopify hashed.
Next.js 16 deprecated the single-argument
revalidateTag(tag), so Route Handlers now pass a cache life profile that keeps serving cached pages while regeneration runs.A handler that verifies a signature, expires one cache tag, and returns 200 needs no queue, since Fluid compute bills active CPU rather than wall-clock time.
Shopify retries a failed delivery eight times over four hours, so deduplication needs
X-Shopify-Webhook-Id, the per-delivery key, and somewhere to store it.
Copy link to headingWhat are Shopify webhooks in a headless storefront?
A Shopify webhook is a notification Shopify sends over HTTP when a subscribed event happens in a store. Shopify serializes the affected resource using the Admin API version the subscription is pinned to, signs the payload, and POSTs it to the URI you registered.
Topics name a resource and an action together, so products/update fires when an existing product changes and products/create fires when a new one appears.
The first handler a team writes usually treats the payload as the thing to act on. It parses the product, compares it against stored state, and writes the result to a database. Maintaining that second copy of the catalog is work a headless storefront doesn't need, since the copy can drift from the version the Storefront API already serves to every page.
The one field the handler needs is the x-shopify-topic header, which names what changed so the cache knows what to expire.
Copy link to headingMap Shopify webhook topics to cache tags
A storefront doesn't need a subscription per resource type. Six topics cover the whole catalog, and all six collapse into two cache tags:
Variant edits and price changes arrive as products/update, and inventory movements are expected to fire it as well. Shopify doesn't guarantee webhook delivery, so treat that topic as the trigger for stock changes and run periodic reconciliation for deliveries that go missing.
Shopify emits no topic for page content updates, so storefront pages backed by Shopify Pages rely on time-based expiry or a manual trigger.
Copy link to headingKeep Shopify webhook subscriptions from stopping after repeated failures
Shopify offers two ways to register a subscription, and only one of them is still in place after your endpoint goes down. Subscriptions declared in shopify.app.toml and pushed with shopify app deploy cover every shop that installs the app, and Shopify keeps them through failed deliveries.
Subscriptions created per shop through the GraphQL Admin API webhookSubscriptionCreate mutation are removed if failures persist after Shopify's retries, which run up to eight times over four hours.
That deletion is how a storefront ends up serving week-old prices with nothing in your own logs to explain it. No errors fire, deliveries stop, and the next signal is a merchant asking why a sale price never went live.
The file is the right default, and the mutation earns its place only when topics or delivery URLs differ from one shop to the next.
Declaring subscriptions in the file requires a current version of the Shopify command-line interface (CLI), plus one [[webhooks.subscriptions]] block per subscription:
[webhooks]api_version = "2026-07"
[[webhooks.subscriptions]] topics = [ "products/create", "products/update", "products/delete", "collections/create", "collections/update", "collections/delete" ] uri = "https://your-storefront.example.com/api/webhooks/shopify"
[[webhooks.subscriptions]] compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"] uri = "https://your-storefront.example.com/api/compliance"One api_version covers every subscription in the file. Shopify serializes payloads with that version, so raising it can rename or restructure the fields your handler reads. A version bump is a code change with a test behind it, not a housekeeping edit.
Compliance topics are the one case where the file isn't optional. customers/data_request, customers/redact, and shop/redact can't be registered through the Admin API at all, and they go in a compliance_topics field rather than in topics.
That second endpoint receives personal data, and Shopify signs its deliveries the same way it signs the rest. It gets the same verification the revalidation route gets, since an unauthenticated handler there accepts anyone's redaction request as though Shopify sent it.
Copy link to headingWhat you need before writing a Shopify webhook handler
Most of the friction in a Shopify webhook handler comes from version and plan gating rather than from code. These are the pieces to confirm before the first deploy:
None of those takes a NEXT_PUBLIC_ prefix. Next.js inlines prefixed values into the JavaScript it sends to the browser, and nothing in the browser reads any of them. The .env.local file holding them during development stays out of version control.
Copy link to headingIdentify which secret verifies webhook signatures
SHOPIFY_CLIENT_SECRET is the client secret listed with your app's credentials in the Shopify Dev Dashboard, which Shopify's own app templates store as SHOPIFY_API_SECRET. It is not an Admin API access token.
An Admin API token can edit orders and delete products, and it has no place in a storefront project at all, in this variable or any other. Shopify supports rotating the client secret after launch, so a suspected leak can be fixed without rebuilding the app.
SHOPIFY_STOREFRONT_ACCESS_TOKEN needs care for a different reason. The Headless channel issues a public token and a private token with near-identical names, differing only by the header that carries them.
The public one travels in X-Shopify-Storefront-Access-Token and the private one in Shopify-Storefront-Private-Token, which must never reach a browser. A storefront's permissions should cover only what its pages read, and the private token can be rotated from the Headless channel when it changes hands.
The vercel/commerce template also uses a SHOPIFY_REVALIDATION_SECRET to keep setup simple. A handler that verifies signatures can leave it out. With those in place, the handler is short enough to read on one screen. Getting it right depends almost entirely on what it does before it parses anything.
Copy link to headingHow to verify Shopify webhooks before parsing the payload
Shopify gives a handler five seconds for the whole request, and anything outside the 200 range counts as a failure. A 301 from a trailing-slash redirect counts too.
Failed deliveries get retried eight times over four hours with exponential backoff, each retry carrying the original payload from the moment the event fired. Signature verification must fit inside that window, and it must run before anything parses the payload.
Copy link to headingOrder the steps that make signature checks pass
Every delivery arrives with an X-Shopify-Hmac-Sha256 header carrying a hash-based message authentication code (HMAC). Verify the raw-body HMAC using the signing secret associated with the webhook subscription. Shopify produces it by hashing the exact bytes of the request body with SHA-256, keyed on your app's client secret, then base64-encoding the result. Your handler recomputes that digest from the bytes it received and compares the two.
The comparison only works if both sides hash identical bytes. Middleware that parses the body into JavaScript Object Notation (JSON) and serializes it again can reorder keys or drop whitespace, and once the bytes differ the digests differ. At that point a legitimate delivery looks identical to a forged one.
Verification passes when five operations run in this order:
Read the raw body with
await request.text(). Route Handlers don't parse the body for you, and a request body can only be read once, so this has to happen before anything else touches the request.Read the
x-shopify-hmac-sha256header, and return 401 when it's missing. An unsigned request has nothing to verify against.Hash the raw body with
createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET).update(rawBody, 'utf8').digest('base64'). The key is the app's client secret, not the API key, and the digest has to be base64 to match the header.Decode the header value and your own digest from base64 into buffers, then check that the two are the same length.
crypto.timingSafeEqualthrows aRangeErroron buffers of different lengths.Compare the buffers with
crypto.timingSafeEqual, which takes the same time whether the digests match on the first byte or the last, and return 401 when it comes back false. Parse the body only after that comparison succeeds.
Setting runtime = 'edge' is deprecated in Next.js 16, and the Node.js runtime is the default for Vercel Functions. The old Web Crypto detour goes away with it, so node:crypto gives you createHmac and timingSafeEqual with no polyfill:
import crypto from 'node:crypto';
export async function POST(request: Request) { const rawBody = await request.text();
const hmacHeader = request.headers.get('x-shopify-hmac-sha256'); if (!hmacHeader) { return new Response('Unauthorized', { status: 401 }); }
const computedDigest = crypto .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET!) .update(rawBody, 'utf8') .digest('base64');
const hmacBuffer = Buffer.from(hmacHeader, 'base64'); const computedBuffer = Buffer.from(computedDigest, 'base64');
const isValid = hmacBuffer.length === computedBuffer.length && crypto.timingSafeEqual(hmacBuffer, computedBuffer);
if (!isValid) { return new Response('Unauthorized', { status: 401 }); }
// Signature verified. Revalidation goes here, covered in the next section. return new Response('OK', { status: 200 });}The vercel/commerce template keeps setup simple by appending a shared secret to the webhook address as a query parameter and comparing it to SHOPIFY_REVALIDATION_SECRET.
That works for getting started. For production, HMAC verification is the stronger choice, because query strings can end up in access and proxy logs, while the HMAC secret never travels with the request at all.
HMAC verification also proves the payload came from Shopify, not just that the caller knows a URL. Once the subscription belongs to a real app, ship it.
Shipping the route with no check at all is the worse outcome, and it's an easy one to reach, because a handler that only expires a cache tag looks harmless. Anyone who finds the URL can force regeneration on every tagged path, for as long as they keep calling, and each round bills ISR writes and function invocations.
Copy link to headingAvoid common Shopify webhook verification failures
One failure here has nothing to do with the code. Subscriptions created by hand in the Shopify admin aren't signed with the app's client secret, so they fail verification against every key you try. The rest of what commonly breaks does live in the code:
Once the signature passes, the rest of the handler is a lookup and a single call.
Copy link to headingHow to trigger revalidation from a Shopify webhook
The handler reads x-shopify-topic, maps it to a tag, and expires that tag.
Here is the complete route, using the topic mapping from the vercel/commerce template with signature verification in place of its query-string secret:
import crypto from 'node:crypto';import { revalidateTag } from 'next/cache';
const TAGS = { products: 'products', collections: 'collections' };const collectionWebhooks = ['collections/create', 'collections/update', 'collections/delete'];const productWebhooks = ['products/create', 'products/update', 'products/delete'];
export async function POST(request: Request) { const rawBody = await request.text();
const hmacHeader = request.headers.get('x-shopify-hmac-sha256'); if (!hmacHeader) { return new Response('Unauthorized', { status: 401 }); }
const computedDigest = crypto .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET!) .update(rawBody, 'utf8') .digest('base64');
const hmacBuffer = Buffer.from(hmacHeader, 'base64'); const computedBuffer = Buffer.from(computedDigest, 'base64');
const isValid = hmacBuffer.length === computedBuffer.length && crypto.timingSafeEqual(hmacBuffer, computedBuffer);
if (!isValid) { return new Response('Unauthorized', { status: 401 }); }
const topic = request.headers.get('x-shopify-topic') ?? 'unknown';
if (collectionWebhooks.includes(topic)) { revalidateTag(TAGS.collections, 'seconds'); } if (productWebhooks.includes(topic)) { revalidateTag(TAGS.products, 'seconds'); }
return Response.json({ revalidated: true, now: Date.now() });}The handler marks tagged data as stale. Subsequent requests trigger revalidation and may receive cached content while it runs. For price changes that require the next request to wait for fresh data, use revalidateTag(tag, { expire: 0 }).
The tag names only work if your data functions set the same tags with cacheTag('products') and cacheTag('collections'). A handler that expires a tag that carries nothing expires nothing. The response is 200 even when the topic matches nothing, because any other status puts Shopify into a retry loop for an event the storefront was never going to act on.
Copy link to headingUpdate revalidateTag calls for Next.js 16
revalidateTag takes two arguments in Next.js 16. The first names the tag to expire. The second is a cache life profile, and it decides what visitors get served while fresh data loads in the background.
With Cache Components enabled in Next.js 16, use 'use cache' to cache a function, cacheTag() to assign tags, and cacheLife() to set its lifetime. Applications using the previous caching model can continue tagging cached fetch requests.
Calling it with one argument expires the entry outright, so the next request waits on a full cache miss. A profile keeps the cached copy in circulation until the new one is ready. Next.js documents 'max' as the recommended profile, and vercel/commerce uses 'seconds' for a shorter stale window. updateTag is the one to use in Server Actions, where a user needs to read their own write.
Copy link to headingWhen a Shopify webhook handler needs a queue
Standard advice for any webhook consumer is to return 200 immediately and push all work onto a queue, on the grounds that five seconds is too short for real processing. For work bound by the central processing unit (CPU), that advice holds.
For a handler whose entire job is one revalidateTag call, it adds a consumer, a queue, and an idempotency store. Three new ways to fail, in exchange for latency headroom the handler was never going to use.
The cost argument for queueing doesn't survive Fluid compute either. A handler that hashes a payload and expires a tag bills for a few milliseconds of execution, and nothing for the time it spends waiting.
When lightweight work shouldn't delay the 200, there are two in-process options. after() from next/server (Next.js 15.1 and later) and waitUntil() from @vercel/functions both run work after the response leaves, inside the same function lifecycle.
Neither survives a crash or a redeploy. They defer work without making it durable, which is a meaningful distinction when the deferred work is the only record that an event happened.
A real queue earns its place in three situations:
CPU-bound work: Image processing or large aggregations bill Active CPU for their whole run and can outlast the five-second window. A consumer takes them off the delivery path entirely.
Work that has to outlive the request: A crash or a redeploy ends anything deferred in-process. Vercel Queues (public beta) and Vercel Workflows redeliver and resume across both, which is the line between deferred and durable.
Bursts that repeat the same work: A bulk catalog edit can fire hundreds of near-simultaneous deliveries. Idempotency keys applied at publish time drop repeats before any consumer starts, rather than after each handler begins its own check.
A queue buys durability but adds a second system to operate and debug. Add it when the work is durable work, not because the word webhook appeared in a design doc.
Copy link to headingHow Vercel runs Shopify webhook handlers in production
A working handler is a few dozen lines of application code. What determines whether it holds up during a flash sale is the layer underneath it.
Copy link to headingExpire cache tags without rebuilding the storefront
Rebuilding the site on every catalog edit is the fix teams reach for first, and it turns a one-second invalidation into a multi-minute deploy. A busy catalog can trigger that dozens of times an hour.
Incremental Static Regeneration (ISR) purges the HTML and data payloads for every path with an expired tag, then pushes fresh content to every CDN region. Caches converge within 300ms, with no rebuild anywhere.
Visitors keep getting the cached version while regeneration runs in the background, and a failed regeneration leaves the stale content in place rather than serving an error. PAIGE ran this architecture through Black Friday Cyber Monday 2024 after moving from commercetools and Angular to Shopify, Next.js, and Vercel, with conversion rates up 76%.
Copy link to headingBill for execution instead of wall-clock time
Developers building a first webhook handler on serverless infrastructure tend to assume every millisecond of wall-clock time gets billed, and that assumption is what pushes a two-line handler onto a queue. Fluid compute prices active CPU, provisioned memory, and invocations separately, so execution time and elapsed time land on different meters.
The design space widens as a result. Verifying a signature inline, calling the Storefront API when a handler needs fresh data, and returning a 200 all become reasonable choices rather than latency risks to engineer around.
Copy link to headingConfirm which webhook deliveries landed
Nothing inside your own application reports a subscription Shopify has deleted, so the check has to come from outside it. The Monitoring page in the Shopify Dev Dashboard shows delivery counts, failed delivery rates, and 90th-percentile response time per topic over the past seven days.
Individual deliveries sit on the Logs page. Shopify notes that those delivery logs aren't real-time and can lag by several minutes, so read them as a trend rather than a live feed.
Runtime Logs are the record for your side of the exchange, showing each invocation's start type and duration. Retention runs one hour on Hobby, one day on Pro, and 30 days with Observability Plus.
Structured JSON logging makes the webhook identifier and revalidation status filterable, and vercel logs --query "revalidated" --since 1h pulls them from the CLI.
Copy link to headingCheck the time budget before spending it
Handlers that call back to Shopify before responding are the ones that drift toward the five-second ceiling, and they usually do it gradually as a catalog grows rather than failing on day one.
The function's own deadline doesn't help here, because it comes from maxDuration, not from Shopify.
Measure from when the request arrived instead, and skip any downstream call the handler can't finish inside the window:
export async function POST(request: Request) { const startedAt = Date.now(); const withinShopifyWindow = () => Date.now() - startedAt < 3_000;
// Verify the signature and expire the tag first. if (withinShopifyWindow()) { // Make the optional call back to Shopify. }
return Response.json({ revalidated: true });}Return success after the required invalidation completes. Skip optional work when time is short; durably enqueue required follow-up work before acknowledging the delivery.
Copy link to headingShip a storefront that matches the admin within seconds
The gap between a merchant changing a price and a shopper seeing it is an infrastructure decision, not a Shopify limitation. Developers who close it treat webhooks as invalidation signals, verify the raw bytes before parsing anything, map the topic to a cache tag, and return 200. Filling that gap with a queue, a consumer, and a deduplication table before the handler needs one means debugging the pipeline instead of the storefront.
Here's what the layer underneath that handler contributes:
ISR with tag-based invalidation: A single expiry reaches the product page, every collection that lists it, and search results at once, then refreshes them worldwide without touching the build.
Fluid compute with Active CPU pricing: Pricing that follows execution rather than elapsed time makes inline verification affordable, which removes the usual reason to reach for a queue.
Vercel Functions on the Node.js runtime: Standard library crypto is available with no polyfill to install and no runtime flag to set.
Runtime Logs and Observability: Per-invocation duration and start type, filterable on structured fields, with 30-day retention through Observability Plus for tracing a delivery that failed last week.
Vercel Queues and Workflows: Durable processing for the day a handler grows past one cache tag, with no rewrite of the endpoint that receives deliveries.
Start a new project to put the pattern in place, or browse Vercel templates for the vercel/commerce storefront with the revalidation handler already wired up.
Copy link to headingFrequently asked questions about Shopify webhooks
Copy link to headingDo Shopify webhooks work on the Vercel Hobby plan?
Yes. Hobby functions run in a single region, Washington, D.C. by default, and Runtime Log retention is one hour. On the Shopify side, a headless build needs Storefront API tokens from the Headless channel, which isn't available on every Shopify plan.
Copy link to headingWhy does Shopify webhook HMAC verification keep failing?
Two causes account for almost all of it. The body reached a parser before it reached the hash, or the API key was used in place of the client secret. A third cause looks identical and can't be fixed in code, since a subscription set up manually in the Shopify admin carries no app signature to check against.
Copy link to headingWhat happens to Shopify webhooks if my endpoint is down?
Shopify retries on exponential backoff for four hours, then stops and discards the event. Nothing replays it afterward. Recovery from a longer outage needs a reconciliation job that polls Shopify with an updated_at filter and expires the affected tags itself.
Copy link to headingShould I use revalidatePath or revalidateTag for Shopify webhooks?
Use revalidateTag when product data is shared across product pages, collections, and search results. revalidatePath can invalidate individual paths, route patterns, or layouts, but tags map more directly to shared catalog data