Gemini API の event driven webhook を AWS Lambda で受けて署名検証するところまでやってみた
はじめに
2026 年 5 月 4 日に Google が Gemini API へ event driven webhook 機能を追加しました。バッチ処理や動画生成のように完了まで時間のかかるジョブの結果を、ポーリングではなく push で受け取れるようになりました。
早速この webhook を AWS Lambda Function URL で受け取り、Node.js + TypeScript で署名検証までやってみました。 実際に受信したヘッダーとボディを並べて、Standard Webhooks 仕様に沿った検証コードを書き、シークレットローテーションの挙動も確認しました。
対象読者
- Gemini API を業務で利用している方
- webhook を実装したことがあり、HMAC 署名や replay 防御の概念を把握している方
検証環境
- Node.js 24.15.0 (ローカルと AWS Lambda の両方で
nodejs24.x) - TypeScript 5.6 系 (strict mode)
- AWS Lambda Function URL (ap-northeast-1, AuthType=NONE)
- 主な npm パッケージ :
@google/genai@1.52.0,standardwebhooks@1.0.0
参考
- Reduce friction and latency for long-running jobs with Webhooks in Gemini API (Google Blog)
- Webhooks | Gemini API 公式ドキュメント
- Standard Webhooks 仕様
- google-gemini/cookbook quickstart Webhooks
- @google/genai (npm)
- standardwebhooks (npm)
受信エンドポイントを作って疎通確認する
まず Gemini 側から webhook を受け取れる HTTPS エンドポイントを用意します。今回は AWS Lambda の Function URL を採用しました。HTTPS 公開がコマンド数行で済み、CloudWatch Logs に受信内容をそのまま残せるためです。
Function URL の認可方針
Function URL は AuthType=NONE に設定し、IAM 認証は使いません。エンドポイント自体はインターネットから到達可能になりますが、Gemini からのリクエストには Standard Webhooks 仕様の HMAC 署名が付与されるため、Lambda の処理冒頭でこの署名を検証し、正当な Gemini 由来のリクエストだけを本処理に進めます。
const fn = new Function(this, "ReceiverFunction", {
runtime: Runtime.NODEJS_24_X,
handler: "handler.handler",
code: Code.fromAsset(resolve(import.meta.dirname, "..", "dist")),
timeout: Duration.seconds(10),
memorySize: 256,
});
fn.addFunctionUrl({
authType: FunctionUrlAuthType.NONE,
invokeMode: InvokeMode.BUFFERED,
});
webhook を登録する
@google/genai の webhooks.create で webhook リソースを作ります。subscribed_events には今回の検証で対象としたイベントを指定します。
const created = await client.webhooks.create({
name: "gemini-webhook-poc-static",
uri: url,
subscribed_events: [
"batch.succeeded",
"batch.failed",
"batch.expired",
"interaction.requires_action",
"interaction.completed",
"interaction.failed",
"video.generated",
],
});
レスポンスには平文の署名シークレットが 1 度だけ 含まれます。フィールド名は new_signing_secret です。取得後に webhooks.get で見える signing_secrets[].truncated_secret は短縮形のみで、平文は二度と取得できないので注意してください。
ping で疎通確認する
webhook を作成したら、webhooks.ping(<id>) で署名付きの ping リクエストを送れます。受信側 Lambda が 200 を返せば疎通確認は完了です。
await client.webhooks.ping(webhookId);
CloudWatch Logs を確認すると、ヘッダーとボディが届いている様子がわかります。
受信内容を観察する
ping を発火した直後の Lambda ログから、ヘッダーとボディを抜き出して観察します。
ヘッダーの中身
主要なヘッダーは次のとおりです。
webhook-id: 配信メッセージの一意識別子
例:msg_1dc624e1-0585-4387-ad6c-43bf85a1c67c_<webhook_resource_id>webhook-timestamp: 送信時刻 (Unix epoch 秒)
例:1778142093webhook-signature: HMAC 署名
例:v1,P0Dlzxo5fk7gZE/bdRu0PTbTDE4BLwT67C1t7gCXxbM=user-agent: 送信元の自己申告
例:Googlex-forwarded-for: 送信元 IP (Google ボット帯)
例:66.249.84.96content-type: ボディ形式
例:application/json
webhook-id webhook-timestamp webhook-signature の 3 つは Standard Webhooks 仕様で必須化されているヘッダーです。署名検証ではこの 3 つを参照します。本検証の ping では webhook-id が msg_<event_uuid>_<webhook_resource_id> の連結形式になっており、後述するボディの id (evt_<event_uuid>) と中央の UUID 部分が同じ値となっていましたが、冪等性キーとしては Standard Webhooks や Gemini 公式ドキュメントが示すとおり webhook-id をそのまま使うのが安全でしょう。
参考: 受信ヘッダー全文 (webhook resource id とホスト名はマスク済)
{
"content-length": "90",
"x-amzn-tls-version": "TLSv1.3",
"x-forwarded-proto": "https",
"webhook-id": "msg_1dc624e1-0585-4387-ad6c-43bf85a1c67c_<webhook_resource_id>",
"x-forwarded-port": "443",
"x-forwarded-for": "66.249.84.96",
"webhook-signature": "v1,P0Dlzxo5fk7gZE/bdRu0PTbTDE4BLwT67C1t7gCXxbM=",
"x-amzn-tls-cipher-suite": "TLS_AES_128_GCM_SHA256",
"x-amzn-trace-id": "Root=1-69fc4b8d-17968be178c21c9c7ef6ea26",
"host": "<function-url-host>.lambda-url.ap-northeast-1.on.aws",
"webhook-timestamp": "1778142093",
"content-type": "application/json",
"accept-encoding": "gzip, deflate, br",
"user-agent": "Google"
}
x-amzn-* 系のヘッダーは Lambda Function URL のフロントが付与しているもので、Gemini から流れてくるヘッダーは webhook-* の 3 種と user-agent: Google です。
ボディの中身
ping のボディは 90 byte の compact JSON でした。
{
"created_at": 0,
"data": null,
"id": "evt_1dc624e1-0585-4387-ad6c-43bf85a1c67c",
"type": "ping"
}
今回の ping では、ボディは id type data created_at の 4 フィールドでした。data は null、created_at は 0 で固定です。
署名検証とローテーションを実装する
受信した webhook が本当に Gemini から送られたものかどうか、Standard Webhooks 仕様に沿って検証しました。
仕様の概要
Standard Webhooks では、署名対象を次のように定義します。
<webhook-id>.<webhook-timestamp>.<rawBody>
これを HMAC SHA256 で署名し、base64 エンコードした値を v1,<base64> の形でヘッダー webhook-signature に乗せます。受信側は同じ計算をして timing-safe 比較で一致を見ます。timestamp はリクエスト到着時の時刻と ±5 分の範囲で一致している必要があります。幸い standardwebhooks 公式の npm パッケージが用意されているので、検証ロジックをそのまま使えます。
最小実装
検証部 verify-static.ts は以下の通りです。
verify-static.ts
import { Webhook, WebhookVerificationError } from "standardwebhooks";
export { WebhookVerificationError };
export interface VerifyResult {
body: unknown;
webhookId: string;
webhookTimestamp: string;
}
export class StaticWebhookVerifier {
private readonly webhook: Webhook;
constructor(signingSecret: string) {
this.webhook = new Webhook(signingSecret);
}
verify(rawBody: string, headers: Record<string, string>): VerifyResult {
const body = this.webhook.verify(rawBody, headers);
const lowered = lowercaseHeaders(headers);
return {
body,
webhookId: lowered["webhook-id"] as string,
webhookTimestamp: lowered["webhook-timestamp"] as string,
};
}
}
function lowercaseHeaders(headers: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v;
return out;
}
Webhook のコンストラクタにはシークレットの平文を渡し、verify に raw body と headers を渡します。失敗時は WebhookVerificationError を投げます。
raw body はバイトで保持する
検証で詰まりやすい点が 1 つあります。ボディを 受信した raw bytes のまま 保持しなければ署名は一致しません。たとえば JSON をパースしてから再シリアライズしたり、pretty-print して保存したりすると、フィールド順や空白の差でバイト列が変わり失敗します。
実際に筆者は、保存した ping ボディをローカルテストに流したところ、初回は No matching signature found で失敗しました。原因は、ファイル保存時に pretty-print してしまったことでした。受信した 90 byte の compact JSON のまま fixture として保持し直したところ検証が通りました。
Lambda Function URL から呼び出される場合は、event.body の文字列をそのまま検証関数に渡します。isBase64Encoded が true のリクエストでは、検証前に元の UTF-8 文字列へ戻す必要があります。
失敗ケースを vitest で押さえる
standardwebhooks の Webhook.sign を使うと、テスト用の署名済みペイロードをコードから合成できます。これを vitest のフィクスチャとして使い、改竄や欠落で意図通りに弾かれることを確認します。
| 改竄 / 欠落の対象 | 期待される挙動 |
|---|---|
| body の 1 文字を変える | No matching signature found |
webhook-signature の値を変える |
No matching signature found |
webhook-id の値を変える |
No matching signature found |
webhook-id ヘッダーを落とす |
Missing required headers |
webhook-timestamp ヘッダーを落とす |
Missing required headers |
webhook-signature ヘッダーを落とす |
Missing required headers |
webhook-timestamp を 6 分前にずらす |
Message timestamp too old |
webhook-timestamp を 6 分後にずらす |
Message timestamp too new |
8 通りすべてが期待通り WebhookVerificationError で弾かれることをテストで確認しました。
ローテーション中の二重署名
Standard Webhooks 仕様では、署名ローテーションの過渡期に 新旧 2 つの署名を半角スペース区切り で webhook-signature に並べる挙動を許容しています。新シークレットだけを保持している受信側でも、新旧併記の署名から新しい方を見つけて通せれば良いという建付けです。
const headers = {
"webhook-id": msgId,
"webhook-timestamp": String(Math.floor(date.getTime() / 1000)),
"webhook-signature": `${oldSignature} ${newSignature}`,
};
expect(() => verifierForNewSecret.verify(body, headers)).not.toThrow();
新シークレットだけを持つ verifier でも、上記のように二重署名のヘッダーは通ります。今回は実環境でこの形を受け取ってはいませんが、ライブラリ側はこの仕様に対応していました。
シークレットローテーションを試す
webhooks.rotateSigningSecret(<id>) を呼ぶとシークレットを再発行できます。今回の検証では revocation_behavior を省略して呼び出しました。公式ドキュメントでは revocation_behavior を指定でき、旧シークレットを即時失効させるか、24 時間の猶予期間後に失効させるかを選べます。実運用では、このパラメータを明示したうえで、受信側の複数シークレット対応と切り替え手順をセットで設計します。
レスポンスは次の形でした。
{
"secret": "whsec_<base64 string>"
}
平文は secret フィールドに入っています。ローテーション直後に webhooks.get を呼んで前後を比べたところ、signing_secrets 配列の中身は次のように変化していました。
| 観察ポイント | rotate 前 | rotate 後 |
|---|---|---|
signing_secrets 要素数 |
1 件 | 1 件 |
truncated_secret |
whsec_...dDg= |
whsec_...czU= |
webhooks.get の signing_secrets の見え方だけでは、ローテーション中にどの旧シークレットをどこまで受け付けるべきかは判断しづらいです。前述の revocation_behavior の指定と、受信側のシークレット管理 (新シークレットを保管 → 受信側に反映 → 新旧両方の署名を一定時間受け付ける → 旧側の使用停止を確認) をセットで設計するのが安全です。
まとめ
本記事では、Gemini API の event driven webhook を AWS Lambda Function URL で受け取り、Standard Webhooks 仕様に沿った署名検証とシークレットローテーション運用までを通しで実装しました。検証時に受け取ったヘッダーやボディの中身、vitest で押さえた失敗ケース、webhooks.rotateSigningSecret 前後の signing_secrets の変化など、触ってみて初めて掴める箇所を共有しました。
今回踏み込めなかった interaction LRO や動画完了通知の挙動、dynamic webhook を user_metadata 付きで使うパターンは、別の機会に検証してみたいと考えています。Gemini API のジョブを運用されている方の webhook 化検討の参考になれば幸いです。









