DEV Community

Cover image for Debugging Shopify Webhook Delivery Failures: A Developer Checklist
Lucy
Lucy

Posted on

Debugging Shopify Webhook Delivery Failures: A Developer Checklist

Quick answer: Most "missing webhook" bugs on Shopify aren't Shopify's fault. Your endpoint either takes longer than 5 seconds to respond, fails HMAC verification because the body was already parsed, or processes the same delivery twice because there's no dedupe check. Shopify retries a failed delivery 8 times over 4 hours with exponential backoff, then drops the event for good. This post walks through how to find out which of those it actually is, using Shopify's own delivery logs instead of guesswork.

If you've ever had a merchant email you about an order that "never showed up" in your system, only to find Shopify's dashboard says the order exists and the webhook fired, this is for you.

What actually causes Shopify webhook delivery failures?

In practice, almost every failed delivery falls into one of five buckets:

  1. Timeout. Your endpoint didn't return a 200 within 5 seconds.
  2. Signature mismatch. HMAC verification failed, usually because the raw body was mutated before verification ran.
  3. Silent duplicate processing. The same event arrived twice and got processed twice, which looks like a data bug but is really a missing dedupe check.
  4. Subscription removal. Repeated failures caused Shopify to auto-delete the subscription, so you stopped receiving anything and didn't notice.
  5. Stale payload assumptions. A retried webhook carries the original payload from when it was first triggered, not the current state, so code that assumes "this payload is always fresh" quietly does the wrong thing. None of these show up as a stack trace in your own logs. They show up as a missing order, a duplicate charge, or a support ticket. The fastest way to tell them apart is to stop guessing and pull Shopify's own delivery data first, which is covered a few sections down.

How does Shopify's retry system actually work?

Shopify's current policy, in place since a September 2024 update, retries a failed webhook delivery up to 8 times over a 4-hour window using exponential backoff. A 200-series response counts as success. Anything else, including a 3xx redirect, counts as a failure and queues a retry. After the 8th failed attempt, Shopify stops trying and the event is gone unless you reconstruct it yourself from the Admin API.

Shopify doesn't publish the exact per-attempt schedule, only the total shape (8 attempts, 4 hours, exponential backoff), so treat the chart below as directional rather than a literal timetable. If you're reading an older tutorial describing a much longer retry window, it predates this change.

Two details in the retry mechanism changelog matter more than the headline number:

  • Retried deliveries reuse the original payload and address. If you change your endpoint URL mid-retry-cycle, the retry still goes to the old address. Keep the old endpoint alive for a while during any migration.
  • Use X-Shopify-Triggered-At, not "now," to judge freshness. If your handler assumes every delivery reflects the current state of the resource, a late retry will overwrite newer data with older data. ## Why do webhooks time out even when your server is healthy?

This is the failure mode that confuses people most, because their server looks fine in every other metric. According to Shopify's guidance on verifying deliveries, Shopify allows a one-second connection timeout and a five-second timeout for the entire request-response cycle. That five seconds has to cover TLS handshake, routing through any load balancer or serverless cold start, your handler's logic, and the response write.

The usual culprit is doing real work inside the request handler: writing to a database, calling a third-party API, resizing an image, or running any business logic before responding. If any of that occasionally takes more than a couple of seconds, you'll see intermittent failures that look mysterious, because the work usually finishes anyway, just after Shopify already marked the delivery as failed and queued a retry.

The fix Shopify itself recommends is to treat the webhook endpoint as a thin acknowledgment layer and nothing else:

// webhook-receiver.js
// Keep this handler doing almost nothing. Verify, queue, respond.
const express = require('express');
const crypto = require('crypto');
const app = express();

