mcpField
S
shopify-webhooks

shopify-webhooks

github.com/finsilabs开发6078 次安装1 周前更新
代码仓库

关于 shopify-webhooks

shopify-webhooks 是一项面向 Shopify 应用开发者的技能,指导接收并安全处理商店事件通知。内容涵盖签名验证、幂等处理、GDPR 端点及投递失败监控。

主要功能

  1. 01通过 Admin API 注册 Webhook
  2. 02验证 HMAC-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 Webhooks 会向应用的 HTTP 端点发送实时事件通知,例如下单、产品更新、创建客户和卸载应用。每个 Webhook 载荷都在 X-Shopify-Hmac-SHA256 标头中包含一个 HMAC-SHA256 签名,处理前必须验证该签名。Shopify 保证至少投递一次,因此处理程序必须具备幂等性。

何时使用此技能

  • 在订单付款后立即触发履约工作流
  • 近乎实时地将产品或库存变更同步到外部系统
  • 在客户注册时将客户数据发送到营销自动化平台
  • 商家卸载应用后清理应用数据(app/uninstalled)
  • 实现 App Store 合规所需的 GDPR Webhooks
  • 替换持续查询 Admin API 以获取变更的轮询循环

核心说明

  1. 通过 Admin API 注册 Webhooks

    建议在 Shopify 应用的 afterAuth 钩子中以编程方式注册 Webhooks。这样可以确保重新安装后重新注册:

    // 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 Webhooks

    Shopify 要求所有 App Store 应用都提供以下三个端点。即使应用不存储个人数据,也必须返回 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 会在 48 小时内使用指数退避策略,最多重试失败的 Webhooks(非 2xx 响应或超时)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 完成后重新注册 Webhooks——重新安装应用的商家会获得新会话;如果不重新注册,Webhooks 将指向已删除的订阅
  • 高流量场景使用 EventBridge 或 Pub/Sub 投递——Shopify 支持将 Webhooks 投递到 AWS EventBridge 和 Google Pub/Sub;这些服务内置重试和顺序保证

常见问题

问题解决方案
HMAC 验证始终失败确保使用原始请求体(Buffer)——Express 的 JSON 请求体解析器会将 Buffer 转换为对象;在 webhook 路由上,先配置原始请求体解析器,再配置 JSON 解析器
Webhook 事件被处理两次使用 X-Shopify-Webhook-Id 作为唯一键实现幂等性;Bull 的 jobId 选项可防止队列中出现重复条目
未收到 APP_UNINSTALLED确保已注册此主题——否则不会触发应用清理(删除会话、清除数据),导致商家数据泄露
Shopify 在 48 小时后停止重试添加监控以检测事件处理缺口;实现一个协调任务,在停机期间查询 Admin API,找出遗漏的事件
GDPR webhook 未通过 Shopify 审核所有三个 GDPR 端点都必须在超时时间内返回 200——即使应用不存储任何数据,也要确认收到请求并记录请求日志
Webhook 注册重复对现有主题使用 webhookSubscriptionUpdate,而不是 webhookSubscriptionCreate;或者检查是否存在 ALREADY_EXISTS 用户错误并跳过

相关技能

  • @shopify-app-development
  • @shopify-admin-api
  • @webhook-architecture
  • @event-driven-architecture
  • @gdpr-compliance

评论

支持 Markdown