Shopify Webhook Signature Verification, Explained
Shopify HMAC verification trips up almost every first-time integrator. Here is exactly how the signature is computed, what goes wrong, and a working implementation in Node, Python, Go, and Ruby.
Why This Always Breaks the First Time
You wire up a Shopify webhook, point it at your endpoint, and your signature check returns false on every event. You triple-check the secret. You log the header. The math looks right. Nothing works.
The problem is almost never the secret. It's that you're hashing the wrong bytes.
How Shopify Signs Webhooks
Shopify sends an X-Shopify-Hmac-Sha256 header containing a base64-encoded HMAC-SHA256 of the raw request body, computed with your shared secret as the key.
Three things matter, in this exact order:
- Raw bytes, not parsed JSON. The HMAC is over the exact bytes Shopify sent. The instant your framework parses the body and re-serializes it, key order shifts and whitespace changes — your hash no longer matches.
- Base64, not hex. Shopify uses base64. GitHub uses hex. Stripe uses hex. Mixing these up is a common copy-paste mistake.
- The secret is the one from the webhook config, not your API secret key. They're different.
The Working Implementation
Node.js (Express)
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Shopify-Hmac-Sha256');
const computed = crypto
.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
.update(req.body)
.digest('base64');
if (
!signature ||
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
)
) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
console.log('Topic:', req.header('X-Shopify-Topic'));
res.status(200).send('ok');
}
);
The critical line is express.raw({ type: 'application/json' }). The default express.json() middleware parses the body into an object — by the time you'd hash it, the original bytes are gone.
Python (Flask)
import hmac, hashlib, base64, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['SHOPIFY_WEBHOOK_SECRET'].encode()
@app.post('/webhook')
def webhook():
signature = request.headers.get('X-Shopify-Hmac-Sha256', '')
computed = base64.b64encode(
hmac.new(SECRET, request.get_data(), hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(signature, computed):
abort(401)
return 'ok'
request.get_data() returns raw bytes. Do not use request.json.
Go
func verify(body []byte, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write(body)
expected := base64.StdEncoding.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
Read the body with io.ReadAll(r.Body) once, then use those bytes for both verification and parsing.
Ruby (Rails)
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
body = request.body.read
signature = request.headers['X-Shopify-Hmac-Sha256']
computed = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_WEBHOOK_SECRET'], body)
)
head :unauthorized and return unless ActiveSupport::SecurityUtils.secure_compare(signature, computed)
head :ok
end
end
The Five Mistakes to Avoid
1. Letting your framework parse the body first. This is the #1 cause of "my HMAC doesn't match." Read raw bytes, hash those, then parse.
2. Using == instead of constant-time comparison. Timing attacks against HMAC verification are real. Always use the language's secure-compare primitive.
3. Re-encoding the body to UTF-8 unnecessarily. Shopify sends bytes. Treat them as bytes. Encoding round-trips can change them.
4. Hashing in hex. A two-character mistake — .digest('hex') instead of .digest('base64') — that produces a wrong hash that's exactly the right length, so the comparison fails silently.
5. Using the wrong secret. The webhook secret is shown once when you create the webhook, either in the Shopify Partner dashboard or via the Admin API. Store it then; you can't retrieve it later.
When Verification Should Happen
Verify the signature before doing anything else with the payload — including logging it. An attacker who can post arbitrary payloads to your endpoint can poison your logs, trigger downstream alerts, or fill your queue with junk. The signature check is the first gate.
// 1. Read raw body
// 2. Verify signature -> return 401 if bad
// 3. Parse JSON
// 4. Acknowledge with 200
// 5. Process async
How Hookbase Removes the Footgun
Hookbase verifies Shopify HMACs (and 30+ other providers) before the event reaches your handler. You configure the secret once, and every event that arrives at your endpoint is guaranteed verified. You can't accidentally skip it, parse the body too early, or compare in non-constant time — because there's no signature code in your handler at all.