Test Shopify Webhooks Locally
Your HMAC keeps failing because you hashed a parsed body, compared against hex, or used the wrong app client secret. Get a stable URL Shopify can reach, see the exact X-Shopify-Hmac-SHA256 it sent, and Replay the delivery until your base64 compare matches.
30-second setup
From a stuck handler to a live Shopify delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL — something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3App-specific webhooks: put a relative
uri = "/webhooks"inshopify.app.tomland let the CLI point it at your tunnel. For shop-specific or admin webhooks, register the full URL —https://your-app-12.localcan.dev/webhooks— via the Admin APIwebhookSubscriptionCreate, or in the merchant admin under Settings → Notifications → Webhooks. Because the LocalCan URL is fixed, you can hardcode it once instead of chasing a rotating tunnel. - 4Fire one with
shopify app webhook trigger --topic=orders/create --address=https://your-app-12.localcan.dev/webhooks --client-secret=shpss_... --delivery-method=http. You pass--client-secretso the CLI can sign a validX-Shopify-Hmac-SHA256. For admin-created webhooks, use Settings → Notifications → Webhooks → ⋯ → Send test. Note: there is no per-delivery Replay button in Shopify — you re-trigger, or you hit Replay in LocalCan. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Shopify webhooks
Shopify sends webhooks from its servers to a public HTTPS URL. localhost:3000 only exists on your machine, so Shopify cannot connect and every orders/create shows up as a failed delivery. Worse than a 404: after 8 consecutive failures Shopify auto-deletes Admin-API webhook subscriptions, so a few hours of an unreachable endpoint can silently remove your subscription entirely.
You need a public URL that forwards to your handler — and one that does not change on restart. shopify app dev opens a Cloudflare Quick Tunnel whose URL rotates every run, which is why app-config webhooks use a relative uri. The moment you move to shop-specific subscriptions or a real merchant admin, that rotating URL means re-registering the endpoint every session.
What makes the Shopify loop fast
One URL that survives the auto-delete
Your …localcan.dev URL is fixed and reused on every restart, so the webhook subscription you register stays pointed at a reachable endpoint. That matters more on Shopify than most providers: Admin-API subscriptions are deleted after 8 failed deliveries, so a tunnel URL that rotated overnight does not just break — it can wipe the subscription. Register once, stop re-pasting URLs into webhookSubscriptionCreate.
See base64 vs hex at a glance
The inspector shows the exact X-Shopify-Hmac-SHA256 header and the unparsed body Shopify sent. The two failures that eat hours here are obvious side by side: a hex digest where Shopify sent base64, and a body your JSON parser already re-serialized. Compare the real header and raw bytes against what your code computes instead of guessing why timingSafeEqual returned false.
Replay the same bytes after a rotation
Hit Replay to re-send the identical captured request — same body, same header — without firing a new shopify app webhook trigger (which always sends the same canned sample and is never retried). When you swap in the right shpss_ secret or fix the raw-body capture, replay the exact delivery that failed and confirm the HMAC matches, no dev-store order needed.
Inspect & replay Shopify events
Common events
orders/createproducts/updateapp/uninstalledcustomers/data_requestmandatory compliance topiccustomers/redactmandatory compliance topicshop/redactmandatory compliance topicapp/scopes_update
These are the topics most apps wire up first — orders/create and products/update for the core loop, app/uninstalled and app/scopes_update for lifecycle, and the three mandatory compliance topics (customers/data_request, customers/redact, shop/redact) every public app must handle to pass review. Trigger one, watch it land in Inspect Traffic with full headers and JSON body, and expand it to read the topic and payload.
Shopify-specific caveat: the endpoint must return 200 within a 5-second total timeout (1s to connect). Verify the HMAC, enqueue the work, and respond immediately — do the heavy lifting async. When the handler throws or your compare fails, fix the code and hit Replay; LocalCan re-sends the exact same bytes, which is the only way to re-exercise a payload-specific case since shopify app webhook trigger always sends one fixed sample.
Verify the Shopify signature
- Header
X-Shopify-Hmac-SHA256- Algorithm
- HMAC-SHA256, base64-encoded
- What's signed
- Shopify signs the RAW request body bytes only — no timestamp prefix, no versioned signing string. You compute
base64(HMAC-SHA256(rawBody, clientSecret))and compare it, timing-safe, against theX-Shopify-Hmac-SHA256header. Parse the JSON before reading the raw body, or compare against a hex digest, and it can never match. - Secret
- The signing key is your app’s CLIENT SECRET (a.k.a. API secret key / shared secret), found in the Partner/Dev Dashboard under Apps → your app → client credentials. It is one per-app secret — not a per-endpoint secret — so the same
shpss_…signs every webhook. Newer secrets are 38 chars with a staticshpss_prefix; older ones were 32 chars with no prefix. After rotating it, Shopify can take up to an hour to start signing with the new secret, so verification may transiently fail during that window.
import express from 'express'
import crypto from 'crypto'
const clientSecret = process.env.SHOPIFY_API_SECRET // shpss_...
const app = express()
// Shopify signs the RAW bytes — mount express.raw BEFORE any JSON parser.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Shopify-Hmac-SHA256') || ''
// base64(HMAC-SHA256(rawBody, clientSecret)) — NOT hex.
const digest = crypto
.createHmac('sha256', clientSecret)
.update(req.body) // req.body is a Buffer of the raw bytes
.digest('base64')
// Compare the decoded base64 values, length-checked, in constant time.
const a = Buffer.from(header, 'base64')
const b = Buffer.from(digest, 'base64')
const ok = a.length === b.length && crypto.timingSafeEqual(a, b)
if (!ok) {
return res.status(401).send('HMAC verification failed')
}
// Return 200 within 5s — enqueue heavy work, do not block on it.
const topic = req.get('X-Shopify-Topic')
if (topic === 'orders/create') {
// handle it asynchronously
}
res.sendStatus(200)
})LocalCan does not verify the HMAC for you — your code does — but it hands you both halves so a failure is debuggable instead of silent. The inspector shows the X-Shopify-Hmac-SHA256 Shopify actually sent and the untouched raw body, so you can tell a base64-vs-hex mistake from a parsed body from the wrong shpss_ secret in seconds.
Then hit Replay to re-send the identical request and confirm your fix — far faster than re-triggering through the CLI, which always sends the same canned sample and never retries on failure.
LocalCan vs Shopify CLI
Shopify ships a capable CLI, but it is not a general local-forward tool — shopify app webhook trigger sends one fixed sample payload (no payload variation, no retries, and Shopify says it can’t validate your real subscriptions), while shopify app dev opens a rotating Cloudflare tunnel. The honest split: lean on the CLI for a quick canned orders/create inside an app project; reach for LocalCan when you need one stable URL across providers, a GUI inspector, or Replay of the exact real payload.
| LocalCan | Shopify CLI | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | shopify app dev tunnel rotates each run |
| Works across providers | One URL for Shopify, Stripe, GitHub… | Shopify app projects only |
| Real payload, not a sample | Replay the exact delivery Shopify sent | Trigger sends one fixed canned sample |
| Trigger a topic on demand | Use the CLI / a real dev-store action | shopify app webhook trigger --topic=… built in |
| Sign with a valid HMAC | Real Shopify signs with your shpss_ | CLI signs via --client-secret |
| Inspect full payloads | GUI inspector, headers + body, on by default | Terminal logs from the dev process |
| Re-send a failed delivery | One-click Replay of the exact request | Re-trigger only (triggers aren’t retried) |
| Share with a teammate | Shareable URL + basic auth | Local to your machine |
Shopify webhook FAQ
Stop guessing why the webhook 401'd. Get a stable Shopify endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.