Claude Media
Claude CodeでStripe webhookの冪等受信を書く — 重複配信と順序の崩れに耐える

Claude CodeでStripe webhookの冪等受信を書く — 重複配信と順序の崩れに耐える

イベントIDの処理済みテーブルと生ボディの署名検証、createdに頼らない順序対策を、フック付きの検証ループでClaude Codeに書かせる手順です。

Stripeのwebhookは、同じイベントを複数回受け取ることがあります。しかも、生成された順に届く保証はありません。Claude Codeに受信コードを書かせるとき、署名検証が通って200を返すだけの実装で止まりがちなのは、この2点です。

結論は、イベントIDを主キーにした受信箱テーブルを置き、署名検証には未加工のボディを使い、順序の判断をcreatedに頼らない構成です。この記事は、その実装と、テストを自動で回すフック設定を示します。stripe listenと秘密鍵の扱いはStripe CLIのwebhook開発の記事が受け持つので、ここでは受信処理の中身に絞ります。

Stripeが保証しないことを先に固定する

Claude Codeへの指示は、Stripeの仕様を先に渡すと外れにくくなります。受信側が前提にしてよいことと、してはいけないことは次のとおりです。

観点Stripeの仕様受信側の対応
重複Stripeの仕様同じイベントを複数回受信することがある受信側の対応イベントIDを記録し、記録済みなら処理しない
別イベントの重複Stripeの仕様2つのEventオブジェクトが別々に生成・送信される場合がある受信側の対応data.objectのIDとevent.typeの組で見分ける
順序Stripeの仕様生成された順での配信は保証されない受信側の対応順序に依存しない。必要なら最新のオブジェクトをAPIで取得する
再試行Stripeの仕様本番では指数バックオフで最長3日間受信側の対応失敗時は非2xxを返し、再配信を受け入れる
応答Stripeの仕様複雑な処理の前に2xxを素早く返す受信側の対応保存だけして返し、本処理は非同期で行う

再試行されたイベントは、新しい署名とタイムスタンプで届きます。つまり同じイベントでも、配信のたびにStripe-Signatureの中身は変わります。署名の文字列を重複判定のキーにはできません。

「古いイベントを無視する」の落とし穴

古いイベントは捨てればよい、と考えてevent.createdの大小で判定する実装は、よく見かけます。Stripeはこれを勧めていません。スナップショットイベントはcreatedを秒単位で記録するため、異なるイベントが同じタイムスタンプを持つことがあります。公式は、順序や処理済みかどうかの判断にcreatedを使わないよう案内しています。

代わりの手段は2つです。重複の判定にはイベントIDを使います。順序の食い違いには、イベントの中身を信じず、APIで対象オブジェクトの現在の状態を取り直します。たとえばinvoice.paidが先に届いても、そこからinvoiceやsubscriptionを取得できます。

くらべる

順序の食い違いへの2つの対処

避ける

createdで古いものを捨てる

同じ秒のイベントが並ぶと、どちらが新しいか決まりません。新しい状態を古いイベントが上書きする経路も残ります。

採用

APIで最新の状態を取り直す

届いたイベントは「何かが変わった」という合図として扱います。保存する値は、処理時点のAPI応答から取ります。

Claude Codeに渡す指示とCLAUDE.mdの規約

規約をCLAUDE.mdに置いておけば、毎回の指示で言い直さずに済みます。上の表をそのまま規約にします。

## Stripe webhook受信
- 署名検証には未加工のボディを使う。このルートに `express.json()` を適用しない
- 受信箱 `webhook_inbox` の主キーは `event.id`。挿入は `ON CONFLICT DO NOTHING`
- `event.created` を順序や重複の判定に使わない
- 状態の更新は、イベントの中身ではなくAPIで取得した最新のオブジェクトから行う
- 署名検証の失敗は `400`、受信箱への保存が成功したら `200`
- 検証は `npm test -- test/webhooks`。テストが落ちたまま完了と言わない

プロンプトは、実装の中身ではなく、満たす条件を並べます。

src/webhooks/ にStripeのwebhook受信を実装して。
CLAUDE.mdの規約に従うこと。
重複配信と順序の入れ替わりを再現するテストを test/webhooks/ に先に書き、
落ちることを確認してから実装して。

テストを先に書かせるのは、重複や順序の不具合が、正常系のテストでは一切表に出ないためです。

受信箱テーブルと署名検証の実装

例えば次のような形になります。ここではExpressとSQLite(better-sqlite3)を使った骨格です。ライブラリは手元のスタックに置き換えてください。

// src/webhooks/stripe.ts
import express from "express";
import Stripe from "stripe";
import type Database from "better-sqlite3";
 
