How to Verify Shopify Webhook Signatures (and the Base64 Raw-Body Trap)

작성자

카테고리:

← 피드로
DEV Community · EventDock · 2026-07-21 개발(SW)

EventDock

Shopify webhook verification fails for two reasons more than any other, and both come from habits that work everywhere else. The signature is base64, not the hex string Stripe and GitHub hand you, and Shopify’s own quickstart parses the body with express.json() before you get a chance to hash the bytes it actually signed. Get either wrong and your comparison never matches even though your code looks right. This post shows the exact Shopify webhook check, which secret to use, and the traps that account for most of the failures.

The exact scheme

Per Shopify’s docs, each webhook request carries an HMAC in the X-Shopify-Hmac-Sha256 header. It is HMAC-SHA256 computed over the raw request body, keyed with your app’s client secret, and the result is base64-encoded. Three details decide whether your check works: the raw body, the client secret, and base64.

Verifying the signature

Compute the HMAC over the untouched body, base64-encode it, and compare it to the header in constant time.

const crypto = require('crypto');

// Your app's client secret: Partner Dashboard > your app > API credentials
const CLIENT_SECRET = process.env.SHOPIFY_API_SECRET;

function isValidShopifyWebhook(rawBody, hmacHeader) {
  const digest = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(rawBody)              // the raw bytes, not a re-serialized object
    .digest('base64');           // base64, NOT hex

  const a = Buffer.from(digest);
  const b = Buffer.from(hmacHeader || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body, verify, then parse
app.post('/webhooks/shopify',
  express.raw({ type: '*/*' }),
  (req, res) => {
    const rawBody = req.body;     // Buffer, untouched
    const hmac = req.get('X-Shopify-Hmac-Sha256');

    if (!isValidShopifyWebhook(rawBody, hmac)) {
      return res.status(401).send('invalid hmac');
    }

    const payload = JSON.parse(rawBody.toString('utf8'));  // parse only after
    const topic = req.get('X-Shopify-Topic');              // e.g. orders/create
    // ... handle the event
    res.sendStatus(200);
  });

Enter fullscreen mode Exit fullscreen mode

The comparison uses crypto.timingSafeEqual rather than ===, because a plain string compare returns early on the first differing byte and leaks timing. Guard the length first, since timingSafeEqual throws when the two buffers differ in length. Shopify expects a fast 200 on success and treats a non-2xx as a failed delivery, so respond with 401 when the HMAC does not match and keep the handler quick.

The raw-body trap

HMAC signs bytes, not objects. If a JSON body parser runs first and you rebuild a string from the parsed object to verify against, the bytes are no longer identical to what Shopify sent. Whitespace and key order shift and the signature never matches. This one bites Shopify developers especially often because the platform’s introductory tutorials wire up express.json() at the top of the app, which consumes and reparses the body before your webhook route runs. Put the raw capture on the webhook route itself with express.raw(), verify against the Buffer, and call JSON.parse only after the check passes. It is the same trap Stripe, Paddle, and most others share, laid out in the webhook signature verification comparison.

Base64, not hex

Most signature guides you have read, including for Stripe and GitHub, end in .digest('hex'). Shopify does not. It base64-encodes the HMAC, so the header looks like XWJ...= with mixed case and often a trailing =, not a long run of lowercase hex. If you copy verification code from a Stripe example and only change the header name and secret, the digest encoding stays hex and every Shopify comparison fails. Change hex to base64 and the same code starts matching. When you compare, compare like to like: base64 digest against the base64 header, or decode both to bytes first, but do not mix one of each.

Which secret Shopify signs with

The signing key is your app’s client secret, the same value you use in the OAuth flow, found under API credentials in the Partner Dashboard. Reach for the wrong value and every check fails, and there are a few wrong values sitting right next to it. The client secret is not your API access token, and it is not the API key that pairs with it, it is the secret. If you run more than one app, or a development app alongside a production one, each has its own client secret, so a webhook delivered under one app verified with the other app’s secret will never match. When verification fails and you are sure the body and the encoding are right, the secret is the next thing to check.

The mistakes that account for most failures

  • Encoding the digest as hex instead of base64.
  • Verifying against a re-serialized body instead of the raw bytes, usually because express.json() ran first.
  • Using the wrong secret: the API access token or API key instead of the client secret, or another app’s client secret.
  • Comparing with === instead of a constant-time function.
  • Testing against one store or app and shipping with a different app’s credentials, so the secret no longer matches.

Signatures do not cover the events you never receive

A valid HMAC proves a webhook really came from Shopify. It does nothing for the orders/create or app/uninstalled event that never arrives because your server was mid-deploy, timed out under load, or threw before it acknowledged. Shopify retries a failed delivery 8 times over about 4 hours, and for a subscription created through the Admin API it deletes the subscription after 8 consecutive failures, so a bad stretch does not just lose events, it can switch the topic off entirely, and the events behind it are orders, refunds, and fulfillments, the ones you least want to miss.

That is the layer EventDock adds. You point Shopify at EventDock instead of your app. EventDock verifies the signature against the raw body, stores the event, and acknowledges Shopify right away so no timeout or downtime window costs you a delivery, then forwards it to your app with its own retries and a dead-letter queue you can replay by hand. If your app is down for an hour, the events wait and arrive when it recovers instead of counting toward the failure threshold that disables the topic. For the duplicate deliveries any retrying pipeline produces, the exactly-once processing pattern keeps your handler idempotent.

You can point a Shopify test webhook at EventDock on the free tier and watch every event get verified, stored, and delivered. Start with EventDock free.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다