Shopify webhook signature verification

Shopify signs each webhook with a base64 HMAC-SHA256 of the raw body. Paste the X-Shopify-Hmac-Sha256 header, the body and your app's client secret to check it.

Last reviewed · scheme facts from the site's provider table · Shopify documentation

Check a Shopify signature

Paste the provider's signature header as a JSON object. Header names are matched case-insensitively.

The secret is sent to AnyHook's own API to compute the HMAC. It is not stored or logged, and the result only ever shows masked signatures.

The exact rule

These are the facts this verifier implements, read from the same provider table that powers /providers. A mismatch here is a bug in the tool, not a preference.

Signature header
x-shopify-hmac-sha256
Algorithm
HMAC-SHA256
Signed string
raw body
Encoding
base64
Timestamp / tolerance
Not published
Signing secret
App client secret

How Shopify signs a webhook

The header is `X-Shopify-Hmac-Sha256: <base64>`. Shopify computes HMAC-SHA256 with the app's client secret as the key and the raw request body as the message, then encodes the digest as base64. There is no prefix such as `sha256=` and no timestamp component; the provider table this site publishes records a null tolerance because Shopify publishes none for the signature.

Because the body alone is signed, the verifier must receive exactly the bytes Shopify sent. JSON examples in Shopify's docs are pretty-printed and are not what a delivery looks like; a re-serialised copy of a preview payload will not verify. Keep the raw bytes from the request, verify, then parse.

The same client secret signs every webhook topic for the app, so one verifier works for orders, products and app-specific topics. If you rotate the client secret, old and new deliveries are not both valid: rotate during a quiet period or accept the short window of failed deliveries.

Which secret to use, and what the timestamp means

The key is the app's client secret from the Shopify Partner Dashboard or the app's API credentials page — not an access token, not an API key and not a store's webhook secret from a different app. A token begins with `shpat_` and is used for the Admin API; the client secret is the value Shopify uses for webhook HMACs.

Shopify publishes no timestamp tolerance for the webhook signature, so this tool does not reject on age and the result card reports no timestamp block. That is worth saying plainly: the signature does not protect against replay, and a handler that needs replay protection must add its own identifier check.

Keep the client secret in the same secret store as the rest of the app's credentials and read it at request time, not at boot, so a rotation can be applied without a code change. When you do rotate it, expect every in-flight retry signed with the old secret to fail; that is the scheme working as designed, not a verifier bug. A handler that logs the X-Shopify-Webhook-Id on failure can tell the difference between a replayed body and a secret that is simply out of date.

The raw body is the input

Frameworks that parse JSON for you are the trap here too. In Next.js use `await request.text()`; in Express use `express.raw({ type: "application/json" })` and pass the Buffer; in PHP read `php://input` rather than `$_POST`. If the body has already been consumed, the raw bytes are gone and no amount of re-serialising will reproduce them.

Character encoding matters at the byte level. Read the body as UTF-8 text (or keep the Buffer) and compute the HMAC over that exact value. Do not normalise Unicode, strip a trailing newline or decode percent-escapes before verifying.

A delivery also carries headers that describe it — X-Shopify-Topic, X-Shopify-Shop-Domain, X-Shopify-Webhook-Id and the API version. None of them is part of the HMAC, which is why a signature can be valid while your topic routing is wrong. The webhook id is useful for deduplication and for matching a failed delivery to a retry, and a handler that returns a non-2xx should expect to see the same delivery again.

A sample signature header

The sample header below was generated from this body with a made-up client secret. Load a fresh sample in the tool for a self-consistent set that verifies.

Header(s)

{
  "x-shopify-hmac-sha256": "zZaMvXMqcfw74UxHLaAMKrvFCqNlEKBwekXPOzj3GNI="
}

Raw body

{"id":820982911946154508,"topic":"orders/create","shop_domain":"example.myshopify.com","body":"{\"id\":820982911946154508}"}

Example secret (not a real credential)

shpss_example_secret_not_real

Verify it in your own server