app.post('/webhooks/orders', express.raw({ type: '*/*' }), (req, res) => {
  const digest = crypto
    .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET)
    .update(req.body)
    .digest('base64');

  const signature = req.headers['x-shopify-hmac-sha256'];
  const isValid = crypto.timingSafeEqual(
    Buffer.from(digest, 'base64'),
    Buffer.from(signature, 'base64')
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  // Hand the raw payload to a queue and return immediately.
  // Do NOT touch a database or call another API before this line.
  jobQueue.enqueue('shopify-webhook', {
    webhookId: req.headers['x-shopify-webhook-id'],
    topic: req.headers['x-shopify-topic'],
    triggeredAt: req.headers['x-shopify-triggered-at'],
    body: req.body.toString('utf8'),
  });

  res.status(200).send('ok');
});
Enter fullscreen mode Exit fullscreen mode

A background worker then pulls from that queue and does the actual database write or API call, on its own schedule, with no 5-second clock running. This single change eliminates most timeout-related failures without touching your retry or business logic at all.

How do you verify a delivery actually came from Shopify?

Every HTTPS delivery includes a base64-encoded HMAC-SHA256 signature in the X-Shopify-Hmac-Sha256 header, computed from your app's client secret and the raw request body, per Shopify's verification docs. Skipping this check, or getting it wrong, is the second most common source of "delivery failed" errors after timeouts.

