mcpField
W
webhook-architecture

webhook-architecture

github.com/finsilabs开发6077 次安装5 天前更新
代码仓库

关于 webhook-architecture

此技能指南介绍电商系统中可靠的 webhook 接收与发送设计,面向处理 Shopify、Stripe 等平台事件或构建自定义事件系统的开发者。重点包括 HMAC 验证、幂等处理、重试、死信队列和 Outbox Pattern。

主要功能

  1. 01验证 HMAC 签名
  2. 02通过事件 ID 实现幂等处理
  3. 03使用指数退避进行重试
  4. 04为失败事件设置死信队列与重放
  5. 05用 Outbox Pattern 可靠发送事件

适用场景

  1. 01接收并处理 Shopify 或 Stripe 事件
  2. 02排查 webhook 丢失或重复处理
  3. 03向外部系统可靠发送业务事件
  4. 04将单个事件分发给多个消费者

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/finsilabs/awesome-ecommerce-skills --skill webhook-architecture

安装后在 Claude Code 中输入/webhook-architecture确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

tile.json
evals/outbox-pattern-sender-with-retry-and-dlq/
criteria.json
task.md
evals/secure-idempotent-webhook-receiver/
criteria.json
task.md
evals/webhook-subscription-management-api/
criteria.json
task.md

详细介绍

Webhook 架构

概述

Webhook 是一种 HTTP 回调,电商平台(Shopify、Stripe)通过它向你的应用推送实时事件通知。可靠的 Webhook 基础设施需要:HMAC 签名验证以防止伪造事件、能够容忍重复投递的幂等处理器、指数退避重试逻辑,以及用于存放耗尽所有重试次数的事件的死信队列。本技能介绍如何构建可靠的 Webhook 接收器,以及如何为自定义平台使用 Outbox 模式构建 Webhook 发送器。

何时使用此技能

  • 接收来自 Shopify、Stripe、Square 或其他平台的 Webhook 时
  • 排查因 Webhook 投递问题导致的事件遗漏或重复处理时
  • 构建需要向外部系统通知事件的电商平台或应用时
  • 设计电商微服务之间的事件驱动架构时
  • 设置 Webhook 扇出(将单个事件投递给多个使用方)时

核心说明

步骤 1:确定你的平台以及需要处理的 Webhook
平台Webhook 配置位置最重要的订阅主题
ShopifySettings → Notifications → Webhooks(或通过 Admin API)orders/create、orders/paid、orders/cancelled、inventory_levels/update、refunds/create
WooCommerce安装 WP Webhooks 插件(免费,wordpress.org),或使用 WooCommerce 内置的 Webhook,路径为 WooCommerce → Settings → Advanced → Webhooks订单状态变更(处理中、已完成、已退款)、库存更新
BigCommerceAdvanced Settings → Legacy API Settings → Webhooks 或通过 APIstore/order/statusUpdated、store/product/inventory/updated、store/cart/abandoned
自定义 / Headless构建自己的 Webhook 系统使用 Outbox 模式发送;使用 HMAC 验证和幂等性接收;参见下方实现
步骤 2:平台专属 Webhook 设置

Shopify

通过 Shopify 管理后台注册 Webhook:

  1. 前往 Settings → Notifications,向下滚动到 Webhooks
  2. 点击 Create webhook
  3. 选择事件主题(例如 Order creation),并输入你的端点 URL
  4. 选择 JSON 作为格式

获取用于 HMAC 验证的 Webhook 密钥:

创建 Webhook 时会显示密钥。将其保存为环境变量 — Shopify 使用此密钥通过 HMAC-SHA256 为每个 Webhook 签名。

对于使用 Admin API 的应用,以编程方式注册 Webhook:

