Reliable Shopify webhooks in Python
HMAC verification, five-second acknowledgments, asynchronous processing and deduplication of at-least-once deliveries.
Overview
Shopify delivers webhooks at-least-once and gives you five seconds to answer - duplicates and timeouts are guaranteed, not edge cases.
You receive them through a REST channel - an HTTP endpoint that points at a Python service. You create the channel once in the Dashboard with a URL path such as /shopify/webhooks, register that URL with Shopify for the topics you care about - orders/create, products/update and so on - and the service receives each event as parsed JSON in self.request.input.
The platform gives you the endpoint, but reliability is a contract with four clauses that your handler must honor:
- Verify the HMAC signature
- Answer within five seconds
- Expect every event more than once
- Never lose an event you have acknowledged
Verifying the HMAC signature
Every delivery includes an X-Shopify-Hmac-Sha256 header - the Base64-encoded HMAC-SHA256 of the raw request body, keyed with your webhook signing secret. Recompute it over the raw body - not the parsed JSON - and compare with hmac.compare_digest. Requests that fail the check are not from Shopify.
# -*- coding: utf-8 -*-
# stdlib
import base64
import hashlib
import hmac
from http import HTTPStatus
# Zato
from zato.server.service import Service
class ShopifyWebhook(Service):
name = 'shopify.webhook'
def handle(self):
# The raw body, exactly as Shopify sent it
raw_body = self.request.raw
received = self.request.http.headers['X-Shopify-Hmac-Sha256']
# The signing secret, from config rather than code
secret = self.config.shopify.webhook_secret
digest = hmac.new(secret.encode('utf8'), raw_body.encode('utf8'), hashlib.sha256).digest()
expected = base64.b64encode(digest).decode('utf8')
if not hmac.compare_digest(expected, received):
self.response.status_code = HTTPStatus.UNAUTHORIZED
return
# Verified - continue with dedup and async processing
self.invoke_async('shopify.webhook-process', self.request.input)
For webhooks configured in the Shopify admin, the secret is shown in Settings, then Notifications, then Webhooks. For subscriptions created through the API by a custom app, it is the app's client secret.
Answering within five seconds
Shopify allows one second to open the connection and five seconds for the whole request - a handler that talks to a database, calls another API or does anything slow will blow that budget under load. A delivery that times out counts as failed, Shopify retries it - 19 consecutive failures over roughly 48 hours and the subscription is silently removed, which surfaces later as "our webhooks just stopped".
The fix is structural: the channel service does verification and deduplication only, then hands the payload to a second service with self.invoke_async and returns immediately. The second service does the real work - it can take as long as it needs, be retried on its own schedule and be tested in isolation.
Deduplicating deliveries
Treat duplicates as normal input, because they are - Shopify's delivery is at-least-once by design, the orders/create topic is known to fire more than once within a second, and creating a product fires both products/create and products/update. Without deduplication this becomes double order rows, double fulfillments and double charges downstream.
Each event has a stable identity in the X-Shopify-Event-Id header - the same event keeps the same ID across retries and across multiple subscriptions of the same topic. Check the ID against the built-in cache before processing and record it after, with an expiry comfortably longer than Shopify's 48-hour retry window.
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
# Keep processed event IDs for 24 hours
Dedup_Expiry = 86400
class OrderCreated(Service):
name = 'shopify.order-created'
def handle(self):
# The REST channel parsed the JSON payload
order = self.request.input
# Shopify delivers at-least-once, so deduplicate
event_id = self.request.http.headers['X-Shopify-Event-Id']
if self.cache.get(event_id):
return
self.cache.set(event_id, True, expiry=Dedup_Expiry)
# Acknowledge now, process in the background
self.invoke_async('shopify.order-process', order)
Deduplication protects the entry point, and the processing service should be idempotent too - keyed on the order or product ID it works with - so that a replay that slips through any layer still cannot double-apply.
One endpoint is simpler than one per topic - read the X-Shopify-Topic header and route inside the service, invoking a per-topic processing service asynchronously.
Testing locally
The channel is plain HTTP, so a local curl request with a sample payload exercises the entire path without any tunnel or store:
curl -X POST http://localhost:11223/shopify/webhooks \
-H "X-Shopify-Event-Id: test-event-1" \
-H "X-Shopify-Topic: orders/create" \
-d '{"id": 5678, "name": "#1001", "total_price": "19.99"}'
One trap from real projects: if you also run a Shopify CLI app in development, each shopify app dev session registers its own webhook URL, and events then fire at both your development tunnel and the deployed endpoint - keep the production subscription pointed only at the production channel.
Webhooks and the truth
Do not rely on webhooks alone to mirror Shopify data - practitioner consensus is that missed events happen and order is not guaranteed. Use webhooks for freshness and a periodic bulk operation for truth, comparing the two on a schedule. A short outage costs nothing - Shopify keeps retrying each delivery over roughly 48 hours - but after any longer incident, reconcile with a catalog or order read to catch anything that expired.