shopify-webhooks について
shopify-webhooksは、Shopifyアプリ開発者向けにストアイベント通知の実装を解説するスキルです。署名検証、冪等処理、必須のGDPRエンドポイント、配信失敗の監視を扱います。
主な機能
- 01Admin APIでWebhookを登録
- 02HMAC-SHA256署名を検証
- 03イベントIDで冪等性を確保
- 04必須のGDPR Webhookを処理
- 05配信失敗と再試行を監視
活用シーン
- 01注文の支払い後にフルフィルメントを開始
- 02商品や在庫の変更を外部システムへ同期
- 03登録時に顧客データをマーケティング基盤へ送信
- 04アプリのアンインストール後にデータを整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill shopify-webhooksインストール後、Claude Code で/shopify-webhooksを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
tile.jsonevals/hmac-signature-verification-and-raw-body/criteria.jsontask.mdevals/idempotent-processing-with-background-qu/criteria.jsontask.mdevals/webhook-registration-with-gdpr-topics-an/criteria.jsontask.md詳細
Shopify Webhooks
概要
Shopify Webhook は、注文の確定、商品の更新、顧客の作成、アプリのアンインストールなど、ストアのイベントが発生した際に、アプリの HTTP エンドポイントへリアルタイムのイベント通知を送信します。すべての Webhook ペイロードには、X-Shopify-Hmac-SHA256 ヘッダーに HMAC-SHA256 署名が含まれており、処理前に必ず検証する必要があります。Shopify は少なくとも 1 回の配信を保証するため、ハンドラーは冪等でなければなりません。
このスキルを使用する場面
- 注文の支払いが完了した時点で、フルフィルメントのワークフローを起動する場合
- 商品や在庫の変更を、ほぼリアルタイムで外部システムに同期する場合
- 顧客の登録時に、顧客データをマーケティングオートメーションプラットフォームに送信する場合
- マーチャントがアプリをアンインストールした後に、アプリのデータをクリーンアップする場合(
app/uninstalled) - App Store のコンプライアンス要件を満たすために、必須の GDPR Webhook を実装する場合
- 変更を確認するために Admin API を絶えずクエリするポーリングループを置き換える場合
基本的な手順
-
Admin API 経由で Webhook を登録する
Shopify アプリの
afterAuthフックで、プログラムによって Webhook を登録することを推奨します。これにより、再インストール後に確実に再登録されます。// 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}`); } } } -
HMAC 署名を検証する
最も重要な手順です。署名を検証せずに Webhook を処理してはいけません。
// 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 ミドルウェアの例:
// 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(); } ); -
冪等性を確保して Webhook イベントを処理する
Shopify は同じイベントを複数回配信する場合があります。冪等性キーとして
X-Shopify-Webhook-Idヘッダーを使用します。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); }); -
必須の GDPR Webhook を処理する
Shopify は、すべての App Store アプリに以下の 3 つのエンドポイントを必須としています。アプリが個人データを保存していない場合でも、200 を返す必要があります。
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 }); }); -
配信失敗を監視し、再試行を把握する
Shopify は、失敗した Webhook(2xx 以外の応答、またはタイムアウト)を、指数バックオフ方式で 48 時間にわたり最大 19 回再試行します。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; }
例
エラー処理とキューを備えた注文作成ハンドラー全体
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 });
古い Webhook サブスクリプションの一覧表示と削除
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 } });
}
}
ベストプラクティス
- 5 秒以内に 200 を返す — 負荷の高い処理はバックグラウンドキュー(Bull、BullMQ、SQS)に移します。応答が遅いと Shopify は失敗と判断し、再試行を開始します。
- HMAC を検証せずに信頼しない — 署名の検証に失敗したリクエストはすべて 401 で拒否します。
- HMAC の計算には生のボディを使用する — HMAC の検証前にボディを解析すると、バイト表現が壊れて署名検証が誤って失敗します。
- 冪等性のために
X-Shopify-Webhook-Idを保存する — 再試行による二重処理を防ぐため、処理済みの Webhook ID をテーブルに記録します。 - OAuth の完了ごとに Webhook を再登録する — アプリを再インストールしたマーチャントには新しいセッションが発行されます。再登録しないと、Webhook の送信先は削除済みのサブスクリプションを指したままになります。
- 大量のイベントには
EventBridgeまたはPub/Subによる配信を使用する — Shopify は AWS EventBridge と Google Pub/Sub への Webhook 配信をサポートしています。これらには、再試行と順序保証の機能が組み込まれています。
よくある問題
| 問題 | 解決策 |
|---|---|
| HMAC 検証が常に失敗する | 生のボディ(Buffer)を使用していることを確認してください。Express の JSON ボディパーサーは Buffer をオブジェクトに変換するため、Webhook ルートでは JSON パーサーより先に raw パーサーを設定してください |
| Webhook イベントが二重に処理される | 一意のキーとして X-Shopify-Webhook-Id を使用し、冪等性を実装してください。Bull の jobId オプションを使用すると、キューへの重複登録を防止できます |
APP_UNINSTALLED が受信されない | このトピックが登録されていることを確認してください。登録されていないと、アプリのクリーンアップ(セッションの削除、データの消去)が実行されず、マーチャントのデータが漏えいします |
| Shopify が 48 時間後に再試行を停止する | イベント処理の欠落を検知できるよう、監視を追加してください。ダウンタイム中に見逃したイベントを照会するため、Admin API に問い合わせる照合ジョブを実装してください |
| GDPR Webhook が Shopify の審査に通らない | 3 つすべての GDPR エンドポイントがタイムアウト以内に 200 を返す必要があります。アプリにデータが保存されていない場合でも、受信を確認し、リクエストを記録してください |
| Webhook 登録が重複する | 既存のトピックには webhookSubscriptionCreate ではなく webhookSubscriptionUpdate を使用するか、ALREADY_EXISTS のユーザーエラーを確認してスキップしてください |
関連スキル
- @shopify-app-development
- @shopify-admin-api
- @webhook-architecture
- @event-driven-architecture
- @gdpr-compliance