Verify Shopify Webhook Signature (X-Shopify-Hmac-SHA256 Header)
Shopify signs webhook requests using the X-Shopify-Hmac-SHA256 header. To verify the signature, you must compute an HMAC-SHA256 hash of the raw request body using your webhook secret and compare it to the Base64-encoded header value.
🔒 Your webhook secret never leaves your browser. All verification happens client-side.
Find this in your Shopify Admin → Settings → Notifications → Webhooks
Code Examples
Copy-paste examples for verifying Shopify webhook signatures in your application.
Node.js / Express
Using crypto moduleconst express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use raw body for webhook routes
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const hmacHeader = req.headers['x-shopify-hmac-sha256'];
const secret = process.env.SHOPIFY_WEBHOOK_SECRET;
if (!hmacHeader) {
return res.status(400).send('Missing HMAC header');
}
// Compute expected HMAC (Base64-encoded)
const hmac = crypto.createHmac('sha256', secret);
hmac.update(req.body);
const expectedHmac = hmac.digest('base64');
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(expectedHmac)
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Parse and handle the event
const event = JSON.parse(req.body.toString());
console.log('Shopify webhook:', event);
res.json({ received: true });
}
);
app.listen(3000);Next.js App Router
Route handler// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
export async function POST(req: NextRequest) {
const body = await req.text();
const hmacHeader = req.headers.get('x-shopify-hmac-sha256');
const secret = process.env.SHOPIFY_WEBHOOK_SECRET!;
if (!hmacHeader) {
return NextResponse.json(
{ error: 'Missing HMAC header' },
{ status: 400 }
);
}
// Compute expected HMAC (Base64-encoded)
const hmac = crypto.createHmac('sha256', secret);
hmac.update(body);
const expectedHmac = hmac.digest('base64');
// Timing-safe comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(hmacHeader),
Buffer.from(expectedHmac)
);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse and handle the event
const event = JSON.parse(body);
console.log('Shopify webhook:', event);
return NextResponse.json({ received: true });
}Python / Flask
Using hmac moduleimport hmac
import hashlib
import base64
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
hmac_header = request.headers.get('X-Shopify-Hmac-SHA256')
secret = os.getenv('SHOPIFY_WEBHOOK_SECRET').encode()
if not hmac_header:
return jsonify({'error': 'Missing HMAC header'}), 400
# Compute expected HMAC (Base64-encoded)
payload = request.get_data()
expected_hmac = base64.b64encode(
hmac.new(secret, payload, hashlib.sha256).digest()
).decode()
# Timing-safe comparison
if not hmac.compare_digest(hmac_header, expected_hmac):
return jsonify({'error': 'Invalid signature'}), 401
# Parse and handle the event
event = request.get_json()
print(f"Shopify webhook: {event}")
return jsonify({'success': True}), 200
if __name__ == '__main__':
app.run(port=3000)Ruby / Rails
Using OpenSSL# config/routes.rb
post '/webhook', to: 'webhooks#shopify'
# app/controllers/webhooks_controller.rb
require 'openssl'
require 'base64'
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def shopify
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
unless hmac_header
render json: { error: 'Missing HMAC header' }, status: 400
return
end
payload = request.body.read
secret = ENV['SHOPIFY_WEBHOOK_SECRET']
# Compute expected HMAC (Base64-encoded)
expected_hmac = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', secret, payload)
)
# Timing-safe comparison
unless Rack::Utils.secure_compare(hmac_header, expected_hmac)
render json: { error: 'Invalid signature' }, status: 401
return
end
# Parse and handle the event
event = JSON.parse(payload)
puts "Shopify webhook: #{event}"
render json: { success: true }, status: 200
end
endPHP
Using hash_hmac<?php
// webhook.php
$hmacHeader = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '';
if (empty($hmacHeader)) {
http_response_code(400);
die('Missing HMAC header');
}
$payload = file_get_contents('php://input');
$secret = getenv('SHOPIFY_WEBHOOK_SECRET');
// Compute expected HMAC (Base64-encoded)
$expectedHmac = base64_encode(
hash_hmac('sha256', $payload, $secret, true)
);
// Timing-safe comparison
if (!hash_equals($hmacHeader, $expectedHmac)) {
http_response_code(401);
die('Invalid signature');
}
// Parse and handle the event
$event = json_decode($payload, true);
error_log('Shopify webhook: ' . print_r($event, true));
http_response_code(200);
echo json_encode(['success' => true]);
?>🔒 Security reminders:
- Shopify uses Base64 encoding (not hex like GitHub/Stripe)
- Always use timing-safe comparison functions
- Verify signatures BEFORE parsing the JSON payload
- Use the webhook secret, not your API secret key
Frequently Asked Questions
Where do I find my Shopify webhook secret?
To find your webhook secret:
- Log in to your Shopify Admin
- Go to Settings → Notifications
- Scroll down to the Webhooks section
- Click on the webhook you want to verify
- The secret is shown in the webhook details
Note: This is different from your API secret key. Don't confuse them!
What is the X-Shopify-Hmac-SHA256 header?
The X-Shopify-Hmac-SHA256 header is a security header that Shopify includes in every webhook request. It contains a Base64-encoded HMAC-SHA256 hash of the raw request body, generated using your Shopify webhook secret.
Shopify computes this HMAC-SHA256 signature before sending the webhook. To verify the request, your server must compute the same HMAC-SHA256 hash using the exact raw request body bytes and your webhook secret, then compare the result to the value in the X-Shopify-Hmac-SHA256 header.
If the computed hash matches the header value, the webhook is authentic and was sent by Shopify. If it does not match, the request should be rejected because it may have been modified or forged.
How does Shopify webhook verification work?
Shopify uses HMAC-SHA256 to sign webhook payloads:
- Shopify computes an HMAC of the raw request body using your secret
- The HMAC is Base64-encoded and sent in the
X-Shopify-Hmac-SHA256header - Your server computes the same HMAC and compares it
- If they match, the webhook is authentic
Why does verification fail in my code but work here?
Common causes:
- Body parsing: Your framework parsed the JSON before verification
- Encoding mismatch: Shopify uses Base64, not hex like GitHub
- Wrong secret: Using API key instead of webhook secret
- Character encoding: Body was converted to/from string incorrectly
Fix: Verify against the raw request body bytes before parsing JSON.
What's the difference between Shopify, Stripe, and GitHub signatures?
| Feature | Shopify | Stripe | GitHub |
|---|---|---|---|
| Header | X-Shopify-Hmac-SHA256 | Stripe-Signature | X-Hub-Signature-256 |
| Encoding | Base64 | Hex | Hex |
| Algorithm | HMAC-SHA256 | HMAC-SHA256 | HMAC-SHA256 |
| Timestamp | No | Yes (5 min tolerance) | No |
How do I test Shopify webhooks locally?
Options for local testing:
1. Use ngrok or Cloudflare Tunnel:
ngrok http 3000 # Copy HTTPS URL to Shopify webhook settings
2. Use Shopify CLI:
shopify app dev # Shopify CLI creates a tunnel automatically
3. Manually craft test webhooks:
Use this tool to verify your manually created HMAC signatures match expectations.
What webhook topics does Shopify support?
Shopify supports webhooks for many events:
orders/create,orders/updated,orders/paidproducts/create,products/update,products/deletecustomers/create,customers/updatefulfillments/create,fulfillments/update- And many more...
See the Shopify webhook documentation for a complete list.