Hookbase
LoginGet Started Free
Back to Blog
Tutorial

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.

Hookbase Team
April 25, 2026
6 min read

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:

  1. 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.
  2. Base64, not hex. Shopify uses base64. GitHub uses hex. Stripe uses hex. Mixing these up is a common copy-paste mistake.
  3. 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.

Try it free.

shopifywebhookssignature-verificationsecuritytutorial

Related Articles

Product Update

The Architecture Diagram Is Now an Interactive Canvas

The topology view used to be a static Mermaid render that kept erroring out on perfectly valid input. It is now a pan / zoom / drag canvas built on React Flow with dagre auto-layout — interactive, dark-mode aware, and stable across every diagram we have thrown at it.

Product Update

Smart Monitoring — Catch Provider Changes and Traffic Anomalies Before They Break You

Anomaly detection learns each source's normal traffic pattern and alerts when volume spikes or drops vs. that baseline. Schema drift auto-infers a JSON schema from observed payloads and tells you the moment a provider adds, removes, or retypes a field. Both ship today on Pro and Business.

Product Update

Hookbase CLI v2.4 — Scaffold, Filter, and Sign in One Release

The new hookbase init command scaffolds a working webhook handler in any of five frameworks. hookbase listen filters tunnel traffic before it reaches localhost. And hookbase trigger now signs payloads using the live provider catalog. Three additions, one upgrade.

Ready to Try Hookbase?

Start receiving, transforming, and routing webhooks in minutes.

Get Started Free
Hookbase

Reliable webhook infrastructure for modern teams. Built on Cloudflare's global edge network.

Product

  • Features
  • Pricing
  • Use Cases
  • Integrations
  • ngrok Alternative

Resources

  • Documentation
  • API Reference
  • CLI Guide
  • Blog
  • FAQ

Free Tools

  • All Tools
  • Webhook Bin
  • HMAC Calculator
  • JSONata Playground
  • Cron Builder
  • Payload Formatter
  • Local Testing

Legal

  • Privacy Policy
  • Terms of Service
  • Contact
  • Status

© 2026 Hookbase. All rights reserved.