Webhooks are the backbone of Shopify automation. They enable real-time communication between your Shopify store and external systems — ERPs, warehouses, marketing platforms, analytics tools, and custom applications. Without webhooks, you would need to poll the Shopify API constantly to detect changes, which is inefficient, slow, and wasteful of your API rate limit budget.

This guide covers everything you need to build reliable, production-grade webhook consumers as part of your Shopify development stack. We have built webhook-driven integrations for dozens of stores, and the patterns here reflect the lessons we have learnt from handling millions of webhook deliveries.

What are Shopify webhooks

A webhook is an HTTP POST request that Shopify sends to a URL you specify when a specific event occurs. When a customer places an order, Shopify sends the order data to your endpoint within seconds. When a product is updated, Shopify notifies your system immediately. This push-based model is fundamentally more efficient than the pull-based model of polling the API.

Each webhook delivery includes the full resource data as a JSON payload in the request body, along with headers that identify the event type, the shop, and an HMAC signature for verification.

Webhooks are fire-and-forget from Shopify’s perspective. They send the data and expect a 200 response within 5 seconds. All processing must happen asynchronously after you acknowledge receipt.

Webhook delivery flow from Shopify to consumer endpoint
Shopify sends webhook payloads via HTTP POST to your registered endpoint. Your server must respond within 5 seconds and process the data asynchronously.

Common webhook events

Shopify offers over 60 webhook topics. The most commonly used for ecommerce automation include:

  • orders/create — triggered when a new order is placed. The most important webhook for ERP integration, fulfilment workflows, and analytics.
  • orders/updated — triggered when any order attribute changes (payment status, fulfilment status, notes).
  • orders/paid — triggered specifically when payment is captured. Useful for triggering fulfilment only after payment.
  • products/update — triggered when product data changes (title, price, inventory). Essential for keeping external catalogues in sync.
  • inventory_levels/update — triggered when inventory quantities change at a specific location.
  • customers/create — triggered when a new customer account is created. Useful for CRM sync.
  • app/uninstalled — triggered when your app is uninstalled. Use this for cleanup.
  • shop/update — triggered when store settings change.

Registering webhooks via GraphQL

The recommended approach is registering webhooks via the GraphQL Admin API. This gives you programmatic control and works within custom apps:

