You set up the webhook. Got the secret. Wrote the HMAC check. Still getting:
HMAC VALIDATION FAILED
Or worse — it passes locally but fails Shopify's automated review. Here's every real reason it happens.
if you just want all of this handled without thinking about it — Tern fixes every issue below out of the box. keep reading to understand why.
1. You're using the wrong secret
This is the most common one and it's not obvious from the docs.
Shopify doesn't give you a dedicated webhook secret like Stripe does. You use your app's client secret — the API secret key from your Partner Dashboard. Not the API key. Not the access token. The client secret.
# ❌ wrong — this is the API key
SHOPIFY_API_KEY=abc123...
# ✅ correct — this is what signs webhooks
SHOPIFY_API_SECRET=shpss_xyz789...
If you're passing the wrong value here every HMAC will fail silently.
2. The signature is base64 — not hex
Shopify's X-Shopify-Hmac-SHA256 header is base64 encoded, not hex like Stripe.
Most HMAC examples online use .digest('hex'). That gives you the wrong output for Shopify.
// ❌ wrong encoding — hex output won't match
const signature = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
// ✅ correct — base64 to match Shopify's header
const signature = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64')
One word difference. Hours of debugging.
3. Raw body getting parsed before verification
Same problem as Stripe but Shopify's error message gives you even less to go on.
Your framework parses the body into a JavaScript object before your handler runs. You re-stringify it. The whitespace changes. The signature doesn't match.
// ❌ body is already a parsed object here
app.use(express.json())
app.post('/webhook', (req, res) => {
const body = JSON.stringify(req.body) // not the original bytes
// verification fails
})
Fix — raw body parser specifically for the webhook route:
// ✅raw bytes preserved
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const hmac = req.headers['x-shopify-hmac-sha256']
const signature = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(req.body) // Buffer, not parsed object
.digest('base64')
if (signature !== hmac) return res.status(401).send('Unauthorized')
res.status(200).send('OK')
}
)
4. Shopify's automated review check failing
You test manually, everything works. You submit for app review, the HMAC check fails.
This happens because Shopify's automated checker sends GDPR compliance webhooks — not regular order or product events. If your handler only handles specific topics and ignores GDPR webhooks, the check fails.
Make sure your handler responds correctly to:
customers/data_requestcustomers/redactshop/redact
These are mandatory. If they return anything other than 200, the HMAC check is marked as failed even if your signature logic is correct.
5. Body consumed before your route runs
In Remix and some other frameworks, the authentication middleware reads the request body first. By the time your webhook handler runs, the stream is already consumed.
Fix — clone the request before anything touches it:
const reqClone = request.clone()
const rawPayload = await reqClone.text()
// use rawPayload for HMAC verification
The pattern underneath all of this
Shopify webhook failures almost always come down to wrong secret, wrong encoding, or body consumed before verification. The HMAC logic itself is not complicated. The plumbing around it is where everything breaks.
How Tern handles all of this
We kept hitting these same issues — wrong encoding assumptions, raw body bugs, framework quirks. So we built Tern to absorb all of it.
Signature verification — handled correctly
Base64 encoding, raw body extraction, correct header — all handled internally. You get a specific errorCode when something fails instead of a silent 400.
import { createWebhookHandler } from '@hookflo/tern/nextjs'
export const POST = createWebhookHandler({
platform: 'shopify',
secret: process.env.SHOPIFY_API_SECRET!,
handler: async (payload) => {
// verified — base64, raw body, all handled
}
})
Same on Express, Cloudflare Workers and Hono. No raw body setup, no encoding decisions.
After verification — events still fail silently
Shopify retries failed webhooks but you have no visibility into what failed, why, or when. Merchants find out before you do.
Tern's optional reliability layer closes that gap — queue, retries, deduplication, dead letter queue and replay:
export const POST = createWebhookHandler({
platform: 'shopify',
secret: process.env.SHOPIFY_API_SECRET!,
queue: {
token: process.env.QSTASH_TOKEN!,
retries: 3,
},
alerts: {
slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
},
handler: async (payload) => {
// queued, retried, alerted on failure
}
})
Know when something fails before your merchants do.
verify → queue → retry → dedup → DLQ → replay → alert. the full inbound webhook loop, closed.
⭐ if this helped, a star means a lot — Tern
Docs: tern.hookflo.com
github.com/Hookflo/tern — open source, MIT licensed, zero lock-in.
Top comments (0)