export function stripeWebhook(db: Database.Database, stripe: Stripe, secret: string) {
  db.exec(`CREATE TABLE IF NOT EXISTS webhook_inbox (
    event_id TEXT PRIMARY KEY,
    type TEXT NOT NULL,
    object_id TEXT,
    payload TEXT NOT NULL,
    done_at TEXT
  )`);
  const insert = db.prepare(
    `INSERT INTO webhook_inbox (event_id, type, object_id, payload)
     VALUES (?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING`
  );
 
  const router = express.Router();
  // 署名検証のため、未加工のボディ(Buffer)のまま受け取る
  router.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body, req.header("stripe-signature") ?? "", secret
      );
    } catch {
      return res.status(400).send("invalid signature");
    }
    const obj = (event.data.object as { id?: string }).id ?? null;
    insert.run(event.id, event.type, obj, req.body.toString("utf8"));
    res.sendStatus(200); // 2回目以降の配信も、保存済みなら同じく200
  });
  return router;
}

ポイントは3つです。

  1. express.rawをこのルートだけに使います。Stripeの署名検証には未加工のリクエスト本文が必要で、本文に手が加わると検証は失敗します。アプリ全体にexpress.json()を先に適用していると、ここで壊れます。
  2. 重複は、エラーにせず200で返します。2回目の配信に非2xxを返すと、Stripeは失敗とみなして再試行を続けます。
  3. 受信箱は保存だけです。公式も、複雑なロジックの前に2xxを返すよう案内しています。

署名検証は、公式ライブラリの利用が推奨されています。constructEventの引数は手元のstripeパッケージの型定義で確かめさせてください。

受信箱を処理するワーカー

本処理は受信箱の未処理行を拾うワーカーに置きます。ここで、先ほどの「取り直す」を実装します。

// src/webhooks/worker.ts(要点のみ)
async function handle(row: { event_id: string; type: string; object_id: string | null }) {
  if (row.type.startsWith("customer.subscription.") && row.object_id) {
    const sub = await stripe.subscriptions.retrieve(row.object_id); // 現在の状態
    upsertSubscription(sub.id, sub.status);
  }
  markDone(row.event_id);
}

イベントのdata.objectのstatusを保存しない点が肝です。customer.subscription.updatedが2つ、逆順で届いても、どちらの処理でも「処理時点の最新」を書くので、最終状態は同じになります。

別々に生成された同内容のイベントを見分ける

イベントIDが違っても、中身が重複している場合があります。Stripeは、2つのEventオブジェクトが個別に生成・送信されるケースがあると説明し、見分けにはdata.objectのオブジェクトIDとevent.typeを使うよう案内しています。受信箱にobject_id列を持たせたのは、このためです。

副作用が1回限りでなければならない処理、たとえば「支払い完了メールを送る」は、受信箱の主キーだけでは足りません。処理側に、対象とイベント種別の組で一意になる記録を持たせます。

CREATE UNIQUE INDEX IF NOT EXISTS uq_effect
  ON effects (object_id, effect_type);

メール送信などの副作用は、この表に挿入できたときだけ実行します。先に副作用を実行してから記録すると、途中で落ちたときの再実行で二重に送られます。記録を先に取り、その成否を実行の条件にします。

受け取るイベントの種類と、アカウントのAPIバージョン

エンドポイントは、実装に必要なイベントタイプだけを受け取る設定にします。公式は、それ以外を受信すると不要な負荷が増えるとしています。Claude Codeに「念のため全部受ける」実装をさせないよう、CLAUDE.mdには受信するタイプの一覧も書いておきます。

もうひとつ見落としやすいのが、APIバージョンです。イベントの構造は、イベント発生時点でのアカウントのAPIバージョンで決まります。後からアカウントのバージョンを更新しても、既存のイベントがさかのぼって変わることはありません。古いバージョンで作られた再試行中のイベントと、新しいバージョンのイベントが混在すると、data.objectの形が揃いません。ワーカーがdata.objectの中身ではなくAPIの取り直しを使う構成なら、この差の影響を受けにくくなります。

重複・署名・順序を再現するテスト

Stripe CLIのstripe triggerは、イベントを1回発生させるコマンドです。公式の例はstripe trigger payment_intent.succeededです。重複の再現には向かないので、重複と署名の不正はテストコードで作ります。

署名は、公式が手動検証の手順を示しています。signed_payloadはタイムスタンプ、.、リクエスト本文を連結した文字列で、これをエンドポイントのシークレットをキーにしたHMAC-SHA256で署名します。ヘッダーはt=<秒>,v1=<署名>の形です。この手順をテストの補助関数にします。

// test/webhooks/helpers.ts
import { createHmac } from "node:crypto";
 
export function sign(body: string, secret: string, t = Math.floor(Date.now() / 1000)) {
  const sig = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
  return `t=${t},v1=${sig}`;
}
// test/webhooks/duplicate.test.ts(要点のみ)
const body = JSON.stringify({ id: "evt_1", type: "customer.subscription.updated",
  object: "event", data: { object: { id: "sub_1", status: "active" } } });
 
