I tried implementing receiving Gemini API event-driven webhooks with AWS Lambda and verifying the signatures
This page has been translated by machine translation. View original
Introduction
On May 4, 2026, Google added event-driven webhook functionality to the Gemini API. Results from long-running jobs such as batch processing and video generation can now be received via push instead of polling.
I immediately went ahead and received these webhooks using AWS Lambda Function URL, implementing signature verification with Node.js + TypeScript. I laid out the actual received headers and body side by side, wrote verification code in accordance with the Standard Webhooks specification, and confirmed the behavior during secret rotation.
Target Audience
- Those who use the Gemini API in their work
- Those who have implemented webhooks before and are familiar with concepts such as HMAC signatures and replay protection
Verification Environment
- Node.js 24.15.0 (both local and AWS Lambda using
nodejs24.x) - TypeScript 5.6.x (strict mode)
- AWS Lambda Function URL (ap-northeast-1, AuthType=NONE)
- Main npm packages:
@google/genai@1.52.0,standardwebhooks@1.0.0
References
- Reduce friction and latency for long-running jobs with Webhooks in Gemini API (Google Blog)
- Webhooks | Gemini API Official Documentation
- Standard Webhooks Specification
- google-gemini/cookbook quickstart Webhooks
- @google/genai (npm)
- standardwebhooks (npm)
Setting Up a Receiving Endpoint and Verifying Connectivity
First, prepare an HTTPS endpoint to receive webhooks from the Gemini side. This time I chose AWS Lambda Function URL. It allows HTTPS exposure in just a few commands and makes it easy to keep received content as-is in CloudWatch Logs.
Function URL Authorization Policy
The Function URL is set to AuthType=NONE, and IAM authentication is not used. The endpoint itself becomes reachable from the internet, but since requests from Gemini include an HMAC signature per the Standard Webhooks specification, this signature is verified at the beginning of Lambda processing, allowing only legitimate Gemini-originated requests to proceed to actual processing.
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,
});
Registering the Webhook
Create a webhook resource using webhooks.create from @google/genai. Specify the events targeted for this verification in 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",
],
});
The response contains the plaintext signing secret only once. The field name is new_signing_secret. Note that the signing_secrets[].truncated_secret visible via webhooks.get afterward is only a truncated form — the plaintext can never be retrieved again.
Verifying Connectivity with a Ping
Once the webhook is created, you can send a signed ping request using webhooks.ping(<id>). If the receiving Lambda returns 200, connectivity verification is complete.
await client.webhooks.ping(webhookId);
Checking CloudWatch Logs reveals that the headers and body have arrived.
Observing the Received Content
Immediately after firing a ping, I extracted and examined the headers and body from the Lambda logs.
Header Contents
The main headers are as follows.
webhook-id: Unique identifier for the delivered message
Example:msg_1dc624e1-0585-4387-ad6c-43bf85a1c67c_<webhook_resource_id>webhook-timestamp: Send time (Unix epoch seconds)
Example:1778142093webhook-signature: HMAC signature
Example:v1,P0Dlzxo5fk7gZE/bdRu0PTbTDE4BLwT67C1t7gCXxbM=user-agent: Sender's self-identification
Example:Googlex-forwarded-for: Source IP (Google bot range)
Example:66.249.84.96content-type: Body format
Example:application/json
The three headers webhook-id, webhook-timestamp, and webhook-signature are required by the Standard Webhooks specification. These three are referenced during signature verification. In this verification's ping, webhook-id was in the format msg_<event_uuid>_<webhook_resource_id>, and the central UUID portion matched the id (evt_<event_uuid>) in the body described later. However, as indicated by Standard Webhooks and the official Gemini documentation, it is safer to use webhook-id as-is as an idempotency key.
Reference: Full received headers (webhook resource ID and hostname masked)
{
"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"
}
The x-amzn-* headers are added by the Lambda Function URL front-end; the headers that come through from Gemini are the three webhook-* headers and user-agent: Google.
Body Contents
The ping body was 90 bytes of compact JSON.
{
"created_at": 0,
"data": null,
"id": "evt_1dc624e1-0585-4387-ad6c-43bf85a1c67c",
"type": "ping"
}
In this ping, the body had four fields: id, type, data, and created_at. data is null and created_at is fixed at 0.
Implementing Signature Verification and Rotation
I verified that received webhooks were truly sent from Gemini, following the Standard Webhooks specification.
Specification Overview
Standard Webhooks defines the signature target as follows:
<webhook-id>.<webhook-timestamp>.<rawBody>
This is signed with HMAC SHA256, and the base64-encoded value is placed in the webhook-signature header in the format v1,<base64>. The receiving side performs the same calculation and uses timing-safe comparison to check for a match. The timestamp must match the request arrival time within a range of ±5 minutes. Fortunately, the official standardwebhooks npm package is available, so the verification logic can be used directly.
Minimal Implementation
The verification component verify-static.ts is as follows.
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;
}
Pass the plaintext secret to the Webhook constructor, and pass the raw body and headers to verify. On failure, it throws WebhookVerificationError.
Keep the Raw Body as Bytes
There is one common pitfall in verification. The body must be kept as raw bytes exactly as received — otherwise the signature will not match. For example, if you parse the JSON and then re-serialize it, or save it with pretty-printing, the byte sequence will differ due to field order or whitespace, causing failure.
In fact, when I ran a saved ping body through a local test, it failed with No matching signature found on the first try. The cause was that the file had been saved with pretty-printing. Once I preserved the fixture as the original 90-byte compact JSON exactly as received, verification passed.
When invoked from Lambda Function URL, pass the event.body string as-is to the verification function. For requests where isBase64Encoded is true, you need to convert back to the original UTF-8 string before verification.
Covering Failure Cases with vitest
Using Webhook.sign from standardwebhooks, you can synthetically generate signed payloads in code for testing. Using these as vitest fixtures, you can confirm that tampering or missing data is rejected as expected.
| Target of tampering / omission | Expected behavior |
|---|---|
| Change one character in the body | No matching signature found |
Change the value of webhook-signature |
No matching signature found |
Change the value of webhook-id |
No matching signature found |
Drop the webhook-id header |
Missing required headers |
Drop the webhook-timestamp header |
Missing required headers |
Drop the webhook-signature header |
Missing required headers |
Shift webhook-timestamp 6 minutes into the past |
Message timestamp too old |
Shift webhook-timestamp 6 minutes into the future |
Message timestamp too new |
All 8 cases were confirmed to be rejected as WebhookVerificationError as expected through testing.
Dual Signatures During Rotation
The Standard Webhooks specification allows for a behavior during signature rotation where two signatures, old and new, are listed separated by a space in webhook-signature. The idea is that the receiving side, which only holds the new secret, just needs to find and accept the new signature from the dual-listed signatures.
const headers = {
"webhook-id": msgId,
"webhook-timestamp": String(Math.floor(date.getTime() / 1000)),
"webhook-signature": `${oldSignature} ${newSignature}`,
};
expect(() => verifierForNewSecret.verify(body, headers)).not.toThrow();
A verifier holding only the new secret will also accept dual-signature headers as shown above. I did not actually receive this format in a real environment this time, but the library supports this specification.
Testing Secret Rotation
Calling webhooks.rotateSigningSecret(<id>) reissues the secret. In this verification I called it without specifying revocation_behavior. According to the official documentation, you can specify revocation_behavior to choose whether to immediately revoke the old secret or allow a 24-hour grace period before revocation. In production, explicitly specify this parameter and design the receiving side's multi-secret support and switchover procedure together.
The response was in the following format:
{
"secret": "whsec_<base64 string>"
}
The plaintext is in the secret field. After calling webhooks.get immediately after rotation and comparing before and after, the contents of the signing_secrets array changed as follows:
| Observation point | Before rotation | After rotation |
|---|---|---|
Number of signing_secrets elements |
1 | 1 |
truncated_secret |
whsec_...dDg= |
whsec_...czU= |
From the signing_secrets view of webhooks.get alone, it is difficult to determine which old secrets should be accepted and for how long during rotation. It is safer to design the previously mentioned revocation_behavior specification together with receiving-side secret management (store new secret → apply to receiving side → accept both old and new signatures for a period → confirm old one is no longer in use).
Summary
In this article, I implemented end-to-end the process of receiving Gemini API event-driven webhooks with AWS Lambda Function URL, including signature verification per the Standard Webhooks specification and secret rotation operations. I shared details that can only be grasped by actually trying it out: the contents of received headers and body, failure cases covered by vitest, and changes in signing_secrets before and after webhooks.rotateSigningSecret.
The behaviors of interaction LRO and video completion notifications that I did not get to explore this time, as well as patterns for using dynamic webhooks with user_metadata, are things I would like to verify on another occasion. I hope this serves as a useful reference for those considering webhookifying their Gemini API jobs.
