Skip to content
Dashboard

How Shopify webhooks keep storefronts in sync

Edgenivo

Changing a price in Shopify doesn't automatically refresh a headless storefront's cache. The old price can remain visible until cached data expires or is revalidated.

Shopify webhooks notify your application about catalog changes. After verifying each delivery, a Route Handler can invalidate the matching cache tag so later requests retrieve updated data.

Copy link to headingWhat are Shopify webhooks in a headless storefront?

Shopify webhooks notify an HTTP endpoint when a subscribed store event occurs. Shopify serializes the affected resource using the subscription's Admin API version, signs the payload, and sends it to the registered URL.

Topics identify the resource and action. For example, products/update fires when an existing product changes, and products/create fires when a new product is added.

After verifying the delivery, use the x-shopify-topic header to select a cache tag. Subsequent data reads can fetch the current catalog state from Shopify, so this handler doesn't need to maintain a separate product database.

Copy link to headingMap Shopify webhook topics to cache tags

The six topics below cover product and collection changes used by this storefront. They map to two cache tags:

Topic

Fires when

Cache tag

products/create

Product created

products

products/update

Product updated or ordered; variant added, removed, or updated

products

products/delete

Product deleted

products

collections/create

Collection created

collections

collections/update

Collection details, rules, or manual membership change

collections

collections/delete

Collection deleted

collections

Product changes can alter an automated collection's membership without sending collections/update. Tag collection-product queries with both products and collections so either event invalidates them.

For a dedicated stock-change signal, add inventory_levels/update with the read_inventory scope and map it to products. Keep periodic reconciliation because webhook delivery isn't guaranteed.

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.

Once Shopify removes a subscription, your endpoint stops receiving its events. Watch the app's emergency developer email for failure warnings and check Shopify's delivery logs for removed subscriptions.

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.

Add these subscriptions to your app's existing shopify.app.toml. Each block can send several topics to one endpoint:

