Shopify signs each webhook with HMAC-SHA256 over the raw request body, using your app's secret, and sends it base64-encoded in the X-Shopify-Hmac-Sha256 header. A mismatch almost always comes from one of these:
.digest('base64'), not 'hex'.timingSafeEqual / hmac.compare_digest.app.post('/shopify', express.raw({ type: 'application/json' }), (req, res) => {
const digest = crypto.createHmac('sha256', process.env.SHOPIFY_SECRET).update(req.body).digest('base64');
const header = req.get('X-Shopify-Hmac-Sha256') || '';
const ok = header.length === digest.length && crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(header));
res.sendStatus(ok ? 200 : 401);
});
digest = base64.b64encode(hmac.new(secret.encode(), request.get_data(), hashlib.sha256).digest()).decode()
ok = hmac.compare_digest(digest, request.headers.get('X-Shopify-Hmac-Sha256', ''))
The usual Python mistakes, in order:
payload: dict or Pydantic parameter, or await request.json(), then json.dumps(...). Python's default json.dumps output (spacing, \u escapes for non-ASCII) won't match Shopify's bytes. Hash await request.body().hexdigest() instead of base64. The header is base64 of the raw digest: base64.b64encode(mac.digest()).str(body). That gives "b'...'". Pass the bytes straight to hmac.new.import base64, hashlib, hmac, json, os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SHOPIFY_SECRET = os.environ["SHOPIFY_CLIENT_SECRET"].encode()
@app.post("/webhooks/shopify")
async def shopify_webhook(request: Request): # no dict / Pydantic body parameter
body = await request.body() # raw bytes, before any parsing
received = request.headers.get("x-shopify-hmac-sha256", "")
digest = base64.b64encode(hmac.new(SHOPIFY_SECRET, body, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(digest, received):
raise HTTPException(status_code=401, detail="HMAC verification failed")
data = json.loads(body) # parse only after verifying
return {"ok": True}
Shopify expects a 2xx within five seconds, so queue slow work instead of doing it in the handler. If deliveries aren't arriving at all, see Shopify webhook not firing.
The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. It verifies Shopify's X-Shopify-Hmac-Sha256 out of the box. Set SCHEME_<ID>=shopify. It works the same way for Stripe, Lemon Squeezy, Paddle Billing, Square, Twilio (status callbacks) and GitHub.
Fix Your Stripe Webhooks guide · Self-Hosted Webhook Relay Kit. Code LAUNCH40 = 40% off until Sun Oct 11, 6:40 AM MT.
Free download: Webhook debugging cheat sheet.
Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint.