关于 webhook-architecture
此技能指南介绍电商系统中可靠的 webhook 接收与发送设计,面向处理 Shopify、Stripe 等平台事件或构建自定义事件系统的开发者。重点包括 HMAC 验证、幂等处理、重试、死信队列和 Outbox Pattern。
主要功能
- 01验证 HMAC 签名
- 02通过事件 ID 实现幂等处理
- 03使用指数退避进行重试
- 04为失败事件设置死信队列与重放
- 05用 Outbox Pattern 可靠发送事件
适用场景
- 01接收并处理 Shopify 或 Stripe 事件
- 02排查 webhook 丢失或重复处理
- 03向外部系统可靠发送业务事件
- 04将单个事件分发给多个消费者
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ finsilabs/ awesome-ecommerce-skills --skill webhook-architecture安装后在 Claude Code 中输入/webhook-architecture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
tile.jsonevals/outbox-pattern-sender-with-retry-and-dlq/criteria.jsontask.mdevals/secure-idempotent-webhook-receiver/criteria.jsontask.mdevals/webhook-subscription-management-api/criteria.jsontask.md详细介绍
Webhook 架构
概述
Webhook 是一种 HTTP 回调,电商平台(Shopify、Stripe)通过它向你的应用推送实时事件通知。可靠的 Webhook 基础设施需要:HMAC 签名验证以防止伪造事件、能够容忍重复投递的幂等处理器、指数退避重试逻辑,以及用于存放耗尽所有重试次数的事件的死信队列。本技能介绍如何构建可靠的 Webhook 接收器,以及如何为自定义平台使用 Outbox 模式构建 Webhook 发送器。
何时使用此技能
- 接收来自 Shopify、Stripe、Square 或其他平台的 Webhook 时
- 排查因 Webhook 投递问题导致的事件遗漏或重复处理时
- 构建需要向外部系统通知事件的电商平台或应用时
- 设计电商微服务之间的事件驱动架构时
- 设置 Webhook 扇出(将单个事件投递给多个使用方)时
核心说明
步骤 1:确定你的平台以及需要处理的 Webhook
| 平台 | Webhook 配置位置 | 最重要的订阅主题 |
|---|---|---|
| Shopify | Settings → 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 | 订单状态变更(处理中、已完成、已退款)、库存更新 |
| BigCommerce | Advanced Settings → Legacy API Settings → Webhooks 或通过 API | store/order/statusUpdated、store/product/inventory/updated、store/cart/abandoned |
| 自定义 / Headless | 构建自己的 Webhook 系统 | 使用 Outbox 模式发送;使用 HMAC 验证和幂等性接收;参见下方实现 |
步骤 2:平台专属 Webhook 设置
Shopify
通过 Shopify 管理后台注册 Webhook:
- 前往 Settings → Notifications,向下滚动到 Webhooks
- 点击 Create webhook
- 选择事件主题(例如
Order creation),并输入你的端点 URL - 选择 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:
- 前往 WooCommerce → Settings → Advanced → Webhooks
- 点击 Add webhook
- 设置 Name、Status: Active、Topic(例如 Order Created)以及你的 Delivery URL
- Secret 字段会为每次投递生成 HMAC-SHA256 签名 — 复制该密钥,以便端点进行验证
或者使用 WP Webhooks 插件以获得更多控制:
- 安装 WP Webhooks(免费,wordpress.org),以使用高级触发条件和自定义负载
- 前往 Settings → WP Webhooks,为 WooCommerce 订单事件配置触发器
- 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