shopify.app.toml
[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 all subscriptions in the file. Test upgrades before deployment; payload fields can be renamed or restructured.

For apps distributed through the Shopify App Store, configure customers/data_request, customers/redact, and shop/redact in the Dev Dashboard or under compliance_topics in the app configuration. These topics aren't registered through the Admin API.

The compliance endpoint must verify signatures and process data access or deletion requests. Implement it before distributing your app.

Copy link to headingWhat you need before writing a Shopify webhook handler

Confirm the following prerequisites before deploying the webhook handler:

Requirement

Why it matters

Shopify app installed on the store

Its approved read_products scope covers the six product and collection topics

Current Shopify CLI

Deploys the webhook configuration with shopify app deploy

Next.js 16 with Cache Components enabled

Set cacheComponents: true to use 'use cache', cacheTag(), and cacheLife()

Storefront data access

Existing product and collection queries, using the Headless channel or another supported authentication method

Server-side environment variables

SHOPIFY_STORE_DOMAIN and the Storefront token expected by your data layer; SHOPIFY_CLIENT_SECRET for the app that owns the subscriptions

Local HTTPS tunnel

Gives Shopify a reachable URL for local delivery tests

Create and install the Shopify app before deploying its webhook subscriptions. This handler uses one store's data and cache tags; a shared backend serving several stores needs separate tags for each store.

Keep the signing secret and private API tokens in server-only environment variables without a NEXT_PUBLIC_ prefix. Store local values in .env.local and keep that file 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.

Admin API tokens have the permissions granted to the app. Keep them server-side and request only the scopes your integration needs. This webhook handler verifies deliveries with the client secret and doesn't need an Admin API token.

If that secret leaks, rotate it and update webhook verification as part of the rotation.

The Headless channel issues public and private Storefront API tokens. Public tokens can be used in browser requests with X-Shopify-Storefront-Access-Token. Private tokens use Shopify-Storefront-Private-Token and must stay on the server.

Use the token type expected by your data layer and limit its permissions to the data your storefront needs. You can rotate private tokens through the Headless channel.

The archived vercel/commerce template uses SHOPIFY_REVALIDATION_SECRET. This handler uses the app's client secret.

Copy link to headingHow to verify Shopify webhooks before parsing the payload

Shopify allows five seconds for each delivery attempt and treats non-2xx responses as failures, including redirects.

Failed deliveries are retried up to eight times over four hours using the original payload. Verify the signature before parsing the body, and complete the required work within each attempt's response deadline.

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.

For app-managed webhooks, Shopify hashes the exact request bytes with SHA-256 and the app's client secret, then base64-encodes the result. Your handler repeats the calculation and compares the digests.

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:

  1. Read the raw body with await request.text() before parsing JSON. Route Handlers let you read the request body only once.

  2. Read the x-shopify-hmac-sha256 header and return 401 if it is missing.

  3. Use the app's client secret to compute a base64-encoded SHA-256 HMAC.

  4. Decode both digests into buffers and check their lengths. crypto.timingSafeEqual throws if the lengths differ.

  5. Compare the buffers with crypto.timingSafeEqual and return 401 if they do not match. Parse the payload only after verification succeeds.

node:crypto provides createHmac and timingSafeEqual in Node.js. The handler below combines HMAC verification with cache invalidation.

The archived vercel/commerce template compares a secret in the webhook URL with SHOPIFY_REVALIDATION_SECRET. URLs can appear in access and proxy logs, so avoid putting long-lived credentials in them.

HMAC verification keeps the signing secret on the server and checks the received payload against Shopify's signature. Verify it before invalidating the cache; an unprotected route lets any caller repeatedly trigger revalidation.

Copy link to headingAvoid common Shopify webhook verification failures

Subscriptions created manually under Settings > Notifications > Webhooks use a store-specific signing secret rather than the app's client secret. Match the verification key to the subscription's origin. The remaining failures usually come from how the handler reads or hashes the body:

Symptom

Root cause

Fix

Verification fails on every delivery

Body parsed before the HMAC check

Call await request.text() before any .json()

Digest never matches

digest('hex') instead of base64

Use .digest('base64') to match the header

Digest never matches, correct encoding

API key used as the HMAC key

Use SHOPIFY_CLIENT_SECRET, the OAuth secret

Handler throws on some requests

Length mismatch passed to timingSafeEqual

Compare buffer lengths before the comparison

Same event processed twice

No deduplication store

Store the per-delivery webhook ID, X-Shopify-Webhook-Id, with retention covering the four-hour retry window

Webhooks stop with no error

Shop-specific subscription hit the failure limit

Move the subscription into shopify.app.toml

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:

app/api/webhooks/shopify/route.ts
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 secret = process.env.SHOPIFY_CLIENT_SECRET;
if (!secret) {
console.error('SHOPIFY_CLIENT_SECRET is not configured');
return new Response('Server configuration error', { status: 500 });
}
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', secret)
.update(rawBody, 'utf8')
.digest('base64');
const computedBuffer = Buffer.from(computedDigest, 'base64');
const hmacBuffer = Buffer.from(hmacHeader, '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';
const tags: string[] = [];
if (collectionWebhooks.includes(topic)) tags.push(TAGS.collections);
if (productWebhooks.includes(topic)) tags.push(TAGS.products);
for (const tag of tags) revalidateTag(tag, 'seconds');
const revalidated = tags.length > 0;
console.info(JSON.stringify({
event: 'shopify_webhook',
webhookId: request.headers.get('x-shopify-webhook-id'),
topic,
revalidated,
}));
return Response.json({ revalidated, 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 }).

Assign products to product and search queries, and collections to collection metadata. Queries that load products within a collection need both tags: cacheTag('products', 'collections'). Without matching tags on the cached reads, the webhook has nothing to invalidate.

The route returns 200 with revalidated: false for unsupported topics because retries would not trigger an invalidation.

Copy link to headingUpdate revalidateTag calls for Next.js 16

Pass the tag and either a cache life profile or an explicit expiry to revalidateTag. The second argument controls how stale content is handled.

With Cache Components enabled, cache the data function with 'use cache'. Assign tags with cacheTag() and set its lifetime with cacheLife(). Applications using the previous caching model can keep tags on cached fetch calls.

Next.js recommends the 'max' profile for stale-while-revalidate behavior. The 'seconds' profile used here allows a shorter stale window. Use { expire: 0 } when the next read must wait for fresh data; the older single-argument call is deprecated.

For Server Actions that need read-your-own-writes behavior, use updateTag so the user sees the result of their change.

Copy link to headingWhen a Shopify webhook handler needs a queue

Signature verification and tag invalidation can stay in the delivery handler when they finish reliably inside Shopify's five-second limit. Complete that required work before returning success so Shopify can retry a failed attempt.

Use a queue if work needs independent retries, can exceed the response deadline, or arrives faster than downstream systems can process it. Slow API calls can justify queueing even when they consume little CPU.

For optional work, after() schedules a callback after the response. waitUntil() keeps an existing promise running within the function's lifetime. Both are bounded by the function timeout and provide no durable retry record if execution fails.

Choose the background mechanism for the work:

  • Edgenivo Queues persists messages and retries failed deliveries within the retention period. Consumers must tolerate duplicate delivery.

  • Edgenivo Workflows coordinates multiple durable steps, waits, and resumptions.

Idempotency keys deduplicate publishes that share a key. Combining distinct product updates requires logic that preserves the latest state.

Enqueue required follow-up work successfully before acknowledging the webhook. Keep the direct invalidation route when that is all the storefront needs.

Copy link to headingHow Edgenivo runs Shopify webhook handlers in production

Production handlers need reliable invalidation, bounded execution, and logs.

Copy link to headingExpire cache tags without rebuilding the storefront

Catalog edits can invalidate affected cached data without rebuilding the site. This avoids triggering a deployment for every price, product, or collection change.

Incremental Static Regeneration refreshes cached pages without a full deployment. With the stale-while-revalidate profile used here, the webhook marks tagged data stale and later requests trigger regeneration.

Edgenivo propagates cache purges globally within 300ms. Visible updates also depend on webhook delivery, Shopify data availability, and regeneration.

Visitors receive cached content during background regeneration. If regeneration fails, that stale content stays available instead of an error.

PAIGE reported 76% higher conversion rates after moving from commercetools and Angular to Shopify, Next.js, and Edgenivo.

The storefront ran on this architecture through Black Friday Cyber Monday 2024.

Copy link to headingBill for execution instead of wall-clock time

Fluid compute meters Active CPU, provisioned memory, and invocations separately. Waiting for Shopify doesn't consume Active CPU, but memory remains billable while the request is in flight.

These billing rules don't extend Shopify's five-second deadline. Keep required work bounded and queue calls that need separate retries and timeouts.

Copy link to headingConfirm which webhook deliveries landed

Open the app's Overview page in the Shopify Dev Dashboard to inspect delivery counts, failure rates, and 90th-percentile response time. Select the time range on the Operations card, then use Logs to filter deliveries by topic, status, or shop.

Delivery logs cover seven days and can appear several minutes late. Use them to investigate past attempts rather than track live arrivals.

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.

Log the webhook ID, topic, and invalidation result after signature verification. Include a stable event name so you can find these entries in Runtime Logs without recording the full payload.

From the linked project, search the last hour of webhook logs:

vercel logs --query 'shopify_webhook' --since 1h

Copy link to headingCheck the time budget before spending it

Shopify's five-second deadline applies even if the Edgenivo Function can run longer. Verify the signature and invalidate the cache before optional work.

For an optional Shopify read, record const startedAt = Date.now() at the start of POST and pass it to this helper. Save the helper as lib/shopify/optional-fetch.ts. Its three-second work budget leaves headroom for the response; the abort signal bounds both the fetch and JSON body read.

lib/shopify/optional-fetch.ts
export async function fetchOptionalShopifyData(
url: string,
init: RequestInit,
startedAt: number,
) {
const remainingMs = 3_000 - (Date.now() - startedAt);
if (remainingMs <= 0) return;
const timeout = AbortSignal.timeout(remainingMs);
const signal = init.signal
? AbortSignal.any([init.signal, timeout])
: timeout;
try {
const response = await fetch(url, { ...init, signal });
if (!response.ok) throw new Error('Shopify request failed');
return await response.json();
} catch (error) {
console.warn('Optional Shopify work skipped', {
reason: error instanceof Error ? error.name : 'UnknownError',
});
}
}

Call fetchOptionalShopifyData(url, init, startedAt) with your Shopify endpoint and request options. It returns parsed JSON, or skips the work on a timeout or error.

Enqueue required follow-up work durably before acknowledging delivery. This budget reduces timeout risk; it cannot guarantee Shopify's five-second deadline.

Copy link to headingTest the webhook flow before launch

Change a product in Shopify and follow the delivery through Shopify's webhook monitoring and your Edgenivo Runtime Logs. Confirm that the intended cached data refreshes according to your chosen cache profile.

Confirm that an invalid signature returns 401 without invalidating the cache.

Copy link to headingFrequently asked questions about Shopify webhooks

Copy link to headingDo Shopify webhooks work on the Edgenivo Hobby plan?

Hobby can run a webhook handler for a personal, non-commercial project within its usage limits. Use Pro or Enterprise for a commercial storefront; Hobby's eligibility restriction applies even if the handler's traffic is low.

Copy link to headingWhy does Shopify webhook HMAC verification keep failing?

For app webhooks, hash the raw body with the app's client secret. Manual Shopify admin webhooks use the store's webhook signing secret.

Decode both HMACs and compare the buffers only if their lengths match.

Copy link to headingWhat happens to Shopify webhooks if my endpoint is down?

Shopify retries failed deliveries over four hours, then stops. After an outage, restore any removed shop-specific subscriptions and invalidate the product and collection cache tags so the storefront fetches current data.

If your application stores its own catalog copy, reconcile it with Shopify as well. Include deletions and removals; an updated_at query alone won't return records that no longer exist.

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.

More Retail articles

Ready to deploy?