Advanced (更新: 2026/6/3)

Claude CodeでWebhook実装を本番化する方法:署名検証・冪等性・リトライ設計

Claude CodeでWebhookを本番実装する手順。raw body、署名検証、冪等性、リトライ、運用まで解説。

Claude CodeでWebhook実装を本番化する方法:署名検証・冪等性・リトライ設計

Webhookは、外部サービスで起きたイベントをHTTPで自分のアプリへ届けてもらう仕組みです。決済完了、GitHubのpush、問い合わせフォーム、CRM更新、SaaSのステータス変更など、「相手側で起きたことをこちらの処理に反映したい」場面で使います。

ただし本番のWebhook実装は、POST /webhooks/fooを作ってJSONを読むだけでは足りません。署名検証で「本当にそのサービスから来た通知か」を確かめ、raw body(受信したHTTP本文のバイト列そのもの)を壊さず扱い、同じイベントが複数回来ても結果が一度だけになる冪等性(べきとうせい)を入れ、失敗時はリトライキュー(あとで再実行する待ち行列)へ逃がす必要があります。

Claude Codeに任せるときも、指示が浅いと「動くが危ない」コードになりがちです。私がMasaのコンテンツ運用や小さな決済連携を見直すときも、最初に壊れていたのはたいていraw body、重複配送、失敗イベントの再実行でした。この記事では、Claude Codeへ渡す要件、Express + TypeScriptでコピペして動かせる受信コード、テスト、ローカル送信、リプレイ、落とし穴、運用runbook(障害時の手順書)までを一つの流れで整理します。

関連する土台として、Claude CodeでAPI開発を高速化するClaude CodeでSecrets Managementを実装するClaude CodeセキュリティベストプラクティスClaude Codeでキューシステムを作るも合わせて読むと設計がつながります。

最初に決める契約

Webhookは、受信してから考えるより、受信前に契約を固定した方が安全です。Claude Codeへ依頼する前に、最低でも次の表をREADMEや実装メモに書いておきます。

項目GitHubの例Stripeの例実装で見る場所
エンドポイントPOST /webhooks/githubPOST /webhooks/stripeルーティング
イベントIDX-GitHub-Deliveryevent.id冪等性キー
イベント種別X-GitHub-Eventevent.typeハンドラー分岐
署名ヘッダーX-Hub-Signature-256Stripe-Signature署名検証
検証対象raw bodyraw bodybody parser設定
成功応答早く2xx早く2xxキュー投入後の応答

正確な仕様は公式ドキュメントで確認します。GitHubはWebhooksValidating webhook deliveries、StripeはWebhooksWebhook signatures、Expressはexpress.rawが一次情報です。Claude Code自体の使い方は、Anthropic公式のClaude Code best practicesにある「検証できる作業を渡す」「具体的なコンテキストを渡す」という考え方がそのまま使えます。

flowchart LR
  A["Provider<br/>GitHub / Stripe"] --> B["Webhook endpoint<br/>raw body"]
  B --> C["Signature verification"]
  C --> D["Event store"]
  D --> E["Idempotency check"]
  E --> F["Retry queue"]
  F --> G["Domain handler"]
  D --> H["Replay tool"]

Claude Codeへの依頼文

Claude Codeは、曖昧な「Webhookを作って」より、失敗条件まで入れた依頼に強く反応します。harness(エージェントの足場)として、次のような依頼文を先に作ります。

Express + TypeScriptでGitHub Webhook受信を実装してください。

要件:
- POST /webhooks/github を追加する
- Webhookルートだけ express.raw({ type: "*/*" }) でraw bodyを保持する
- JSON parseは署名検証の後に行う
- X-Hub-Signature-256をHMAC SHA-256で検証する
- X-GitHub-Deliveryを冪等性キーにする
- 受理したイベントは処理前にevent storeへ保存する
- 同じdelivery idは二重処理しない
- 受信時は202を早く返し、重い処理はretry queueで実行する
- 成功、署名失敗、重複配送をnode:testで検証する
- 保存済みdeliveryを再送できるreplay scriptを追加する
- 秘密鍵はWEBHOOK_SECRET環境変数から読む

ポイントは「コードを書いて」ではなく「失敗してはいけない条件」を渡すことです。署名検証、raw body、冪等性、リトライ、リプレイ、テストを最初から入れると、レビューで見るべき差分が明確になります。

