CodeGym /コース /ChatGPT Apps /ウェブフックと外部連携: 署名、タイムアウト、冪等性

ウェブフックと外部連携: 署名、タイムアウト、冪等性

ChatGPT Apps
レベル 15 , レッスン 3
使用可能

1. ChatGPT App におけるウェブフック: だれがだれにリクエストを送るのか

古典的な HTTP の世界はシンプルです。あなたはクライアントとして POST /api/... を行い、サーバーが返答し、めでたしめでたし。一方ウェブフックは逆で、外部サービスが「外側で何かが起きたとき」にあなたのバックエンドへ自ら HTTP リクエストを送ってきます。

ChatGPT Apps のエコシステムでは、これはいくつかの典型的なシナリオで現れます。たとえば GiftGenius は ACP/Instant Checkout でチェックアウトを作成した後、決済プロバイダからウェブフックで payment_succeeded 通知を受け取ります。あるいはギフト画像を生成するバッチサービスがレンダリング完了時に image_ready を送ってくる、といった具合です。こうした場合、ChatGPT とあなたの MCP サーバー側の処理はすでに終わっており、ボールはサードパーティ側にあり、その結果を webhook で通知してきます。

重要な特徴は、主導権があなたのシステム外にあることです。リクエストはいつでも、何度でも来る可能性があります。したがってウェブフックのハンドラーは最も攻撃されやすい入口のひとつと考えるべきです。インターネット全体から叩かれる可能性があるからです。

対比のための簡単な表:

呼び出しの種類 開始する側 GiftGenius の例
通常の API リクエスト あなた MCP サーバーが Stripe API を呼ぶ
ウェブフック 外部サービス Stripe が payment_succeeded をあなたに送る

2. ざっくりした流れ: ChatGPT/MCP/ウェブフックの位置づけ

概略は次のとおりです。

sequenceDiagram
    participant User as ChatGPT のユーザー
    participant GPT as ChatGPT + モデル
    participant App as GiftGenius (MCP/App)
    participant PSP as 決済サービス (Stripe/ACP)

    User->>GPT: "ギフトを買いたい"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (チェックアウト情報)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (イベント受領)
    App->>DB: 注文を支払い済みにマーク

前半は、あなたがすでに慣れている通常の送信リクエストです。ウェブフックは図の下側、決済サービスがあなたへ叩いてくる部分であり、本講義の主眼はここにあります。

3. Next.js におけるウェブフックハンドラー(スケルトン)

学習用の GiftGenius を Next.js 16 で引き続き拡張します。テンプレートには UI のある app/ と、MCP サーバーのある app/mcp/route.ts があります。

ウェブフックのハンドラーは、たとえば app/api/webhooks/commerce/route.ts のように、専用の HTTP ルートへ切り出すのが自然です。

最小構成は次のとおりです。


// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. 本文を文字列として読む
  const headers = Object.fromEntries(req.headers); // 2. ヘッダーを取得

  // 3. TODO: 署名の検証(後で追加)
  // 4. TODO: JSON のパースとイベント処理

  return new Response("ok", { status: 200 }); // 5. すばやく 2xx を返す
}

ここにはすでにいくつかの重要な考え方が隠れています。

まず、本文は await req.json() ではなくテキストとして読み取ります。多くのプロバイダは本文の「生」バイトストリームに署名します。検証前にパース(ましてや整形)してしまうと、署名が一致しなくなります。

次に、できるだけ早く 2xx を返すことを意識します。重い処理は別のワーカーに逃がすか、少なくともイベントを記録した後に非同期関数で行うのがよいでしょう。これは後述するタイムアウトや再試行と直結しています。

4. ウェブフックの署名: 「Stripe」と「ただの curl ユーザー」をどう見分けるか

先ほどのスケルトンにあった TODO ― 「署名の検証」を思い出しましょう。現実の Stripe を、適当に curl を叩いている人とどう区別するのかを分解していきます。

URL が複雑(/api/webhooks/stripe/super-secret-abc123)なら見つからないだろう、と考えるのは危険です。URL シークレットは本質的に security through obscurity であり、弱い防御に過ぎません。正攻法は暗号学的な署名です。

まじめなプロバイダ(Stripe、ACP、各種 CRM など)は、本文と時刻から HMAC 署名を計算し、その結果をヘッダーに入れます。受信側であるあなたも同じ計算を行い比較します。少しでも違えば偽造として破棄します。

