shopify-webhooks
Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency handling
Best use case
shopify-webhooks is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency handling
Teams using shopify-webhooks should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/shopify-webhooks/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How shopify-webhooks Compares
| Feature / Agent | shopify-webhooks | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Register, verify, and reliably process Shopify webhook events for orders, inventory, and customers with HMAC validation and idempotency handling
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# 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
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
```typescript
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-complianceRelated Skills
shopify-theme-development
Build and customize Shopify themes using Liquid templating, JSON sections, dynamic blocks, and theme app extensions for added functionality
shopify-storefront-api
Build a headless Shopify frontend using the GraphQL Storefront API for product queries, cart management, and checkout with the Buy SDK
shopify-metafields
Store custom data on any Shopify resource — products, orders, customers — using typed metafield definitions accessible from Liquid and the Storefront API
shopify-checkout-extensions
Customize Shopify's checkout with UI extensions for upsells and custom fields, plus Shopify Functions for serverless discount and shipping logic
shopify-hydrogen
Build a custom Shopify storefront using the Hydrogen React framework with Remix routing and deploy it to Shopify's Oxygen edge hosting
wishlist-save-for-later
Let shoppers save products to a wishlist, share it with friends, and get notified when saved items come back in stock or drop in price
storefront-theming
Build a themeable storefront with design tokens and CSS custom properties that supports white-labeling, multi-brand variants, and dark mode
search-autocomplete
Speed up product discovery with instant search suggestions, fuzzy typo matching, and category-aware results powered by Algolia or Elasticsearch
responsive-storefront
Build a mobile-first storefront with thumb-friendly navigation, sticky add-to-cart buttons, and touch-optimized components for high mobile conversion
recently-viewed-products
Show shoppers the products they recently browsed using browser storage so they can easily pick up where they left off on your store
quick-view-modal
Let shoppers preview product details and add items to cart from the listing page without navigating away, reducing friction in the shopping flow
product-page-design
Design high-converting product detail pages with image galleries, variant selectors, social proof, and clear calls-to-action that drive add-to-cart