mcpField
S
stripe-webhook-idempotency

stripe-webhook-idempotency

github.com/josiahsiegel开发5972 次安装19 周前更新
代码仓库

关于 stripe-webhook-idempotency

用于实现 Stripe webhook 处理程序和可安全重试的变更端点,涵盖签名验证、事件与交易去重,以及数据库更新顺序。

主要功能

  1. 01Stripe webhook 签名验证
  2. 02短期事件去重
  3. 03持久幂等检查点
  4. 04Idempotency-Key 验证
  5. 05读取前行锁定

适用场景

  1. 01实现 Stripe webhook 处理程序
  2. 02让计费类 POST 端点支持安全重试
  3. 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