
Shopify Webhooks
- 62 installs
- 41 repo stars
- Updated March 13, 2026
- finsilabs/awesome-ecommerce-skills
Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency.
About
Registers and processes Shopify webhooks for orders, inventory, and customers with HMAC validation and idempotency handling. A developer uses it to reliably react to store events.
- HMAC signature validation of webhook payloads
- Idempotency handling for reliable event processing
Shopify Webhooks by the numbers
- 62 all-time installs (skills.sh)
- Ranked #3,145 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill shopify-webhooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 62 |
|---|---|
| repo stars | ★ 41 |
| Last updated | March 13, 2026 |
| Repository | finsilabs/awesome-ecommerce-skills ↗ |
What it does
Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency.
Files
Shopify Webhooks
Overview
Shopify webhooks deliver real-time event notifications to your app's HTTP endpoints when store events occur — orders placed, products updated, customers created, apps uninstalled. Every webhook payload includes an HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header that must be verified before processing. Shopify guarantees at-least-once delivery, so handlers must be idempotent.
When to Use This Skill
- When triggering fulfillment workflows the moment an order is paid
- When syncing product or inventory changes to an external system in near real time
- When sending customer data to a marketing automation platform upon registration
- When cleaning up app data after a merchant uninstalls the app (
app/uninstalled) - When implementing required GDPR webhooks for App Store compliance
- When replacing polling loops that constantly query the Admin API for changes
Core Instructions
1. Register webhooks via the Admin API
Prefer registering webhooks programmatically in the afterAuth hook of your Shopify app. This ensures re-registration after reinstall:
// Webhook registration helper
export async function registerWebhooks(adminClient: GraphqlClient, appUrl: string) {
const webhooksToRegister = [
{ topic: "ORDERS_CREATE", callbackUrl: `${appUrl}/webhooks/orders-create` },
{ topic: "ORDERS_UPDATED", callbackUrl: `${appUrl}/webhooks/orders-updated` },
{ topic: "PRODUCTS_UPDATE", callbackUrl: `${appUrl}/webhooks/products-update` },
{ topic: "APP_UNINSTALLED", callbackUrl: `${appUrl}/webhooks/app-uninstalled` },
// Mandatory GDPR webhooks
{ topic: "CUSTOMERS_DATA_REQUEST", callbackUrl: `${appUrl}/webhooks/gdpr/customers-data-request` },
{ topic: "CUSTOMERS_REDACT", callbackUrl: `${appUrl}/webhooks/gdpr/customers-redact` },
{ topic: "SHOP_REDACT", callbackUrl: `${appUrl}/webhooks/gdpr/shop-redact` },
];
for (const { topic, callbackUrl } of webhooksToRegister) {
const response = await adminClient.request(`
mutation WebhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
webhookSubscription { id topic }
userErrors { field message }
}
}
`, {
variables: {
topic,
webhookSubscription: {
callbackUrl,
format: "JSON",
},
},
});
const { userErrors } = response.data.webhookSubscriptionCreate;
if (userErrors.length > 0) {
// ALREADY_EXISTS is expected on reinstall — not a real error
const realErrors = userErrors.filter((e: any) => e.message !== "Address for this topic has already been taken");
if (realErrors.length > 0) throw new Error(`Webhook registration failed: ${realErrors[0].message}`);
}
}
}2. Verify the HMAC signature
The most critical step — never process a webhook without verifying its signature:
// middleware/verify-shopify-webhook.ts
import crypto from "crypto";
export function verifyShopifyWebhook(
rawBody: Buffer,
hmacHeader: string,
secret: string
): boolean {
const digest = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("base64");
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(digest),
Buffer.from(hmacHeader)
);
} catch {
return false;
}
}Express middleware example:
// routes/webhooks.ts (Express)
import express from "express";
import { verifyShopifyWebhook } from "../middleware/verify-shopify-webhook";
const router = express.Router();
// CRITICAL: Use raw body parser BEFORE json parser for webhook routes
router.use(
"/webhooks",
express.raw({ type: "application/json" }),
(req, res, next) => {
const hmac = req.headers["x-shopify-hmac-sha256"] as string;
if (!verifyShopifyWebhook(req.body, hmac, process.env.SHOPIFY_API_SECRET!)) {
return res.status(401).send("Unauthorized");
}
req.body = JSON.parse(req.body.toString());
next();
}
);3. Handle webhook events with idempotency
Shopify may deliver the same event multiple times. Use the X-Shopify-Webhook-Id header as an idempotency key:
router.post("/webhooks/orders-create", async (req, res) => {
// Respond 200 quickly — Shopify retries if response takes > 5 seconds
res.status(200).json({ received: true });
const webhookId = req.headers["x-shopify-webhook-id"] as string;
const shop = req.headers["x-shopify-shop-domain"] as string;
const order = req.body;
// Idempotency check — skip if already processed
const alreadyProcessed = await db.processedWebhooks.findFirst({
where: { webhookId, shop },
});
if (alreadyProcessed) return;
// Record processing attempt
await db.processedWebhooks.create({
data: { webhookId, shop, topic: "orders/create", processedAt: new Date() },
});
// Process the order asynchronously
await processNewOrder(order, shop);
});4. Handle the mandatory GDPR webhooks
Shopify requires these three endpoints for all App Store apps. They must respond 200 even if your app doesn't store personal data:
router.post("/webhooks/gdpr/customers-data-request", async (req, res) => {
const { shop_id, shop_domain, customer, orders_requested } = req.body;
// Return customer data your app has stored for this customer
await sendCustomerDataReport(shop_domain, customer.id);
res.status(200).json({ received: true });
});
router.post("/webhooks/gdpr/customers-redact", async (req, res) => {
const { shop_domain, customer } = req.body;
// Delete all personal data for this customer
await deleteCustomerData(shop_domain, customer.id);
res.status(200).json({ received: true });
});
router.post("/webhooks/gdpr/shop-redact", async (req, res) => {
const { shop_domain } = req.body;
// Delete all store data 48 hours after APP_UNINSTALLED
await deleteShopData(shop_domain);
res.status(200).json({ received: true });
});5. Monitor delivery failures and set up retry awareness
Shopify retries failed webhooks (non-2xx response or timeout) up to 19 times over 48 hours using exponential backoff. Check delivery health via Admin API:
export async function getWebhookFailures(adminClient: GraphqlClient) {
const response = await adminClient.request(`
query {
webhookSubscriptions(first: 20) {
edges {
node {
id
topic
callbackUrl
endpoint {
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
}
}
}
`);
return response.data.webhookSubscriptions.edges;
}Examples
Full order creation handler with error handling and queue
import { Queue, Worker } from "bullmq";
const connection = { host: "localhost", port: 6379 };
const orderQueue = new Queue("order-processing", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
},
});
router.post("/webhooks/orders-create", async (req, res) => {
// Must respond within 5 seconds
res.status(200).json({ received: true });
const webhookId = req.headers["x-shopify-webhook-id"] as string;
const shop = req.headers["x-shopify-shop-domain"] as string;
// Push to queue for reliable async processing
await orderQueue.add(
"process-order",
{ order: req.body, shop, webhookId },
{
jobId: webhookId, // Prevents duplicate jobs for same webhook
}
);
});
const worker = new Worker("order-processing", async (job) => {
const { order, shop, webhookId } = job.data;
await syncOrderToERP(order, shop);
await updateInventoryInWarehouse(order.line_items);
await sendConfirmationNotification(order);
}, { connection });List and delete stale webhook subscriptions
export async function cleanupWebhooks(adminClient: GraphqlClient, appUrl: string) {
const response = await adminClient.request(`
query {
webhookSubscriptions(first: 100) {
edges { node { id callbackUrl topic } }
}
}
`);
const stale = response.data.webhookSubscriptions.edges.filter(
({ node }: any) => !node.callbackUrl.startsWith(appUrl)
);
for (const { node } of stale) {
await adminClient.request(`
mutation DeleteWebhook($id: ID!) {
webhookSubscriptionDelete(id: $id) {
deletedWebhookSubscriptionId
userErrors { field message }
}
}
`, { variables: { id: node.id } });
}
}Best Practices
- Respond 200 within 5 seconds — offload heavy processing to a background queue (Bull, BullMQ, SQS); Shopify marks slow responses as failures and starts retry cycle
- Never trust without verifying HMAC — reject any request that fails signature validation with 401
- Use raw body for HMAC computation — any body parsing before HMAC check corrupts the byte representation and causes false signature failures
- Store `X-Shopify-Webhook-Id` for idempotency — keep a table of processed webhook IDs to prevent double-processing on retries
- Re-register webhooks on every OAuth completion — merchants who reinstall the app get a new session; without re-registration, webhooks point to deleted subscriptions
- Use `EventBridge` or `Pub/Sub` delivery for high volume — Shopify supports delivering webhooks to AWS EventBridge and Google Pub/Sub; these provide built-in retry and ordering guarantees
Common Pitfalls
| Problem | Solution |
|---|---|
| HMAC verification always fails | Ensure raw body (Buffer) is used — Express's JSON body parser converts Buffer to object; configure raw parser before the JSON parser on webhook routes |
| Webhook events processed twice | Implement idempotency using X-Shopify-Webhook-Id as a unique key; Bull jobId option prevents duplicate queue entries |
APP_UNINSTALLED not received | Ensure this topic is registered — without it, app cleanup (session deletion, data purge) won't fire and merchant data leaks |
| Shopify stops retrying after 48 hours | Add monitoring to detect gaps in event processing; implement a reconciliation job that queries Admin API for events missed during downtime |
| GDPR webhooks fail Shopify review | All three GDPR endpoints must return 200 within the timeout — even if your app stores no data, acknowledge receipt and log the request |
| Webhook registrations duplicated | Use webhookSubscriptionUpdate instead of webhookSubscriptionCreate for existing topics, or check for ALREADY_EXISTS user errors and skip |
Related Skills
- @shopify-app-development
- @shopify-admin-api
- @webhook-architecture
- @event-driven-architecture
- @gdpr-compliance
{
"context": "Tests whether the agent correctly implements Shopify HMAC verification using the right crypto approach (timingSafeEqual), uses the raw body for signature computation, and structures the Express middleware pipeline so that raw body parsing happens before any JSON parsing on webhook routes.",
"type": "weighted_checklist",
"checklist": [
{
"name": "HMAC algorithm",
"max_score": 12,
"description": "Verification function uses `crypto.createHmac('sha256', secret).update(rawBody).digest('base64')` to compute the digest"
},
{
"name": "Timing-safe comparison",
"max_score": 12,
"description": "Comparison of the computed digest with the provided HMAC header uses `crypto.timingSafeEqual` (not `===` or other direct string comparison)"
},
{
"name": "Buffer wrapping",
"max_score": 8,
"description": "Both arguments to `crypto.timingSafeEqual` are wrapped with `Buffer.from(...)` before comparison"
},
{
"name": "timingSafeEqual error handling",
"max_score": 8,
"description": "A try/catch wraps the `timingSafeEqual` call and returns false on exception (handles unequal-length buffers)"
},
{
"name": "Raw body parser placement",
"max_score": 14,
"description": "The Express router applies `express.raw({ type: 'application/json' })` on the webhook route BEFORE the verification middleware or any JSON body parsing"
},
{
"name": "No pre-parse JSON on webhook routes",
"max_score": 10,
"description": "No `express.json()` or `bodyParser.json()` is applied globally or before the raw parser on the webhook route path"
},
{
"name": "401 on failure",
"max_score": 10,
"description": "The middleware responds with HTTP 401 when HMAC verification fails"
},
{
"name": "rawBody is Buffer",
"max_score": 8,
"description": "The raw body is passed as a `Buffer` (not a string) to the HMAC computation function"
},
{
"name": "200 quick response",
"max_score": 9,
"description": "The sample `orders-create` handler sends a 200 response before or immediately upon receiving the request (not after performing heavy processing inline)"
},
{
"name": "HMAC header source",
"max_score": 9,
"description": "The HMAC value is read from the `x-shopify-hmac-sha256` header"
}
]
}
Shopify Webhook Security Middleware
Problem/Feature Description
A merchant platform company has built a Shopify app that receives order, product, and customer events via HTTP webhooks. Their current implementation has been flagging legitimate webhook deliveries as unauthorized, while the security team suspects some forged requests may be slipping through. After investigation, the team suspects the issue is in how the HMAC signatures are being checked.
You have been asked to write a clean, standalone TypeScript module that correctly verifies incoming Shopify webhook requests. The implementation must follow security best practices and handle the request body correctly so that the signature check works reliably. The team will integrate this module into their Express application.
Output Specification
Produce the following files:
src/middleware/verify-shopify-webhook.ts— The verification function that accepts the raw request body (as a Buffer), the HMAC header value, and the webhook secret, and returns a boolean.src/routes/webhooks.ts— An Express router that mounts webhook handling middleware on/webhooks, properly handles the body parsing pipeline, and includes a sample handler for thePOST /webhooks/orders-createendpoint. The handler should respond immediately and then log that the order was received.README.md— A short explanation of how to integrate this middleware and why certain implementation choices were made.
{
"context": "Tests whether the agent sends a 200 response immediately (before heavy processing), uses X-Shopify-Webhook-Id as an idempotency key in both the database store and as the Bull jobId, configures retries with exponential backoff, and uses a queue to offload processing.",
"type": "weighted_checklist",
"checklist": [
{
"name": "Immediate 200 response",
"max_score": 12,
"description": "The route handler calls `res.status(200).json(...)` or equivalent BEFORE awaiting any downstream processing (ERP sync, inventory update, queue operations that do actual work)"
},
{
"name": "X-Shopify-Webhook-Id header used",
"max_score": 8,
"description": "The handler reads the `x-shopify-webhook-id` request header to obtain the webhook ID"
},
{
"name": "Idempotency check before processing",
"max_score": 12,
"description": "The handler checks whether the webhook ID has already been processed (via the processed-webhooks store or equivalent) and skips processing if it has"
},
{
"name": "webhookId as Bull jobId",
"max_score": 12,
"description": "The Bull `queue.add(...)` call includes `jobId: webhookId` (or equivalent) to prevent duplicate job entries for the same webhook"
},
{
"name": "Bull queue used for async processing",
"max_score": 10,
"description": "Processing (ERP sync, inventory, notifications) happens inside a Bull `queue.process(...)` worker, not inline in the HTTP handler"
},
{
"name": "Retry attempts configured",
"max_score": 8,
"description": "The Bull job is added with `attempts` set to a value greater than 1"
},
{
"name": "Exponential backoff configured",
"max_score": 8,
"description": "The Bull job specifies `backoff: { type: 'exponential', ... }` for retry delays"
},
{
"name": "shop domain captured",
"max_score": 8,
"description": "The handler reads the `x-shopify-shop-domain` header to identify the shop"
},
{
"name": "Processed record stored",
"max_score": 10,
"description": "After deciding to process a webhook, the handler records the webhookId and shop in the processed-webhooks store before or during processing (not just checking)"
},
{
"name": "README addresses timing",
"max_score": 6,
"description": "The README explains that the 200 response is sent before processing to meet Shopify's response timeout requirement"
},
{
"name": "README addresses duplicates",
"max_score": 6,
"description": "The README explains that duplicate deliveries are handled via the idempotency key (webhookId)"
}
]
}
Reliable Order Event Processing Pipeline
Problem/Feature Description
A growing e-commerce logistics company has built a Shopify app that listens for order creation events and performs several downstream operations: syncing the order to their ERP system, updating warehouse inventory levels, and sending a confirmation notification to the merchant. Occasionally, Shopify delivers the same order event twice (this is expected behavior), causing orders to be synced to the ERP twice and triggering duplicate warehouse updates and notifications. The operations team has also reported that during peak traffic, some webhook deliveries appear to time out before any response is sent.
You need to redesign the order creation webhook handler so that it responds quickly to Shopify, processes orders reliably in the background, and is safe to receive the same event multiple times without side effects.
Output Specification
Produce the following files:
src/queues/order-queue.ts— TypeScript module that defines and exports a Bull job queue for order processing, including a job processor that callssyncOrderToERP(order, shop),updateInventoryInWarehouse(order.line_items), andsendConfirmationNotification(order)(stub implementations are fine).src/handlers/order-create.ts— TypeScript module exporting an Express route handler for thePOST /webhooks/orders-createendpoint.src/db/processed-webhooks.ts— TypeScript module exporting a simple in-memory or database-backed store withhasProcessed(webhookId, shop)andmarkProcessed(webhookId, shop, topic)functions.README.md— A brief description of how duplicate delivery is handled and how the response timing requirement is met.
{
"context": "Tests whether the agent registers webhooks in the afterAuth hook, includes all three mandatory GDPR topics and APP_UNINSTALLED, correctly handles ALREADY_EXISTS errors as non-fatal, and ensures GDPR handlers always return 200.",
"type": "weighted_checklist",
"checklist": [
{
"name": "afterAuth placement",
"max_score": 8,
"description": "The README or code comments state that `registerWebhooks` should be called in the `afterAuth` hook (or equivalent OAuth completion callback), not at server startup"
},
{
"name": "GDPR: customers-data-request",
"max_score": 7,
"description": "The registration list includes the `CUSTOMERS_DATA_REQUEST` topic"
},
{
"name": "GDPR: customers-redact",
"max_score": 7,
"description": "The registration list includes the `CUSTOMERS_REDACT` topic"
},
{
"name": "GDPR: shop-redact",
"max_score": 7,
"description": "The registration list includes the `SHOP_REDACT` topic"
},
{
"name": "APP_UNINSTALLED topic",
"max_score": 10,
"description": "The registration list includes the `APP_UNINSTALLED` topic"
},
{
"name": "ALREADY_EXISTS ignored",
"max_score": 12,
"description": "The registration code explicitly checks for the 'Address for this topic has already been taken' error message (or equivalent ALREADY_EXISTS condition) and does NOT throw or fail on it"
},
{
"name": "Real errors still thrown",
"max_score": 8,
"description": "Errors other than ALREADY_EXISTS are still propagated (thrown or otherwise surfaced) after filtering"
},
{
"name": "GraphQL mutation used",
"max_score": 8,
"description": "Webhook subscriptions are created using the `webhookSubscriptionCreate` GraphQL mutation (not REST API)"
},
{
"name": "GDPR handlers return 200",
"max_score": 10,
"description": "All three GDPR endpoint handlers respond with HTTP 200, even if no data processing logic is present"
},
{
"name": "All three GDPR routes present",
"max_score": 10,
"description": "The gdpr.ts file contains route handlers for all three paths: `/webhooks/gdpr/customers-data-request`, `/webhooks/gdpr/customers-redact`, and `/webhooks/gdpr/shop-redact`"
},
{
"name": "JSON format in subscription",
"max_score": 5,
"description": "The `webhookSubscription` input object includes `format: \"JSON\"`"
},
{
"name": "callbackUrl construction",
"max_score": 8,
"description": "Each webhook's callback URL is constructed from the provided `appUrl` parameter (not hardcoded)"
}
]
}
Shopify App Webhook Registration Setup
Problem/Feature Description
A startup is launching a Shopify app in the App Store. The app needs to react to events across the merchant's store: when orders are placed (to trigger their fulfillment pipeline), when products are updated (to sync with their inventory system), and when a merchant uninstalls the app (to clean up stored data). Legal has also flagged that the app must comply with Shopify's data privacy requirements before the app can go live.
The engineering team has a working OAuth flow but the webhook subscriptions aren't being set up reliably — especially after merchants reinstall the app. Merchants who reinstall the app are not getting their events delivered, and in one case a merchant's uninstall event was never received, leaving orphaned data in the database. You need to write the webhook registration logic that runs after each OAuth completion.
Output Specification
Produce the following files:
src/webhooks/register.ts— A TypeScript module exporting aregisterWebhooks(adminClient, appUrl)function that registers all required webhook subscriptions via the Shopify Admin GraphQL API.src/webhooks/handlers/gdpr.ts— A TypeScript module containing Express route handlers for all required GDPR endpoints.README.md— A brief explanation of whereregisterWebhooksshould be called and how GDPR endpoints should be mounted.
{
"name": "finsi/shopify-webhooks",
"version": "0.1.0",
"summary": "Webhook registration, verification, and reliable event processing",
"skills": {
"shopify-webhooks": {
"path": "SKILL.md"
}
}
}