Three complete implementations of the same rule. Each reads the exact header(s), HMACs the raw body with the secret and compares in constant time; the comment at the top says where the raw body comes from in that runtime.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the exact bytes Shopify sent. Do not run JSON.parse first.
export function verifyShopifySignature(rawBody, hmacHeader, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("base64");
  const expectedBuffer = Buffer.from(expected);
  const providedBuffer = Buffer.from(hmacHeader);
  return (
    expectedBuffer.length === providedBuffer.length &&
    timingSafeEqual(expectedBuffer, providedBuffer)
  );
}

Python

import base64
import hashlib
import hmac

def verify_shopify_signature(raw_body: bytes, hmac_header: str, secret: str) -> bool:
    digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(expected, hmac_header.encode())

PHP

<?php
// $rawBody = file_get_contents("php://input");
function verify_shopify_signature(string $rawBody, string $hmacHeader, string $secret): bool {
    $expected = base64_encode(hash_hmac("sha256", $rawBody, $secret, true));
    return hash_equals($expected, $hmacHeader);
}

Common Shopify failures

Each row is a failure seen while verifying this scheme, not a generic webhook checklist. The symptom column is what the checker or your handler reports; the fix is the change that resolves it.

MistakeSymptomFix
Access token used instead of the client secretEvery HMAC fails; the token looks plausible and is the right length for an API credential.Use the app's client secret from API credentials, not `shpat_` tokens or store API keys.
Base64 compared against a hex digestThe expected and provided values look entirely different even for a correct body.Base64-encode the digest (`digest("base64")`, `base64.b64encode`, `base64_encode`).
Body parsed and re-serialisedA captured raw body verifies in the tool but the handler rejects the same delivery.Verify the raw bytes before JSON parsing; keep one raw copy for both steps.
Buffer read as UTF-8 text then re-encodedOnly fails for bodies with non-ASCII characters; test payloads in English pass.Compute the HMAC over the Buffer/bytes you received, not over a transformed string.
Trailing whitespace or newline added by a proxyWorks with a direct curl replay, fails in production behind a gateway.Check the body length at the handler; a proxy that appends bytes invalidates the HMAC.
Wrong app's client secret in a multi-app storeSome topics verify and others do not, or only one app's webhooks fail.Each app has its own secret; map the delivery's app before choosing the key.
Comparing the decoded signature with `===`Verification is correct but not constant-time on a public endpoint.Use `timingSafeEqual`, `hmac.compare_digest` or `hash_equals` after a length check.
Expecting a timestamp to validateHandlers reject or flag correct deliveries because there is no timestamp header.Shopify's scheme has no timestamp; add replay protection yourself if you need it.

Shopify signature FAQ

Where is the Shopify client secret?
In the Shopify Partner Dashboard for the app, on the app's API credentials page (called the client secret or app secret). It is not an Admin API access token.
Why does Shopify not send a timestamp?
The published webhook signature scheme signs the body only. Shopify documents no timestamp header or tolerance for webhook HMAC verification, so replay protection is not provided by this header.
Does the HMAC cover the headers too?
No. The HMAC covers the raw request body. The signature header is the base64 digest of that body, keyed with the app's client secret.
Why does base64 matter?
The digest bytes are the same either way; the encoding is not. Shopify sends base64 and a handler comparing hex will never match. Encode to base64, padding included.
Can I verify Shopify webhooks without a library?
Yes — HMAC-SHA256 plus a constant-time compare, as in the three snippets above. The math is the same in every runtime; only the header read and encoding helper differ.
Does this tool store the payload or secret?
No. The check runs in AnyHook's own API route for the request and returns a masked result; the body and secret are not written anywhere and are not sent to Shopify.

What this tool cannot tell you

  • It cannot detect replay: Shopify's signature has no timestamp, so an old captured delivery still verifies.
  • It cannot prove the request came from Shopify's infrastructure; a valid HMAC only proves possession of the app's client secret.
  • It cannot tell you which app in a multi-app store the delivery belongs to; the HMAC has to use the right app's secret.
  • It cannot recover a body your framework parsed before the check.

Next checks

A valid signature does not mean the delivery succeeded. Time your handler against each sender's response budget, and keep the event so a failure can be replayed.