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
- A Webhook Relay account
- Python 3.8+
- The relay CLI
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_digestprevents 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
- Go to your Shopify admin
- Settings > Notifications > Webhooks
- Copy the signing secret at the top (that's your
SHOPIFY_WEBHOOK_SECRET) - Click "Create webhook"
- Pick an event, set format to JSON, paste your Webhook Relay URL
- 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:
gunicorninstead 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
