How to Test Shopify Webhooks Locally (Without ngrok)
To test Shopify webhooks locally without ngrok:
- Start your webhook handler locally (e.g.
node server.js) - Authenticate the CLI:
hooklistener login - Start a tunnel:
hooklistener tunnel --port 3000 - Register the tunnel URL as a webhook in Shopify Admin or via the Admin API
- Trigger events (create a test order) and watch them arrive locally
Shopify requires a publicly accessible HTTPS URL for every webhook subscription. During local development, that means you need a tunnel — and ngrok's free tier now requires an account, rotates URLs on restart, and adds latency. This guide shows how to use Hooklistener's CLI tunnel as a faster, simpler alternative.
Why Shopify webhook testing is painful
Shopify's webhook system has several properties that make local testing harder than other platforms:
- HTTPS required — Shopify rejects plain HTTP webhook URLs. No exceptions, even for development.
- No localhost — Shopify's servers need to reach your endpoint over the public internet.
- Automatic removal — Shopify removes webhook subscriptions after 19 consecutive failures. If your tunnel is down, your webhooks silently disappear.
- No built-in test button — Unlike Stripe, Shopify doesn't have a "Send test event" button in the admin. You need to trigger real events or use the Shopify CLI.
- HMAC verification — Every webhook must be verified with HMAC-SHA256, and getting the raw body handling right is a common stumbling block.
Shopify webhook architecture
Shopify webhooks deliver events as JSON POST requests to your configured URL. Key concepts:
- Topics — Events are organized by resource and action:
orders/create,products/update,customers/delete, etc. - API versioning — Each subscription is pinned to an API version (e.g.
2024-10). The payload schema changes between versions. Always specify the version explicitly. - HMAC-SHA256 signature — Every request includes an
X-Shopify-Hmac-Sha256header containing a base64-encoded HMAC of the raw request body, computed with your app's shared secret. - Idempotency — Shopify may deliver the same webhook more than once. Use the
X-Shopify-Webhook-Idheader to deduplicate.
Info:Shopify gives you 5 seconds to respond with a 2xx status. If your handler takes longer, Shopify considers it a failure. Return 200 immediately and process the event asynchronously if needed.
1. Set up your local webhook handler
Create a handler that receives Shopify webhooks and verifies the HMAC signature. This example uses Express:
const express = require("express");
const crypto = require("crypto");
const app = express();
const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
// Important: use raw body for HMAC verification
app.post("/webhooks/shopify", express.raw({ type: "application/json" }), (req, res) => {
const hmacHeader = req.headers["x-shopify-hmac-sha256"];
const body = req.body; // Buffer when using express.raw()
const generatedHmac = crypto
.createHmac("sha256", SHOPIFY_WEBHOOK_SECRET)
.update(body)
.digest("base64");
const isValid = crypto.timingSafeEqual(
Buffer.from(generatedHmac),
Buffer.from(hmacHeader)
);
if (!isValid) {
console.error("HMAC verification failed");
return res.status(401).send("Unauthorized");
}
const payload = JSON.parse(body.toString());
const topic = req.headers["x-shopify-topic"];
console.log(`Received ${topic}:`, payload.id);
// Return 200 immediately — process async if needed
res.status(200).send("OK");
});
app.listen(3000, () => console.log("Listening on port 3000"));Important:The most common HMAC verification failure is using express.json() instead of express.raw(). If Express parses the body to JSON first, the HMAC won't match because the raw bytes have changed. Always compute the HMAC from the raw request body.
2. Start a Hooklistener tunnel
Authenticate the CLI and create a public HTTPS URL that forwards to your local server:
# First time only — authenticate
hooklistener login
# Start a tunnel to your local port
hooklistener tunnel --port 3000Copy the tunnel URL (e.g. https://k7x2m9.hook.events). Your webhook URL will be:
https://k7x2m9.hook.events/webhooks/shopifyRequests to this URL are forwarded to http://localhost:3000/webhooks/shopify with all headers, body, and query parameters preserved.
3. Register the webhook in Shopify
Option A: Shopify Admin UI
- Go to Settings → Notifications in your Shopify admin
- Scroll to the bottom and click Create webhook
- Select the event (e.g. Order creation)
- Set the format to JSON
- Paste your Hooklistener tunnel URL:
https://<subdomain>.hook.events/webhooks/shopify - Select the API version
- Click Save
Option B: GraphQL Admin API
For programmatic registration, use the webhookSubscriptionCreate mutation:
mutation {
webhookSubscriptionCreate(
topic: ORDERS_CREATE
webhookSubscription: {
callbackUrl: "https://k7x2m9.hook.events/webhooks/shopify"
format: JSON
}
) {
webhookSubscription {
id
}
userErrors {
field
message
}
}
}Run this against https://<store>.myshopify.com/admin/api/2024-10/graphql.json with your Admin API access token.
4. Trigger test events
Shopify doesn't have a "Send test webhook" button. You have two options:
Option A: Trigger real events
Create a test order in your development store. Use Shopify's Bogus Gateway for payment testing — it processes immediately without real charges.
Option B: Shopify CLI
The Shopify CLI can trigger webhook events directly to your URL:
# Trigger an orders/create event
shopify webhook trigger --topic orders/create \
--api-version 2024-10 \
--address https://k7x2m9.hook.events/webhooks/shopifyWatch your Hooklistener CLI — you'll see the request appear with status code and response time. Check your handler logs to confirm the HMAC verified and the payload was processed.
Info:The Shopify CLI sends test payloads with a valid structure but won't include a valid HMAC for your app's secret. You may want to skip HMAC verification when testing with the CLI, or use a known test secret.
5. Debugging common Shopify webhook issues
HMAC mismatch
The #1 cause: using the parsed JSON body instead of the raw body. The HMAC must be computed from the exact bytes Shopify sent — before any JSON parsing, whitespace normalization, or character encoding changes.
Also check that you're using the correct secret. For custom apps, it's the API secret key from the app settings. For Shopify admin notifications, it's shown at the bottom of Settings → Notifications.
Webhooks silently disappearing
Shopify removes subscriptions after 19 consecutive delivery failures. If your tunnel was down overnight, your webhooks may have been deleted. Re-register them and consider monitoring your webhook subscriptions programmatically.
API version deprecation
Shopify deprecates API versions regularly. If your webhook subscription uses a deprecated version, Shopify auto-upgrades it — which may change the payload format. Pin your subscriptions to a supported version and test after each quarterly release.
Handler returning non-2xx
If your handler throws an unhandled exception or returns 4xx/5xx, Shopify counts it as a failure. After 19 failures, the subscription is removed. Always wrap your handler in a try-catch and return 200 before doing heavy processing.
6. Handling Shopify's mandatory GDPR webhooks
Every Shopify app must implement three mandatory GDPR webhook endpoints, even if your app doesn't store customer data:
| Topic | Purpose |
|---|---|
customers/data_request | Customer requests their data — respond with what you store |
customers/redact | Customer requests deletion — erase their data from your systems |
shop/redact | Store uninstalls your app — erase all store data within 48 hours |
These are configured in the Partner Dashboard under your app's settings, not via the API. During development, point them at your Hooklistener tunnel URL to test locally:
app.post("/webhooks/customers/data_request", express.raw({ type: "application/json" }), (req, res) => {
// Verify HMAC, then collect customer data
const payload = JSON.parse(req.body.toString());
console.log("Data request for customer:", payload.customer.id);
res.status(200).send("OK");
});
app.post("/webhooks/customers/redact", express.raw({ type: "application/json" }), (req, res) => {
const payload = JSON.parse(req.body.toString());
console.log("Redact customer:", payload.customer.id);
// Delete customer data from your database
res.status(200).send("OK");
});
app.post("/webhooks/shop/redact", express.raw({ type: "application/json" }), (req, res) => {
const payload = JSON.parse(req.body.toString());
console.log("Redact shop:", payload.shop_domain);
// Delete all data for this shop
res.status(200).send("OK");
});Important:Shopify will reject your app submission if these endpoints don't return 200. Test them locally through your Hooklistener tunnel before submitting for review.