Receive Shopify webhooks on Flask API

How to get Shopify webhooks working on your local Flask app, with proper signature verification

If you're building a Shopify integration, you'll need to receive webhooks. The problem is Shopify can't reach your laptop. This post shows how to use Webhook Relay to forward webhooks to your local Flask app, with proper HMAC verification so you don't get burned by fake requests in production.

What you'll need

Set up the Flask project

I'm using uv here because it's fast, but pip works fine too.

curl -LsSf https://astral.sh/uv/install.sh | sh

mkdir flask-webhook-server
cd flask-webhook-server

uv venv
source .venv/bin/activate
uv pip install flask

The Flask app

Here's the full app. The important part is verifying the HMAC signature before you trust anything in the payload. Shopify signs every webhook with your store's secret, and you should check it.

Create app.py:

import hmac
import hashlib
import base64
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

# Grab this from Shopify admin: Settings > Notifications > Webhooks
# Don't commit it to your repo
SHOPIFY_WEBHOOK_SECRET = os.environ.get('SHOPIFY_WEBHOOK_SECRET', '')

def verify_shopify_webhook(data: bytes, hmac_header: str) -> bool:
    """Check if the webhook actually came from Shopify."""
    if not SHOPIFY_WEBHOOK_SECRET:
        print("ERROR: SHOPIFY_WEBHOOK_SECRET not set. Please set it:")
        print("  export SHOPIFY_WEBHOOK_SECRET='your-secret-from-shopify-admin'")
        return False
    
    calculated_hmac = base64.b64encode(
        hmac.new(
            SHOPIFY_WEBHOOK_SECRET.encode('utf-8'),
            data,
            hashlib.sha256
        ).digest()
    ).decode('utf-8')
    
    return hmac.compare_digest(calculated_hmac, hmac_header)

@app.route('/webhook', methods=['POST'])
def webhook():
    # Get raw body BEFORE parsing JSON - you need the exact bytes for HMAC
    data = request.get_data()
    
    hmac_header = request.headers.get('X-Shopify-Hmac-Sha256', '')
    topic = request.headers.get('X-Shopify-Topic', 'unknown')
    shop_domain = request.headers.get('X-Shopify-Shop-Domain', 'unknown')
    
    if not verify_shopify_webhook(data, hmac_header):
        print(f"Bad signature from {shop_domain}")
        return jsonify({"error": "Invalid signature"}), 401
    
    payload = request.get_json()
    
    print(f"Got {topic} webhook from {shop_domain}")
    print(f"Payload: {payload}")
    
    # Do something with it
    if topic == 'orders/create':
        print(f"New order #{payload.get('order_number')} - ${payload.get('total_price')}")
    
    return jsonify({"status": "received"}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=4444, debug=True)

A couple things to note:

  • You have to read the raw request body before Flask parses it. The HMAC is computed over the exact bytes Shopify sent.
  • hmac.compare_digest prevents timing attacks. Regular string comparison leaks information about which characters matched.

Run the server

export SHOPIFY_WEBHOOK_SECRET="your-secret-from-shopify-admin"
python app.py

You'll see:

 * Running on http://0.0.0.0:4444

Forward webhooks with Webhook Relay

Open another terminal and run:

relay login -k your-token-key -s your-token-secret
relay forward -b shopify-webhooks http://localhost:4444/webhook

You'll get a public URL like https://xxx.hooks.webhookrelay.com. Copy that.

Configure Shopify

  1. Go to your Shopify admin
  2. Settings > Notifications > Webhooks
  3. Copy the signing secret at the top (that's your SHOPIFY_WEBHOOK_SECRET)
  4. Click "Create webhook"
  5. Pick an event, set format to JSON, paste your Webhook Relay URL
  6. Save

Test it

You can use Shopify's "Send test notification" button, or hit it with curl:

curl -X POST https://xxx.hooks.webhookrelay.com \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Topic: orders/create" \
  -H "X-Shopify-Shop-Domain: your-store.myshopify.com" \
  -d '{"id": 12345, "order_number": "1001", "total_price": "99.99"}'

Your Flask server should print the payload.

Going to production

For production, you'll want:

  • gunicorn instead of the Flask dev server
  • The Webhook Relay agent running as a service (see our Docker guide)
  • Or just point the Webhook Relay output directly at your public server