test("同じイベントを2回受けても受信箱は1行", async () => {
  for (let i = 0; i < 2; i++) {
    const r = await post("/webhook", body, { "stripe-signature": sign(body, SECRET) });
    assert.equal(r.status, 200);
  }
  assert.equal(count("webhook_inbox"), 1);
});
 
test("ボディを再整形すると署名検証に失敗する", async () => {
  const reformatted = JSON.stringify(JSON.parse(body), null, 2);
  const r = await post("/webhook", reformatted, { "stripe-signature": sign(body, SECRET) });
  assert.equal(r.status, 400);
});

2つ目のテストは、ミドルウェアが本文を整形し直す不具合を捕まえます。順序の入れ替わりは、ワーカーのテストで、APIの応答をスタブに差し替えて確かめます。古いイベントを後から処理しても、保存されるのはスタブが返す最新の値であることを検証します。

テストをフックで自動実行する

ファイルを書き換えるたびにテストを走らせ、失敗をClaudeに読ませるために、PostToolUseフックを使います。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/webhook-test.sh",
            "args": [],
            "timeout": 120
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/webhook-test.sh  (chmod +x が必要。jqを使う)
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
  */src/webhooks/*|*/test/webhooks/*) ;;
  *) exit 0 ;;
esac
OUT=$(npm test -- test/webhooks 2>&1) || { echo "$OUT" | tail -40 >&2; exit 2; }

PostToolUseは、ツールがすでに実行された後に走ります。そのため、exit 2で失敗を返してもファイルの変更は取り消されません。代わりに、標準エラー出力がClaudeに渡されます。これが目的です。テストの失敗箇所が、次の修正の入力になります。

matcherはツール名で絞る項目です。パスでの絞り込みはifでも書けますが、ここでは複数ディレクトリをまとめて見るため、スクリプト側で判定しています。同じ型のフックはフックでテストを自動実行する記事でも扱っています。Bashで書き換えられたファイルには、Edit|Writeのフックは走らない点に注意してください。

stripe triggerとresendで確認する範囲

テストが通ったら、実際のStripeからのイベントでも確かめます。

stripe listen --forward-to localhost:4242/webhook
stripe trigger customer.subscription.updated

stripe listenは人が別ターミナルで動かします。理由は、前述の記事のとおりシークレットを画面に出すためです。triggerでイベントが1件届き、受信箱に1行入ることを確かめます。

手動の再送には2つの方法があります。ダッシュボードの「再送する」はイベント作成後最大15日間、Stripe CLIのstripe events resend <event_id> --webhook-endpoint=<endpoint_id>は最大30日間使えます。後者は登録済みのエンドポイントを指定する形です。

再送で気をつけたいのは、手動の再送が2xxで成功しても、自動再試行はキャンセルされない点です。処理済みのイベントが、後から自動再試行でも届き得ます。この二重配信を無害にするのが、受信箱の主キーです。再送を使う確認は、受信箱の行数が増えないことを見るところまでです。

よくあるつまずき

落とし穴

フレームワークが本文を先にJSONへ変換して署名が通らない

Stripeの署名検証には未加工の本文が必要です。グローバルなJSONパーサーを先に通していると、Bufferではなくオブジェクトが渡って400になります。webhookのルートだけexpress.rawを使うか、パーサーより前にルートを登録します。

落とし穴

重複を検知したときにエラーを返してしまう

挿入の一意制約違反を、そのまま500にしていると、正常な重複配信が失敗扱いになります。再試行が止まらず、ダッシュボードの失敗件数も増えます。ON CONFLICT DO NOTHINGで受け流し、200を返します。

落とし穴

フレームワークのCSRF保護でwebhookが弾かれる

RailsやDjangoのように、すべてのPOSTにCSRFトークンを要求するフレームワークでは、Stripeからのリクエストが拒否されます。公式は、webhookのルートをCSRF保護から除外するよう案内しています。署名検証が代わりの認証になるため、除外したルートでは検証を省かないでください。

落とし穴

受信箱に入れたのに、ワーカーが落ちて取りこぼす

受信箱は未処理の行を残します。done_atが空の行を定期的に拾い直す処理を用意します。こうしておけば、重複排除のためにイベントIDを記録することと、取りこぼしを避けることが両立します。

まとめ

受信処理の肝は、署名検証の前に本文を触らないこと、イベントIDで重複を落とすこと、順序は「取り直し」で吸収することの3つです。Claude Codeには、この3つを規約とテストの形で先に渡すと、正常系だけが通る実装を避けられます。PostToolUseフックでテストを回し続ければ、修正のたびに重複と署名の回帰が確認されます。ローカルでngrokを使ってStripeから直接受ける構成なら、ngrokプラグインの記事も参考になります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.stripe.com/webhooks
  2. code.claude.com/docs/en/hooks
この記事を共有:XはてブLinkedIn