Three mistakes account for almost all HMAC verification bugs:

  • Body already parsed. If express.json() or an equivalent middleware runs before your verification code, it has already mutated the body you need to hash. Verification must happen against the exact raw bytes Shopify sent, which means your raw-body middleware has to run first, not your JSON parser.
  • String comparison instead of constant-time comparison. Comparing signatures with === leaks timing information. Use crypto.timingSafeEqual (or your language's equivalent) instead.
  • Verifying against a secret that just rotated. If you rotate your app's client secret, Shopify notes it can take up to an hour before the HMAC digest is generated with the new secret. A verification failure right after a rotation isn't necessarily a bug in your code. Treat header names as case-insensitive in your code. Shopify documents that HTTP/2 often lowercases them, so X-Shopify-Hmac-Sha256 and x-shopify-hmac-sha256 need to resolve to the same value in your handler.

Why are you getting duplicate webhook deliveries?

Shopify's own documentation is direct about this: it minimizes duplicate deliveries but doesn't guarantee exactly-once delivery, so your app might receive the same webhook more than once, for example after a network timeout that happens right as your response is in flight. If your database write isn't idempotent, a duplicate delivery becomes a duplicate order note, a double-counted inventory adjustment, or a second email to a customer.

Two headers, documented in Shopify's delivery structure reference, matter here, and mixing them up is a common source of confusion:

Header What it identifies Use it to
X-Shopify-Webhook-Id A unique key per individual delivery Deduplicate a single delivery you may have already processed
X-Shopify-Event-Id Shared across every delivery triggered by the same merchant action Correlate deliveries across multiple subscriptions to the same topic

If you have more than one subscription listening to the same topic, you'll get a separate delivery, with a separate X-Shopify-Webhook-Id, for each one. Dedupe on the webhook ID, not the event ID, or you'll silently drop deliveries you actually needed.

The dedupe check itself is simple: before processing, look up the X-Shopify-Webhook-Id in whatever persistent store you're already using (Redis, a database table, whatever fits your stack). If it's been seen, skip processing and return success anyway, since Shopify already got the acknowledgment it needed. If it's new, save the ID and process it.

We've hit this exact problem doing custom Shopify webhook integration development for merchants pushing orders into an external system in near real time. The failure mode is never dramatic. It's a handful of orders that get written twice into an ERP because a retry landed a few seconds after the original delivery finished processing, and nobody notices until someone reconciles the numbers weeks later.

How do you read Shopify's delivery logs before building your own?

If your app was created through the Dev Dashboard or Shopify CLI, Shopify's troubleshooting guide points you to a delivery metrics report before you write a single line of custom monitoring. It's worth using this first, because it already has the data you'd otherwise have to reconstruct from your own logs.

The Monitoring page (Dev Dashboard → your app → Monitoring) shows, per topic, over the last 7 days:

  • Total deliveries and failed delivery rate
  • Response time at the 90th percentile
  • How many webhook subscriptions have been auto-removed
    Shopify's own guidance on what counts as a real problem is specific enough to skip a lot of debate:

  • A failed delivery rate over 0.5% is higher than average and worth investigating.

  • Response times sitting between 4 and 5 seconds mean you're right at the timeout edge, not comfortably under it.

  • If every topic has a high failure rate at once, the problem is your backend being down, not any single handler.

  • If removed webhooks shows a nonzero count, you have subscriptions that stopped delivering and nobody re-created them.
    The Logs page lets you inspect an individual delivery: response code, response time, payload size, delivery attempt number, and the HMAC signature that was sent. That last field is genuinely useful for a specific class of bug: when your local testing works, production HMAC verification doesn't, and you need to confirm whether the signature Shopify sent even matches what you're computing.

What happens after Shopify removes your webhook subscription?

This is the failure mode that does the most silent damage, because nothing errors, you just... stop getting data. If deliveries to a subscription keep failing, Shopify auto-removes it, and warning emails go to your app's emergency developer email address, which is easy to have pointed at an inbox nobody checks.

Recovery depends on how the subscription was created:

  • App-specific subscriptions (declared in shopify.app.toml and deployed with your app) don't need manual re-subscription; they're tied to the app's own configuration rather than a one-off API call.
  • Shop-specific subscriptions (created through the Admin API for an individual merchant) need to be recreated, and Shopify recommends checking existing subscriptions first so you only create what's missing. Either way, re-subscribing only fixes the going-forward problem. The gap in data during the outage still has to be backfilled by fetching the missing records from the Admin API and feeding them back through your normal processing path. This is the part teams skip, and it's the part that actually prevents a customer support ticket three weeks later asking why an order from last month never triggered a fulfillment.

A quick diagnostic checklist

Symptom Likely cause What to check first
Deliveries fail intermittently, server looks fine Handler doing real work before responding Response time in delivery logs, especially the 4-5s range
All deliveries return 401 HMAC secret mismatch or wrong raw body Confirm raw-body middleware runs before any JSON parser
Same order processed twice No dedupe check Add a store keyed on X-Shopify-Webhook-Id
Data stopped arriving entirely, no errors anywhere Subscription auto-removed after repeated failures Dev Dashboard "Removed webhooks" metric, emergency developer email
Old data overwriting new data after an outage Retry delivered a stale, original payload Compare X-Shopify-Triggered-At against current record timestamp
Works locally, fails in production Different secret, or a proxy mutating the body Re-verify HMAC using the exact production request body

Building retry logic that survives a longer outage

Shopify's 4-hour retry window is generous for a blip, not for a real outage. If your own deployment goes down for longer than that, you need your own resilience layer on top of Shopify's, not instead of it. The pattern that holds up in production, echoed in AWS's canonical guidance on backoff and jitter and its Well-Architected Framework retry guidance, comes down to a short list: acknowledge fast, classify errors so only transient ones get retried, add jitter so recovering clients don't all retry in the same instant, cap the number of attempts, and reconcile against the source of truth afterward rather than assuming your queue caught everything.

None of that is Shopify-specific. It's the same reason exponential backoff without jitter still clusters retries into waves, and why a dead letter queue matters more than a longer retry window.

This is exactly why we hit similar sync issues building custom order-sync middleware for a manufacturing client, connecting Shopify to an ERP and a 3PL provider. Retry tuning helped. A reconciliation job that periodically compared both systems and caught what fell through is what actually stopped orders from silently going missing.


What's the strangest webhook failure you've had to track down, and did Shopify's own delivery logs actually explain it, or did you end up debugging blind? Drop it in the comments, I'd like to compare notes.

Top comments (0)