mutation webhookSubscriptionCreate(
  $topic: WebhookSubscriptionTopic!
  $webhookSubscription: WebhookSubscriptionInput!
) {
  webhookSubscriptionCreate(
    topic: $topic
    webhookSubscription: $webhookSubscription
  ) {
    webhookSubscription {
      id
      topic
      endpoint {
        ... on WebhookHttpEndpoint {
          callbackUrl
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

// Variables
{
  "topic": "ORDERS_CREATE",
  "webhookSubscription": {
    "callbackUrl": "https://your-app.com/webhooks/orders/create",
    "format": "JSON"
  }
}

Registering multiple webhooks at app installation

// Register all required webhooks after OAuth completes
const WEBHOOK_TOPICS = [
  'ORDERS_CREATE',
  'ORDERS_UPDATED',
  'PRODUCTS_UPDATE',
  'INVENTORY_LEVELS_UPDATE',
  'CUSTOMERS_CREATE',
  'APP_UNINSTALLED'
];

async function registerWebhooks(admin, appUrl) {
  for (const topic of WEBHOOK_TOPICS) {
    const topicSlug = topic.toLowerCase().replace('_', '-');
    await admin.graphql(`
      mutation {
        webhookSubscriptionCreate(
          topic: ${topic}
          webhookSubscription: {
            callbackUrl: "${appUrl}/webhooks/${topicSlug}"
            format: JSON
          }
        ) {
          userErrors { field message }
        }
      }
    `);
  }
}

HMAC signature verification

Every webhook from Shopify includes an X-Shopify-Hmac-Sha256 header containing an HMAC-SHA256 digest of the request body, signed with your app’s secret key. You must verify this signature before processing any webhook. Without verification, any external party could send fake webhooks to your endpoint.

import crypto from 'crypto';

function verifyShopifyWebhook(req, res, next) {
  const hmacHeader = req.headers['x-shopify-hmac-sha256'];
  const body = req.rawBody; // Must be the raw request body, not parsed JSON

  if (!hmacHeader || !body) {
    return res.status(401).send('Unauthorized');
  }

  const generatedHmac = crypto
    .createHmac('sha256', process.env.SHOPIFY_API_SECRET)
    .update(body, 'utf8')
    .digest('base64');

  const verified = crypto.timingSafeEqual(
    Buffer.from(generatedHmac),
    Buffer.from(hmacHeader)
  );

  if (!verified) {
    console.error('Webhook HMAC verification failed');
    return res.status(401).send('Unauthorized');
  }

  next();
}

Critical: use crypto.timingSafeEqual for the comparison, not a simple string equality check. String comparison is vulnerable to timing attacks that can leak information about the expected HMAC.

HMAC verification flow for Shopify webhooks
HMAC verification ensures that webhook payloads genuinely originate from Shopify and have not been tampered with in transit.

Payload handling and processing

Webhook payloads contain the full resource data at the time of the event. Here is an example of an orders/create payload structure:

// Key fields in an orders/create webhook payload
{
  "id": 820982911946154508,
  "name": "#1001",
  "email": "customer@example.com",
  "created_at": "2026-03-16T09:30:00+00:00",
  "financial_status": "paid",
  "fulfillment_status": null,
  "total_price": "99.95",
  "currency": "GBP",
  "line_items": [
    {
      "id": 866550311766439020,
      "title": "Organic Cotton T-Shirt",
      "variant_title": "Medium / Black",
      "quantity": 2,
      "price": "29.99",
      "sku": "OCT-M-BLK"
    }
  ],
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Smith",
    "address1": "123 High Street",
    "city": "Bristol",
    "province": "England",
    "country": "United Kingdom",
    "zip": "BS1 2AB"
  }
}

Asynchronous processing patterns

Your webhook endpoint must respond with a 200 status within 5 seconds. If it takes longer, Shopify considers the delivery failed and will retry. This means all actual processing must happen asynchronously:

// Pattern 1: Queue-based processing (recommended)
import { Queue } from 'bullmq';

const webhookQueue = new Queue('shopify-webhooks');

app.post('/webhooks/orders/create', verifyShopifyWebhook, async (req, res) => {
  // Acknowledge immediately
  res.status(200).send();

  // Queue for async processing
  await webhookQueue.add('order-created', {
    payload: req.body,
    shopDomain: req.headers['x-shopify-shop-domain'],
    webhookId: req.headers['x-shopify-webhook-id'],
    timestamp: new Date().toISOString()
  });
});

// Worker processes the queue
const worker = new Worker('shopify-webhooks', async (job) => {
  switch (job.name) {
    case 'order-created':
      await processNewOrder(job.data.payload);
      break;
  }
});

Retry logic and failure handling

Shopify retries failed webhook deliveries with exponential backoff. The retry schedule spans up to 48 hours with 19 attempts. After all retries are exhausted, Shopify deletes the webhook subscription. Your system must be prepared for this:

  • Always respond with 200 within 5 seconds, even if processing will happen later.
  • Implement your own internal retry logic for processing failures.
  • Set up monitoring to detect when webhook subscriptions are removed.
  • Re-register webhooks automatically when your app detects missing subscriptions.

Idempotency and deduplication

Shopify may deliver the same webhook more than once. Your webhook handlers must be idempotent — safe to execute multiple times with the same input:

// Deduplication using webhook ID
const processedWebhooks = new Set(); // Use Redis in production

app.post('/webhooks/orders/create', verifyShopifyWebhook, async (req, res) => {
  const webhookId = req.headers['x-shopify-webhook-id'];

  // Check for duplicate delivery
  if (await isAlreadyProcessed(webhookId)) {
    console.log(`Duplicate webhook ${webhookId}, skipping`);
    return res.status(200).send();
  }

  // Mark as processing
  await markAsProcessed(webhookId);

  // Acknowledge and queue
  res.status(200).send();
  await webhookQueue.add('order-created', { payload: req.body, webhookId });
});
Idempotent webhook processing with deduplication
Idempotent webhook handlers use deduplication keys to safely handle duplicate deliveries without creating duplicate records in downstream systems.

Event-driven architecture patterns

For complex integrations, webhooks become the entry point for an event-driven architecture. A single webhook can trigger multiple downstream processes:

// Event fan-out pattern
async function processNewOrder(order) {
  // Run multiple handlers concurrently
  await Promise.allSettled([
    syncOrderToERP(order),           // Send to NetSuite/SAP
    notifyWarehouse(order),           // Trigger pick-and-pack
    updateAnalytics(order),           // Update dashboards
    triggerPostPurchaseFlow(order),    // Klaviyo post-purchase email
    updateInventoryForecasts(order)   // Adjust stock projections
  ]);
}

// Each handler is independent and fault-tolerant
async function syncOrderToERP(order) {
  try {
    await erpClient.createSalesOrder({
      reference: order.name,
      customer: order.email,
      items: order.line_items.map(item => ({
        sku: item.sku,
        quantity: item.quantity,
        price: item.price
      })),
      currency: order.currency,
      total: order.total_price
    });
  } catch (error) {
    // Log and retry, do not fail other handlers
    await retryQueue.add('erp-sync', { order, error: error.message });
  }
}

Monitoring and debugging

Webhook systems fail silently if you are not monitoring them. Set up alerting for:

  • Delivery failures — track the ratio of 200 responses to non-200 responses.
  • Processing latency — time from webhook receipt to processing completion.
  • Queue depth — if the queue is growing faster than it drains, you have a throughput problem.
  • Subscription status — periodically check that all expected webhook subscriptions are active.
  • HMAC failures — a spike in verification failures may indicate a security issue or misconfigured secret.
// Health check: verify all webhook subscriptions are active
async function verifyWebhookSubscriptions(admin) {
  const response = await admin.graphql(`
    query {
      webhookSubscriptions(first: 50) {
        edges {
          node {
            id topic
            endpoint {
              ... on WebhookHttpEndpoint { callbackUrl }
            }
          }
        }
      }
    }
  `);

  const active = response.data.webhookSubscriptions.edges.map(e => e.node.topic);
  const expected = ['ORDERS_CREATE', 'PRODUCTS_UPDATE', 'INVENTORY_LEVELS_UPDATE'];
  const missing = expected.filter(t => !active.includes(t));

  if (missing.length > 0) {
    console.error('Missing webhook subscriptions:', missing);
    // Re-register missing webhooks
    for (const topic of missing) {
      await registerWebhook(admin, topic);
    }
  }
}
Webhook monitoring dashboard showing delivery metrics
A webhook monitoring dashboard tracks delivery rates, processing latency, and queue depth to catch issues before they impact operations.

Reliable webhook handling is the foundation of any serious Shopify integration. The patterns in this guide — HMAC verification, async processing, idempotency, and monitoring — are non-negotiable for production systems. If you need help building webhook-driven integrations for your Shopify store, get in touch — we build these systems as part of our Shopify development services.