コピペで動く最小構成

まず検証用の小さなプロジェクトを作ります。

npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express

src/server.tsを作成します。永続化は説明用にMapですが、本番ではPostgreSQL、Redis、Cloudflare D1、DynamoDBなどに置き換えます。

import crypto from "node:crypto";
import express from "express";

type EventStatus = "queued" | "processing" | "processed" | "failed";

type WebhookEvent = {
  id: string;
  provider: "github";
  type: string;
  headers: Record<string, string>;
  rawBody: Buffer;
  payload: unknown;
  receivedAt: string;
  status: EventStatus;
  attempts: number;
  lastError?: string;
};

export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";

app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());

function firstHeader(value: string | string[] | undefined): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

function safeCompare(leftValue: string, rightValue: string): boolean {
  const left = Buffer.from(leftValue);
  const right = Buffer.from(rightValue);
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

export function signGitHubBody(
  rawBody: Buffer | string,
  secret = WEBHOOK_SECRET
): string {
  return (
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
  );
}

export function verifyGitHubSignature(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  secret = WEBHOOK_SECRET
): boolean {
  if (!signatureHeader?.startsWith("sha256=")) return false;
  return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}

function headersForStorage(req: express.Request): Record<string, string> {
  const result: Record<string, string> = {};
  for (const [key, value] of Object.entries(req.headers)) {
    if (typeof value === "string") result[key] = value;
  }
  return result;
}

app.post("/webhooks/github", (req, res) => {
  const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
  const signature = firstHeader(req.headers["x-hub-signature-256"]);
  const deliveryId = firstHeader(req.headers["x-github-delivery"]);
  const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";

  if (!verifyGitHubSignature(rawBody, signature)) {
    return res.status(401).json({ error: "invalid_signature" });
  }

  if (!deliveryId) {
    return res.status(400).json({ error: "missing_delivery_id" });
  }

  const id = `github:${deliveryId}`;
  if (processedEvents.has(id) || eventStore.has(id)) {
    return res.status(202).json({ id, status: "duplicate" });
  }

  let payload: unknown;
  try {
    payload = JSON.parse(rawBody.toString("utf8"));
  } catch {
    return res.status(400).json({ error: "invalid_json" });
  }

  eventStore.set(id, {
    id,
    provider: "github",
    type: eventType,
    headers: headersForStorage(req),
    rawBody,
    payload,
    receivedAt: new Date().toISOString(),
    status: "queued",
    attempts: 0,
  });

  retryQueue.push(id);
  void processNextEvent();

  return res.status(202).json({ id, status: "queued" });
});

export async function processNextEvent(): Promise<void> {
  const id = retryQueue.shift();
  if (!id) return;

  const event = eventStore.get(id);
  if (!event || event.status === "processed") return;

  event.status = "processing";
  event.attempts += 1;

  try {
    await handleWebhookEvent(event);
    event.status = "processed";
    processedEvents.add(id);
  } catch (error) {
    event.status = "failed";
    event.lastError = error instanceof Error ? error.message : String(error);

    if (event.attempts < 5) {
      const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
      setTimeout(() => {
        event.status = "queued";
        retryQueue.push(id);
        void processNextEvent();
      }, delayMs);
    }
  }
}

async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
  if (event.type === "ping") {
    console.log("GitHub ping received", event.id);
    return;
  }

  if (event.type === "push") {
    console.log("GitHub push received", event.id);
    return;
  }

  console.log("Webhook ignored", event.provider, event.type);
}

if (process.env.NODE_ENV !== "test") {
  const port = Number(process.env.PORT ?? 3000);
  app.listen(port, () => {
    console.log(`Webhook server listening on http://127.0.0.1:${port}`);
  });
}

起動します。

WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts

Windows PowerShellなら次のように環境変数を入れます。

$env:WEBHOOK_SECRET="dev-secret-change-me"
npx tsx src/server.ts

ローカル送信スクリプト

curlとopensslでも試せますが、チームで再現しやすいのはNodeの送信スクリプトです。scripts/send-local-webhook.tsを作ります。

import crypto from "node:crypto";

const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
  process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({
  ref: "refs/heads/main",
  after: "local-test",
});

const signature =
  "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");

