- Shopify
- Engineering
Shopify Webhook Verification: Raw Body HMAC in Node.js
Learn how to handle Shopify webhook verification in Node.js using raw request buffers, crypto.timingSafeEqual, and secure middleware patterns.
Handling incoming notifications from Shopify is central to building apps that react to inventory shifts, customer updates, or completed checkouts. Because these endpoints accept payloads directly from the public internet, reliable shopify webhook verification is your primary line of defense. Without validating the signature attached to each incoming request, an attacker could forge order data, alter customer statuses, or inject arbitrary payloads directly into your backend pipelines.
The validation process relies on HMAC-SHA256, but in Node.js environments, developers frequently run into verification failures caused by JSON body parsers that mutate the incoming payload before the cryptographic hash is computed. Here is how Shopify signs these requests, why standard parsing breaks your validation logic, and how to verify signatures cleanly in Node.js.
How Shopify HMAC Validation Works
Every webhook request sent by Shopify includes a set of custom HTTP headers. The key header for integrity validation is X-Shopify-Hmac-Sha256. This header contains a base64-encoded HMAC-SHA256 hash computed using your app's shared API secret key and the raw string bytes of the HTTP request body.
When your server receives a webhook, your responsibility is to replicate this computation: calculate an HMAC-SHA256 digest of the exact raw body received on the wire using your app secret, encode it in base64, and compare that digest to the value supplied in the header. If the two signatures match, the payload is authentic and was not altered in transit.
There are two major pitfalls here. The first is character encoding and JSON key ordering: if your framework parses JSON into an object and you then serialize it back with JSON.stringify(), whitespace changes and key order variations will yield an entirely different hash. The second pitfall is timing attacks: comparing strings with standard equality operators exposes subtle execution time differences that attackers can measure to guess signatures.
Capturing the Raw Request Buffer in Express
Standard Express setups apply express.json() globally across all routes. By default, this middleware parses the stream, builds a JavaScript object, and drops the underlying raw buffer. To fix this, you must retain the raw buffer specifically for your webhook endpoints.
You can achieve this by passing a custom verify callback directly into the express.json() middleware options. This callback grants access to the underlying Buffer before the JSON body parser transforms it into an object, letting you attach it directly to the request object for later verification.
import express, { Request, Response, NextFunction } from 'express';
declare global {
namespace Express {
interface Request {
rawBody?: Buffer;
}
}
}
export const jsonWithRawBody = express.json({
verify: (req: Request, _res: Response, buf: Buffer, encoding: BufferEncoding) => {
if (buf && buf.length) {
req.rawBody = Buffer.from(buf);
}
},
});Notice that we store the raw body as a Buffer rather than converting it to a UTF-8 string right away. Keeping the raw bytes intact prevents any accidental re-encoding or normalization issues when dealing with internationalized characters or custom payload formats.
Implementing the Shopify Webhook Verification Middleware
With the raw buffer safely stored on the request, we can now write a dedicated verification middleware. This middleware forms a crucial part of your overall Shopify API security posture by dropping unverified traffic at the application boundary.
To compare the calculated digest against the incoming header safely, use crypto.timingSafeEqual. This function takes constant time regardless of how many bytes match, preventing timing attacks. Because timingSafeEqual requires both buffers to have the exact same length, we must verify their byte lengths before executing the comparison.
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
const SHOPIFY_APP_SECRET = process.env.SHOPIFY_API_SECRET || '';
export function verifyShopifyWebhook(req: Request, res: Response, next: NextFunction): void {
const hmacHeader = req.header('X-Shopify-Hmac-Sha256');
const shopDomain = req.header('X-Shopify-Shop-Domain');
const topic = req.header('X-Shopify-Topic');
if (!hmacHeader || !shopDomain || !topic) {
res.status(401).json({ error: 'Missing required Shopify webhook headers' });
return;
}
if (!req.rawBody) {
res.status(500).json({ error: 'Raw body buffer missing on request' });
return;
}
const generatedHash = crypto
.createHmac('sha256', SHOPIFY_APP_SECRET)
.update(req.rawBody)
.digest();
const providedHash = Buffer.from(hmacHeader, 'base64');
if (generatedHash.length !== providedHash.length) {
res.status(401).json({ error: 'Invalid webhook signature length' });
return;
}
const isValid = crypto.timingSafeEqual(generatedHash, providedHash);
if (!isValid) {
res.status(401).json({ error: 'Invalid webhook signature' });
return;
}
next();
}This pattern guarantees that only requests legitimately signed by Shopify can pass down to your business logic. If you are developing features like fulfillment syncs or cart transforms within a custom app setup, pairing this validation middleware alongside patterns from building custom Shopify apps with the Admin GraphQL API gives you a resilient foundation.
Handling Async Work and Timeouts
Shopify expects your webhook endpoint to return a 200 OK status code within 5 seconds. If your handler takes longer to respond, Shopify considers the delivery a failure and initiates an exponential retry cycle. If failures persist across several hours, Shopify will eventually suspend webhook deliveries to your endpoint entirely.
Never perform expensive operations like external API calls or PDF generation synchronously inside the webhook request lifecycle. Once the signature passes verification, respond to Shopify immediately with a 200 OK and delegate the payload processing to background jobs with BullMQ and Redis.
import { Router, Request, Response } from 'express';
import { verifyShopifyWebhook } from '../middleware/verifyShopifyWebhook';
import { webhookQueue } from '../queues/webhookQueue';
const router = Router();
router.post(
'/webhooks/orders/create',
verifyShopifyWebhook,
async (req: Request, res: Response) => {
const shop = req.header('X-Shopify-Shop-Domain') as string;
const topic = req.header('X-Shopify-Topic') as string;
const webhookId = req.header('X-Shopify-Webhook-Id') as string;
// Enqueue job for background processing
await webhookQueue.add('orders-create', {
shop,
topic,
webhookId,
payload: req.body,
});
// Respond quickly to satisfy Shopify's 5-second deadline
res.status(200).send('Webhook received');
}
);
export default router;Notice that the route accepts both the raw body validation middleware and parses JSON into req.body. Because the custom verify callback captured req.rawBody beforehand, express.json() proceeds normally to construct req.body, giving your handler typed access to the parsed JSON without invalidating the cryptographic signature.
Handling Idempotency in Webhook Handlers
Even with signature verification solved, webhook consumers must be designed to handle duplicate payloads. Network fluctuations, dropped connections, or internal Shopify retries mean your server may receive the exact same webhook more than once.
Always inspect the X-Shopify-Webhook-Id header sent with every payload. Store processed webhook IDs in a fast key-value store such as Redis or a dedicated PostgreSQL table with a unique constraint. If a job attempts to run with an identifier that has already been recorded within the last 48 hours, acknowledge the event and exit immediately without re-executing your business logic.
By combining raw buffer capture, crypto.timingSafeEqual, non-blocking background task queues, and idempotent job keys, your Node.js application can safely process high volumes of Shopify webhooks while remaining resilient against spoofing and duplicate deliveries.
Related posts
Implementing NestJS Cursor Pagination with TypeORM
Learn how to replace slow offset queries with stable, high-performance cursor pagination in your NestJS and TypeORM APIs.
Read articleMastering Docker Multi Stage Build for Node and Python
Learn how to write lean Dockerfiles using a docker multi stage build to reduce image size and speed up CI/CD pipeline deployments.
Read articleBuilding Reusable React Hooks: TypeScript Patterns That Scale
Learn how to build reusable react hooks with typescript for handling data fetching and local state cleanly in your web applications.
Read article