mcpField
S
shopify-webhooks

shopify-webhooks

github.com/finsilabs開発6078 回インストール1 週間前に更新
リポジトリ

shopify-webhooks について

shopify-webhooksは、Shopifyアプリ開発者向けにストアイベント通知の実装を解説するスキルです。署名検証、冪等処理、必須のGDPRエンドポイント、配信失敗の監視を扱います。

主な機能

  1. 01Admin APIでWebhookを登録
  2. 02HMAC-SHA256署名を検証
  3. 03イベントIDで冪等性を確保
  4. 04必須のGDPR Webhookを処理
  5. 05配信失敗と再試行を監視

活用シーン

  1. 01注文の支払い後にフルフィルメントを開始
  2. 02商品や在庫の変更を外部システムへ同期
  3. 03登録時に顧客データをマーケティング基盤へ送信
  4. 04アプリのアンインストール後にデータを整理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill shopify-webhooks

インストール後、Claude Code で/shopify-webhooksを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

tile.json
evals/hmac-signature-verification-and-raw-body/
criteria.json
task.md
evals/idempotent-processing-with-background-qu/
criteria.json
task.md
evals/webhook-registration-with-gdpr-topics-an/
criteria.json
task.md

詳細

Shopify Webhooks

概要

Shopify Webhook は、注文の確定、商品の更新、顧客の作成、アプリのアンインストールなど、ストアのイベントが発生した際に、アプリの HTTP エンドポイントへリアルタイムのイベント通知を送信します。すべての Webhook ペイロードには、X-Shopify-Hmac-SHA256 ヘッダーに HMAC-SHA256 署名が含まれており、処理前に必ず検証する必要があります。Shopify は少なくとも 1 回の配信を保証するため、ハンドラーは冪等でなければなりません。

このスキルを使用する場面

  • 注文の支払いが完了した時点で、フルフィルメントのワークフローを起動する場合
  • 商品や在庫の変更を、ほぼリアルタイムで外部システムに同期する場合
  • 顧客の登録時に、顧客データをマーケティングオートメーションプラットフォームに送信する場合
  • マーチャントがアプリをアンインストールした後に、アプリのデータをクリーンアップする場合(app/uninstalled)
  • App Store のコンプライアンス要件を満たすために、必須の GDPR Webhook を実装する場合
  • 変更を確認するために Admin API を絶えずクエリするポーリングループを置き換える場合

基本的な手順

  1. 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}`);
        }
      }
    }
    
  2. 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();
      }
    );
    
  3. 冪等性を確保して 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);
    });
    
  4. 必須の 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 });
    });
    
  5. 配信失敗を監視し、再試行を把握する

    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

コメント