一般的な手順:

  1. プロバイダの管理画面で取得したウェブフック用シークレットを環境のシークレットに保存します(例: Vercel の環境変数 STRIPE_WEBHOOK_SECRET)。
  2. プロバイダは送信時に timestamp + '.' + rawBody に対して HMAC を計算します。
  3. その結果と timestamp を Stripe-Signature などのヘッダーに入れて送ります。
  4. 受信側では timestamp を取り出し、同じ規則で HMAC を計算して比較します。

TypeScript と crypto を使ったミニ例:

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // アルゴリズムを選択
    .update(payload, "utf8")       // 本文の生テキスト
    .digest("hex");                // hex 文字列
}

署名とイベントの鮮度をチェックする例:

const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });

const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
  return new Response("timestamp too old", { status: 400 });
}

const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
  process.env.STRIPE_WEBHOOK_SECRET!,
  payload
);

if (!crypto.timingSafeEqual(
  Buffer.from(expectedSig, "hex"),
  Buffer.from(theirSig, "hex")
)) {
  return new Response("invalid signature", { status: 400 });
}

timingSafeEqual に注目してください。これは比較にかかる時間差から署名を推測しようとするタイミング攻撃への防御です。

署名検証に成功したら、JSON.parse(rawBody)await req.json() を安心して使えます。実在のプロバイダから届いたデータだと確信できるからです。

IP アロウリスト(プロバイダのアドレスだけ許可)やウェブフック専用ドメインなど、追加の防御層を置くのも有効ですが、真正性を担保するのはあくまで暗号署名です。

5. タイムアウト、迅速な応答、非同期処理

ウェブフックは「速く返す」人を好みます。多くの決済・コマース系プラットフォームは、エンドポイントが数秒(しばしば 10 秒以内、さらに短いことも)で 2xx を返すことを期待します。考え込んでいると失敗と見なされ、再試行が始まります。

素朴に実装するとこうなります。署名を検証し、DB にアクセスし、さらに 3 つの外部 API に行き、レポートを計算し、PDF を生成してから 200 OK。どれかが少しでも詰まれば、決済サービスはウェブフックが落ちたと判断し、再送します。結果として注文を二重作成、メールを二重送信、GPT のツールを二度呼ぶ…と混乱に陥ります。

正しいパターンは「受け取る・記録する・後回しにする」です。

  1. 署名と基本的な不変条件(イベント種別、必須フィールド)を確認する。
  2. イベントをすばやくテーブル/キューに記録する(DB 操作は最小限)。
  3. 2xx を返す。
  4. 実際の処理はバックグラウンドのワーカーで行う。

専用キューを使わない簡易版ですが、迅速な確定を行う「半ば正しい」ハンドラーの例です。

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifySignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await saveWebhookEvent(event); // DB へ素早く記録

  // ここで setImmediate/queue などでバックグラウンドに回せます。
  // 学習用の例なのでいったん記録だけにし、200 をすぐ返すために await しません。
  processWebhookEventLater(event).catch(console.error);

  return new Response("ok", { status: 200 });
}

重要なのは、await processWebhookEventLater(...) をしないことです。ハンドラーはバックグラウンドに投げてすぐ 200 を返し、ウェブフックのタイムアウトに引っかからないようにします。

実運用ではここにキュー(たとえば webhook_jobs テーブルや外部のキューサービス)を置き、ワーカーが順次イベントを処理して新規受付をブロックしないようにするのが一般的です。

6. 冪等性と重複排除: 二重課金を避ける

学習用の図では「1 イベント → 1 回の処理 → ハッピーな注文」と描かれがちですが、現実のウェブフックはしばしばバーストし、短時間に何度も届きます。

理由は単純です。ネットワークは不安定でタイムアウトは起こり、さらに多くのプロバイダは 2xx がはっきり返るまでイベントを繰り返し送る設計だからです。特に決済では、payment_succeeded を失うより再送したほうが安全です。

したがって、ビジネスロジックは冪等であるべきです。同一イベントの再処理で結果が変わらない(少なくともシステムを壊さない)ようにします。

典型パターン:

  1. イベントには安定した識別子がある(例: event.idcheckout_session_id)。
  2. 処理済みイベントのテーブルに保存し、そのフィールドにユニークインデックスを張る。
  3. ウェブフックごとにまず確認し、同じ id で「処理済み」の記録があれば 200 を返して何もしない。

