How to Secure and Verify Gemini Webhooks with Hookdeck
Webhook security and verification are critical components of any integration, and Gemini webhooks are no exception. An unverified webhook handler is an unauthenticated public endpoint that does work on receipt of an HTTP POST — exactly the kind of surface area attackers look for to trigger replay attacks, forge events, or fan out denial-of-service traffic.
Gemini follows the Standard Webhooks specification, but with a twist: there are two configuration models with two different signing schemes. Static webhooks (project-level endpoints) are signed with HMAC-SHA256 and a shared secret. Dynamic webhooks (per-job endpoints, configured inside an individual API call) are signed with RS256 JWTs that you verify against Google's JWKS endpoint. Any production handler that supports both models has to implement both verification flows. This article walks through each, plus how Hookdeck collapses both into a single edge. For a comprehensive overview of Gemini webhook capabilities, see our guide to Gemini webhooks.
How to manually secure Gemini webhooks
When you create a static webhook endpoint via client.webhooks.create(...), Gemini returns a signing secret. From that point on, every event delivered to the endpoint includes three Standard Webhooks headers: webhook-id (a unique delivery identifier that doubles as the idempotency key), webhook-timestamp (Unix epoch seconds), and webhook-signature (an HMAC of the canonical signed payload). Dynamic webhooks share the same headers but the webhook-signature value is an RS256 JWT signed by Google.
The eight things you need to do on every request:
- Store the static webhook signing secret as an environment variable. Never check it into source control or hardcode it. The secret is shown once at creation; if you lose it you have to delete and recreate the endpoint.
- Read the raw, unparsed request body. If you parse JSON before you verify, the byte-for-byte input to the HMAC won't match what Gemini signed and verification will always fail.
- Read the three Standard Webhooks headers.
- Determine which verification flow to run. Dynamic webhooks emit JWT-shaped values in
webhook-signature; static webhooks emitv1,<base64-HMAC>values. - For static webhooks, build the canonical signed payload by concatenating
webhook-id + "." + webhook-timestamp + "." + raw_body, compute an HMAC-SHA256 with the signing key, and compare against the signature header using a constant-time comparison. - For dynamic webhooks, fetch Google's public keys from
https://generativelanguage.googleapis.com/.well-known/jwks.json(cache with a sensible TTL), find the key matching the JWT'skidclaim, and verify the RS256 signature. - Reject anything with a
webhook-timestampmore than five minutes off real time. The freshness check is what protects against replay attacks. - Deduplicate on
webhook-id— Gemini's at-least-once delivery means the same event can arrive multiple times.
A minimal manual verification handler in Python (Flask) handling the static-webhook case looks like this:
import hashlib, hmac, base64, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["GEMINI_WEBHOOK_SECRET"]
KEY = base64.b64decode(SECRET.split("_", 1)[-1]) # strip prefix, decode
@app.post("/webhooks/gemini")
def webhook():
body = request.get_data(as_text=True)
webhook_id = request.headers["webhook-id"]
timestamp = request.headers["webhook-timestamp"]
signatures = request.headers["webhook-signature"]
if abs(time.time() - int(timestamp)) > 300:
abort(400)
signed = f"{webhook_id}.{timestamp}.{body}".encode()
expected = base64.b64encode(
hmac.new(KEY, signed, hashlib.sha256).digest()
).decode()
valid = any(
hmac.compare_digest(sig.split(",", 1)[1], expected)
for sig in signatures.split(" ")
if "," in sig
)
if not valid:
abort(401)
handle_event(webhook_id, request.get_json())
return "", 200
The dynamic-webhook flow looks different — verifying an RS256 JWT against Google's JWKS:
import jwt, requests
from jwt import PyJWKClient
JWKS_URL = "https://generativelanguage.googleapis.com/.well-known/jwks.json"
jwk_client = PyJWKClient(JWKS_URL, cache_keys=True)
@app.post("/webhooks/gemini-dynamic")
def webhook_dynamic():
token = request.headers["webhook-signature"]
signing_key = jwk_client.get_signing_key_from_jwt(token)
try:
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="your-configured-audience",
)
except jwt.InvalidTokenError:
abort(401)
handle_event(request.headers["webhook-id"], request.get_json())
return "", 200
For the static-webhook case, the standardwebhooks library collapses most of this into a single helper:
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["GEMINI_WEBHOOK_SECRET"])
@app.post("/webhooks/gemini")
def webhook():
body = request.get_data(as_text=True)
try:
event = wh.verify(body, request.headers)
except WebhookVerificationError:
abort(400)
handle_event(request.headers["webhook-id"], event)
return "", 200
The library verifies the signature, enforces the freshness window, and returns the parsed event. There's no equivalent first-party helper for the dynamic-webhook JWT path — you'll use PyJWT plus a JWKS client.
Even with libraries, manual verification still leaves you with a list of operational responsibilities: maintaining two verification paths, rotating the static secret without breaking deliveries, caching the JWKS response and handling key rotation, deduplicating on webhook-id because Gemini's at-least-once delivery means the same event arrives more than once, and surviving the 24-hour retry window when something goes wrong downstream. The work compounds the more endpoints, environments, and event types you handle.
How to secure and verify Gemini webhooks with Hookdeck
Hookdeck Event Gateway centralizes both verification flows at the edge, so your handler only ever sees pre-verified events regardless of which Gemini configuration model produced them. The setup is mostly point-and-click:
- Create a free Hookdeck Event Gateway account.
- In the Hookdeck dashboard, create a new Source and pick "Gemini" — Hookdeck has the Standard Webhooks verification logic built in for both the HMAC and RS256 JWT paths.
- For static webhooks, paste your Gemini signing secret into the Source's authentication settings. Hookdeck stores it encrypted. For dynamic webhooks, Hookdeck handles the JWKS fetch and key rotation automatically.
- Create a Connection from the Gemini Source to a Destination (your application's webhook URL, or the Hookdeck CLI for local development).
- Configure the URL Hookdeck gave you when creating the webhook endpoint via
client.webhooks.create(...), or use it as theuriin yourwebhook_configfor dynamic webhooks. - Subscribe to the event types you care about.
- Trigger an event from Gemini. Hookdeck verifies the signature, rejects anything outside the freshness window, and only forwards verified payloads to your application.
- Optionally enable Hookdeck's own outbound signature verification so your application can verify that requests really came from Hookdeck.
Once that's wired up, the operational responsibilities collapse. Secret rotation is a dashboard change. JWKS caching and key rotation are Hookdeck's problem, not yours. Every handler in every environment receives the same pre-verified event, regardless of whether the underlying delivery was static or dynamic.
Conclusion
Securing Gemini webhooks means more than checking a signature once. It means maintaining two verification flows, rotating secrets, handling JWKS rotation, surviving retries without re-running side effects, and keeping verification consistent across every service that consumes events. Manual verification is possible (and the standardwebhooks library handles the static path cleanly) but the dual signing model is real infrastructure work that doesn't differentiate your product.
Get started with Hookdeck Event Gateway to verify, queue, retry, and replay your Gemini webhooks without writing the boilerplate yourself. For more, see our guide to Gemini webhook features and best practices and how to test and replay Gemini webhooks locally.