const response = await fetch(url, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-github-event": "push",
    "x-github-delivery": `local-${Date.now()}`,
    "x-hub-signature-256": signature,
  },
  body,
});

console.log(response.status, await response.text());

別ターミナルで実行します。

WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts

202が返れば、署名検証、JSON parse、event store保存、キュー投入まで通っています。

テストを先に固定する

Webhookは画面操作で確認しにくいので、署名成功、署名失敗、重複配送を最低限テストします。test/webhook.test.tsを作ります。

import assert from "node:assert/strict";
import { AddressInfo } from "node:net";
import { beforeEach, test } from "node:test";
import {
  app,
  eventStore,
  processedEvents,
  retryQueue,
  signGitHubBody,
  verifyGitHubSignature,
} from "../src/server";

const secret = "dev-secret-change-me";

beforeEach(() => {
  eventStore.clear();
  processedEvents.clear();
  retryQueue.length = 0;
});

async function postWebhook(port: number, deliveryId: string, body: string) {
  return fetch(`http://127.0.0.1:${port}/webhooks/github`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-github-event": "push",
      "x-github-delivery": deliveryId,
      "x-hub-signature-256": signGitHubBody(body, secret),
    },
    body,
  });
}

test("valid signature is accepted and stored once", async (t) => {
  const server = app.listen(0);
  t.after(() => server.close());

  const { port } = server.address() as AddressInfo;
  const body = JSON.stringify({ ref: "refs/heads/main" });

  const first = await postWebhook(port, "delivery-1", body);
  assert.equal(first.status, 202);
  assert.equal(eventStore.has("github:delivery-1"), true);

  const duplicate = await postWebhook(port, "delivery-1", body);
  assert.equal(duplicate.status, 202);
  assert.equal(eventStore.size, 1);
});

test("invalid signature is rejected", () => {
  const body = Buffer.from(JSON.stringify({ ok: true }));
  assert.equal(verifyGitHubSignature(body, "sha256=bad", secret), false);
});

実行コマンドです。

NODE_ENV=test npx tsx --test test/webhook.test.ts

このテストをClaude Codeに渡すと、修正後に自分で再実行できます。Anthropic公式ドキュメントでも、Claude Codeにはテストやビルドなど検証できる信号を渡すのが有効だと説明されています。

リプレイできる形で保存する

障害対応では「失敗したイベントをもう一度流したい」が必ず起きます。ログだけでは足りません。event storeにはraw body、主要ヘッダー、受信時刻、処理ステータス、試行回数、最後のエラーを保存します。

小さなリプレイスクリプトは次の形です。scripts/replay-webhook.tsを作ります。

import { readFile } from "node:fs/promises";

type SavedDelivery = {
  url: string;
  headers: Record<string, string>;
  body: string;
};

const file = process.argv[2];
if (!file) {
  console.error("Usage: npx tsx scripts/replay-webhook.ts delivery.json");
  process.exit(1);
}

const delivery = JSON.parse(await readFile(file, "utf8")) as SavedDelivery;
const response = await fetch(delivery.url, {
  method: "POST",
  headers: delivery.headers,
  body: delivery.body,
});

console.log(
  JSON.stringify(
    {
      status: response.status,
      responseBody: await response.text(),
    },
    null,
    2
  )
);

保存ファイルの例です。署名はbodyに対して作られるため、bodyの空白や改行を変えると検証に失敗します。

{
  "url": "http://127.0.0.1:3000/webhooks/github",
  "headers": {
    "content-type": "application/json",
    "x-github-event": "push",
    "x-github-delivery": "replay-001",
    "x-hub-signature-256": "sha256=actual-signature-for-this-body"
  },
  "body": "{\"ref\":\"refs/heads/main\"}"
}

具体的なユースケース

1つ目は決済です。Stripeのcheckout.session.completedで注文を確定し、invoice.payment_failedでアクセスを止め、領収書メールを送ります。決済Webhookは遅延や重複配送が普通に起きるため、event.idを冪等性キーにして、注文確定を二重に走らせない設計が必要です。

2つ目は開発ワークフローです。GitHubのpushpull_requestを受け取り、プレビュー環境の作成、ドキュメント生成、Slack通知、内部レビュー依頼を起動します。pushは短時間に集中するため、受信中に重い処理をしないことが重要です。

