Claude
Skills
Sign in
Back

shopify-webhooks

Included with Lifetime
$97 forever

Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency handling

platform-shopifyshopifywebhookshmacevent-processingidempotencygdprpub-sub

What this skill does


# 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:

   ```typescript
   // 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:

   ```typescript
   // 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:

   ```typescript
   // 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:

   ```typescript
   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:

   ```typescript
   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:

   ```typescript
   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

```typescript
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
  

Related in platform-shopify