All posts
shopifywebhooksguideecommerce

Shopify Webhooks: Setup, Verification, and Common Pitfalls

How to receive, verify, and reliably process Shopify webhook events. Covers topic subscriptions, HMAC verification, compliance webhooks, and the mistakes that break integrations.

24 August 20265 min read

Shopify webhooks look similar to Stripe or GitHub's at first glance: HMAC-signed HTTP POST requests. The details diverge quickly once you start building against them. The signature encoding is different, subscriptions work per-topic rather than per-webhook, and public apps carry mandatory compliance requirements that have nothing to do with the app's actual functionality.

This guide covers what's specific to Shopify.

Topics, not one webhook for everything

GitHub lets you pick from a checkbox list of events on a single webhook. Shopify instead treats each event type, called a topic, as its own subscription.

orders/create
orders/updated
orders/cancelled
products/create
products/update
customers/create
app/uninstalled

You can point every topic at the same URL. Route based on the X-Shopify-Topic header once the request lands, rather than registering a different endpoint per topic.

Create subscriptions via the Admin API or CLI

For a custom app, use the Admin API. For a public app built with the Shopify CLI, topics are usually declared in your app's configuration and registered automatically on install.

shopify app webhooks trigger --topic=orders/create

Point every subscription at one endpoint

You don't need a route per topic. One handler, dispatched by header, is simpler to maintain.

Verify and acknowledge fast

Shopify gives you 5 seconds to respond. Verify the signature, queue the payload, return 200.

Verifying the signature

Shopify signs the raw request body with your app's client secret, using HMAC-SHA256, and base64-encodes the result into the X-Shopify-Hmac-SHA256 header. If you're coming from GitHub or Stripe, the algorithm is the same but the encoding isn't: those use hex, Shopify uses base64. Reusing verification code without changing the encoding is a common first bug.

import { createHmac, timingSafeEqual } from 'crypto';

function verifyShopifyWebhook(
  rawBody: Buffer,
  hmacHeader: string | undefined,
  secret: string
): boolean {
  if (!hmacHeader) return false;

  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');

  try {
    return timingSafeEqual(
      Buffer.from(hmacHeader),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

Shopify signs the exact bytes received, so anything that reformats the JSON body before verification (a middleware that re-serializes it, a proxy that normalizes whitespace) breaks the check. Verify against the raw buffer, before any body parser touches it.

The 5-second window

Shopify expects a response within 5 seconds, tighter than GitHub's 10 or Stripe's 20. If your endpoint doesn't respond in time, or returns an error, Shopify retries 8 times over the next 4 hours, with most attempts front-loaded into the first half hour.

app.post('/webhooks/shopify', async (req, res) => {
  const topic = req.headers['x-shopify-topic'] as string;
  const hmac = req.headers['x-shopify-hmac-sha256'] as string;

  if (!verifyShopifyWebhook(req.rawBody, hmac, process.env.SHOPIFY_CLIENT_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  // Acknowledge immediately
  res.status(200).send('ok');

  // Process asynchronously, well outside the 5-second window
  void processShopifyWebhook(topic, req.body);
});

Anything beyond a fast, guaranteed-quick database write belongs in the async path. A slow handler and a broken one produce identical retry behaviour from Shopify's side.

Deduplication: use the delivery ID, not the payload

Shopify includes an X-Shopify-Webhook-Id header, a UUID unique to each delivery attempt, on every request. Store it and check for it before processing:

async function processShopifyWebhook(topic: string, payload: unknown, deliveryId: string) {
  const seen = await db.processedWebhooks.findUnique({ where: { deliveryId } });
  if (seen) return; // Already handled, e.g. a retry after a timeout

  await db.processedWebhooks.create({ data: { deliveryId, topic } });
  // ... handle the event
}

Retries during the 4-hour window will reuse the same delivery ID, so this catches the most common source of duplicate processing without needing to hash the payload.

Compliance webhooks: mandatory for public apps

If you're building a public app for the Shopify App Store, three topics are non-negotiable regardless of what your app actually does:

  • customers/data_request - a merchant's customer has requested their data; you must respond with what you hold on them
  • customers/redact - a merchant's customer has requested deletion; you must erase their data within 30 days
  • shop/redact - a merchant has uninstalled your app and 48 hours have passed; erase their shop's data

Shopify sends these automatically to every registered app; you don't request them. App review checks that all three return a 200 and are handled correctly, even for an app that stores nothing about individual customers. In that case, the handler can be a no-op that still returns 200, but it has to exist.

Common failure patterns

"HMAC verification passes in Postman but fails from Shopify"

Almost always a body-parsing issue: something in the request pipeline (a proxy, a global JSON middleware) is altering the bytes before your verification code sees them. Use a raw body parser scoped to the webhook route only.

"Webhook fires once per order but I'm seeing it twice"

Check whether you're deduplicating on X-Shopify-Webhook-Id. A retry after a slow response looks, from your database's perspective, exactly like a second genuine order unless you're checking the delivery ID first.

"App review rejected for compliance webhooks"

The most common cause is one of the three topics returning a non-200, often because the handler throws on shops that have no data to redact. Make sure all three succeed even in the empty case.


Shopify's per-topic subscriptions and base64 HMAC are the two things that catch people coming from other providers. Once those are handled, the rest (fast acknowledgment, async processing, dedup on delivery ID) is the same pattern that works for any webhook source.

If you're connecting Shopify to Webhook Watchtower, the Shopify setup guide covers the built-in preset: it selects the base64 HMAC algorithm automatically and deduplicates on X-Shopify-Webhook-Id, so a retried delivery never reaches your endpoint twice.

Frequently asked questions

How do I verify a Shopify webhook is genuine?

Compute an HMAC-SHA256 hash of the raw request body using your app's client secret, base64-encode it, and compare against the X-Shopify-Hmac-SHA256 header using a constant-time comparison. Shopify's encoding is base64, not hex, which trips up anyone reusing verification code written for Stripe or GitHub.

Why do I need one webhook subscription per topic in Shopify?

Unlike GitHub, where one webhook can cover many event types, Shopify requires a separate subscription for each topic: orders/create, products/update, and so on. You can point every subscription at the same endpoint URL, and route based on the X-Shopify-Topic header once requests arrive.

What are Shopify's mandatory compliance webhooks?

Any public Shopify app must implement three GDPR-related webhook topics: customers/data_request, customers/redact, and shop/redact. Shopify sends these automatically, and app review will reject a submission that doesn't handle all three correctly, even if your app doesn't otherwise store customer data.

Share this post

Webhook Watchtower

Stop debugging webhooks in logs.

Capture every event, replay failures, and get alerted when deliveries fail, without changing your existing setup.

Try it free →