const res = await fetch(`https://${shopDomain}/admin/api/2025-01/webhooks.json`, {
  method: 'POST',
  headers: { 'X-Shopify-Access-Token': accessToken, 'Content-Type': 'application/json' },
  body: JSON.stringify({ webhook: {
    topic: 'orders/create',
    address: `${process.env.APP_URL}/api/webhooks/shopify/order-created`,
    format: 'json',
  }}),
});

WooCommerce

使用 WooCommerce 内置的 Webhook:

  1. 前往 WooCommerce → Settings → Advanced → Webhooks
  2. 点击 Add webhook
  3. 设置 Name、Status: Active、Topic(例如 Order Created)以及你的 Delivery URL
  4. Secret 字段会为每次投递生成 HMAC-SHA256 签名 — 复制该密钥,以便端点进行验证

或者使用 WP Webhooks 插件以获得更多控制:

  1. 安装 WP Webhooks(免费,wordpress.org),以使用高级触发条件和自定义负载
  2. 前往 Settings → WP Webhooks,为 WooCommerce 订单事件配置触发器
  3. WP Webhooks 开箱即支持重试逻辑和投递日志

自定义 / Headless

对于自定义店面,实现可靠的接收(接收来自 Stripe、Shopify 等平台的传入 Webhook)和可靠的发送(使用 Outbox 模式通知你自己的集成)。

HMAC 签名验证(Shopify 和通用):

// lib/webhooks/verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyShopifyWebhook(rawBody: Buffer, hmacHeader: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody).digest('base64');
  const received = Buffer.from(hmacHeader);
  const expectedBuffer = Buffer.from(expected);
  if (received.length !== expectedBuffer.length) return false;
  return timingSafeEqual(received, expectedBuffer);
}

