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
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}`);
}
}
}
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();
}
);
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);
});
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 });
});
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
1---2name: shopify-webhooks3description: Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency handling4---56# Shopify Webhooks78## Overview910Shopify 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.1112## When to Use This Skill1314- When triggering fulfillment workflows the moment an order is paid15- When syncing product or inventory changes to an external system in near real time16- When sending customer data to a marketing automation platform upon registration17- When cleaning up app data after a merchant uninstalls the app (`app/uninstalled`)18- When implementing required GDPR webhooks for App Store compliance19- When replacing polling loops that constantly query the Admin API for changes2021## Core Instructions22231. **Register webhooks via the Admin API**2425 Prefer registering webhooks programmatically in the `afterAuth` hook of your Shopify app. This ensures re-registration after reinstall:2627 ```typescript28 // Webhook registration helper29 export async function registerWebhooks(adminClient: GraphqlClient, appUrl: string) {30 const webhooksToRegister = [31 { topic: "ORDERS_CREATE", callbackUrl: `${appUrl}/webhooks/orders-create` },32 { topic: "ORDERS_UPDATED", callbackUrl: `${appUrl}/webhooks/orders-updated` },33 { topic: "PRODUCTS_UPDATE", callbackUrl: `${appUrl}/webhooks/products-update` },34 { topic: "APP_UNINSTALLED", callbackUrl: `${appUrl}/webhooks/app-uninstalled` },35 // Mandatory GDPR webhooks36 { topic: "CUSTOMERS_DATA_REQUEST", callbackUrl: `${appUrl}/webhooks/gdpr/customers-data-request` },37 { topic: "CUSTOMERS_REDACT", callbackUrl: `${appUrl}/webhooks/gdpr/customers-redact` },38 { topic: "SHOP_REDACT", callbackUrl: `${appUrl}/webhooks/gdpr/shop-redact` },39 ];4041 for (const { topic, callbackUrl } of webhooksToRegister) {42 const response = await adminClient.request(`43 mutation WebhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {44 webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {45 webhookSubscription { id topic }46 userErrors { field message }47 }48 }49 `, {50 variables: {51 topic,52 webhookSubscription: {53 callbackUrl,54 format: "JSON",55 },56 },57 });5859 const { userErrors } = response.data.webhookSubscriptionCreate;60 if (userErrors.length > 0) {61 // ALREADY_EXISTS is expected on reinstall — not a real error62 const realErrors = userErrors.filter((e: any) => e.message !== "Address for this topic has already been taken");63 if (realErrors.length > 0) throw new Error(`Webhook registration failed: ${realErrors[0].message}`);64 }65 }66 }67 ```68692. **Verify the HMAC signature**7071 The most critical step — never process a webhook without verifying its signature:7273 ```typescript74 // middleware/verify-shopify-webhook.ts75 import crypto from "crypto";7677 export function verifyShopifyWebhook(78 rawBody: Buffer,79 hmacHeader: string,80 secret: string81 ): boolean {82 const digest = crypto83 .createHmac("sha256", secret)84 .update(rawBody)85 .digest("base64");8687 // Use timingSafeEqual to prevent timing attacks88 try {89 return crypto.timingSafeEqual(90 Buffer.from(digest),91 Buffer.from(hmacHeader)92 );93 } catch {94 return false;95 }96 }97 ```9899 Express middleware example:100101 ```typescript102 // routes/webhooks.ts (Express)103 import express from "express";104 import { verifyShopifyWebhook } from "../middleware/verify-shopify-webhook";105106 const router = express.Router();107108 // CRITICAL: Use raw body parser BEFORE json parser for webhook routes109 router.use(110 "/webhooks",111 express.raw({ type: "application/json" }),112 (req, res, next) => {113 const hmac = req.headers["x-shopify-hmac-sha256"] as string;114 if (!verifyShopifyWebhook(req.body, hmac, process.env.SHOPIFY_API_SECRET!)) {115 return res.status(401).send("Unauthorized");116 }117 req.body = JSON.parse(req.body.toString());118 next();119 }120 );121 ```1221233. **Handle webhook events with idempotency**124125 Shopify may deliver the same event multiple times. Use the `X-Shopify-Webhook-Id` header as an idempotency key:126127 ```typescript128 router.post("/webhooks/orders-create", async (req, res) => {129 // Respond 200 quickly — Shopify retries if response takes > 5 seconds130 res.status(200).json({ received: true });131132 const webhookId = req.headers["x-shopify-webhook-id"] as string;133 const shop = req.headers["x-shopify-shop-domain"] as string;134 const order = req.body;135136 // Idempotency check — skip if already processed137 const alreadyProcessed = await db.processedWebhooks.findFirst({138 where: { webhookId, shop },139 });140 if (alreadyProcessed) return;141142 // Record processing attempt143 await db.processedWebhooks.create({144 data: { webhookId, shop, topic: "orders/create", processedAt: new Date() },145 });146147 // Process the order asynchronously148 await processNewOrder(order, shop);149 });150 ```1511524. **Handle the mandatory GDPR webhooks**153154 Shopify requires these three endpoints for all App Store apps. They must respond 200 even if your app doesn't store personal data:155156 ```typescript157 router.post("/webhooks/gdpr/customers-data-request", async (req, res) => {158 const { shop_id, shop_domain, customer, orders_requested } = req.body;159 // Return customer data your app has stored for this customer160 await sendCustomerDataReport(shop_domain, customer.id);161 res.status(200).json({ received: true });162 });163164 router.post("/webhooks/gdpr/customers-redact", async (req, res) => {165 const { shop_domain, customer } = req.body;166 // Delete all personal data for this customer167 await deleteCustomerData(shop_domain, customer.id);168 res.status(200).json({ received: true });169 });170171 router.post("/webhooks/gdpr/shop-redact", async (req, res) => {172 const { shop_domain } = req.body;173 // Delete all store data 48 hours after APP_UNINSTALLED174 await deleteShopData(shop_domain);175 res.status(200).json({ received: true });176 });177 ```1781795. **Monitor delivery failures and set up retry awareness**180181 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:182183 ```typescript184 export async function getWebhookFailures(adminClient: GraphqlClient) {185 const response = await adminClient.request(`186 query {187 webhookSubscriptions(first: 20) {188 edges {189 node {190 id191 topic192 callbackUrl193 endpoint {194 ... on WebhookHttpEndpoint {195 callbackUrl196 }197 }198 }199 }200 }201 }202 `);203 return response.data.webhookSubscriptions.edges;204 }205 ```206207## Examples208209### Full order creation handler with error handling and queue210211```typescript212import { Queue, Worker } from "bullmq";213214const connection = { host: "localhost", port: 6379 };215216const orderQueue = new Queue("order-processing", {217 connection,218 defaultJobOptions: {219 attempts: 3,220 backoff: { type: "exponential", delay: 5000 },221 },222});223224router.post("/webhooks/orders-create", async (req, res) => {225 // Must respond within 5 seconds226 res.status(200).json({ received: true });227228 const webhookId = req.headers["x-shopify-webhook-id"] as string;229 const shop = req.headers["x-shopify-shop-domain"] as string;230231 // Push to queue for reliable async processing232 await orderQueue.add(233 "process-order",234 { order: req.body, shop, webhookId },235 {236 jobId: webhookId, // Prevents duplicate jobs for same webhook237 }238 );239});240241const worker = new Worker("order-processing", async (job) => {242 const { order, shop, webhookId } = job.data;243 await syncOrderToERP(order, shop);244 await updateInventoryInWarehouse(order.line_items);245 await sendConfirmationNotification(order);246}, { connection });247```248249### List and delete stale webhook subscriptions250251```typescript252export async function cleanupWebhooks(adminClient: GraphqlClient, appUrl: string) {253 const response = await adminClient.request(`254 query {255 webhookSubscriptions(first: 100) {256 edges { node { id callbackUrl topic } }257 }258 }259 `);260261 const stale = response.data.webhookSubscriptions.edges.filter(262 ({ node }: any) => !node.callbackUrl.startsWith(appUrl)263 );264265 for (const { node } of stale) {266 await adminClient.request(`267 mutation DeleteWebhook($id: ID!) {268 webhookSubscriptionDelete(id: $id) {269 deletedWebhookSubscriptionId270 userErrors { field message }271 }272 }273 `, { variables: { id: node.id } });274 }275}276```277278## Best Practices279280- **Respond 200 within 5 seconds** — offload heavy processing to a background queue (Bull, BullMQ, SQS); Shopify marks slow responses as failures and starts retry cycle281- **Never trust without verifying HMAC** — reject any request that fails signature validation with 401282- **Use raw body for HMAC computation** — any body parsing before HMAC check corrupts the byte representation and causes false signature failures283- **Store `X-Shopify-Webhook-Id` for idempotency** — keep a table of processed webhook IDs to prevent double-processing on retries284- **Re-register webhooks on every OAuth completion** — merchants who reinstall the app get a new session; without re-registration, webhooks point to deleted subscriptions285- **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 guarantees286287## Common Pitfalls288289| Problem | Solution |290|---------|----------|291| 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 |292| Webhook events processed twice | Implement idempotency using `X-Shopify-Webhook-Id` as a unique key; Bull `jobId` option prevents duplicate queue entries |293| `APP_UNINSTALLED` not received | Ensure this topic is registered — without it, app cleanup (session deletion, data purge) won't fire and merchant data leaks |294| 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 |295| 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 |296| Webhook registrations duplicated | Use `webhookSubscriptionUpdate` instead of `webhookSubscriptionCreate` for existing topics, or check for `ALREADY_EXISTS` user errors and skip |297298## Related Skills299300- @shopify-app-development301- @shopify-admin-api302- @webhook-architecture303- @event-driven-architecture304- @gdpr-compliance