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. 01Stripe 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 ヘッダー > 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 ヘッダーの優先順位と検証
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 は、ペイロードが大きい場合にワーカーをブロックします。

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" });

コメント