NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Shopify delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 3App-specific webhooks: put a relative uri = "/webhooks" in shopify.app.toml and 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 API webhookSubscriptionCreate, 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.
  4. 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-secret so the CLI can sign a valid X-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.
  5. 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/create
  • products/update
  • app/uninstalled
  • customers/data_requestmandatory compliance topic
  • customers/redactmandatory compliance topic
  • shop/redactmandatory compliance topic
  • app/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 the X-Shopify-Hmac-SHA256 header. 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 static shpss_ 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.
Node.js
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.

LocalCanShopify CLI
Stable public URLPersistent …localcan.dev, survives restartsshopify app dev tunnel rotates each run
Works across providersOne URL for Shopify, Stripe, GitHub…Shopify app projects only
Real payload, not a sampleReplay the exact delivery Shopify sentTrigger sends one fixed canned sample
Trigger a topic on demandUse the CLI / a real dev-store actionshopify app webhook trigger --topic=… built in
Sign with a valid HMACReal Shopify signs with your shpss_CLI signs via --client-secret
Inspect full payloadsGUI inspector, headers + body, on by defaultTerminal logs from the dev process
Re-send a failed deliveryOne-click Replay of the exact requestRe-trigger only (triggers aren’t retried)
Share with a teammateShareable URL + basic authLocal 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.