Shopify Webhooks You Are Not Receiving

The webhook is registered, orders are being created, and nothing arrives. Here is where they go and how to stop being unsubscribed.

Share
Shopify Webhooks You Are Not Receiving. Abstract shopify illustration in orange and dark grey on debugly.dev

Disclosure: I spent four years building Shopify apps and themes at Debutify, finishing as CDO.

The short answer

Four reasons a Shopify webhook does not arrive:

  1. You were automatically unsubscribed after repeated failures
  2. The topic is not what you think, for example orders/create fires on order creation, not on payment
  3. The subscription is on a different API version with a different payload shape
  4. HMAC verification is failing and you are returning a non-2xx, which counts as a failure

Check what is actually registered:

curl -s "https://SHOP.myshopify.com/admin/api/2026-01/webhooks.json" \
  -H "X-Shopify-Access-Token: $TOKEN" | jq '.webhooks[] | {topic, address, api_version}'

If your endpoint is not in that list, you were unsubscribed.

Automatic unsubscription is the big one

Shopify retries a failing webhook over roughly 48 hours with backoff. After 19 consecutive failures, it deletes the subscription and sends the app owner an email.

The email is easy to miss. The subscription is gone and no further webhooks are attempted, so from your side it looks like the store stopped generating events.

A failure is anything that is not a 2xx within the timeout, which is 5 seconds. That includes:

  • Your app being down during a deploy
  • A slow response because you did work inline
  • A 401 from your own HMAC check failing
  • A 500 from an unhandled error
  • A redirect, since Shopify does not follow them

That third one is worth dwelling on. A bug in your HMAC verification means every webhook returns 401, and 19 of those unsubscribes you. Your verification bug becomes a silent, permanent loss of all events for that topic.

Re-register defensively at app startup and on a schedule, rather than only at install:

async function ensureWebhooks(shop, token) {
  const existing = await listWebhooks(shop, token);
  for (const [topic, address] of Object.entries(REQUIRED_WEBHOOKS)) {
    const found = existing.find(w => w.topic === topic && w.address === address);
    if (!found) {
      logger.warn({ shop, topic }, "webhook missing, re-registering");
      await createWebhook(shop, token, topic, address);
    }
  }
}

Run this daily. It converts a silent permanent failure into a self healing one.

Respond fast, process later

The 5 second timeout is short and it is the root cause of most unsubscriptions.

app.post("/webhooks/orders-create",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verifyHmac(req.body, req.get("X-Shopify-Hmac-Sha256"))) {
      return res.status(401).send();
    }

    const payload = JSON.parse(req.body.toString("utf8"));
    const eventId = req.get("X-Shopify-Webhook-Id");

    const isNew = await claimEvent(eventId);
    res.status(200).send();                    // acknowledge immediately

    if (isNew) await queue.push({ eventId, topic: "orders/create", payload });
  }
);

Verify, claim, acknowledge, queue. Everything real happens in a worker.

X-Shopify-Webhook-Id is the deduplication key. Shopify delivers at least once, so duplicates are normal operation and you need an atomic claim rather than a read-then-check.

HMAC verification, correctly

import crypto from "node:crypto";

function verifyHmac(rawBody, hmacHeader) {
  if (!hmacHeader) return false;
  const digest = crypto
    .createHmac("sha256", process.env.SHOPIFY_API_SECRET)
    .update(rawBody)              // raw bytes, not a re-serialised object
    .digest("base64");            // base64, not hex

  const a = Buffer.from(digest, "utf8");
  const b = Buffer.from(hmacHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Three things people get wrong.

Base64, not hex. Shopify webhooks use base64. App proxy signatures use hex. Copying one implementation to the other silently fails every time.

The raw body. If any middleware parses JSON before your handler, re-serialising produces different bytes. A global express.json() mounted before your webhook route is the single most common cause of verification failing for no visible reason.

The right secret. The API secret from your app's settings, not an access token and not the webhook signing key from a different integration.

To debug a persistent mismatch, log both values once, in a development environment only:

logger.debug({ computed: digest, received: hmacHeader,
               bodyLength: rawBody.length,
               bodyStart: rawBody.toString("utf8").slice(0, 40) });

If bodyLength looks wrong, or the body starts with something unexpected, your middleware is interfering.

Topics do not always mean what they sound like

A frequent source of "the webhook is not firing" when it is firing and you are listening to the wrong thing.

Topic Fires when
orders/create An order object is created, which can be before payment
orders/paid Payment is captured
orders/fulfilled All line items fulfilled
orders/updated Almost any change, very chatty
checkouts/create A checkout begins, most never become orders
products/update Any field, including inventory in some cases

orders/create firing before payment catches people building fulfilment flows. An order created with a pending payment method fires orders/create immediately and may never be paid. If your logic should run on payment, listen to orders/paid.

orders/updated is chatty enough to be a problem. Every fulfilment, tag change, note edit, and inventory adjustment fires it. If you subscribe and do real work per event, you will have a load problem.

API version and payload shape

A webhook is registered against an API version, and the payload shape is fixed at registration:

curl -s ".../webhooks.json" -H "X-Shopify-Access-Token: $TOKEN" \
  | jq '.webhooks[] | {topic, api_version}'

If you registered on an old version and your code expects current fields, the fields are absent. This presents as a parsing error or a null field rather than a missing webhook, so it is a slightly different symptom in the same family.

Shopify deprecates API versions on a schedule. A webhook on a version past its end of life stops working, and this is one of the more common causes of an integration that worked for a year and then quietly stopped.

Re-register on version upgrades and put the version in your monitoring.

Mandatory compliance webhooks

If your app is in the Shopify App Store, three webhooks are required:

customers/data_request
customers/redact
shop/redact

They must be implemented, must verify HMAC, and must return 200. Shopify tests them during review, and failing them blocks approval. They are also easy to forget because they are configured in the app settings rather than registered through the API.

Testing

shopify app webhook trigger --topic=orders/create --address=https://your-tunnel/webhooks/orders-create

The CLI sends a correctly signed test payload, which tests your verification path properly. A hand crafted curl with a fake signature only tests the rejection path.

For local development you need a tunnel, since Shopify has to reach you:

cloudflared tunnel --url http://localhost:3000

Remember to re-register the webhook when the tunnel URL changes, which it does on every restart with a quick tunnel.

Monitoring

Log every received webhook, including duplicates you skip. When a merchant says an order did not sync, the first question is whether the event arrived at all.

Alert on the absence of expected webhooks. A store that normally receives 200 order webhooks a day and receives zero has either stopped trading or been unsubscribed. This is the absence-of-signal check and it is the only thing that catches silent unsubscription quickly.

Track your response time. If p95 is creeping toward 5 seconds, you are approaching unsubscription. This should alert well before it becomes a problem.

Reconcile periodically. A daily job comparing orders in Shopify against orders in your system, alerting on drift, catches everything else. Webhooks are a notification mechanism and they are not a guarantee, so anything financially important needs a reconciliation path.