Shopify Webhooks: The Plumbing That Makes Headless Work
A headless storefront is only as good as its synchronization with Shopify. Webhooks are the plumbing. They keep products, inventory, orders, and prices in sync between Shopify's backend and your frontend. Here is how we wire them up, with real handler code.
Why Webhooks Matter for Headless
On a standard Shopify store, there is no synchronization problem. The Liquid template reads directly from Shopify's database on every page load. The product page always shows the current price, the current inventory, the current description. There is no cache. There is no separate frontend to update.
When you go headless, your frontend is a separate application running on separate infrastructure. Product pages are pre-rendered as static HTML and cached on a CDN. If someone changes a price in Shopify admin, the cached product page still shows the old price until something triggers a regeneration.
That "something" is a webhook. Shopify sends an HTTP POST to your endpoint whenever a relevant event occurs. Your endpoint processes the event and triggers the appropriate action: revalidate a page, update a CRM record, send a notification, bust a cache.
Without webhooks, your headless store is a snapshot of Shopify data at build time. With webhooks, it is a living, synchronized storefront.
The Webhook Architecture
┌──────────────────┐ HTTP POST ┌─────────────────────┐
│ Shopify Admin │ ─────────────────> │ Your API Routes │
│ │ │ (/api/webhooks/) │
│ Product updated │ │ │
│ Order created │ │ 1. Verify HMAC │
│ Inventory change │ │ 2. Parse payload │
│ Payment received │ │ 3. Route to handler│
│ Fulfillment │ │ 4. Execute action │
└──────────────────┘ │ 5. Return 200 │
└──────────┬──────────┘
│
┌─────────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Revalidate│ │ Update │ │ Record │
│ ISR Page │ │ HubSpot │ │Analytics │
└──────────┘ └──────────┘ └──────────┘
Every webhook follows the same five-step pattern: verify the signature, parse the payload, route to the correct handler, execute the action, and return a 200 status. The 200 must be returned quickly. Shopify expects a response within 5 seconds. If your handler takes longer, Shopify will retry the webhook, and you will process it twice.
HMAC Verification: The Non-Negotiable Step
Every webhook from Shopify includes an HMAC signature in the X-Shopify-Hmac-Sha256 header. This signature proves the webhook came from Shopify and was not tampered with in transit. If you skip verification, anyone can send fake webhooks to your endpoint.
// lib/verify-webhook.ts
import crypto from 'crypto';
export function verifyShopifyWebhook(
rawBody: Buffer,
hmacHeader: string,
secret: string
): boolean {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64');
try {
return crypto.timingSafeEqual(
Buffer.from(computed, 'utf8'),
Buffer.from(hmacHeader, 'utf8')
);
} catch {
return false;
}
}
// IMPORTANT: Disable body parsing to get raw body
export const config = {
api: {
bodyParser: false,
},
};
Two details that trip people up every time.
First, you must access the raw request body. If Next.js parses the body into JSON before you compute the HMAC, the signature will not match. The raw bytes are different from JSON.stringify(JSON.parse(rawBytes)). Always disable bodyParser for webhook routes.
Second, use crypto.timingSafeEqual instead of ===. A regular string comparison returns as soon as it finds a mismatched character. An attacker can measure the response time for different HMAC values and gradually reconstruct the secret. Timing-safe comparison always takes the same amount of time regardless of where the mismatch occurs.
The Webhook Router
Rather than creating separate API routes for each webhook topic, we use a single entry point with a router:
// pages/api/webhooks/shopify.ts
import { verifyShopifyWebhook } from '@/lib/verify-webhook';
import { handleProductUpdate } from '@/lib/webhooks/product';
import { handleOrderCreate } from '@/lib/webhooks/order';
import { handleInventoryUpdate } from '@/lib/webhooks/inventory';
import { handleFulfillmentCreate } from '@/lib/webhooks/fulfillment';
export const config = { api: { bodyParser: false } };
const handlers: Record<string, (payload: any) => Promise<void>> = {
'products/update': handleProductUpdate,
'products/create': handleProductUpdate, // Same handler
'products/delete': handleProductUpdate,
'orders/create': handleOrderCreate,
'orders/paid': handleOrderCreate, // Re-process with payment data
'inventory_levels/update': handleInventoryUpdate,
'fulfillments/create': handleFulfillmentCreate,
'fulfillments/update': handleFulfillmentCreate,
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
// 1. Read raw body
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const rawBody = Buffer.concat(chunks);
// 2. Verify HMAC
const hmac = req.headers['x-shopify-hmac-sha256'] as string;
if (!hmac || !verifyShopifyWebhook(rawBody, hmac, process.env.SHOPIFY_WEBHOOK_SECRET!)) {
console.error('Webhook HMAC verification failed');
return res.status(401).json({ error: 'Unauthorized' });
}
// 3. Route to handler
const topic = req.headers['x-shopify-topic'] as string;
const handler = handlers[topic];
if (!handler) {
console.warn(`Unhandled webhook topic: ${topic}`);
return res.status(200).json({ received: true, handled: false });
}
// 4. Execute handler (fire and forget for long operations)
const payload = JSON.parse(rawBody.toString('utf8'));
// Return 200 immediately, process in background
res.status(200).json({ received: true });
try {
await handler(payload);
} catch (error) {
console.error(`Webhook handler failed for ${topic}:`, error);
// Error is logged but 200 was already sent
// Idempotent handlers mean Shopify's retry will fix it
}
}
Notice the pattern: we return 200 before executing the handler. This ensures Shopify always gets a timely response. The actual processing happens after the response is sent. If the handler fails, Shopify will retry the webhook within an hour, and our handler must be idempotent (processing the same webhook twice produces the same result).
Product Update Handler: Cache Busting
When a product is updated in Shopify admin (title, description, price, images, variants), we need to regenerate the cached product page on the frontend.
// lib/webhooks/product.ts
export async function handleProductUpdate(payload: any) {
const handle = payload.handle;
const productId = payload.id;
// 1. Revalidate the product page
try {
await fetch(`${process.env.SITE_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.REVALIDATION_SECRET}`,
},
body: JSON.stringify({
paths: [
`/products/${handle}`,
],
}),
});
} catch (error) {
console.error(`Failed to revalidate /products/${handle}:`, error);
}
// 2. Revalidate collection pages this product belongs to
if (payload.collections) {
const collectionPaths = payload.collections.map(
(col: any) => `/collections/${col.handle}`
);
await revalidatePaths(collectionPaths);
}
// 3. Revalidate homepage (may show featured products)
await revalidatePaths(['/']);
// 4. Update search index if using custom search
if (process.env.ALGOLIA_APP_ID) {
await updateAlgoliaProduct(payload);
}
console.log(`Product updated: ${handle} (ID: ${productId})`);
}
async function revalidatePaths(paths: string[]) {
for (const path of paths) {
try {
await fetch(`${process.env.SITE_URL}/api/revalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.REVALIDATION_SECRET}`,
},
body: JSON.stringify({ paths: [path] }),
});
} catch (error) {
console.error(`Failed to revalidate ${path}:`, error);
}
}
}
The revalidation API route uses Next.js on-demand ISR:
// pages/api/revalidate.ts
export default async function handler(req, res) {
// Verify internal auth token
const auth = req.headers.authorization?.replace('Bearer ', '');
if (auth !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { paths } = req.body;
const results = await Promise.allSettled(
paths.map((path: string) => res.revalidate(path))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
return res.json({
revalidated: succeeded,
failed,
total: paths.length,
});
}
The product update webhook typically fires within 2-3 seconds of the merchant saving changes in Shopify admin. The revalidation takes another 1-2 seconds. Total latency from admin save to live page update: under 5 seconds. Good enough for any use case except real-time auctions.
Order Creation Handler: Source Attribution and CRM
When a customer completes checkout, Shopify fires the orders/create webhook. This is where the real value of a headless build becomes visible. We use this event to do three things that a template cannot do.
// lib/webhooks/order.ts
export async function handleOrderCreate(payload: any) {
const order = {
id: payload.id,
orderNumber: payload.order_number,
email: payload.email,
total: payload.total_price,
currency: payload.currency,
lineItems: payload.line_items.map((item: any) => ({
title: item.title,
sku: item.sku,
quantity: item.quantity,
price: item.price,
})),
shippingAddress: payload.shipping_address,
createdAt: payload.created_at,
// Source attribution from note attributes
source: extractSourceAttribution(payload),
};
// 1. Update HubSpot contact and deal
await updateHubSpotDeal(order);
// 2. Record in analytics
await recordOrderAnalytics(order);
// 3. Trigger internal notification
await sendOrderNotification(order);
console.log(`Order processed: #${order.orderNumber} ($${order.total})`);
}
function extractSourceAttribution(payload: any) {
// We store attribution data in Shopify order note_attributes
// set by the frontend during checkout
const attrs = payload.note_attributes || [];
return {
landingPage: attrs.find((a: any) => a.name === '_landing_page')?.value,
utmSource: attrs.find((a: any) => a.name === '_utm_source')?.value,
utmMedium: attrs.find((a: any) => a.name === '_utm_medium')?.value,
utmCampaign: attrs.find((a: any) => a.name === '_utm_campaign')?.value,
aiAssisted: attrs.find((a: any) => a.name === '_ai_assisted')?.value === 'true',
sessionCount: parseInt(attrs.find((a: any) => a.name === '_session_count')?.value || '1'),
};
}
The source attribution is powerful. The headless frontend tracks which landing page the customer first visited, which UTM parameters brought them, whether they interacted with the AI assistant, and how many sessions they had before purchasing. This data gets attached to the Shopify order as note attributes and then piped to HubSpot.
// lib/hubspot.ts
async function updateHubSpotDeal(order: any) {
const hubspot = new HubSpotClient({ accessToken: process.env.HUBSPOT_API_KEY });
// Find or create contact by email
let contact;
try {
contact = await hubspot.contacts.search({
filters: [{ propertyName: 'email', operator: 'EQ', value: order.email }],
});
} catch {
contact = null;
}
if (!contact?.results?.length) {
contact = await hubspot.contacts.create({
properties: {
email: order.email,
firstname: order.shippingAddress?.first_name,
lastname: order.shippingAddress?.last_name,
source: 'shopify_order',
},
});
}
// Create deal
await hubspot.deals.create({
properties: {
dealname: `Order #${order.orderNumber}`,
amount: order.total,
pipeline: 'default',
dealstage: 'closedwon',
utm_source: order.source.utmSource || '',
utm_campaign: order.source.utmCampaign || '',
ai_assisted_purchase: order.source.aiAssisted ? 'Yes' : 'No',
sessions_before_purchase: String(order.source.sessionCount),
},
});
}
Now the HubSpot CRM has complete attribution data for every order. Marketing can see which campaigns drive revenue. Sales can see which customers used the AI assistant. Leadership can see the median number of sessions before purchase. None of this exists on a template store.
Inventory Update Handler: Real-Time Stock Display
Inventory webhooks are the most frequent. Every sale, every restock, every warehouse adjustment triggers one. For a store doing 50 orders per day, that is at least 50 inventory webhooks per day, plus any manual adjustments.
// lib/webhooks/inventory.ts
export async function handleInventoryUpdate(payload: any) {
// payload contains inventory_item_id and location-specific levels
const inventoryItemId = payload.inventory_item_id;
const available = payload.available;
// 1. Find the product/variant associated with this inventory item
const variant = await findVariantByInventoryItem(inventoryItemId);
if (!variant) {
console.warn(`No variant found for inventory item: ${inventoryItemId}`);
return;
}
// 2. If item just went out of stock, revalidate immediately
if (available <= 0) {
await revalidateProduct(variant.productHandle);
console.log(`Out of stock: ${variant.productHandle} (variant: ${variant.title})`);
}
// 3. If item just came back in stock, revalidate and notify
if (available > 0 && variant.previousAvailable <= 0) {
await revalidateProduct(variant.productHandle);
await sendBackInStockNotifications(variant);
console.log(`Back in stock: ${variant.productHandle} (variant: ${variant.title})`);
}
// 4. If low stock (under threshold), add urgency on product page
if (available > 0 && available <= 5) {
await revalidateProduct(variant.productHandle);
console.log(`Low stock (${available}): ${variant.productHandle}`);
}
// 5. Update inventory cache for real-time display
await updateInventoryCache(variant.productHandle, variant.id, available);
}
The inventory cache is a lightweight key-value store (Redis or Vercel KV) that stores current stock levels. The product page reads from this cache for real-time inventory display, while the static page content comes from ISR. This separation means the page itself is cached and fast, but the stock count is always current.
// On the product page component
function StockIndicator({ productHandle, variantId }) {
const [stock, setStock] = useState(null);
useEffect(() => {
// Fetch real-time stock from cache API
fetch(`/api/inventory/${productHandle}/${variantId}`)
.then(res => res.json())
.then(data => setStock(data.available));
}, [productHandle, variantId]);
if (stock === null) return null;
if (stock <= 0) return <span className="out-of-stock">Out of Stock</span>;
if (stock <= 5) return <span className="low-stock">Only {stock} left</span>;
return <span className="in-stock">In Stock</span>;
}
Fulfillment Webhook: Closing the Loop
When an order ships, Shopify fires the fulfillments/create webhook with tracking information. We use this to update the CRM deal stage and optionally trigger a review request sequence.
// lib/webhooks/fulfillment.ts
export async function handleFulfillmentCreate(payload: any) {
const fulfillment = {
orderId: payload.order_id,
status: payload.status,
trackingNumber: payload.tracking_number,
trackingUrl: payload.tracking_url,
carrier: payload.tracking_company,
createdAt: payload.created_at,
};
// 1. Update HubSpot deal stage to "Shipped"
await updateHubSpotDealStage(fulfillment.orderId, 'shipped');
// 2. Schedule review request (7 days after shipment)
if (fulfillment.status === 'success') {
await scheduleReviewRequest(fulfillment.orderId, {
delayDays: 7,
trackingUrl: fulfillment.trackingUrl,
});
}
console.log(`Fulfillment: Order ${fulfillment.orderId} - ${fulfillment.carrier} ${fulfillment.trackingNumber}`);
}
Registering Webhooks with Shopify
You register webhooks through the Shopify Admin API. We do this programmatically in a setup script:
// scripts/register-webhooks.ts
import Shopify from '@shopify/shopify-api';
const WEBHOOKS = [
{ topic: 'PRODUCTS_UPDATE', path: '/api/webhooks/shopify' },
{ topic: 'PRODUCTS_CREATE', path: '/api/webhooks/shopify' },
{ topic: 'PRODUCTS_DELETE', path: '/api/webhooks/shopify' },
{ topic: 'ORDERS_CREATE', path: '/api/webhooks/shopify' },
{ topic: 'ORDERS_PAID', path: '/api/webhooks/shopify' },
{ topic: 'INVENTORY_LEVELS_UPDATE', path: '/api/webhooks/shopify' },
{ topic: 'FULFILLMENTS_CREATE', path: '/api/webhooks/shopify' },
{ topic: 'FULFILLMENTS_UPDATE', path: '/api/webhooks/shopify' },
];
async function registerAll() {
for (const webhook of WEBHOOKS) {
const response = await Shopify.Webhooks.Registry.register({
path: webhook.path,
topic: webhook.topic,
accessToken: process.env.SHOPIFY_ADMIN_API_TOKEN,
shop: process.env.SHOPIFY_DOMAIN,
});
if (response.success) {
console.log(`Registered: ${webhook.topic}`);
} else {
console.error(`Failed: ${webhook.topic}`, response.result);
}
}
}
registerAll();
Run this script once during initial setup, and again whenever you add new webhook topics. Shopify persists webhook registrations. They do not disappear on deploy.
Handling Failures and Retries
Webhooks fail. Your server might be down during a deploy. A database connection might time out. An API call to HubSpot might fail. You need to handle this gracefully.
Shopify retries failed webhooks (non-200 responses) up to 19 times over 48 hours with exponential backoff. If all retries fail, Shopify removes the webhook subscription entirely. You will not get notifications until you re-register.
Shopify Webhook Retry Schedule:
Attempt 1: Immediate
Attempt 2: ~10 seconds later
Attempt 3: ~1 minute later
Attempt 4: ~5 minutes later
Attempt 5: ~30 minutes later
...continues with exponential backoff...
Attempt 19: ~48 hours after first attempt
After 19: Webhook subscription DELETED
This is why returning 200 immediately (before processing) is important. If your handler takes 8 seconds to call HubSpot and HubSpot is slow, Shopify considers that a failure and retries. Now your handler runs twice. If your handler is not idempotent, you create duplicate CRM records.
For critical webhooks (order creation), we add a deduplication layer:
// lib/dedup.ts
import { kv } from '@vercel/kv';
export async function isProcessed(webhookId: string): Promise<boolean> {
const key = `webhook:${webhookId}`;
const exists = await kv.exists(key);
if (exists) return true;
// Mark as processed, expire after 48 hours (Shopify's retry window)
await kv.set(key, '1', { ex: 172800 });
return false;
}
// In webhook handler:
const webhookId = req.headers['x-shopify-webhook-id'] as string;
if (await isProcessed(webhookId)) {
return res.status(200).json({ received: true, duplicate: true });
}
Monitoring and Alerting
You need to know when webhooks stop working. If Shopify deletes your subscription after 19 failed retries, your store stops syncing silently. No error messages. No alerts. Just stale data.
// Periodic health check (runs on cron, e.g., every 6 hours)
async function checkWebhookHealth() {
// List all registered webhooks via Admin API
const response = await fetch(
`https://${process.env.SHOPIFY_DOMAIN}/admin/api/2024-01/webhooks.json`,
{
headers: {
'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_API_TOKEN!,
},
}
);
const { webhooks } = await response.json();
const expectedTopics = [
'products/update', 'products/create', 'products/delete',
'orders/create', 'orders/paid',
'inventory_levels/update',
'fulfillments/create', 'fulfillments/update',
];
const registeredTopics = webhooks.map((w: any) => w.topic);
const missing = expectedTopics.filter(t => !registeredTopics.includes(t));
if (missing.length > 0) {
console.error('MISSING WEBHOOKS:', missing);
// Send alert via Slack, email, or PagerDuty
await sendAlert(`Missing Shopify webhooks: ${missing.join(', ')}`);
// Auto-re-register
await registerMissingWebhooks(missing);
}
}
We run this check every 6 hours via a cron job. If any webhook subscriptions are missing, the script sends an alert and automatically re-registers them. This has saved us twice in the past year when Shopify API changes silently broke webhook subscriptions.
The Complete Webhook Map
Shopify Webhook Map for Headless Storefront:
==============================================
Topic Action Priority
products/create Revalidate pages High
products/update Revalidate pages High
products/delete Remove pages High
inventory_levels/update Update stock cache High
orders/create CRM update, analytics Medium
orders/paid Deal stage update Medium
orders/cancelled CRM update, restock Medium
fulfillments/create CRM update, review req Low
fulfillments/update Tracking update Low
customers/create CRM contact create Low
customers/update CRM contact update Low
refunds/create CRM deal update Low
app/uninstalled Alert, graceful shutdown Critical
Start with the High priority webhooks. They keep your storefront accurate. Add Medium and Low priority webhooks as your CRM integration matures. The app/uninstalled webhook is critical if you build a Shopify app. For a headless storefront using a custom app for Admin API access, it alerts you if someone accidentally uninstalls your app.
Webhooks are not glamorous. Nobody showcases their webhook handlers in a portfolio. But they are the difference between a headless store that works and one that shows stale prices and phantom inventory. Get the plumbing right, and everything built on top of it works. Need help wiring up your headless plumbing? We have built this stack dozens of times.