擬似 ORM を使ったミニ例:

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // すでに完了済み
  }

  await db.$transaction(async (tx) => {
    await tx.webhookEvents.upsert({
      where: { providerId: event.id },
      update: { processedAt: new Date() },
      create: {
        provider: "stripe",
        providerId: event.id,
        type: event.type,
        payload: event,
        processedAt: new Date(),
      },
    });

    await tx.orders.update({
      where: { checkoutSessionId: event.data.object.id },
      data: { status: "PAID" },
    });
  });
}

ここで重要なのはトランザクションです。イベントを処理済みにマークする操作と注文の更新を同時に行います。途中で失敗すればロールバックされ、後続の再送で改めて試みられ、二重登録を避けられます。

また、操作そのものを冪等にするのも有効です。たとえば:

  • 「注文ステータスを PAID に設定する」ようにし、「+100 増額する」のような加算にしない。
  • 「存在しなければ作成する」にし、「さらにもう 1 行追加する」ではない形にする。

7. ウェブフックデータの検証と PII: 署名だけがフィルターではない

たとえ署名済みで実在のサービスから届いたウェブフックでも、そのデータはユーザー入力やツール引数と同じくらい疑って扱うべきです。前回の講義でも触れたとおり、スキーマと正規化はあなたのファイアウォールです。

イベントのスキーマは、たとえば TypeScript/Zod では次のように書けます。

import { z } from "zod";

const paymentSucceededSchema = z.object({
  id: z.string(),
  type: z.literal("payment_succeeded"),
  data: z.object({
    object: z.object({
      id: z.string(),            // checkout_session_id
      amount_total: z.number(),
      currency: z.string(),
      metadata: z.record(z.string(), z.string()).optional(),
    }),
  }),
});

ハンドラー側では次のようにバリデーションします。

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// 以降は parsed のみを使って処理する

これにより、「プロバイダがフォーマットを変えた」「テスト環境では nullable になった」などのサプライズから身を守れます。不整合があればログに記録して 400 を返し、プロバイダ側の再送やアラートに任せます。

PII(個人情報)にも注意しましょう。ウェブフックの本文にはしばしばメールアドレスや配送先住所、ときにはトークン化された決済情報の断片が含まれます。ログではマスキングし、外部の APM/ログサービスに生のまま送らないのが必須の実務です(秘密情報・機微データの章で扱いました)。

また、ウェブフックの完全な JSON をフィルターなしで ChatGPT に ToolOutput としてそのまま送るべきではありません。モデルは UX に不要なプロバイダの全データを見るべきではありません。

8. GiftGenius の実践: ACP/Instant Checkout の決済ウェブフック

GiftGenius のコマース/ACP 章では、エージェントがチェックアウトセッションを作成し、Instant Checkout により課金が行われる流れを扱いました。バックエンドの視点では、その後にウェブフック order.paid(Stripe では checkout.session.completed)を待ち受け、次を行います。

  • 注文ステータスを確定する。
  • 「メール送信」「出荷準備」などの後続処理を開始する。
  • エージェントに「支払い完了」の確信をもって答えられるようにする。

Next.js のシンプルなハンドラー例:

// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifyCommerceSignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === "payment_succeeded") {
    // 前章の冪等ハンドラー
    await handlePaymentSucceeded(event);
  }

  return new Response("ok", { status: 200 });
}

verifyCommerceSignature は、先ほど説明した HMAC 署名検証のロジックを実装します。実プロジェクトではプロバイダごとにモジュールを分ける(verifyStripeSignatureverifyACPCheckoutSignature など)と、スキーマが混ざらずに済みます。

handlePaymentSucceeded の中では次を行います。

  • オブジェクトをスキーマ(Zod)で検証する。
  • トランザクションでイベントを処理済みにし、注文を更新する。
  • 必要に応じて、メール・分析・追加 API 呼び出しなどの「遅い」処理をキューに投げる。

このアプローチにより、「ACP → ウェブフック → GiftGenius」のチェーンは、再送・一時的障害・予期せぬデータに対して頑健になります。

9. ウェブフックは MCP・ChatGPT・ツールとどこでつながるか

一見すると、ウェブフックは ChatGPT App とは無関係にバックエンドのどこかの HTTP ルートで受けるだけに見えます。しかし、実際には全体アーキテクチャの重要な一部です。

