shopify-webhooks
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
afterAuthhook 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-Idheader 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-Idfor 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
EventBridgeorPub/Subdelivery 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