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

MCP Tools for Webhook Recovery — Let Claude or Cursor Drive the Fix

The clusters page, replay-with-edit modal, and pattern hints we shipped over the last three weeks are all the same loop: triage → probe → fix → confirm → fan out. Today that loop is callable from MCP, so any AI assistant can drive recovery end to end.

Product Update

Active Incidents — Tell Me Which Cluster Is Spiking Right Now

Failure clusters last week told you what failure patterns exist. They didn't tell you which one is on fire right now. Two new rate windows split clusters into "active incidents" (escalating) and everything else — so when you arrive during an incident, the page tells you where to look.

Product Update

Two New Tabs That Tell You What Likely Broke, Before RCA Even Runs

A hand-curated library of 12 common webhook failure patterns matches every failed delivery in microseconds — likely cause and suggested fix appear before any AI call. Alongside it, a new Recent Changes tab pulls every audit log entry for the route/destination/transform involved in the failure over the last 14 days.

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.