Claude
Skills
Sign in
Back

gemini-webhooks

Included with Lifetime
$97 forever

Receive and verify Google Gemini API webhooks. Use when setting up Gemini webhook handlers for batch jobs, video generation, or Interactions API function-calling LROs, debugging signature verification, or handling events like batch.succeeded, batch.failed, video.generated, or interaction.completed.

Image & Video

What this skill does


# Gemini Webhooks

## When to Use This Skill

- Setting up Google Gemini API webhook handlers
- Debugging Gemini webhook signature verification failures
- Handling `batch.succeeded` / `batch.failed` notifications for the Batch API
- Handling `video.generated` notifications for the Veo/video generation API
- Handling `interaction.completed` / `interaction.requires_action` events for the Interactions API
- Replacing polling for long-running Gemini operations (LROs)
- Verifying Standard Webhooks-format signatures from Google `generativelanguage.googleapis.com`

## Essential Code (USE THIS)

Gemini webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification.
Each delivery includes three headers:

- `webhook-id` — unique message id (use for idempotency)
- `webhook-timestamp` — Unix seconds (reject if > 5 minutes old)
- `webhook-signature` — one or more space-separated `v1,<base64-hmac-sha256>` entries over `webhook-id.webhook-timestamp.body` (multiple entries appear during secret rotation)

The signing secret is returned once when the webhook is created via the WebhookService API
and is base64-encoded, prefixed with `whsec_`.

### Express Webhook Handler

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

function verifyGeminiSignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) {
  if (!webhookId || !webhookTimestamp || !webhookSignature || !webhookSignature.includes(',')) {
    return false;
  }

  // Reject payloads older than 5 minutes to prevent replay attacks
  const currentTime = Math.floor(Date.now() / 1000);
  const timestampDiff = currentTime - parseInt(webhookTimestamp);
  if (timestampDiff > 300 || timestampDiff < -300) {
    return false;
  }

  // Signed content: webhook_id.webhook_timestamp.raw_body
  const payloadStr = payload instanceof Buffer ? payload.toString('utf8') : payload;
  const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`;

  // Strip whsec_ prefix and base64-decode the secret
  const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const secretBytes = Buffer.from(secretKey, 'base64');

  const expectedSignature = crypto
    .createHmac('sha256', secretBytes)
    .update(signedContent, 'utf8')
    .digest('base64');
  const expectedBuf = Buffer.from(expectedSignature);

  // Standard Webhooks allows space-separated entries during secret rotation:
  // `v1,<sig1> v1,<sig2>`. Accept the message if any v1 entry matches.
  for (const part of webhookSignature.split(' ')) {
    const commaIdx = part.indexOf(',');
    if (commaIdx === -1) continue;
    const version = part.slice(0, commaIdx);
    const signature = part.slice(commaIdx + 1);
    if (version !== 'v1') continue;
    const sigBuf = Buffer.from(signature);
    if (sigBuf.length !== expectedBuf.length) continue;
    try {
      if (crypto.timingSafeEqual(sigBuf, expectedBuf)) return true;
    } catch {
      // length mismatch — try the next entry
    }
  }
  return false;
}

// CRITICAL: use express.raw() — signature is computed over the raw body
app.post('/webhooks/gemini',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const webhookId = req.headers['webhook-id'];
    const webhookTimestamp = req.headers['webhook-timestamp'];
    const webhookSignature = req.headers['webhook-signature'];

    if (!verifyGeminiSignature(
      req.body,
      webhookId,
      webhookTimestamp,
      webhookSignature,
      process.env.GEMINI_WEBHOOK_SECRET
    )) {
      return res.status(400).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString());

    switch (event.type) {
      case 'batch.succeeded':
        console.log(`Batch succeeded: ${event.data.id}`);
        break;
      case 'batch.failed':
        console.log(`Batch failed: ${event.data.id}`);
        break;
      case 'batch.cancelled':
        console.log(`Batch cancelled: ${event.data.id}`);
        break;
      case 'batch.expired':
        console.log(`Batch expired: ${event.data.id}`);
        break;
      case 'video.generated':
        console.log(`Video generated: ${event.data.id}`);
        break;
      case 'interaction.completed':
        console.log(`Interaction completed: ${event.data.id}`);
        break;
      case 'interaction.requires_action':
        console.log(`Interaction requires action: ${event.data.id}`);
        break;
      case 'interaction.failed':
        console.log(`Interaction failed: ${event.data.id}`);
        break;
      case 'interaction.cancelled':
        console.log(`Interaction cancelled: ${event.data.id}`);
        break;
      default:
        console.log(`Unhandled event: ${event.type}`);
    }

    res.json({ received: true });
  }
);
```

### Python (FastAPI) Webhook Handler

```python
import os
import hmac
import hashlib
import base64
import time
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()

def verify_gemini_signature(
    payload: bytes,
    webhook_id: str,
    webhook_timestamp: str,
    webhook_signature: str,
    secret: str
) -> bool:
    if not webhook_id or not webhook_timestamp or not webhook_signature or ',' not in webhook_signature:
        return False

    current_time = int(time.time())
    try:
        timestamp_diff = current_time - int(webhook_timestamp)
    except ValueError:
        return False
    if timestamp_diff > 300 or timestamp_diff < -300:
        return False

    signed_content = f"{webhook_id}.{webhook_timestamp}.{payload.decode('utf-8')}"

    secret_key = secret[6:] if secret.startswith('whsec_') else secret
    secret_bytes = base64.b64decode(secret_key)

    expected_signature = base64.b64encode(
        hmac.new(secret_bytes, signed_content.encode('utf-8'), hashlib.sha256).digest()
    ).decode('utf-8')

    # Standard Webhooks allows space-separated entries during secret rotation:
    # `v1,<sig1> v1,<sig2>`. Accept the message if any v1 entry matches.
    for part in webhook_signature.split(' '):
        if ',' not in part:
            continue
        version, _, signature = part.partition(',')
        if version != 'v1':
            continue
        if hmac.compare_digest(signature, expected_signature):
            return True
    return False


@app.post("/webhooks/gemini")
async def gemini_webhook(
    request: Request,
    webhook_id: str = Header(None, alias="webhook-id"),
    webhook_timestamp: str = Header(None, alias="webhook-timestamp"),
    webhook_signature: str = Header(None, alias="webhook-signature"),
):
    payload = await request.body()

    if not verify_gemini_signature(
        payload,
        webhook_id,
        webhook_timestamp,
        webhook_signature,
        os.environ.get("GEMINI_WEBHOOK_SECRET", "")
    ):
        raise HTTPException(status_code=400, detail="Invalid signature")

    event = await request.json()
    # Handle event...
    return {"received": True}
```

> **For complete working examples with tests**, see:
> - [examples/express/](examples/express/) - Full Express implementation
> - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation
> - [examples/fastapi/](examples/fastapi/) - Python FastAPI implementation

## Common Event Types

| Event | Description |
|-------|-------------|
| `batch.succeeded` | Batch API job processing finished successfully |
| `batch.failed` | Batch API job hit a system or validation error |
| `batch.cancelled` | Batch API job was cancelled by the user |
| `batch.expired` | Batch API job did not complete within 24 hours |
| `video.generated` | Video generation (Veo) completed |
| `interaction.completed` | Long-running Interactions API call succeeded |
| `interaction.requires_action` | Interactions API call needs a function-call result |
| `interaction.failed` | Interactions API call failed |
| `interaction.cancelled` | Interactions API call was cancelled |

> **For the full event reference**, see [Gemini API webhooks](https://ai.

Related in Image & Video