Test Shopify webhooks without deploying
Capture order, product and app events from a Shopify store, and verify the base64 X-Shopify-Hmac-SHA256 header.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Shopify at Partner dashboard app settings, or Settings, Notifications, Webhooks, then trigger something. Events worth starting with:
orders/paidorders/createproducts/updateapp/uninstalled
Verify the X-Shopify-Hmac-SHA256 header#
Shopify signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA256 over the raw body, base64-encoded, keyed with your app client secret.
- Header
X-Shopify-Hmac-SHA256- Algorithm
- HMAC-SHA256, base64
- Signed content
- the raw body
const crypto = require("crypto");
// Base64, not hex. This is the detail people copy wrong from a Stripe example.
const expected = crypto
.createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET)
.update(rawBody, "utf8")
.digest("base64");
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers["x-shopify-hmac-sha256"]),
);crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The digest is base64, where Stripe and GitHub are hex. Copying a verifier between them silently fails every request.
- The key is your app's client secret, not a per-webhook secret.
- express.json() and equivalent middleware must not run before you have captured the raw body.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Shopify event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Shopify handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "orders/paid" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Shopify’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.