典型的なつながりは次のとおりです。

  1. MCP のツール create_checkout が ChatGPT のモデルから呼ばれる。
  2. MCP サーバーが決済サービスへアクセスしてチェックアウトセッションを作成し、ToolOutput で注文情報と「支払い待ち」ステータスを返す。
  3. ユーザーが UI で支払いを完了する(Instant Checkout は ChatGPT 内で完結)。
  4. 決済サービスがあなたのバックエンドへウェブフックを送る。
  5. バックエンドは DB で注文ステータスを更新。次のツール呼び出しやモデルからの follow-up 時には「支払いが完了しました。詳細はこちら」のように正直に返せる。

バックエンドが間接的に follow-up を開始することもあります。たとえばウィジェットや Realtime 連携がサーバーからのシグナルで sendFollowUpMessage を自動で呼ぶなどです。そうでなくても、支払い事実はあなたの側に保存されており、次回のツール呼び出し時にはバックエンドが DB から新しいステータスを読み取り、モデルへ最新情報を返せます。

重要なのは、ウェブフックが MCP サーバーと同じレイヤーの入口であり、同じサービス群(DB、キュー、シークレット)を使うということです。セキュリティの基本方針も同じで、最小権限・バリデート済み入力・慎重なロギングが要点です。

10. ウェブフックと外部連携でよくあるミス

エラー #1: ウェブフック署名を検証していない。
「秘密の」URL や単純な Bearer my-secret ヘッダーだけで済ませることがあります。シークレットがどこかで漏れれば、だれでもウェブフックを投げ放題で、注文の作成・決済ステータス変更など好き放題にされます。正しいアプローチは本文への暗号署名(HMAC)と timestamp 検証です。これは「URL を当てる」よりはるかに偽造が難しくなります。

エラー #2: ウェブフックのリクエスト内で重い処理をしている。
ウェブフックの中で「注文作成 → 外部 API を 2 つ呼ぶ → PDF 生成 → GPT モデル呼び出し → メール 5 通」というのは、タイムアウトと再試行を招く近道です。その結果、自分で重複を生み出し、後から回収に追われます。より堅牢なのは、イベント受領をすばやく確定(2xx)し、DB やキューに記録してバックグラウンドで処理することです。

エラー #3: 冪等でないビジネスロジック。
たとえば、毎回の payment_succeeded で残高を金額分だけ増やすコード。ウェブフックが 2 回来れば残高は 2 倍です。あるいは同じ注文を 2 回作ったり、同じメールを 2 通送ったり。冪等性は、安定したイベント ID、処理済みイベント表、トランザクション、そして「状態を設定する」型の操作(加算ではない)で実現します。

エラー #4: ウェブフックデータにスキーマや検証がない。
署名付きでも期待どおりとは限りません。プロバイダがフォーマットを変えた、ドキュメントの JSON を写経したがテスト環境ではフィールド名が違う、型を誤解していた、など。スキーマなしで処理すれば、静かに注文を壊したり、チェーンの途中で例外が飛びます。入口で Zod/JSON Schema を使えば、診断が容易になり、不正なイベントを明確に弾けます。

エラー #5: PII を含むウェブフック本文を生でログに出す。
デバッグ中の勢いで console.log(rawBody) を入れ、そのまま忘れがちです。本番ではメールや住所などの PII だらけのログが外部ログサービスへ送られることになります。プライバシーや規制(GDPR など)の観点で自爆です。最初から PII スクラブを入れ、必要最小限だけをログするようにしましょう。

エラー #6: テストと本番のウェブフックを混在させる。
同じエンドポイントでテスト環境と本番環境のイベントをどちらも受ける、というのは典型的な事故要因です。結果として、テスト決済が本番注文のステータスを変えたり、その逆が起きたりします。URL を分ける(例: /webhooks/commerce/test/webhooks/commerce/live)か、少なくとも設定で「モード」を持ち、入口で検証しましょう。

エラー #7: ChatGPT のシナリオを同期的なウェブフックに全面依存させる。
ツール呼び出しとチェックアウトセッション作成の直後に、モデルがすぐ支払い結果を知っている前提で組みたくなることがあります。しかしウェブフックは本質的に非同期で、決済には時間がかかることがあります。すべてが即時に完了する前提は悪手です。注文状態を保存し、ユーザーがチャットに戻って後から最新情報を得られるようにするなど、遅延イベントと整合的に動く対話・ツール設計にしましょう。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION