# スナップショットイベントから Thin イベントに移行する 本番環境を中断せずに Thin イベントに移行する方法をご紹介します。 > API v1 リソースの [Thin イベント](https://docs.stripe.com/event-destinations.md#thin-events)はプライベートプレビューで使用できます。以前は、Thin イベントは API v2 リソースのみをサポートしていました。[詳細を確認し、アクセスをリクエスト](https://docs.google.com/forms/d/e/1FAIpQLSeEkqzB02afvlklMkqwA6wsBH90eW8gxmc-hBOvqe2N6TRujQ/viewform?usp=dialog)してください。 [Thin イベント](https://docs.stripe.com/event-destinations.md#thin-events)は、スナップショットイベントに代わる軽量でバージョン安定性の高い手段を提供します。Webhook ペイロードで完全なリソースオブジェクトを受信する代わりに、コンパクトな通知を受け取り、必要な詳細を取得します。これにより、API バージョンをアップグレードする際に Webhook ハンドラーを更新する必要がなくなります。 このガイドを使用して、本番環境を中断せずにスナップショットイベントから Thin イベントに移行します。移行では、移行中に両方のハンドラーが並行して実行されるデュアルデスティネーション戦略が使用されます。メリットやユースケースなど、Thin イベントの概要については、[Thin イベント](https://docs.stripe.com/event-destinations.md#thin-events)を参照してください。 ## スナップショットイベントの Thin バージョン 移行を支援するために、Stripe は既存のスナップショットイベントの Thin イベントバージョンを作成します。たとえば、`customer.created` の Thin バージョンは `v1.customer.created` です。移行中に、1 つのアクションが両方のイベントをトリガーすると、Thin バージョンには元のスナップショットイベント ID を含む `snapshot_event` フィールドが含まれます。これをべき等キーとして使用し、両方のハンドラーを同時に実行する際の処理の重複を防ぎます。 ## Before you begin 以下があることを確認してください。 - Webhook エンドポイントコードにアクセスして新しいルートを追加する - Stripe ダッシュボードまたは API でイベントの送信先を作成する権限 - 本番環境に反映する前に移行を検証するためのサンドボックスへのアクセス - ハンドラー間の動作を比較する監視とロギング > サンドボックスでこの移行プロセス全体を完了してから、本番環境で実行してください。 ## 段階的な移行戦略 移行は次のフェーズで構成されます。 1. アプリケーションに新しい Thin Webhook ルートを追加します。 1. 既存の登録内容をミラーリングする Thin イベントの送信先を作成します。 1. シャドウモードで実行して、変更を加えずに動作を検証します。 1. べき等性を使用してカットオーバーし、両方の送信先を同時に処理します。 1. 安定性を確認したら、スナップショットの送信先を破棄します。 この戦略により、ダウンタイムがなくなり、必要に応じて検証して元に戻すことが複数回行えます。 ## Thin Webhook ルートを追加する Thin イベント専用の新しいエンドポイントをアプリケーションに作成します。署名のみを検証し、200 ステータスコードを返す最小限の実装から開始します。 > `snapshot_event` フィールドにアクセスするには、プレビュー API バージョンを使用するように Stripe SDK を設定します。 > > ```javascript const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, { apiVersion: '2025-11-17.preview' }); ``` > > 安定版 API バージョン (`.clover`) には、`snapshot_event` などのプライベートプレビュー機能は含まれていません。 ```javascript const express = require('express'); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, { apiVersion: '2025-11-17.preview' }); const app = express(); // New thin event endpoint app.post( '/webhook/thin', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const thinWebhookSecret = process.env.THIN_WEBHOOK_SECRET; try { // Verify the signature using the same method as snapshot events const thinNotification = stripe.webhooks.constructEvent( req.body, sig, thinWebhookSecret ); console.log(`Verified thin event: ${thinNotification.id}`); // For now, just acknowledge receipt res.sendStatus(200); } catch (err) { console.log(`Webhook Error: ${err.message}`); res.status(400).send(`Webhook Error: ${err.message}`); } } ); app.listen(3000, () => console.log('Running on port 3000')); ``` この変更をデプロイし、エンドポイントにアクセスできることを確認します。 ## Thin イベントの送信先を作成する Stripe ダッシュボードまたは API で、Thin イベント用に設定された新しいイベントの送信先を作成します。 ### ダッシュボードを使用する 1. **開発者** > **Webhook** に移動します。 1. **送信先を追加** をクリックします。 1. **詳細設定** で、 **Thin イベントを使用** をオンに切り替えます。 1. スナップショットの送信先で使用されているものと同じイベントタイプに、プレフィックス `v1.` を付けて登録します。 - `customer.created` → `v1.customer.created` - `payment_intent.succeeded` → `v1.payment_intent.succeeded` - `invoice.paid` → `v1.invoice.paid` 1. 新しいエンドポイント URL (`https://yourdomain.com/webhook/thin` など) を入力します。 ### API を使用する ```bash curl https://api.stripe.com/v2/core/event_destinations \ -H "Authorization: Bearer sk_test_..." \ -H "Stripe-Version: 2025-11-17.preview" \ -d "name"="Thin Events Destination" \ -d "type"="webhook_endpoint" \ -d "event_payload"="thin" \ -d "webhook_endpoint[url]"="https://yourdomain.com/webhook/thin" \ -d "enabled_events[]"="v1.customer.created" \ -d "enabled_events[]"="v1.payment_intent.succeeded" \ -d "enabled_events[]"="v1.invoice.paid" ``` > 新しい Webhook 署名シークレットをスナップショット Webhook シークレットとは別に保存します。混同を避けるために、明確にラベルを付けます (`SNAPSHOT_WEBHOOK_SECRET` や `THIN_WEBHOOK_SECRET` など)。 この時点で、両方の送信先がアクティブになり、アプリケーションにイベントが配信されます。 ## シャドウモードで実行 Thin Webhook ハンドラーを更新して、イベントの詳細と関連オブジェクトを取得しますが、データベースにはまだ書き込まないようにします。代わりに、実行するアクションをログに記録して、Thin ハンドラーがスナップショットハンドラーと同じ動作をすることを監視できるようにします。 ステップ 1 のハンドラーに以下のコンポーネントを追加します。 1. シャドウモードフラグを有効にします (ファイルの先頭に追加します): ```javascript // Enable shadow mode via environment variable const SHADOW_MODE = process.env.SHADOW_MODE !== 'false'; ``` 1. 完全なイベント詳細を取得する (署名検証後に追加): ```javascript // Fetch the full event details const event = await stripe.v2.core.events.retrieve(thinNotification.id); console.log(`Processing ${event.type} (thin ID: ${event.id})`); // For interop events, log the original snapshot event ID if (event.snapshot_event) { console.log(`Correlated snapshot event: ${event.snapshot_event}`); } ``` 1. 関連オブジェクトとシャドウログを取得する (イベント処理ロジックを追加): ```javascript // Fetch the related object if needed if (event.type === 'v1.customer.created') { const customer = await stripe.customers.retrieve(event.related_object.id); if (SHADOW_MODE) { // SHADOW MODE: Log what you would do, but don't do it yet console.log(`[SHADOW] Would create customer record: ${customer.id}`); // recordMetric('customer.created.thin', 1); } else { // Production mode: Actually write to database await createCustomerInDatabase(customer); } } ``` 1. シャドウモードでハンドラーを完了します。 ```javascript const SHADOW_MODE = process.env.SHADOW_MODE !== 'false'; // ← NEW app.post( '/webhook/thin', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const thinWebhookSecret = process.env.THIN_WEBHOOK_SECRET; try { const thinNotification = stripe.webhooks.constructEvent( req.body, sig, thinWebhookSecret ); // ← NEW: Fetch full event details const event = await stripe.v2.core.events.retrieve(thinNotification.id); console.log(`Processing ${event.type} (thin ID: ${event.id})`); // ← NEW: Log snapshot event ID for correlation if (event.snapshot_event) { console.log(`Correlated snapshot event: ${event.snapshot_event}`); } // ← NEW: Fetch related object and handle in shadow mode if (event.type === 'v1.customer.created') { const customer = await stripe.customers.retrieve(event.related_object.id); if (SHADOW_MODE) { console.log(`[SHADOW] Would create customer record: ${customer.id}`); } else { await createCustomerInDatabase(customer); } } res.sendStatus(200); } catch (err) { console.log(`Webhook Error: ${err.message}`); res.status(400).send(`Webhook Error: ${err.message}`); } } ); ``` ### シャドウモードで確認すべきこと シャドウモードを少なくとも 24 〜 48 時間実行し、以下を監視します。 - **署名の検証**: すべての Thin イベントが正常に検証されることを確認します。 - **フェッチ遅延**: イベントと関連オブジェクトの取得にかかる時間を測定します。 - **動作の一貫性**: シャドウログをスナップショットハンドラーの実際の動作と比較します。 - **エラー率**: 予期しない障害やデータの欠落がないか監視します。 Thin ハンドラーのシャドウログがスナップショットハンドラーの実際の動作と異なる場合は、(本番環境への影響がない間に) 今すぐ不一致を調査して修正してください。 > interop イベントの場合、`snapshot_event` フィールドには元のスナップショットイベント ID が含まれます。Thin `event.id` と `event.snapshot_event` の両方をログに記録して、移行中に両方のハンドラー間でイベントを関連付けます。 ## べき等性によるカットオーバー シャドウモードが問題なく実行されたら、Thin ハンドラーで実際の書き込みを有効にします。イベントを見逃さないように、短い重複期間の間は両方の送信先をアクティブに保ちます。 ### べき等性を実装する 重複している間に、同じ論理イベントを 2 回受信することがあります。1 回はスナップショットイベントとして、もう 1 回は Thin イベントとして受信します。べき等キーを使用して、重複処理を防止してください。 > Thin interop イベントの `snapshot_event` フィールドには、元のスナップショットイベント ID が含まれます。このフィールドをべき等キーとして使用することで、両方のハンドラーが同じキーに対して重複排除を実行できます。 まず、べき等性データベーステーブルを設定します。 ```sql CREATE TABLE event_idempotency ( idempotency_key TEXT PRIMARY KEY, event_type TEXT NOT NULL, processed_at INTEGER NOT NULL ); ``` 次に、べき等性ヘルパーを実装します。 ```javascript // Try to insert idempotency key. Returns true if successfully inserted, false if duplicate. function tryInsertIdempotencyKey(idempotencyKey, eventType) { try { db.prepare(` INSERT INTO event_idempotency (idempotency_key, event_type, processed_at) VALUES (?, ?, ?) `).run(idempotencyKey, eventType, Date.now()); return true; // Insert succeeded - event is new } catch (err) { if (err.code === 'SQLITE_CONSTRAINT') { return false; // Duplicate - event already processed (use your database-specific error code for unique constraint violations) } throw err; // Re-throw unexpected errors } } ``` このパターンを使用するように両方のハンドラーを更新します。まず、*スナップショットハンドラー*: ```javascript app.post( '/webhook/snapshot', express.raw({type: 'application/json'}), (req, res) => { const sig = req.headers['stripe-signature']; const snapshotWebhookSecret = process.env.SNAPSHOT_WEBHOOK_SECRET; try { const event = stripe.webhooks.constructEvent( req.body, sig, snapshotWebhookSecret ); // For snapshot events, idempotency key is just the event ID const idempotencyKey = event.id; // Try to insert idempotency key. If fails (duplicate), skip processing. if (!tryInsertIdempotencyKey(idempotencyKey, event.type)) { console.log(`Already processed: ${idempotencyKey}`); return res.sendStatus(200); } // Process the event if (event.type === 'customer.created') { createCustomerInDatabase(event.data.object); } res.sendStatus(200); } catch (err) { console.log(`Webhook Error: ${err.message}`); res.status(400).send(`Webhook Error: ${err.message}`); } } ); ``` 次に、 *Thin ハンドラー*: ```javascript const SHADOW_MODE = process.env.SHADOW_MODE !== 'false'; app.post( '/webhook/thin', express.raw({type: 'application/json'}), async (req, res) => { const sig = req.headers['stripe-signature']; const thinWebhookSecret = process.env.THIN_WEBHOOK_SECRET; try { const thinNotification = stripe.webhooks.constructEvent( req.body, sig, thinWebhookSecret ); const event = await stripe.v2.core.events.retrieve(thinNotification.id); // For thin interop events, use snapshot_event if present, otherwise event ID const idempotencyKey = event.snapshot_event || event.id; // Check idempotency first to avoid unnecessary work if (!SHADOW_MODE) { // Try to insert idempotency key. If fails (duplicate), skip processing. if (!tryInsertIdempotencyKey(idempotencyKey, event.type)) { console.log(`Already processed: ${idempotencyKey}`); return res.sendStatus(200); } } // Process the event if (event.type === 'v1.customer.created') { const customer = await stripe.customers.retrieve(event.related_object.id); if (!SHADOW_MODE) { // Process the event createCustomerInDatabase(customer); } else { console.log(`[SHADOW] Would create customer: ${customer.id}`); console.log(`[SHADOW] Idempotency key: ${idempotencyKey}`); } } res.sendStatus(200); } catch (err) { console.log(`Webhook Error: ${err.message}`); res.status(400).send(`Webhook Error: ${err.message}`); } } ); ``` **重複を防ぐ方法**: 1. 顧客が作成されると、Stripe は両方のイベントを生成します。 - スナップショットイベント: `evt_abc123` - Thin イベント: `evt_xyz789` と `snapshot_event: "evt_abc123"` 1. 両方のハンドラーがイベントを同時に受信します。 - スナップショットハンドラー: `idempotencyKey = event.id` > `"evt_abc123"` - Thin handler: `idempotencyKey = event.snapshot_event || event.id` → `"evt_abc123"` 1. 両方のハンドラーが同じキーを挿入しようとします。 - スナップショットハンドラー: `INSERT INTO ... VALUES ('evt_abc123', ...)` > 成功 - Thin ハンドラー: `INSERT INTO ... VALUES ('evt_abc123', ...)` > **一意制約違反** 1. 結果: 顧客が作成されます。 ### カットオーバーを監視する 両方のハンドラーがデータベースに書き込む場合: - **重複検出を監視する**: べき等性ロジックが重複するイベントを捕捉することを確認します。 - **書き込みパターンの比較**: Thin ハンドラーとスナップショットハンドラーで同じデータベース状態が生成されることを確認します。 - **エラー率を追跡する**: 障害の増加についてアラートを設定します。 - **パフォーマンスの監視**: 関連オブジェクトを取得する追加の API コールの影響を測定します。 > 何か問題がある場合は、Thin ハンドラーの書き込みを無効にして調査してください。Thin パスに確信が持てるまで、スナップショットハンドラーが参照ポイントとして残ります。 重複期間は短くします (数時間から長くても 1 日)。これにより、イベントを 2 回処理する期間が制限され、問題が発生した場合のトラブルシューティングが簡単になります。 ## スナップショットの送信先を廃止 Thin ハンドラーが快適な期間イベントを確実に処理した後、スナップショットの送信先を廃止できます。 ### フェーズ 1: 無効化 (コードは保持) 1. Stripe ダッシュボードで、 **開発者** > **Webhook** > **イベントの送信先** に移動します。 1. スナップショットイベントの送信先を見つけます。 1. **無効化** をクリックします。 これにより、Stripe がスナップショットエンドポイントにイベントを送信することができなくなりますが、安全対策としてコードが保持されます。Thin のみのフローを監視して安定性を確認します。 ### フェーズ 2: スナップショットの削除 すべてが安定したままの場合: 1. ダッシュボードからスナップショットイベントの送信先を削除します。 1. アプリケーションからスナップショット Webhook ハンドラーコードを削除します。 1. 構成から `SNAPSHOT_WEBHOOK_SECRET` を削除します。 1. スナップショットイベントを参照するドキュメントやランブックを更新します。 ## トラブルシューティング ### Thin ハンドラーがイベントを受信しない **エンドポイント URL を確認する**: Thin の送信先が正しい URL (`/webhook` ではなく `/webhook/thin` など) を指していることを確認します。 **ローカルでテストする**: [ngrok](https://ngrok.com/) などのトンネリングツールを使用してローカルエンドポイントを公開し、その URL を指す Thin イベントの送信先を作成します。 ### 署名の検証に失敗する **Webhook シークレットを確認する**: `THIN_WEBHOOK_SECRET` に、スナップショットの送信先ではなく、Thin イベントの送信先からの署名シークレットが含まれていることを確認します。 **未加工のペイロードを調べる**: 署名の検証には未加工のリクエスト本文が必要です。検証前に JSON を解析しないでください。 ```javascript // Correct: use express.raw() app.post('/webhook/thin', express.raw({type: 'application/json'}), handler); // Incorrect: express.json() parses the body app.post('/webhook/thin', express.json(), handler); ``` ## 推奨事項 ### イベントタイプ別の移行 新しい送信先で特定の Thin イベントタイプを登録して、イベントごとに移行することをお勧めします。たとえば、`v1.customer.created` と `v1.customer.updated` から開始し、検証してから、イベントタイプを追加します。 ### デュアルハンドラー デュアルハンドラーの実行や無期限の実行はお勧めしません。デュアルハンドラーを実行すると、運用の複雑さ、コスト (帯域幅と処理) が増加し、動作の不整合リスクが高まります。数週間以内に移行を完了してください。 ### 移行後の API アップグレード Thin イベントの主なメリットは、Webhook ペイロードが変更されないことです。プッシュ通知は安定したままで、現在の API バージョンを使用して、必要に応じてバージョン管理されたリソースの詳細を取得します。 ## See also - [Thin イベントの概要](https://docs.stripe.com/event-destinations.md#thin-events) - [イベントの送信先](https://docs.stripe.com/event-destinations.md) - [Webhook ベストプラクティス](https://docs.stripe.com/webhooks.md) - [API のバージョン管理](https://docs.stripe.com/api/versioning.md)