Shopify signs webhook deliveries with HMAC-SHA256, encoded as base64 (not hex like Stripe and GitHub) in the X-Shopify-Hmac-Sha256 header. The base64 encoding is the most common reason teams' Stripe-shaped verification code fails when copy-pasted for Shopify.
Root Causes
1. Comparing hex against base64
Your verification code computes hmac.digest('hex') and compares against the header. Shopify's header is base64. They never match. Use hmac.digest('base64').
2. Body was parsed before HMAC was computed
Same root cause as Stripe and GitHub. Use express.raw() on the webhook route.
3. Wrong secret type
Shopify has two kinds of webhook secrets:
- App webhooks (custom apps + public apps): signed with the app's API secret key (from Partner Dashboard → App → Configuration). All webhooks for all stores using that app share the same secret.
- Admin-API webhooks (created via REST/GraphQL): signed with a per-shop secret returned in the create-webhook response.
Using the app secret to verify admin-API webhooks (or vice versa) produces 100% mismatches.
4. EU vs US data residency
Shopify's EU data residency (shopify.eu) signs differently in some legacy paths. If your store is on EU residency and your verification is failing, contact Shopify support to confirm which secret applies.
Fix It — Working Shopify Verification
import express from 'express'
import crypto from 'node:crypto'
const app = express()
const secret = process.env.SHOPIFY_WEBHOOK_SECRET
app.post('/webhooks/shopify',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-shopify-hmac-sha256']
if (!sig) return res.status(401).send('missing signature')
const expected = crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('base64') // BASE64, not hex
const sigBuf = Buffer.from(sig, 'utf-8')
const expBuf = Buffer.from(expected, 'utf-8')
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('signature mismatch')
}
const payload = JSON.parse(req.body.toString())
res.status(200).send('ok')
}
)
Python Equivalent
import hmac, hashlib, base64
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ['SHOPIFY_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/shopify')
def shopify_webhook():
sig = request.headers.get('X-Shopify-Hmac-Sha256', '')
body = request.get_data()
digest = hmac.new(SECRET, body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
if not hmac.compare_digest(sig, expected):
return 'invalid signature', 401
return 'ok', 200
Shopify-Specific Gotchas
- Shopify retries up to 19 times across 48 hours. Idempotency store retention should cover at least 72 hours.
- The
X-Shopify-Webhook-Idheader is your delivery ID. TheX-Shopify-Topicheader is the event type. - Shopify sends a
X-Shopify-Test: trueheader on test webhooks fired from the admin. Don't confuse them with real events. - Shopify webhook timeouts are 5 seconds — much tighter than Stripe's 10s or GitHub's 10s. Your handler must be fast.
How to Reproduce
In Shopify Admin: Settings → Notifications → Webhooks → click your webhook → "Send test notification." Capture the request in WebhookWhisper, copy the body and signature, then verify locally. If your local verification works on the captured body but production doesn't, the prod body is being mutated upstream.
Frequently Asked Questions
Why is my hex-encoded signature failing for Shopify?
Shopify uses base64, not hex. This is the single biggest difference between Stripe/GitHub verification and Shopify verification. Switch your digest encoding.
Can I skip HMAC verification for Shopify if I'm using IP allowlists?
No. Shopify doesn't publish stable webhook IPs. HMAC is the only reliable authentication mechanism. Always verify.
My Shopify webhook signature works for some events but not others. Why?
Almost certainly different secrets. App-level webhooks use the app secret; admin-API webhooks use per-shop secrets. Check which type each webhook is and load the matching secret.