← All articles

Shopify Webhooks: HMAC & GDPR Topics

Webhooker Team 8 min read
Flat illustration of Shopify HMAC verification: two equal-length digests compared segment by segment, matching, and accepted by a blue pulse node.

Shopify signs every webhook with an X-Shopify-Hmac-Sha256 header. To verify it, compute an HMAC-SHA256 over the raw, unparsed request body using your app’s API secret, encode that digest as base64, then compare it to the header value with a constant-time check. The bug that catches almost everyone: comparing as hex instead of base64.

We have debugged this exact failure more times than we would like to admit. The signature looks right, the length is even close, and the comparison still fails, because one side is 64 hex characters and the other is a 44-character base64 string. Get the encoding right and the rest of the Shopify integration falls into place. This article walks through verification, the three mandatory GDPR webhooks Shopify sends, and how to make sure a redaction request never gets dropped on the floor.

How Shopify signs its webhooks

When Shopify sends a webhook, it computes an HMAC-SHA256 of the exact bytes it is about to POST, keyed with your app’s API secret (the same secret from your app settings, not the API access token). It base64-encodes that digest and puts it in the X-Shopify-Hmac-Sha256 header. A few other headers ride along and are worth reading:

The key detail is what Shopify hashes: the raw body, byte for byte. Not a re-serialized JSON object. Not a pretty-printed version your framework handed you. The bytes on the wire. Shopify’s own reference makes this explicit, so it is worth reading their webhook verification docs once before you write a line of code.

Verifying the signature correctly

Here is the whole thing in pseudocode. It is short on purpose, because the logic is short. Everything that goes wrong goes wrong in the details around it.

raw_body   = request.raw_bytes          // before any JSON parsing
secret     = app_api_secret
computed   = base64(hmac_sha256(secret, raw_body))
received   = header["X-Shopify-Hmac-Sha256"]

if constant_time_equals(computed, received):
    accept()
else:
    reject(401)

Three things decide whether this works.

Use the raw bytes. If your web framework parses the JSON body before your handler runs, and you then re-encode that object to hash it, the bytes will differ from what Shopify signed. Key order, whitespace, and Unicode escaping all shift, and the signature breaks. Capture the body before any middleware touches it. In Express that means a raw body parser on the webhook route; in Rails it means reading request.body.read rather than params.

Encode as base64, not hex. This is the one. Shopify’s signature is base64. If your HMAC library defaults to a hex digest, which many do, you will produce a string that can never match no matter how correct the hash is underneath. Encode the digest bytes as base64 before comparing. Encoding is one of six causes behind a failed signature check, and the only one where the digest itself was correct all along.

Compare in constant time. Do not use a plain == string comparison. A normal comparison can leak, through timing, how many leading characters matched, which over enough requests helps an attacker forge a signature. Use a constant-time comparison function: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, Rack::Utils.secure_compare in Ruby.

If you would rather not hand-roll and maintain this per store, you can add a Shopify source with base64 HMAC-SHA256 verification in Webhooker and let the gateway check every signature before anything reaches your app. The per-source HMAC support covers Shopify’s base64 scheme directly, which is the same mechanism that trips up a first hand-rolled integration.

The three mandatory GDPR webhooks

Any public Shopify app has to handle three compliance webhooks. Shopify sends them, and your app is expected to respond. They are your hook into a merchant’s data-subject obligations, and Shopify documents the contract for each in its privacy law compliance guide. Here is what each one means in practice:

All three arrive signed with the same X-Shopify-Hmac-Sha256 header, so verify them exactly like any other webhook before acting. An unverified redaction request is a request you cannot trust, and acting on a forged one, or ignoring a real one, both put you in a bad spot. Read Shopify’s guide for the current response expectations and timelines, since those are Shopify’s terms and they can change.

Handling redactions reliably

Here is what makes the compliance webhooks different from an orders/create you might occasionally afford to miss: a dropped redaction request is a data-deletion obligation you silently failed to meet. Nobody retries it for you on a schedule you control. So the handling has to be boring and durable.

The pattern we trust is persist, verify, confirm, in that spirit but reordered for safety:

  1. Verify the signature first. No valid HMAC, no action.
  2. Persist the request before doing the deletion work. Write the redaction job to durable storage and acknowledge receipt, so a crash mid-processing does not lose it.
  3. Do the actual erasure as a tracked job, and record that it completed.

The reason to persist before processing is simple. Deletions can touch several systems, some of them slow, and if your process dies halfway through, you want a record that says “this redaction is owed” so it gets picked up again rather than vanishing. This is where at-least-once delivery on a durable queue earns its keep. Webhooker writes each event to a PostgreSQL deliveries queue, retries with backoff and a circuit breaker when your endpoint is down, and drops anything that exhausts retries into a dead-letter queue you can replay. A redaction request that failed at 3am because your service was mid-deploy is still sitting in the DLQ, replayable, rather than gone.

Where data residency matters

Every one of these webhooks can carry customer personal data: names, email addresses, and the identifiers tied to a customers/redact. That is enough to make the payload itself personal data under GDPR, with the retention and erasure duties that follow. That payload flows through whatever receives your webhooks before it reaches your database, which means your webhook layer is part of your data-processing footprint whether you think of it that way or not.

For a merchant serving EU shoppers, where that layer runs is a real question, not a formality. Webhooker keeps ingest, storage, workers, and backups EU-only, with a DPA available on paid plans, which is a direct plus when the data in flight is Shopify customer PII. We wrote more about the reasoning behind keeping Shopify customer data in the EU if you want the longer version. The short one: fewer cross-border transfers to explain in your records of processing.

Reliable delivery so nothing gets lost

Signature verification tells you a webhook is authentic. It does nothing to guarantee you actually processed it. Those are separate problems, and the second one is where integrations quietly rot.

Shopify will retry a failed webhook for a while, but its retry window is its own, not tuned to your incident. If your endpoint is down for an afternoon, you are relying on Shopify’s schedule and hoping the event has not aged out. A gateway in front of your app changes the shape of that: it returns a fast 200 OK to Shopify in milliseconds so ingestion is decoupled from your processing, then handles delivery to your service out of band with its own retries, circuit breaker, and a replayable DLQ. That is the whole point of putting verification, retries and replay for Shopify events in one layer instead of scattering it across ad-hoc handlers.

If you want to try it against a real store, you can receive and verify Shopify webhooks on the free tier and watch the signature checks and retries happen before you commit anything to your own code.

Frequently asked questions

Hex or base64?

Base64. Shopify’s X-Shopify-Hmac-Sha256 header is the base64 encoding of the raw HMAC-SHA256 digest, not its hex representation. If your HMAC library returns a hex string by default, which many do, convert the digest to base64 before you compare. A signature that fails despite correct key and body is almost always this: the right hash in the wrong encoding.

Do I have to implement all three GDPR topics?

For a public app, yes. Shopify requires public apps to subscribe to and respond to customers/data_request, customers/redact, and shop/redact, and it verifies this during app review. Even a private or custom app benefits from handling them, since the underlying obligation to delete customer data on request does not disappear just because Shopify is not enforcing it. Check Shopify’s compliance docs for current requirements.

Where is the data stored?

With Webhooker, in the EU. Ingest endpoints, the PostgreSQL delivery queue, the workers that process events, and backups all run in EU regions, and a DPA is available on paid plans. For Shopify merchants whose webhooks carry customer PII, that keeps the webhook layer inside the EU rather than adding another cross-border transfer to account for in your GDPR records.