SaaS API連携で二重課金させた僕が固めた、Webhook署名検証とリトライ冪等の型
外部SaaS APIを自分のアプリへ連携する実装手順。APIキー管理、Webhook署名検証、指数バックオフのリトライ、冪等性、サンドボックス検証をコピペで動くコードで解説。
決済SaaSのWebhookを受け取って「購入完了メール」を送る、それだけの実装でした。テストでは完璧。本番に出した翌週、1人のお客さんに同じメールが3通届きました。
原因は笑えないくらい単純で、SaaS側が「届いたか確認できなかった」と判断して、同じイベントを3回投げてきていたんです。僕のコードはそれを3回とも真面目に処理した。メールならまだ謝って済みます。でもこれが課金APIだったら、お客さんのカードを3回切っていました。
外部のSaaS APIを自分のアプリに繋ぐ仕事は、見た目より地雷が多い。今日は僕が実際に踏んだ地雷を順番に潰しながら、APIキーの置き場所、Webhookの署名検証、失敗したときのリトライ、そして「何回呼ばれても1回ぶんしか効かない」冪等性まで、コピペで動くコードつきで書いていきます。
この記事の要点
- 外部SaaS連携で本当に効くのは、APIキーの隠し方・Webhookの署名検証・リトライ・冪等性の4点。賢いコードより先にここを固める
- Webhookは公開URLに誰でも投げられる。JSONにパースする前の生データ(raw body)でHMAC署名を検証し、
timingSafeEqualで比較する - 失敗時のリトライは指数バックオフ+上限つきで。再試行で二重実行しないよう、POSTには冪等性キーを付ける
- 本番キーで試すな。テストモードのキーとサンドボックス環境で動かしてから本番に出す
- 秘密情報はコードに書かず環境変数へ。エラーログにトークンを出さない
まず全体像:どこに何を置くか
最初に手を動かす前に、置き場所だけ決めておくと後がラクです。僕は連携を3つの層に分けて考えています。
| 層 | 役割 | 置くもの |
|---|---|---|
| 呼び出し層 | 外部APIを叩く | リトライ・冪等性キー・タイムアウト |
| 受信層 | SaaSからのWebhookを受ける | 署名検証・即レス・キュー投入 |
| 秘密情報 | 鍵を管理する | 環境変数・Secret Manager |
ありがちな失敗は、この3つを1つの巨大な関数に押し込むことです。僕も最初はやりました。handlePayment() の中で鍵を読み、APIを叩き、リトライし、Webhookも受けて……となって、どこで何が壊れたか追えなくなる。層を分けておくと、テストも差し替えも一気に楽になります。
呼び出し層と受信層は別物だという感覚が大事です。呼び出し層は「こちらから外へ」。受信層は「外からこちらへ」。守るべき脅威も違います。呼び出し層では自分のミスやネットワーク不調が敵で、受信層では偽のリクエストを投げてくる第三者が敵です。
APIキーは「名前」だけ共有する
連携の出発点はAPIキーです。サーバー用の合い鍵だと思ってください。これが漏れると、相手のサービスを僕の権限で好きに操作されます。
鉄則は1つ。鍵の値はコードにもチャットにも書かない。共有していいのは「名前」だけです。.env.example に名前の枠だけ作り、本物はローカルの .env か本番のSecret Managerに閉じ込めます。
# .env.example (これはコミットする。値は空のまま)
INTEGRATION_ENV=sandbox
SAAS_API_TOKEN=
SAAS_WEBHOOK_SECRET=
AUDIT_LOG_PATH=logs/saas-audit.ndjson
# .gitignore (本物の .env は絶対に追跡させない)
.env
.env.*
!.env.example
logs/
僕がやらかした一番恥ずかしい事故は、デバッグ中に console.log(headers) で Authorization ヘッダーをまるごとログに吐いていたことです。トークンがログ基盤に平文で残り続けていた。エラー文や監査ログにトークンを混ぜないのは、地味だけど効きます。鍵まわりの隠し方をもっと詰めたい人は、APIキーを漏らさない秘密情報管理の手順に具体策をまとめてあります。
リトライは「指数バックオフ+上限」で
外部APIは普通に失敗します。一瞬の通信不良、相手の一時的な過負荷、レート制限(短時間に呼びすぎたときの上限)。ここで「失敗したらすぐ全力でもう一度」をやると、弱っている相手をさらに殴る格好になって、状況が悪化します。
正解は指数バックオフです。1回目は0.5秒待つ、2回目は1秒、3回目は2秒……と待ち時間を倍々にして、上限で頭打ちにします。相手が Retry-After(次まで何秒待て、というヘッダー)を返してきたら、それを最優先で尊重します。
そして再試行する前提だからこそ、冪等性キーが効いてきます。同じPOSTを2回送っても相手が「これは前と同じ依頼だ」と気づいて1回ぶんしか処理しないように、毎回ユニークなキーを添えるんです。決済を二重に走らせないための保険ですね。
下のコードはそのまま動きます。Node.js 20以上なら、node saas-request.mjs https://httpbin.org/status/200 で試せます。
// saas-request.mjs
// 外部SaaS APIを叩く共通ラッパー。
// リトライ(指数バックオフ)と冪等性キーを最初から組み込む。
import crypto from "node:crypto";
import { pathToFileURL } from "node:url";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function saasRequest(url, options = {}) {
const {
method = "GET",
token = process.env.SAAS_API_TOKEN,
body,
// POSTだけ冪等性キーを自動付与(同じ依頼を二重実行させない保険)
idempotencyKey = method === "POST" ? crypto.randomUUID() : undefined,
maxRetries = 4,
headers = {},
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const res = await fetch(url, {
method,
headers: {
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.ok) return res;
// 一時的な失敗だけ再試行する。400や401を繰り返し叩いても無駄
const retryAfter = Number(res.headers.get("retry-after"));
const shouldRetry = [408, 409, 425, 429, 500, 502, 503, 504].includes(res.status);
if (!shouldRetry || attempt === maxRetries) {
const text = await res.text();
// エラー文にトークンは載せない。本文も先頭だけに切る
throw new Error(`SaaS request failed: ${res.status} ${text.slice(0, 200)}`);
}
// 相手が Retry-After を返せばそれを優先。なければ倍々で待つ(上限30秒)
const backoffMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(30000, 500 * 2 ** attempt);
await sleep(backoffMs);
}
throw new Error("unreachable");
}
// このファイルを直接実行したときだけ動くお試し用
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const url = process.argv[2];
if (!url) throw new Error("使い方: node saas-request.mjs <url>");
const res = await saasRequest(url);
console.log(`status: ${res.status}`);
console.log((await res.text()).slice(0, 300));
}
ポイントは2つだけ覚えてください。1つは全部の失敗を再試行しないこと。401(認可エラー)を10回叩いても通りません。一時的なエラー(429や5xx)だけリトライします。もう1つは上限を必ず置くこと。無限リトライはサーバーを焼きます。
冪等性キーの使い方は相手のAPIによります。決済系のように Idempotency-Key ヘッダーを正式にサポートするAPIなら、同じPOSTを安全に投げ直せます。詳しくはStripeの冪等リクエストのドキュメントが分かりやすいです。自前のAPIでも、provider + イベントID + 操作 をキーにして「処理済みならスキップ」を実装しておくと事故が減ります。
Webhookは「署名」を確かめてから処理する
ここが冒頭の二重課金事故の本丸です。Webhookは「SaaS側から自分のサーバーへ届く通知」。便利な反面、その受信URLは公開されています。つまり、悪意ある第三者も同じURLに偽のイベントを投げられる。署名検証をサボると、ニセの「購入完了」を信じて商品を渡す、なんてことが普通に起きます。
検証の流れはこうです。
1. リクエストの生データ(raw body)をそのまま読む
2. 署名ヘッダーを取り出す
3. 共有シークレットでHMACを計算する
4. timing-safeな比較で一致を確認する
5. 配送ID(delivery id)を冪等性キーとして保存し、重複を弾く
6. すぐ202を返し、重い処理はキューへ回す
一番ハマりやすいのが手順1です。JSONにパースする前の生データで検証しないと、署名が合いません。フレームワークが勝手に body を整形すると、バイト列が変わって計算がズレるからです。だから生のバイト列を自分で読みます。
下はHMAC-SHA256で署名を検証する受信サーバーです。SAAS_WEBHOOK_SECRET を環境変数に入れてから node verify-webhook.mjs で起動します。
// verify-webhook.mjs
// Webhook受信サーバー。署名を検証してから本処理に渡す。
import crypto from "node:crypto";
import http from "node:http";
const secret = process.env.SAAS_WEBHOOK_SECRET;
if (!secret) throw new Error("環境変数 SAAS_WEBHOOK_SECRET を設定してください");
// 処理済みの配送IDを覚えておく(本番はRedisやDBに置く)
const processed = new Set();
function verifySignature(rawBody, signatureHeader) {
const received = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader ?? "";
// 共有シークレットで生データのHMACを計算する
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
// 長さが違うと timingSafeEqual が例外を投げるので先に弾く
// timing-safe比較:文字列の前後比較と違い、当てずっぽうを防ぐ
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http
.createServer(async (req, res) => {
// 生のバイト列を自分で集める(パース前に検証するため)
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
if (!verifySignature(rawBody, req.headers["x-signature-256"])) {
res.writeHead(401);
res.end("invalid signature");
return;
}
// 配送IDで重複を弾く(同じイベントが何度来ても1回だけ処理)
const delivery = req.headers["x-delivery-id"];
if (delivery && processed.has(delivery)) {
res.writeHead(200);
res.end("duplicate ignored");
return;
}
if (delivery) processed.add(delivery);
// 検証OK。重い処理はここで同期実行せず、キューへ積むのが定石
const event = JSON.parse(rawBody.toString("utf8"));
console.log(JSON.stringify({ type: event.type, delivery, at: new Date().toISOString() }));
// まず即レス。相手の「届いたか不安で再送」を防ぐ
res.writeHead(202, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
})
.listen(3000, () => console.log("Listening on http://localhost:3000"));
このコードには冒頭の事故を防ぐ仕掛けが3つ入っています。署名検証で偽イベントを弾き、配送IDの重複チェックで同じイベントの再送を1回に丸め、即座に202を返すことで相手の「不安からの再送」を減らす。最初からこの3点が入っていれば、僕のお客さんに3通のメールは届きませんでした。
ヘッダー名や署名形式は相手のSaaSごとに違います。実際のサービスでは公式ドキュメントの名前に合わせてください。たとえばGitHubは Webhook配送の検証 で X-Hub-Signature-256 を使った手順を、Stripeは Webhook署名の検証 で専用SDKの検証関数を案内しています。Webhook受信の設計そのものを深掘りしたい人は、Webhook受信の署名検証・冪等性・リトライ実装に受信側の組み立て方を詳しく書いています。呼び出される側のREST APIをどう設計するかは、NodeでREST APIを実装する手順が参考になります。
本番キーで試すな、サンドボックスで動かせ
連携を作っていて一番怖いのは、検証のつもりで本番を動かしてしまうことです。テスト中の「ダミー注文」が本物の課金として記録されたら目も当てられません。
多くのSaaSはテストモードのキーとサンドボックス環境を用意しています。sk_test_... のようにテスト用は接頭辞で見分けられることが多い。僕は環境変数 INTEGRATION_ENV で本番とサンドボックスを切り替え、起動時に「今どっちで動いているか」を必ずログに出すようにしています。
確認しているのは、いつもこの並びです。
- 起動時に
INTEGRATION_ENVを表示し、テストキー(sk_test_など)かどうかも一緒に確認する - Webhookはローカルに直接届かないので、転送サービスでローカルへトンネルして検証する
- 失敗系を先に試す。署名が壊れたリクエスト、タイムアウト、429を意図的に起こす
- 二重配送を手で再現する。同じイベントを2回投げて、本当に1回ぶんしか効かないか見る
- すべて緑になってから、本番キーへ差し替える
特に4番は飛ばさないでください。冪等性は「うまくいったとき」には見えない機能です。わざと壊して初めて、効いているか分かります。
よくある落とし穴
僕や周りが繰り返し踏んだものを並べておきます。
Webhookの順番を信じる。SaaSによってはイベントが作成順に届くとは限りません。「キャンセル」が「作成」より先に着くこともある。イベント内の時刻や対象リソースの最新状態を見て判断します。
リトライで重複を作る。決済、Issue作成、メール送信は、二重実行するとユーザーに見えてしまいます。POSTには冪等性キー、Webhookでは配送IDの保存。これがないリトライは凶器です。
スコープを広げすぎる。最初から「全データ読み書き」を要求せず、読み取り専用・対象を絞る・短いトークン期限から始めます。あとから広げるのは簡単、絞るのは大変です。
生データを捨てる。検証用の raw body をフレームワークに食わせてからHMACを計算して、永遠に署名が合わずに半日溶かす。あるあるです。
エラー文に秘密を載せる。例外メッセージやログにトークンを混ぜない。流出経路はだいたいログです。
コネクタにまとめるタイミング
最初の1本はベタ書きで構いません。きれいに作ろうとして手が止まるより、動くものを1つ出すほうが学びが多い。
ただし、同じ処理を3回書いたらまとめどきです。認可、リトライ、ページめくり、署名検証、エラー整形——これらが複数のSaaSで重なり始めたら、共通のコネクタに切り出します。関数名は callApiX() のような技術寄りより、sendMessage() や recordPayment() のように「何をしたいか」で切ると、後から読んでも使い方に迷いません。
よくある質問
Q. APIキーとOAuthはどう使い分けますか。 A. サーバー自身の権限で動かすならAPIキー、ユーザー個人のデータに本人の許可を得てアクセスするならOAuthです。社内通知やサーバー処理はAPIキー、各ユーザーのアカウントを操作する連携はOAuthが基本です。
Q. Webhookの署名検証は本当に毎回必要ですか。 A. 必要です。受信URLは公開されているので、検証がなければ第三者が偽のイベントを投げられます。署名を確かめるまで、そのリクエストは「身元不明」だと思ってください。
Q. 冪等性キーは何を使えばいいですか。 A. その操作を一意に表す値です。決済なら注文ID、Webhook処理なら配送ID(delivery id)が定番。ランダムなUUIDでも、同じ依頼には同じ値を使い回せば再送をまとめられます。
Q. リトライの回数と間隔の目安は。
A. 上限3〜5回、間隔は指数バックオフで0.5秒から倍々、上限30秒あたりが現実的です。相手が Retry-After を返したら、自分の計算より優先します。
Q. Webhookをローカルで受け取れません。 A. localhostは外から見えないので、そのままでは届きません。開発中は転送サービスで公開URLを作ってローカルへトンネルし、本番はHTTPSの受信エンドポイントを用意します。
実際に試した結果
あの二重課金以来、僕は新しいSaaSを繋ぐとき、APIを叩くコードより先に「署名検証・冪等性・リトライ・サンドボックス検証」の4点から手をつけるようになりました。順番を変えただけで、本番に出してからの事故がほぼ消えました。
特に効いたのは、わざと壊して試す習慣です。署名を1文字いじったリクエスト、同じイベントの2連投、意図的な429。これを必ず通すようにしてから、「テストは緑なのに本番で燃える」がなくなりました。冪等性キーは普段は誰の目にも触れない地味な部品ですが、再送が来た瞬間に一度だけ仕事をして、お客さんへの二重請求を黙って止めてくれます。賢い連携を作るより、何回呼ばれても壊れない連携を作る。これがいちばん速い、というのが今の実感です。
実務用のチェックリストは /products/ に、チームへの導入支援は /training/ にまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。