export function verifyStripeWebhook(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  const parts = signatureHeader.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.replace('t=', '');
  const v1 = parts.find(p => p.startsWith('v1='))?.replace('v1=', '');
  if (!timestamp || !v1) return false;
  // Reject events older than 5 minutes (replay attack protection)
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody.toString('utf8')}`)
    .digest('hex');
  return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

幂等 Webhook 接收器 — 使用平台的事件 ID 去重:

// app/api/webhooks/shopify/route.ts
export async function POST(req: NextRequest) {
  const rawBody = Buffer.from(await req.arrayBuffer());
  const hmac = req.headers.get('x-shopify-hmac-sha256') ?? '';
  const topic = req.headers.get('x-shopify-topic') ?? '';
  const eventId = req.headers.get('x-shopify-webhook-id') ?? '';

  // 1. Verify signature — reject invalid requests immediately
  if (!verifyShopifyWebhook(rawBody, hmac, process.env.SHOPIFY_WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // 2. Idempotency check — deduplicate by event ID
  const alreadyProcessed = await db.processedWebhooks.exists(eventId);
  if (alreadyProcessed) return NextResponse.json({ received: true, status: 'already_processed' });

  // 3. Mark as received BEFORE processing (prevents duplicate on concurrent delivery)
  await db.processedWebhooks.insert({ id: eventId, topic, receivedAt: new Date(), status: 'processing' });

  // 4. Return 200 immediately, process asynchronously
  processWebhookAsync(topic, rawBody, eventId); // Don't await — return fast

  return NextResponse.json({ received: true });
}

async function processWebhookAsync(topic: string, rawBody: Buffer, eventId: string) {
  try {
    const payload = JSON.parse(rawBody.toString('utf8'));
    switch (topic) {
      case 'orders/create': await importOrder(payload); break;
      case 'orders/cancelled': await cancelOrder(payload.id); break;
      case 'inventory_levels/update': await syncInventory(payload); break;
    }
    await db.processedWebhooks.update(eventId, { status: 'processed', processedAt: new Date() });
  } catch (err: any) {
    await db.processedWebhooks.update(eventId, { status: 'failed', error: err.message });
  }
}

使用 Outbox 模式可靠地发送 Webhook — 即使发送器崩溃,也能保证至少投递一次:

// lib/webhooks/outbox.ts
// Write to outbox in the SAME transaction as the business event
export async function publishEvent(trx: Transaction, eventType: string, payload: object) {
  await trx.webhookOutbox.insert({
    id: crypto.randomUUID(),
    eventType,
    payload: JSON.stringify(payload),
    status: 'pending',
    attempts: 0,
    nextRetryAt: new Date(),
  });
}

// Outbox poller — runs every 10 seconds, separate from your main app
export async function processOutbox() {
  const pending = await db.webhookOutbox.findPending({ status: ['pending', 'retrying'], nextRetryAt: { $lte: new Date() }, limit: 100 });
  for (const event of pending) await deliverEvent(event);
}

// Retry schedule: 1min, 5min, 30min, 2hr, 8hr → DLQ after 5 attempts
const RETRY_DELAYS_MS = [60_000, 300_000, 1_800_000, 7_200_000, 28_800_000];

async function handleDeliveryFailure(event: OutboxEvent, error: string) {
  const nextAttempts = event.attempts + 1;

  if (nextAttempts >= RETRY_DELAYS_MS.length) {
    await db.webhookOutbox.update(event.id, { status: 'dead_letter', lastError: error });
    await db.webhookDeadLetters.insert({ eventId: event.id, failedAt: new Date(), reason: error });
    await alertOpsTeam(`Webhook permanently failed after ${nextAttempts} attempts`, { eventType: event.eventType, error });
  } else {
    const nextRetryAt = new Date(Date.now() + RETRY_DELAYS_MS[nextAttempts - 1]);
    await db.webhookOutbox.update(event.id, { status: 'retrying', attempts: nextAttempts, nextRetryAt, lastError: error });
  }
}

重放死信事件(修复订阅方端点后):

export async function replayDeadLetter(deadLetterId: string) {
  const deadLetter = await db.webhookDeadLetters.findById(deadLetterId);
  await db.webhookOutbox.update(deadLetter.eventId, {
    status: 'pending', attempts: 0, nextRetryAt: new Date(),
  });
  await db.webhookDeadLetters.update(deadLetterId, { replayedAt: new Date() });
}

最佳实践

  • 始终立即返回 2xx — 响应缓慢的 webhook 处理程序会阻塞事件投递,可能导致发送方超时并重试;收到事件后将其加入队列,并异步处理
  • 使用 Outbox 模式确保可靠发送 — 在与领域事件相同的数据库事务中写入 outbox 表,即使 webhook 发送方崩溃,也能保证至少投递一次
  • 确保处理程序具备幂等性 — 使用事件的唯一 ID 进行去重;“至少投递一次”是所有 webhook 平台的标准;必须能够容忍同一事件被接收两次
  • 记录每次投递尝试 — 保存投递尝试的响应代码、耗时和错误;这对调试至关重要,也可为合规审计提供证据
  • 实现带告警的死信队列 — 耗尽重试次数的事件需要人工干预;通过 Slack/PagerDuty 发送告警,并提供重放机制

常见问题

问题解决方案
重试的 webhook 导致订单被重复处理使用 webhook 事件 ID 作为唯一键,在 processed_webhooks 表中实现幂等性,并将 TTL 设为 30 天
webhook 处理程序超时导致重试异步处理 webhook:收到后写入队列,立即返回 200,再从队列中处理
WooCommerce webhook 投递失败检查 WooCommerce → System Status → Logs 中的投递错误;常见原因包括 SSL 证书问题,以及共享主机速度较慢导致超时
Shopify webhook HMAC 不匹配对原始请求正文计算 HMAC;切勿先解析 JSON——正文解析器可能会重新格式化 JSON,导致签名改变
死信不断堆积却没有告警当死信数量超过阈值时发送告警(例如 10 个事件);死信表明订阅方存在系统性故障,需要调查

相关技能

  • @analytics-integration
  • @erp-integration
  • @marketplace-connectors
  • @monitoring-alerting-commerce

评论

支持 Markdown