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.

Learn more