Shopify app creation feels finished the moment your app installs and loads in the admin. Then review sends it back because your webhook endpoint accepted a forged request, or your three mandatory GDPR compliance webhooks do nothing. This walkthrough fixes both in a Node.js custom app: verify the HMAC against the raw body, answer fast and process later, and write real handlers for customers/data_request, customers/redact and shop/redact.
If your logs say "HMAC verification failed" while the secret is definitely correct, this is for you.
Why does HMAC verification fail even with the right secret?
Almost always because you are hashing the wrong bytes. Shopify signs the exact raw request body, and express.json() has already parsed it into an object by the time your handler runs.
Shopify computes an HMAC-SHA256 of the body with your app's client secret, base64-encodes it and sends it in the X-Shopify-Hmac-Sha256 header. If you hash JSON.stringify(req.body) instead, spacing and unicode escaping can differ from the original. One changed byte gives a different signature.
The fix is a raw body parser on the webhook route, registered before any JSON middleware.
import express from 'express'
import crypto from 'node:crypto'
const app = express()
// Webhooks need the raw bytes, so this must come before express.json()
app.use('/webhooks', express.raw({ type: 'application/json' }))
app.use(express.json())
function isValidShopifyHmac(rawBody, hmacHeader, secret) {
if (!hmacHeader || !Buffer.isBuffer(rawBody)) return false
const digest = crypto.createHmac('sha256', secret).update(rawBody).digest()
const received = Buffer.from(hmacHeader, 'base64')
return received.length === digest.length &&
crypto.timingSafeEqual(digest, received)
}
Two details matter. timingSafeEqual stops an attacker from guessing the signature byte by byte through response timing. The length check comes first because timingSafeEqual throws on unequal lengths, and a thrown error turns a forged request into a 500 instead of a clean rejection.
What goes wrong if you skip this?
Without verification, anyone who finds your URL can post a fake shop/redact and make you wipe a live merchant's data. Or a fake orders/create that kicks off fulfilment logic.
Shopify's automated review checks this directly. It sends compliance webhooks with an invalid signature and expects a 401. Returning 200 to everything, which plenty of tutorials show, fails the check.
Slow responses cause a quieter failure.
Shopify expects a reply within five seconds. Miss that repeatedly and deliveries are marked failed and retried, and the subscription can eventually be removed. Your app then stops receiving events, and you find out when a merchant asks why orders stopped syncing.
Do you need to write this yourself?
Not always. If you built on Shopify's official React Router or Remix app template, authenticate.webhook(request) already verifies the HMAC and returns a 401 on failure. Keep it and skip to the handler logic below.
This walkthrough is for custom Express or Fastify servers, apps moved off older templates, and teams who want to know what the library does for them.
How do you answer quickly and process later?
Verify, enqueue, acknowledge, then do the real work in a background worker. The request handler should do nothing slow: no Admin API calls, no emails, no bulk deletes.
import { Queue } from 'bullmq'
const connection = { host: process.env.REDIS_HOST, port: 6379 }
const webhookQueue = new Queue('shopify-webhooks', { connection })
app.post('/webhooks', async (req, res) => {
const valid = isValidShopifyHmac(
req.body,
req.get('X-Shopify-Hmac-Sha256'),
process.env.SHOPIFY_API_SECRET,
)
if (!valid) return res.sendStatus(401)
await webhookQueue.add(
req.get('X-Shopify-Topic'),
{
shop: req.get('X-Shopify-Shop-Domain'),
payload: JSON.parse(req.body.toString('utf8')),
},
{
jobId: req.get('X-Shopify-Webhook-Id'),
attempts: 5,
backoff: { type: 'exponential', delay: 10000 },
removeOnComplete: { age: 7 * 24 * 3600 },
},
)
res.sendStatus(200)
})
Using X-Shopify-Webhook-Id as the job ID gives you deduplication. Shopify delivers at least once, so the same event can arrive twice, and BullMQ ignores a job whose ID it still holds. That is why completed jobs are kept for a week rather than removed instantly. No Redis? A database table with a unique constraint on the webhook ID does the same job.
What must the three GDPR compliance handlers actually do?
Each topic maps to a privacy obligation, and review expects real behaviour, not an empty 200.
-
customers/data_request: a customer asked the merchant for their data. The payload carriescustomer.id,customer.emailandorders_requested. Gather what your app stores about that customer and give it to the merchant. You have 30 days. -
customers/redact: delete or anonymise that customer's data, including the orders listed inorders_to_redact. Shopify holds this request back if the customer ordered recently, so when it arrives, act on it. -
shop/redact: sent about two days after a merchant uninstalls. Delete everything you hold for that shop, access tokens included.
import { Worker } from 'bullmq'
new Worker('shopify-webhooks', async (job) => {
const { shop, payload } = job.data
switch (job.name) {
case 'customers/data_request': {
const customerId = String(payload.customer.id)
const records = await db.customerRecord.findMany({
where: { shop, shopifyCustomerId: customerId },
})
await db.dataRequest.create({
data: {
shop,
customerId,
requestId: String(payload.data_request.id),
export: JSON.stringify(records),
status: 'ready_for_merchant',
},
})
await notifyMerchant(shop, payload.data_request.id)
break
}
case 'customers/redact': {
const orderIds = (payload.orders_to_redact ?? []).map(String)
await db.$transaction([
db.customerRecord.deleteMany({
where: { shop, shopifyCustomerId: String(payload.customer.id) },
}),
db.orderNote.deleteMany({
where: { shop, shopifyOrderId: { in: orderIds } },
}),
])
break
}
case 'shop/redact': {
await db.$transaction([
db.customerRecord.deleteMany({ where: { shop } }),
db.orderNote.deleteMany({ where: { shop } }),
db.session.deleteMany({ where: { shop } }),
])
break
}
}
}, { connection })
Then register the topics in shopify.app.toml and run shopify app deploy:
[webhooks]
api_version = "2026-10"
[[webhooks.subscriptions]]
compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
uri = "/webhooks"
What do most implementations miss before review?
The handlers above delete rows in your main database. Customer data usually lives in more places than that. Run this checklist before you submit:
- Logs. If you log full webhook payloads, customer emails stay in your log store long after the redact. Log the webhook ID and topic, not the body.
- Third-party tools. Email, analytics and support tools your app syncs to need their own delete call inside the redact job.
- Caches and search indexes. A deleted customer who still shows up in a Redis cache or search index has not been redacted.
-
A rejection test. Send a request with a made-up HMAC header using
curland confirm you get401, not200or500. -
A delivery test. Use
shopify app webhook triggerfor each compliance topic and watch the job complete in your worker.
The rule of thumb: if a merchant's customer asked "where is my data?", every answer your system could give should come back empty after the redact job runs.
Where to go from here
Webhooks are one piece of a production Shopify app, alongside billing, rate limits and data sync. For the wider picture, the ecommerce development guide covers how these parts fit together.
If you are close to submitting and want a second pair of eyes, Geminate Solutions offers a free review of your app or plan, with a reply within 24 hours.
Top comments (0)