3つ目はフォームとCRM連携です。問い合わせフォーム、HubSpot、Salesforceなどから更新通知を受け取り、社内チケットやメール返信下書きを作ります。外部サービスが再送したときに同じ問い合わせを何件も作らないよう、外部IDと受信時刻を保存します。

4つ目は自社SaaSから顧客システムへ送る送信側Webhookです。この場合もHMAC署名、配信ログ、タイムアウト、リトライ、手動再送、購読イベントの管理APIが必要です。受信側と送信側は似ていますが、送信側では相手先URLの検証と5xx/4xxの扱いが追加で重要になります。

よくある失敗と落とし穴

一番多い失敗は、express.json()で先にbodyをparseしてから署名検証することです。多くの署名は「受信した生の本文」に対して作られます。空白、改行、文字コード、キー順序が変わるとHMACは一致しません。Webhookルートだけはexpress.raw()を先に適用します。

次に、2xxを返す前に重い処理を全部実行する失敗です。プロバイダーがタイムアウトすると再送します。再送自体は正しい動作ですが、こちらが冪等性に弱いと、メールが二重送信されたり、クレジット付与が二重に走ったりします。受信時は保存とキュー投入までにして、処理は非同期へ逃がします。

3つ目は、冪等性キーを自分で生成してしまうことです。受信ごとにcrypto.randomUUID()を作ると、同じ配送かどうか判別できません。GitHubならX-GitHub-Delivery、Stripeならevent.idのように、プロバイダーが再送時も維持するIDを使います。

4つ目は、失敗イベントをログだけで消すことです。ログは検索には向いていても、再実行の単位にはなりません。raw bodyとヘッダーを保存しておけば、障害対応が「ログを眺める」から「対象イベントを再実行する」に変わります。

5つ目は、秘密鍵のローテーションを考えないことです。開発用のdev-secret-change-meはローカル専用です。本番では環境変数かシークレットマネージャーで管理し、一定期間は旧鍵と新鍵を併用できるようにします。署名失敗ログに秘密鍵や完全なpayloadを出さないことも重要です。

運用runbook

公開前に、運用で聞かれる質問へ答えられる状態にします。

  • 署名失敗は401、JSON不正は400、受理済みは202を返す
  • raw bodyを保存するルートと通常JSON APIのルートを分ける
  • event storeにraw body、ヘッダー、ステータス、試行回数、最後のエラーを保存する
  • リトライ回数、バックオフ、最終失敗時の通知先を決める
  • 重複配送が来てもビジネス処理が一度だけになることをテストする
  • replay scriptで保存済みdeliveryを再送できる
  • GitHubまたはStripeのテスト配送を本番公開前に実行する
  • 秘密鍵の保管場所、ローテーション日、担当者を記録する
  • サポート担当が「そのイベントを受信したか」をログだけに頼らず確認できる

Claude Codeへレビューを依頼するときは、「署名検証がJSON parse前に行われているか」「同じイベントIDを二重処理しないか」「2xx応答と内部リトライが競合していないか」「秘密鍵やpayloadがログに出ていないか」を明示します。ここまで指定すると、見た目は動くが本番で壊れるコードを減らせます。

収益化につなげるCTA

Webhookは地味ですが、決済、営業、コンテンツ運用、社内自動化の売上導線に直結します。ClaudeCodeLabでは、Claude Codeで安全なAPI連携を作るためのテンプレートや教材をProductsにまとめています。チームでWebhook、キュー、認証、Secrets管理をまとめて整えたい場合は、Trainingから実装レビューや研修の相談ができます。

まとめ

本番のWebhook実装は、小さなエンドポイントの中にセキュリティ、非同期処理、障害対応が詰まっています。Claude Codeを使うなら、最初の依頼にprovider契約、raw body、署名検証、冪等性、event store、retry queue、テスト、リプレイ、runbookを入れてください。これだけで、修正回数もレビュー観点も大きく変わります。

この記事で紹介した内容を実際に試した結果、raw bodyを最初に固定してから署名検証と冪等性テストを書かせるだけで、Claude Codeの出す差分がかなり安定しました。特に「Webhookを実装して」とだけ頼んだ場合に出やすかった、JSON parserの順序ミス、重複配送の見落とし、失敗イベントを再実行できない設計を、最初のプロンプト段階で潰せたのが大きな改善でした。

#Claude Code #Webhook #API設計 #セキュリティ #非同期処理
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。