技術ログ

Stripe Webhook 冪等性 — 本番で重複処理を防ぐ実装3パターン (TypeScript)

公開: 2026-05-24 · 著者: Sasaki Ryuji

Stripe 公式の at-least-once delivery 仕様に基づく冪等化 3 パターンを、個人開発向けに TypeScript 動くコード付きで整理

Stripe Webhook 冪等性 — 本番で重複処理を防ぐ実装3パターン (TypeScript)

「Stripe Webhook が同じイベントを 2 回送ってきて、DB に二重記録が入った」「通知が 2 回飛んだ」。個人開発で SaaS の Stripe 決済を立ち上げた直後によく起きる事象です。原因は Webhook ハンドラに冪等性 (idempotency) を実装していないこと。Stripe の Webhook 配信は仕様として at-least-once (最低 1 回保証、重複あり得る) のため、受信側で冪等化していないと実装上の前提として扱うべき事象です。

冪等性とは、何回受信しても結果が変わらない設計のことです。Stripe 公式ドキュメントには重複イベントを処理するための専用ガイド (Stripe Docs - Handle duplicate events) があり、受信側で event.id を記録して重複をスキップする実装が推奨されています。

本記事は、個人開発で Stripe を実装する開発者向けに、Stripe 公式仕様に基づく 3 つの冪等化パターンを TypeScript の動くコード付きで整理します。月数千イベント以下なら DB UNIQUE 制約、複数インスタンス並列なら Redis SETNX、開発環境なら In-memory LRU、という選び分けをフローチャートで示します。後半では公式が明記している「event.id 単独では識別できない稀ケース」と、その対応パターンも解説します。


なぜ Stripe Webhook は同じイベントを複数回送るのか

at-least-once delivery が標準仕様

Stripe の Webhook 配信は at-least-once delivery (最低 1 回保証、重複あり得る) です。これは公式ドキュメント (Stripe Docs - Build a webhook endpoint) に明記されており、exactly-once ではありません。分散システムで exactly-once を保証するのは極めてコストが高いため、Stripe は at-least-once + 受信側冪等性を要求するモデルを採用しています。

リトライ条件

受信側エンドポイントが以下を返すと、Stripe は自動的にリトライを開始します。

  1. HTTP ステータスが 2xx 以外 (4xx, 5xx, タイムアウト含む)
  2. 応答が 30 秒以内に返らない
  3. TLS ハンドシェイク失敗、DNS 解決失敗等のネットワークエラー

リトライは指数バックオフで最大 3 日間続きます。Stripe ダッシュボードの「Webhooks」→「Failed events」で、自分の環境でも実際にリトライ中の event をいつでも確認できます。

ネットワーク経路の非対称性

「Stripe → 自サーバー」と「自サーバー → Stripe」は別経路で別途失敗し得ます。サーバー側は受信して DB に書き込み完了したのに応答パケットだけ消えるケースが現実にあり、Stripe は「応答なし = 失敗」でリトライするので、サーバー側には 2 回目の同じ event が届きます。

同じ event.id が複数回届く前提で受信側を設計するしかありません。冪等性は実装上の前提要件として扱うのが安全です。Stripe 課金まわりの落とし穴を包括的に整理したい場合は 関連記事: Stripe 月額課金実装の落とし穴 (1年運用の教訓) も参照してください。


パターン1: DB UNIQUE 制約 + ON CONFLICT (PostgreSQL/SQLite)

最もシンプルで、個人開発規模で最も採用されているパターンです。Webhook 専用テーブルに event.id を UNIQUE 制約付きで INSERT し、重複時は DB レベルで弾きます。

スキーマ

PostgreSQL の場合は以下のテーブルを 1 つ用意します。

CREATE TABLE webhook_events (
  event_id   TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  processed_at TIMESTAMPTZ,
  payload    JSONB NOT NULL
);

CREATE INDEX idx_webhook_events_received_at ON webhook_events (received_at);

SQLite の場合はほぼ同じで、TIMESTAMPTZTEXT に、JSONBTEXT に置き換えます。

TypeScript 実装 (Fastify + Stripe SDK + pg)

import Fastify from "fastify";
import Stripe from "stripe";
import { Pool } from "pg";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.acacia",
});
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

const app = Fastify({ logger: true });

