关于 stripe-webhook-idempotency
用于实现 Stripe webhook 处理程序和可安全重试的变更端点,涵盖签名验证、事件与交易去重,以及数据库更新顺序。
主要功能
- 01Stripe webhook 签名验证
- 02短期事件去重
- 03持久幂等检查点
- 04Idempotency-Key 验证
- 05读取前行锁定
适用场景
- 01实现 Stripe webhook 处理程序
- 02让计费类 POST 端点支持安全重试
- 03防止余额变更重复
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill stripe-webhook-idempotency安装后在 Claude Code 中输入/stripe-webhook-idempotency确认已加载。
详细介绍
快速参考
| 概念 | 权威来源 | 默认值 |
|---|---|---|
| 签名验证 | stripe.webhooks.constructEventAsync(rawBody, sig, secret, tolerance) | tolerance = 300s |
| 事件去重(短期) | stripe_processed_events(保留 30d) | 主键:event_id |
| 余额变更去重(持久) | credit_transactions.idempotency_key UNIQUE 部分索引 | 永久保留 |
| 幂等键优先级 | Idempotency-Key header > body.idempotency_key > crypto.randomUUID() | 验证 len<=128, [A-Za-z0-9_-] |
| 检查点顺序 | 检查点 INSERT -> 用户 UPDATE | 切勿反向操作 |
| 行锁 | 对快照行使用 .for("update") | UPDATE 依赖之前的 SELECT 时必需 |
何时使用此技能
适用于所有 Stripe webhook 处理程序,以及所有可安全重试的客户端变更端点(任何会创建计费实体的 POST 路由,例如 /api/v1/orders、/api/v1/jobs 或项目中的等效路由)。
相关技能:
- 退款/争议状态:
stripe-billing-master:stripe-refund-dispute-lifecycle - 审计行不变量:
stripe-billing-master:stripe-credit-audit-trail - 处理程序主体中签名验证后的发票行/退款分页:
stripe-billing-master:stripe-list-pagination-previous-attributes
核心规则
G1:状态变更之前先写入持久检查点
await db.transaction(async (tx) => {
// 1. Durable checkpoint FIRST
const [inserted] = await tx.insert(creditTransactions).values({ /* ... idempotencyKey: "dispute_hold:<id>" */ })
.onConflictDoNothing({ target: creditTransactions.idempotencyKey })
.returning({ id: creditTransactions.id });
// 2. Dedup
if (!inserted) return;
// 3. Mutate
await tx.update(users).set({ /* ... */ }).where(/* ... */);
});
Idempotency-Key header 优先级与验证
const MAX_LEN = 128;
const VALID = /^[A-Za-z0-9_-]+$/;
export function validateIdempotencyKey(key: string | null): { valid: true; key: string | null } | { valid: false; error: string } {
if (!key || key.trim() === "") return { valid: true, key: null }; // caller falls back to randomUUID
if (key.length > MAX_LEN) return { valid: false, error: "Idempotency-Key too long" };
if (!VALID.test(key)) return { valid: false, error: "Idempotency-Key invalid charset" };
return { valid: true, key };
}
// In the handler:
const headerKey = req.headers.get("Idempotency-Key");
const bodyKey = body.idempotency_key;
const raw = headerKey ?? bodyKey ?? null;
const v = validateIdempotencyKey(raw);
if (!v.valid) return apiError(400, "VALIDATION_ERROR", v.error);
const key = v.key ?? crypto.randomUUID();
签名验证
使用 constructEventAsync——同步的 constructEvent 会在载荷较大时阻塞 worker。
const event = await stripe.webhooks.constructEventAsync(
rawBody, // raw bytes; Next.js: await req.text() BEFORE any json()
sigHeader,
env.STRIPE_WEBHOOK_SECRET,
300, // 5min tolerance
);
完整的 webhook 处理程序骨架
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return apiError(400, "VALIDATION_ERROR", "Missing stripe-signature");
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
300, // 5min tolerance
);
} catch (err) {
logEvent("stripe_webhook_bad_signature", { err: String(err) });
return apiError(400, "INVALID_SIGNATURE", "Signature verification failed");
}
// Transactional dedup: G1 + G9 pattern
const dedup = await db.insert(stripeProcessedEvents)
.values({ eventId: event.id, type: event.type })
.onConflictDoNothing({ target: stripeProcessedEvents.eventId })
.returning({ id: stripeProcessedEvents.id });
if (dedup.length === 0) return apiSuccess({ deduped: true });
await dispatchEvent(event); // handlers apply G1-G9 per rules above
return apiSuccess({ ok: true });
}
对之前读取的快照使用 FOR UPDATE
const [user] = await tx.select(/* ... */).from(users).where(eq(users.id, userId)).for("update");
// snapshot is safe from concurrent invoice/dispute writes
const prev = user.stripeSubscriptionStatus;
await tx.insert(creditTransactions).values({ /* ... */ metadata: { previousStatus: prev } });
await tx.update(users).set({ stripeSubscriptionStatus: "past_due" });
评论
支持 Markdown