Headless Commerce November 25, 2025 14 min read

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.

Tyler Colby · Founder, Colby's Data Movers

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.