app.post("/webhooks/stripe", {
  config: { rawBody: true },
}, async (req, reply) => {
  const sig = req.headers["stripe-signature"] as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.rawBody!, sig, webhookSecret);
  } catch (err) {
    req.log.error({ err }, "Stripe signature verification failed");
    return reply.code(400).send({ error: "Invalid signature" });
  }

  // 冪等性チェック: INSERT ... ON CONFLICT DO NOTHING
  const insertResult = await pool.query(
    `INSERT INTO webhook_events (event_id, event_type, payload)
     VALUES ($1, $2, $3)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id, event.type, event]
  );

  // 既に処理済み (重複) → スキップ
  if (insertResult.rowCount === 0) {
    req.log.info({ eventId: event.id }, "Duplicate event skipped");
    return reply.code(200).send({ received: true, duplicate: true });
  }

  // ここから本処理 (event.type で分岐)
  try {
    await handleEvent(event);
    await pool.query(
      `UPDATE webhook_events SET processed_at = NOW() WHERE event_id = $1`,
      [event.id]
    );
  } catch (err) {
    req.log.error({ err, eventId: event.id }, "Handler failed");
    // 500 を返して Stripe にリトライさせる
    return reply.code(500).send({ error: "Handler failed" });
  }

  return reply.code(200).send({ received: true });
});

async function handleEvent(event: Stripe.Event) {
  switch (event.type) {
    case "payment_intent.succeeded":
      // 本処理
      break;
    case "customer.subscription.updated":
      // 本処理
      break;
    default:
      // 未対応イベントは黙ってスキップ
      break;
  }
}

app.listen({ port: 3000 });

SQLite で同じことをやる場合は、ON CONFLICT (event_id) DO NOTHINGINSERT OR IGNORE INTO webhook_events ... に置き換えるだけで動きます。better-sqlite3 を使うなら db.prepare("INSERT OR IGNORE INTO webhook_events (event_id, event_type, payload) VALUES (?, ?, ?)").run(event.id, event.type, JSON.stringify(event)) の形で、info.changes === 0 なら重複と判定できます。

メリット・デメリット・適用範囲

メリットは 3 点。トランザクション境界が DB と一致して一貫性保証が強い、追加ミドルウェアが不要、デバッグ時に webhook_events テーブルを SELECT すれば受信履歴が全部見える。「あの時の event は届いていたか?」を SQL 1 本で確認できる運用性が、トラブルシュート時に効きます。

デメリットは Webhook 専用テーブルが必要なことと、INSERT のたびに DB アクセスが発生するので超高頻度 (毎秒数百イベント) には向かないこと。月数千〜数万イベントなら問題になりません。


パターン2: Redis SETNX + TTL (高速 + 多インスタンス対応)

Web を複数インスタンスで並列稼働させている場合や、DB INSERT がボトルネック化しつつある場合に Redis 冪等化が向きます。

コアアイデア

SET key value NX EX 604800 (NX = Not eXists、EX = expire in seconds、604800 秒 = 7 日) を使い、キーが存在しなければ作成、存在すれば「処理済み」としてスキップ。TTL 7 日は Stripe のリトライ最大期間 (3 日) より長く取るためです。

TypeScript 実装 (ioredis + Stripe SDK)

import Fastify from "fastify";
import Stripe from "stripe";
import Redis from "ioredis";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.acacia",
});
const redis = new Redis(process.env.REDIS_URL!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const TTL_SECONDS = 60 * 60 * 24 * 7; // 7日

const app = Fastify({ logger: true });

app.post("/webhooks/stripe", {
  config: { rawBody: true },
}, async (req, reply) => {
  const sig = req.headers["stripe-signature"] as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.rawBody!, sig, webhookSecret);
  } catch (err) {
    return reply.code(400).send({ error: "Invalid signature" });
  }

  const key = `webhook:stripe:${event.id}`;

  // SETNX: キーが存在しなければセット、存在すれば何もしない
  const setResult = await redis.set(key, "1", "EX", TTL_SECONDS, "NX");

  if (setResult === null) {
    // 既存 → 重複と判定してスキップ
    req.log.info({ eventId: event.id }, "Duplicate event skipped (Redis)");
    return reply.code(200).send({ received: true, duplicate: true });
  }

  try {
    await handleEvent(event);
    // 処理完了マーカー (オプション、可視化用)
    await redis.set(`${key}:done`, "1", "EX", TTL_SECONDS);
  } catch (err) {
    // 失敗時はキーを削除して次回リトライで再処理可能にする
    await redis.del(key);
    req.log.error({ err, eventId: event.id }, "Handler failed, key deleted for retry");
    return reply.code(500).send({ error: "Handler failed" });
  }

  return reply.code(200).send({ received: true });
});

async function handleEvent(event: Stripe.Event) {
  // 本処理ロジック (パターン 1 と同じ構造)
}

app.listen({ port: 3000 });

ポイントは失敗時の redis.del(key)。これをやらないと「キーは取ったが処理は失敗した」状態が 7 日間残り、永久にスキップされる地獄が発生します。

Redis 障害時のフォールバック設計

Redis 障害で Webhook が全部落ちると Stripe のリトライが積み上がります。最低限のフォールバックは、Redis アクセス失敗時は冪等化をスキップして処理を続行し、警告ログだけ残すパターンです。

let isDuplicate = false;
try {
  const setResult = await redis.set(key, "1", "EX", TTL_SECONDS, "NX");
  isDuplicate = setResult === null;
} catch (redisErr) {
  // Redis 障害: 冪等化なしで処理続行 (ログだけ残す)
  req.log.warn({ err: redisErr, eventId: event.id }, "Redis unavailable, processing without idempotency");
}
if (isDuplicate) {
  return reply.code(200).send({ received: true, duplicate: true });
}

「Redis 落ちてる間は重複処理が走り得る」という許容を意味するので、本処理が DB レベルで冪等 (UPSERT、UPDATE は WHERE 条件で防御) であることが前提です。Redis を使っても、最終的な書き込みは DB 制約に頼るのが安全です。

メリット・デメリット・適用範囲

メリットはサブミリ秒の高速性、横スケール時の一貫性保証、TTL で自動ゴミ掃除されること。デメリットは Redis 運用コスト、障害時フォールバック設計の必須性、SQL で過去ログを掘れないこと。

月数万〜数十万イベント、Web ノード複数台並列なら Redis SETNX が現実解です。個人開発規模ではまだ Redis が必要な段階に至らないケースが多く、まずパターン 1 で始めて、規模が大きくなったら移行する判断が現実的です。


パターン3: In-memory cache (LRU) — 開発環境/超低トラフィック向け

依存パッケージを増やしたくない開発環境用のパターンです。lru-cache で直近 N 件の event.id をプロセス内に保持します。

TypeScript 実装 (lru-cache パッケージ)

import Fastify from "fastify";
import Stripe from "stripe";
import { LRUCache } from "lru-cache";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.acacia",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

const processedEvents = new LRUCache<string, boolean>({
  max: 1000,                  // 直近 1000 件保持
  ttl: 1000 * 60 * 60 * 24 * 7, // 7日
});

const app = Fastify({ logger: true });

app.post("/webhooks/stripe", {
  config: { rawBody: true },
}, async (req, reply) => {
  const sig = req.headers["stripe-signature"] as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.rawBody!, sig, webhookSecret);
  } catch (err) {
    return reply.code(400).send({ error: "Invalid signature" });
  }

  if (processedEvents.has(event.id)) {
    req.log.info({ eventId: event.id }, "Duplicate event skipped (LRU)");
    return reply.code(200).send({ received: true, duplicate: true });
  }
  processedEvents.set(event.id, true);

  try {
    await handleEvent(event);
  } catch (err) {
    processedEvents.delete(event.id); // リトライ可能化
    return reply.code(500).send({ error: "Handler failed" });
  }

  return reply.code(200).send({ received: true });
});

async function handleEvent(event: Stripe.Event) {
  // 本処理
}

app.listen({ port: 3000 });

本番運用に向かない理由

LRU パターンを本番で使うと、以下のシナリオで必ず破綻します。

  1. プロセス再起動で LRU が空になる: PM2 や Docker による再起動・デプロイ・OOM Kill のたびに重複判定がリセットされます。再起動の瞬間に Stripe がリトライキューに積んでいた未応答 event を再送すると、全部「初見」扱いで本処理が走ります
  2. マルチプロセス間で LRU が共有されない: PM2 cluster mode や複数ノードでは各プロセスが別の LRU を持ち、同じ event が別プロセスで「初見」扱いされ得ます
  3. 最大 1000 件しか保持しない設計が破綻する: 月数千イベントを超えると古い event.id が LRU から押し出され、リトライ時に「初見」扱いになる

LRU は「同じプロセス内で短期間の重複だけ捌く」用途で、Webhook 冪等性の本番運用には使えません。本番ではパターン 1 (DB) かパターン 2 (Redis) のどちらかにしてください。


⚠️ event.id 単独では識別できない稀ケース (公式仕様)

ここまで「event.id をキーに重複判定」と書いてきましたが、Stripe 公式ドキュメントには重要な但し書きがあります。

場合によっては、2 つの Event オブジェクトが個別に生成・送信されます。これらの重複を識別するには、data.object のオブジェクト ID と event.type を使用します。 — Stripe Docs - Handle duplicate events

つまり「Stripe 側のリトライによる重複」は同じ event.id ですが、「Stripe 側で別の Event オブジェクトが個別生成された結果として、内容は同じだが event.id が違う」ケースが稀に存在します

具体的にどんなケースか

  • 同じ subscription の更新で、別経路 (Dashboard 操作と API 呼び出し) から同時に変更が入って、結果として 2 つの customer.subscription.updated event が発火するケース
  • Stripe 内部のシステムリトライで、event の再生成が起きるケース (公式が「rare」と表現)

これらは event.id が違うのでパターン 1〜3 のいずれでも検出できません。

ハイブリッド判定パターン (公式推奨)

event.id によるリトライ重複と、object_id + event.type による内容重複の両方を防ぐには、テーブルに 2 種類の UNIQUE 制約を持たせます。

CREATE TABLE webhook_events (
  event_id        TEXT PRIMARY KEY,
  event_type      TEXT NOT NULL,
  object_id       TEXT,         -- data.object.id 抽出
  object_action_key TEXT GENERATED ALWAYS AS (event_type || ':' || object_id) STORED,
  received_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  processed_at    TIMESTAMPTZ,
  payload         JSONB NOT NULL,
  UNIQUE (object_action_key)    -- object_id × event_type の組合せ重複も弾く
);

TypeScript 側では event.data.object.id を抽出して挿入します。

const objectId = (event.data.object as any).id;  // 全 event type で id は存在する

await pool.query(
  `INSERT INTO webhook_events (event_id, event_type, object_id, payload)
   VALUES ($1, $2, $3, $4)
   ON CONFLICT DO NOTHING
   RETURNING event_id`,
  [event.id, event.type, objectId, event]
);

ON CONFLICT DO NOTHING (列指定なし) を使うと、PRIMARY KEY (event_id) 制約と UNIQUE (object_action_key) 制約のどちらかに引っかかった時点で INSERT がスキップされます。これで両種類の重複を 1 つのテーブルで捌けます。

適用すべきか

正直、稀ケースなので「最初からハイブリッド判定」が必須ではありません。多くの個人開発では event.id 単独で運用始めて、実際に重複事故が起きたタイミングで追加する判断が現実的です。ただし subscription 系イベントを多用する SaaS では発生確率が上がるので、最初から対応しておく価値があります。

公式が「稀」と書いているケースを実装に反映する開発者は少ないため、本記事の差別化ポイントでもあります。


3パターンの選び方フローチャート

判断軸は 3 つです。

判断軸 パターン1 (DB) パターン2 (Redis) パターン3 (LRU)
月イベント数 数千 〜 数万 数万 〜 数百万 開発環境のみ
Web インスタンス数 1 〜 数台 複数台並列 1 (開発)
既存技術スタック DB あり Redis あり なし
永続化 あり (SELECT 可能) TTL内のみ プロセス内のみ
障害時の影響 DB ダウン = 全停止 フォールバック設計必須 プロセス再起動で全消失
推奨度 (本番) 不可
実装コスト 低 (テーブル 1 つ) 中 (Redis 運用) 低 (パッケージ 1 つ)

個人開発規模 (Web 1 台、月数千イベント、既存 DB あり) の典型解は、パターン 1 (DB UNIQUE 制約) です。SQL で受信履歴を掘れる運用性がトラブルシュート時に効くこと、追加の運用コンポーネント (Redis 等) が不要なこと、トランザクション境界が DB 内で完結することが理由です。

Web ノード複数台、もしくは月数万イベント超で DB INSERT がボトルネック化しつつある場合は、パターン 2 への移行を検討する段階です。


冪等化を後回しにした場合の典型的な事故パターン

Stripe 公式ドキュメントが警告している通り (Handle duplicate events)、Webhook 冪等化は「あった方がいい」レベルの機能ではなく、本番運用の前提条件です。MVP 段階で後回しにすると、以下のいずれかが必ず起きます。

事故パターン1: 同じ顧客への通知二重送信

payment_intent.succeeded の重複受信で、welcome メールや決済完了通知 (Slack/Discord/メール) が 2 回送られます。ユーザー体験が損なわれるだけでなく、メール送信コスト (SendGrid 等) も二重発生。トランザクションメールの 2 回送信は受信側のスパム判定リスクも上げます。

事故パターン2: DB の更新が冪等でないと数値が壊れる

UPDATE users SET credit = credit + 100 のような 加算系の SQL を Webhook ハンドラ内で実行している場合、重複受信のたびにクレジットが二重加算されます。UPDATE users SET sub_status = 'active' のような 冪等な代入文 ならユーザー状態は壊れませんが、加算・カウンタ・履歴追加系は要注意です。

事故パターン3: 解約処理の二重実行

customer.subscription.deleted の重複受信で、解約処理を 2 回実行すると、顧客への通知メールが 2 回飛びます。1 回目で「ご利用ありがとうございました」と送ったあとに 2 回目が来ると、顧客の信頼を一瞬で失います。

教訓: 「公式が要求しているから」ではなく「実際に起きるから」対応する

これら 3 パターンのいずれも、Webhook ハンドラの最初に冪等化チェック (パターン 1 の DB UNIQUE 制約 + ON CONFLICT、もしくはパターン 2 の Redis SETNX) を入れるだけで防げます。実装コストは 30 行以下、テーブル 1 つ追加するだけです。MVP リリース時から組み込んでおくことを強く推奨します。

公式の「推奨」を自分の本番で踏むまで軽視する癖は個人開発で陥りがちな落とし穴です。冪等化は「実装する/しない」の選択肢ではなく、Stripe Webhook を受信する以上の前提仕様として扱ってください。


まとめ + 関連リソース

Stripe Webhook の冪等性は、Stripe の at-least-once delivery 仕様に対応する必須要件です。本記事で示した 3 パターンのいずれかを、規模と既存技術スタックに合わせて選んでください。

  • パターン 1 (DB UNIQUE 制約 + ON CONFLICT): 月数千〜数万イベント、Web インスタンス 1〜数台 — 個人開発規模の標準解
  • パターン 2 (Redis SETNX + TTL): 高頻度、複数インスタンス並列 — フォールバック設計必須
  • パターン 3 (In-memory LRU): 開発環境専用、本番運用には適さない

参考リソース:

次回は、Webhook の retry 戦略 — 自前で再送制御を組む場合と Stripe のリトライに任せる場合の境界線について書く予定です。


FAQ

Q1. event.id を使えば本当に同一イベントを判定できますか? type や object_id と組み合わせる必要は?

A. 基本ケースでは event.id 単独で十分ですが、公式が「稀」と明記する例外があります。Stripe の event.id は evt_ プレフィックス付きの一意 ID で、同じ event のリトライでは同じ event.id が使われます。ですが、公式ドキュメント (Handle duplicate events) には「場合によっては、2 つの Event オブジェクトが個別に生成・送信されます。これらの重複を識別するには、data.object のオブジェクト ID と event.type を使用します」と書かれており、event.id が違うのに内容が重複する稀ケースが存在します。subscription 系イベントを多用する SaaS では発生確率が上がるため、本記事の「⚠️ event.id 単独では識別できない稀ケース」セクションで紹介したハイブリッド判定 (event_id PRIMARY KEY + (event_type, object_id) UNIQUE) の併用を推奨します。デバッグ用にも event_type / object_id カラムは持っておくと SQL での絞り込みが楽になります。

Q2. webhook_events テーブルが肥大化したらどうすればいいですか?

A. 古いレコードを定期削除します。典型的な実装は cron で「30 日以上前の processed_at が NULL でないレコードを DELETE」するジョブを 1 日 1 回回す形です。Stripe のリトライ最大期間は 3 日なので、30 日も保持すれば冪等性目的としては十分です。それでも肥大化が気になる場合は、received_at にインデックスを張ったうえでパーティションテーブル化 (PostgreSQL の RANGE partitioning) を検討してください。

Q3. Stripe 以外の Webhook 提供サービス (Shopify, GitHub 等) も同じ実装でいけますか?

A. 基本構造は同じで、サービスごとに「冪等化に使えるユニーク ID は何か」を仕様で確認するだけです。Shopify は X-Shopify-Webhook-Id ヘッダ、GitHub は X-GitHub-Delivery ヘッダがそれに該当します。パターン 1 のテーブルを provider, event_id の複合 UNIQUE にして共通化する設計も可能です。ただし署名検証ロジックはサービスごとに別物なので、そこは個別実装が必要です。

Q4. Webhook の処理が重くて 30 秒タイムアウトしそうな場合は?

A. Webhook ハンドラ内で同期的に重い処理を回すのは設計として推奨されません。受信時は「冪等化チェック + キュー投入 + 200 返却」だけ行い、本処理は別ワーカー (BullMQ, RabbitMQ, AWS SQS 等) に任せる構成にしてください。Stripe 公式も Build a webhook endpoint で「Webhook ハンドラは高速に応答すべき、重い処理は非同期化せよ」と推奨しています。個人開発規模で月イベント数が少なく処理が軽いケースでは同期処理で十分許容されますが、外部 API 呼び出しや重い DB 集計を含む場合は最初から非同期化しておくのが安全です。

よくある質問

event.id を使えば本当に同一イベントを判定できますか? type や object_id と組み合わせる必要は?

基本ケースでは event.id 単独で十分ですが、公式が「稀」と明記する例外があります。Stripe の event.id は evt_ プレフィックス付きの一意 ID で、同じ event のリトライでは同じ event.id が使われます。ですが、公式ドキュメント (Handle duplicate events) には「場合によっては、2 つの Event オブジェクトが個別に生成・送信されます。これらの重複を識別するには、data.object のオブジェクト ID と event.type を使用します」と書かれており、event.id が違うのに内容が重複する稀ケースが存在します。subscription 系イベントを多用する SaaS では発生確率が上がるため、本記事の「event.id 単独では識別できない稀ケース」セクションで紹介したハイブリッド判定 (event_id PRIMARY KEY + (event_type, object_id) UNIQUE) の併用を推奨します。

webhook_events テーブルが肥大化したらどうすればいいですか?

古いレコードを定期削除します。典型的な実装は cron で「30 日以上前の processed_at が NULL でないレコードを DELETE」するジョブを 1 日 1 回回す形です。Stripe のリトライ最大期間は 3 日なので、30 日も保持すれば冪等性目的としては十分です。それでも肥大化が気になる場合は、received_at にインデックスを張ったうえでパーティションテーブル化 (PostgreSQL の RANGE partitioning) を検討してください。

Stripe 以外の Webhook 提供サービス (Shopify, GitHub 等) も同じ実装でいけますか?

基本構造は同じで、サービスごとに「冪等化に使えるユニーク ID は何か」を仕様で確認するだけです。Shopify は X-Shopify-Webhook-Id ヘッダ、GitHub は X-GitHub-Delivery ヘッダがそれに該当します。パターン 1 のテーブルを provider, event_id の複合 UNIQUE にして共通化する設計も可能です。ただし署名検証ロジックはサービスごとに別物なので、そこは個別実装が必要です。

Webhook の処理が重くて 30 秒タイムアウトしそうな場合は?

Webhook ハンドラ内で同期的に重い処理を回すのは設計として推奨されません。受信時は「冪等化チェック + キュー投入 + 200 返却」だけ行い、本処理は別ワーカー (BullMQ, RabbitMQ, AWS SQS 等) に任せる構成にしてください。Stripe 公式も Build a webhook endpoint で「Webhook ハンドラは高速に応答すべき、重い処理は非同期化せよ」と推奨しています。個人開発規模で月イベント数が少なく処理が軽いケースでは同期処理で十分許容されますが、外部 API 呼び出しや重い DB 集計を含む場合は最初から非同期化しておくのが安全です。