使用 Claude Code 进行 Webhook 处理:签名验证、幂等性和重试安全性
发布: (2026年3月11日 GMT+8 12:51)
5 分钟阅读
原文: Dev.to
Source: Dev.to
请提供您希望翻译的具体内容(文本、段落或代码块),我将为您准确地翻译成简体中文,并保持原有的格式和技术术语不变。
Webhook 接收规则
安全(必需)
- 验证 所有 传入 webhook 的签名(没有有效签名则拒绝)。
- Stripe:使用 webhook secret 验证
stripe-signature头部。 - GitHub:使用 HMAC‑SHA256 验证
X-Hub-Signature-256。 - 关键:必须基于原始请求体进行验证(而不是已解析的 JSON —— 解析会改变字节)。
幂等性(必需)
- 所有 webhook 处理程序必须具备幂等性(能够安全地接收同一事件两次)。
- 幂等键格式:
{provider}-{eventId},存储在WebhookEvent表中。 - 已处理的事件:立即返回
200并跳过后续处理。
响应时机
- 在 5 秒 内返回
200(如果没有响应,提供方会重试)。 - 重量级处理应在返回之前委派给队列(例如 BullMQ)。
重试安全性
- 幂等性表可防止在重试时出现双重处理。
- 让发送方自行处理其重试逻辑。
Stripe Webhook 实现
端点 (src/webhooks/stripeWebhook.ts)
import Stripe from 'stripe';
import express, { Router } from 'express';
import { prisma } from '../prisma'; // adjust import as needed
import { webhookQueue } from '../queues'; // BullMQ queue
import { logger } from '../logger';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const stripeWebhookRouter = Router();
// IMPORTANT: Use express.raw() — not express.json()
// Stripe signature verification requires the raw request body
stripeWebhookRouter.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
logger.warn({ err }, 'Stripe webhook signature failed');
return res.status(400).send('Signature verification failed');
}
// Idempotency check
const alreadyProcessed = await prisma.webhookEvent.findUnique({
where: { id: `stripe-${event.id}` },
});
if (alreadyProcessed) {
logger.info({ eventId: event.id }, 'Already processed, skipping');
return res.status(200).json({ received: true });
}
// Record as processed (before processing to prevent race conditions)
await prisma.webhookEvent.create({
data: {
id: `stripe-${event.id}`,
type: event.type,
processedAt: new Date(),
},
});
// Return 200 immediately — don't block on processing
res.status(200).json({ received: true });
// Queue for async processing
await webhookQueue.add(event.type, event);
}
);
处理程序 (src/webhooks/handlers/paymentHandler.ts)
import Stripe from 'stripe';
import { prisma } from '../../prisma';
import { emailQueue } from '../../queues';
import { logger } from '../../logger';
export async function handlePaymentIntentSucceeded(
event: Stripe.Event
): Promise {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
logger.warn(
{ paymentIntentId: paymentIntent.id },
'No orderId in metadata'
);
return;
}
await prisma.order.update({
where: { id: orderId },
data: {
status: 'completed',
paidAt: new Date(),
paymentIntentId: paymentIntent.id,
},
});
await emailQueue.add('order-confirmation', { orderId });
logger.info({ orderId }, 'Order completed via webhook');
}
export async function handlePaymentIntentFailed(
event: Stripe.Event
): Promise {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
logger.warn(
{ paymentIntentId: paymentIntent.id },
'No orderId in metadata'
);
return;
}
await prisma.order.update({
where: { id: orderId },
data: {
status: 'failed',
failedAt: new Date(),
},
});
logger.info({ orderId }, 'Order marked as failed via webhook');
}
export async function handleSubscriptionDeleted(
event: Stripe.Event
): Promise {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await prisma.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: { active: false, cancelledAt: new Date() },
});
logger.info(
{ customerId },
'Subscription deactivated via webhook'
);
}
GitHub Webhook 实现 (src/webhooks/githubWebhook.ts)
import crypto from 'crypto';
import express, { Router } from 'express';
import { githubQueue } from '../queues';
import { logger } from '../logger';
const router = Router();
function verifyGitHubSignature(body: Buffer, signature: string): boolean {
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
hmac.update(body);
const expected = `sha256=${hmac.digest('hex')}`;
// Timing‑safe comparison prevents timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
router.post(
'/webhooks/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256'] as string;
if (!signature || !verifyGitHubSignature(req.body, signature)) {
logger.warn('Invalid GitHub webhook signature');
return res.status(401).send('Invalid signature');
}
const event = req.headers['x-github-event'] as string;
const payload = JSON.parse(req.body.toString());
// Return fast, process async
res.status(200).json({ received: true });
await githubQueue.add(event, payload);
}
);
export default router;
Webhook 事件模型(Prisma)
model WebhookEvent {
id String @id // "{provider}-{eventId}"
type String
processedAt DateTime
@@index([processedAt])
}
维护
- 使用计划的 cron 作业在 30 天 后清理旧事件。
有关 webhook 安全最佳实践的更多详细信息,请参阅指南。