stripe-webhook-idempotency について
Stripe webhookハンドラーと安全に再試行できる更新エンドポイントの実装を支援するスキルです。署名検証、イベントやトランザクションの重複排除、データベース更新順序を扱います。
主な機能
- 01Stripe webhook署名検証
- 02短期間のイベント重複排除
- 03永続的な冪等チェックポイント
- 04Idempotency-Keyの検証
- 05先行読み取り時の行ロック
活用シーン
- 01Stripe 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 ヘッダー > 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" });
コメント
Markdown 対応