How to Test Shopify Webhooks Locally Without ngrok
By Max
Testing Shopify webhooks during local development has the same problem every webhook system has. Your app is running on localhost, and Shopify needs a public HTTPS URL to reach it. The default workaround is ngrok — install it, run ngrok http 3000, paste the URL into your webhook subscription, done.
ngrok is fine. It's not the only tool, and for a lot of workflows it's not the best one. This guide covers three ways to test Shopify webhooks locally without touching ngrok, plus the HMAC mistakes that quietly cost developers hours.
TL;DR
- Tunnels (Cloudflare Tunnel, localtunnel): best for full end-to-end testing with real Shopify events
- Crafted payloads with computed HMAC signatures: best for automated tests in CI
- Capture and replay: best for debugging specific events and working across multiple providers
- The non-negotiable detail: hash the raw request body with HMAC-SHA256 using your app's client secret, base64-encode it, compare in constant time
Most developers end up using a combination.
Why ngrok might not be the right fit
ngrok works, and for a quick connectivity check it's hard to beat. But once you're past the first delivery, a few things start to feel limiting:
- URLs rotate. The free tier hands you a fresh subdomain every time you restart, which means re-registering your Shopify webhook subscription each session.
- Signup required. ngrok now needs an account-and-authtoken even for local-only setups, which adds a step every time a teammate joins the project.
- No offline support. A fixture you captured from production three weeks ago is useless if your testing tool needs Shopify to actually fire the event.
- Slow debug loop. "Re-run the order in Shopify and hope the bug reproduces" is a painful way to track down a flaky handler.
- Single-purpose. If you're building against Shopify and Stripe and a SaaS provider, ngrok solves only one third of the problem.
None of these are dealbreakers for the smoke-test use case it's good at. But there are better tools for everything else — once you understand what's actually in a Shopify webhook delivery.
What's in a Shopify webhook delivery
Whichever option you pick, your handler is going to receive the same shape of request. Here's what's in it, so you know what to assert against and what to look for when something doesn't add up.
The body is JSON, encoded as UTF-8. The schema differs per topic (orders/create looks nothing like customers/redact), and Shopify's reference covers each one. The headers, on the other hand, are consistent enough to be worth memorizing:
| Header | What it tells you |
|---|---|
X-Shopify-Topic |
The event topic, e.g. orders/create or inventory_levels/update. Route on this. |
X-Shopify-Shop-Domain |
The *.myshopify.com domain of the store. Use this to look up which install the webhook belongs to. |
X-Shopify-Hmac-SHA256 |
Base64-encoded HMAC-SHA256 of the raw body, computed with your app's client secret. Verify before doing anything else. |
X-Shopify-API-Version |
The API version the payload was serialized in, e.g. 2025-04. Useful when you're handling a long-running app across versions. |
X-Shopify-Webhook-Id |
A unique ID per delivery. Use this for idempotency — store it and refuse to process the same ID twice. |
X-Shopify-Triggered-At |
The timestamp Shopify generated the event, in ISO-8601. Useful for ordering checks. |
The two that matter most for testing are the topic header (tells you which fixture to load) and the HMAC header (the thing every fake-payload script has to compute correctly). Everything else is debugging context.
Option 1: Use a tunnel (Cloudflare Tunnel, localtunnel)
A tunnel is the same shape as ngrok — public URL maps to a port on your machine — but the implementations differ in setup cost, URL stability, and whether you need an account.
Cloudflare Tunnel ships as cloudflared and gives you a stable hostname under your own domain for free. The setup cost is higher than ngrok the first time — you log in to Cloudflare, point a record at the tunnel, run cloudflared tunnel run — but the hostname doesn't rotate.
# One-time setup
cloudflared tunnel login
cloudflared tunnel create shopify-webhooks
cloudflared tunnel route dns shopify-webhooks dev.example.com
# Day-to-day
cloudflared tunnel --url http://localhost:3000 run shopify-webhooks
Now https://dev.example.com reaches your local app, and Shopify never has to be reconfigured between sessions.
localtunnel is the lightest option — npx localtunnel --port 3000 and you're up. URLs rotate the same way ngrok's free ones do, but there's no account required.
npx localtunnel --port 3000
# your url is: https://twelve-fishes-fly.loca.lt
The trade-off: localtunnel has variable reliability and is best for one-off testing rather than long-running development sessions.
When tunnels are the right answer: You want real Shopify-driven webhook deliveries. You're testing the full lifecycle — subscription, delivery, retry — end to end. You're hitting it manually from the dashboard or generating real test orders.
When tunnels are the wrong answer: You need to test the same payload repeatedly without spamming a development store, you need to test edge cases that you can't reliably trigger from Shopify's UI, or you need it to work offline (CI, plane, Wi-Fi-less coffee shop).
Option 2: Craft local webhook payloads yourself
A Shopify webhook is a POST with a JSON body and a base64-encoded HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header. There's nothing magic about it. You can construct one yourself.
The recipe:
- Capture or write the JSON body for the event you want to simulate.
- Compute HMAC-SHA256 of that exact body using your app's client secret as the key.
- Base64-encode the digest.
- POST it to your local handler with the signature in the header.
Here's a one-shot bash script:
#!/usr/bin/env bash
SECRET='your_app_client_secret'
BODY='{"id":820982911946154508,"email":"jon@doe.ca","total_price":"199.65"}'
HMAC=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -X POST http://localhost:3000/webhooks/orders-create \
-H "Content-Type: application/json" \
-H "X-Shopify-Topic: orders/create" \
-H "X-Shopify-Shop-Domain: dev-store.myshopify.com" \
-H "X-Shopify-Hmac-SHA256: $HMAC" \
-d "$BODY"
That's a real, signed Shopify webhook your handler will accept the same way it would accept one delivered from myshopify.com.
For repeat testing, save the body to a fixture file and parameterize the script:
#!/usr/bin/env bash
SECRET=$SHOPIFY_APP_SECRET
TOPIC=$1
FIXTURE="fixtures/${TOPIC//\//-}.json"
BODY=$(cat "$FIXTURE")
HMAC=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -X POST "http://localhost:3000/webhooks/$TOPIC" \
-H "Content-Type: application/json" \
-H "X-Shopify-Topic: $TOPIC" \
-H "X-Shopify-Shop-Domain: dev-store.myshopify.com" \
-H "X-Shopify-Hmac-SHA256: $HMAC" \
-d "$BODY"
Now ./fire.sh orders/create replays a known-good order-creation payload against your local handler — with a valid signature — in milliseconds. Same shape works for inventory_levels/update, customers/redact, anything.
When crafted payloads are the right answer: Repeatable test scenarios. CI integration tests. Edge cases you can't reliably trigger from Shopify (specific timestamp values, malformed line items you want to assert your handler rejects gracefully, GDPR-compliance webhooks). Anywhere offline.
When crafted payloads are the wrong answer: End-to-end testing where the bug might be in the subscription itself. Verifying that Shopify actually fires what you think it fires — for that, you need a real delivery from Shopify.
Option 3: Capture real webhooks, replay them later
The third pattern: subscribe a public capture endpoint, let Shopify deliver real webhooks to it, store them, then replay them to your local handler whenever you want.
Tools in this category include WebhookHub, webhook.site, and RequestBin. Each gives you a unique URL, captures and stores incoming requests, and provides some way to inspect or replay them.
Why this matters for Shopify specifically:
- Shopify's payloads are large and inconsistent across event types. Generating accurate fixtures by hand is fiddly.
- Some events only fire on real shop activity — subscription billing webhooks, inventory adjustments through Shopify POS — and there's no Shopify CLI shortcut to trigger them on demand.
- Production data tends to surface schema variations dev stores never produce. Capturing once from a real workflow and replaying is far more representative than fabricating.
The workflow looks like this:
- Create a capture endpoint with whichever tool you use.
- Subscribe Shopify to it (in the app config, or via the
webhookSubscriptionCreateGraphQL mutation). - Trigger the action in your dev store — create an order, update inventory, whatever.
- Find the captured request in the tool, copy the body and headers, replay it locally.
Most capture tools provide a one-click forward to a different URL — including localhost via a tunnel. Some let you store captured payloads as named fixtures and replay them on demand.
When capture-and-replay is the right answer: You want real production-shaped data. You're debugging a bug that only reproduces against specific payload structures. You want to share a captured webhook with a teammate so they can debug the same case. You want a regression suite built from actual Shopify deliveries.
When capture-and-replay is the wrong answer: First-time setup of a brand-new handler — there's nothing yet for Shopify to deliver to. Use a tunnel for the first delivery, then switch to capture-and-replay for everything after.
Which approach should you actually use?
| Scenario | Best option |
|---|---|
| First-time integration, manual smoke test | Tunnel (Cloudflare or localtunnel) |
| Repeatable handler tests in CI | Crafted payloads from fixtures |
| Debugging a specific bug from production | Capture real webhook, replay locally |
| Testing every Shopify webhook topic | Crafted payloads (you'll never trigger them all manually) |
| Verifying your subscription itself works | Tunnel — you need a real delivery |
| Working offline / on a plane | Crafted payloads (the only one that doesn't need network) |
The shortest answer: use a tunnel for the first delivery so you know your subscription is real, then move to crafted payloads for everything you'll run more than once, and reach for capture-and-replay when you need production-shaped data.
Three mistakes that will cost you hours
1. Hashing the parsed body instead of the raw body
This is the single most common cause of "invalid HMAC" errors in Shopify webhook handlers. Shopify computes the HMAC over the exact bytes they sent. The moment your framework parses the JSON, re-serializes it, or strips whitespace, your local hash will not match.
In Express:
// WRONG — express.json() parses and re-serializes; signature will fail
app.use(express.json());
app.post('/webhooks/orders-create', (req, res) => {
const bodyForHash = JSON.stringify(req.body); // already too late
// ...
});
// RIGHT — capture the raw body before parsing
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks/orders-create', (req, res) => {
const rawBody = req.body; // Buffer, untouched
const expected = crypto
.createHmac('sha256', process.env.SHOPIFY_APP_SECRET)
.update(rawBody)
.digest('base64');
const provided = req.headers['x-shopify-hmac-sha256'];
if (!crypto.timingSafeEqual(
Buffer.from(expected, 'base64'),
Buffer.from(provided, 'base64')
)) {
return res.status(401).end();
}
// safe to JSON.parse(rawBody.toString()) now
});
The same trap exists in Laravel, Rails, Django, and FastAPI. Whatever stack you're on, the rule is identical: get the raw bytes before any middleware touches them.
2. Comparing signatures with == instead of timing-safe compare
== (or .equals(), in some languages) leaks information about how many leading bytes match. An attacker can use the timing differences to guess your signature one byte at a time. It's slow but real, and security reviewers will flag it on sight.
// WRONG
if (computed === shopifyHeader) { /* ... */ }
// RIGHT
if (crypto.timingSafeEqual(
Buffer.from(computed, 'base64'),
Buffer.from(shopifyHeader, 'base64')
)) { /* ... */ }
PHP has hash_equals. Python has hmac.compare_digest. Use them.
3. Forgetting that secret rotation has a propagation window
If you rotate your app's client secret, Shopify takes up to an hour to start signing webhooks with the new value. During that window, deliveries can arrive signed with either the old or new secret depending on which Shopify edge served them.
If your handler only knows about the new secret, half your webhooks will fail verification for the next hour.
The pragmatic fix: support both for a transition period. Verify against the new secret first, fall back to the old one for any delivery within an hour of the rotation. Drop the old secret once the window has passed.
function verify(rawBody, header) {
return [process.env.SHOPIFY_APP_SECRET, process.env.SHOPIFY_APP_SECRET_PREVIOUS]
.filter(Boolean)
.some(secret => {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
return crypto.timingSafeEqual(
Buffer.from(expected, 'base64'),
Buffer.from(header, 'base64')
);
});
}
Wrapping up
ngrok is a fine tool for the problem it solves first time round: a quick public URL for an end-to-end smoke test. For everything else, you've got better options depending on what you're trying to do.
Tunnels get you real events hitting real code. Crafted payloads get you fast, deterministic tests. Capture-and-replay gets you persistent history, replay on demand, and a workflow that scales across every webhook provider you work with, not just Shopify.
The common thread: shorten the feedback loop between "Shopify fires an event" and "I can see what my code does with it." The faster that loop gets, the fewer hours you lose to silent HMAC failures.
If you're working with other providers, the same patterns apply with provider-specific quirks — see How to Test Stripe Webhooks Locally Without Stripe CLI and How to Test GitHub Webhooks Locally Without Smee.io. The signature schemes differ but the raw-body trap and timing-safe-compare rules are identical.
If you're testing webhooks regularly, from Shopify or anywhere else, WebhookHub gives you persistent request history, real-time streaming, and replay on the free tier. Built for exactly this kind of workflow.