Shopify webhooks: verification, retries and reconciliation
Webhooks are how Shopify tells you something happened. Delivery is at-least-once, which means your handlers need to be boring and repeatable.
8 min read · Apps & checkout ·
A webhook is Shopify calling your server when something happens: an order was created, a product changed, a customer was updated, the app was uninstalled. They're how you avoid polling, and they're the backbone of any integration that has to stay in sync.
They're also the part of Shopify development where "it worked in testing" is least predictive, because the failure modes only appear under real traffic.
Subscribing
Declare subscriptions in your app configuration so they're registered on install:
``toml [[webhooks.subscriptions]] topics = ["orders/create", "orders/updated", "products/update"] uri = "/webhooks" ``
Subscribe to what you need and nothing else. Every extra topic is traffic you have to verify, process and pay for.
Verify every request
Shopify signs each webhook with an HMAC in the X-Shopify-Hmac-Sha256 header, computed over the raw request body with your app secret.
Two implementation notes that cause most of the bugs:
- You need the raw body, before any JSON parsing. Frameworks that parse automatically will break the signature. Configure a raw-body route for your webhook endpoint.
- Use a constant-time comparison. A naive string equality check is a timing side channel.
An unverified endpoint accepts anything anyone posts to it, which for an endpoint that creates orders in your ERP is a genuine problem — not a theoretical one.
Respond fast, process later
Shopify expects a 200 quickly. Do the minimum in the request handler:
- Verify the HMAC.
- Write the payload to a queue or a table.
- Return
200.
Then process asynchronously. Doing real work inline — calling another API, writing to an ERP, generating a document — means a slow downstream system causes timeouts, timeouts cause retries, and retries cause duplicates.
Delivery is at-least-once
This is the single most important property. Shopify retries failed deliveries over roughly 48 hours, and the same event will arrive twice eventually. It also occasionally doesn't arrive at all.
So every handler must be idempotent: processing the same event twice must produce the same result as processing it once. In practice that means recording the webhook ID (X-Shopify-Webhook-Id) and skipping anything you've already seen, and using upserts keyed on the Shopify resource ID rather than blind inserts.
Without this you get duplicate orders in the ERP, duplicate emails to customers, and duplicate charges — all discovered by someone downstream.
Ordering is not guaranteed
orders/updated can arrive before orders/create. An update for a later version of a record can arrive before an earlier one.
Two defences. Check the updated_at timestamp and ignore payloads older than what you've already stored. And treat the payload as a notification rather than the truth — for anything important, re-fetch the current state from the Admin API rather than trusting a payload that may be stale.
You still need reconciliation
Webhooks are an optimisation, not a guarantee. If a record absolutely must exist in your system, run a periodic reconciliation job:
- Query everything changed since your last successful sync.
- Compare against what you hold.
- Fill the gaps.
Nightly is enough for most systems. This is the difference between an integration that's usually right and one that's reliably right, and it's the thing missing from most integrations I get called in to fix.
The mandatory privacy webhooks
Every app must implement three topics, and Shopify checks them at review:
customers/data_request— a customer has asked what data you hold.customers/redact— delete a specific customer's data.shop/redact— the store uninstalled 48 hours ago; delete everything.
They're not optional and they can't be stubbed out. Also handle app/uninstalled: the access token is dead the moment it fires, and continuing to call the API with a revoked token is a good way to look broken.
Testing
- Trigger real events on a development store. Place an order, edit a product.
shopify app webhook triggersends a sample payload without needing the event.- Replay the same payload twice and assert nothing duplicates. If you test one thing, test this.
- Return a 500 deliberately and confirm Shopify retries and your system recovers.
Monitoring
Log every webhook received, processed and failed, with the topic and the webhook ID. Alert on a rising failure rate. A silently failing webhook handler is invisible until someone notices the numbers don't add up a fortnight later — which is exactly the failure pattern described in ERP integration.
Webhooks are simple to receive and hard to depend on. The difference is idempotency and a reconciliation job — two things nobody budgets for and everybody eventually builds.
Is this the problem you’re looking at?
Send me the link to your store and a line about what is going wrong. You get a straight answer within one business day — no pitch, no obligation.
[email protected]Or see what I do around Shopify: services, work beyond the theme, selected work.