$npx -y skills add hookdeck/webhook-skills --skill gemini-webhooksReceive 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 int
| 1 | # Gemini Webhooks |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | - Setting up Google Gemini API webhook handlers |
| 6 | - Debugging Gemini webhook signature verification failures |
| 7 | - Handling `batch.succeeded` / `batch.failed` notifications for the Batch API |
| 8 | - Handling `video.generated` notifications for the Veo/video generation API |
| 9 | - Handling `interaction.completed` / `interaction.requires_action` events for the Interactions API |
| 10 | - Replacing polling for long-running Gemini operations (LROs) |
| 11 | - Verifying Standard Webhooks-format signatures from Google `generativelanguage.googleapis.com` |
| 12 | |
| 13 | ## Essential Code (USE THIS) |
| 14 | |
| 15 | Gemini webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification. |
| 16 | Each delivery includes three headers: |
| 17 | |
| 18 | - `webhook-id` — unique message id (use for idempotency) |
| 19 | - `webhook-timestamp` — Unix seconds (reject if > 5 minutes old) |
| 20 | - `webhook-signature` — one or more space-separated `v1,<base64-hmac-sha256>` entries over `webhook-id.webhook-timestamp.body` (multiple entries appear during secret rotation) |
| 21 | |
| 22 | The signing secret is returned once when the webhook is created via the WebhookService API |
| 23 | and is base64-encoded, prefixed with `whsec_`. |
| 24 | |
| 25 | ### Express Webhook Handler |
| 26 | |
| 27 | ```javascript |
| 28 | const express = require('express'); |
| 29 | const crypto = require('crypto'); |
| 30 | |
| 31 | const app = express(); |
| 32 | |
| 33 | function verifyGeminiSignature(payload, webhookId, webhookTimestamp, webhookSignature, secret) { |
| 34 | if (!webhookId || !webhookTimestamp || !webhookSignature || !webhookSignature.includes(',')) { |
| 35 | return false; |
| 36 | } |
| 37 | |
| 38 | // Reject payloads older than 5 minutes to prevent replay attacks |
| 39 | const currentTime = Math.floor(Date.now() / 1000); |
| 40 | const timestampDiff = currentTime - parseInt(webhookTimestamp); |
| 41 | if (timestampDiff > 300 || timestampDiff < -300) { |
| 42 | return false; |
| 43 | } |
| 44 | |
| 45 | // Signed content: webhook_id.webhook_timestamp.raw_body |
| 46 | const payloadStr = payload instanceof Buffer ? payload.toString('utf8') : payload; |
| 47 | const signedContent = `${webhookId}.${webhookTimestamp}.${payloadStr}`; |
| 48 | |
| 49 | // Strip whsec_ prefix and base64-decode the secret |
| 50 | const secretKey = secret.startsWith('whsec_') ? secret.slice(6) : secret; |
| 51 | const secretBytes = Buffer.from(secretKey, 'base64'); |
| 52 | |
| 53 | const expectedSignature = crypto |
| 54 | .createHmac('sha256', secretBytes) |
| 55 | .update(signedContent, 'utf8') |
| 56 | .digest('base64'); |
| 57 | const expectedBuf = Buffer.from(expectedSignature); |
| 58 | |
| 59 | // Standard Webhooks allows space-separated entries during secret rotation: |
| 60 | // `v1,<sig1> v1,<sig2>`. Accept the message if any v1 entry matches. |
| 61 | for (const part of webhookSignature.split(' ')) { |
| 62 | const commaIdx = part.indexOf(','); |
| 63 | if (commaIdx === -1) continue; |
| 64 | const version = part.slice(0, commaIdx); |
| 65 | const signature = part.slice(commaIdx + 1); |
| 66 | if (version !== 'v1') continue; |
| 67 | const sigBuf = Buffer.from(signature); |
| 68 | if (sigBuf.length !== expectedBuf.length) continue; |
| 69 | try { |
| 70 | if (crypto.timingSafeEqual(sigBuf, expectedBuf)) return true; |
| 71 | } catch { |
| 72 | // length mismatch — try the next entry |
| 73 | } |
| 74 | } |
| 75 | return false; |
| 76 | } |
| 77 | |
| 78 | // CRITICAL: use express.raw() — signature is computed over the raw body |
| 79 | app.post('/webhooks/gemini', |
| 80 | express.raw({ type: 'application/json' }), |
| 81 | (req, res) => { |
| 82 | const webhookId = req.headers['webhook-id']; |
| 83 | const webhookTimestamp = req.headers['webhook-timestamp']; |
| 84 | const webhookSignature = req.headers['webhook-signature']; |
| 85 | |
| 86 | if (!verifyGeminiSignature( |
| 87 | req.body, |
| 88 | webhookId, |
| 89 | webhookTimestamp, |
| 90 | webhookSignature, |
| 91 | process.env.GEMINI_WEBHOOK_SECRET |
| 92 | )) { |
| 93 | return res.status(400).send('Invalid signature'); |
| 94 | } |
| 95 | |
| 96 | const event = JSON.parse(req.body.toString()); |
| 97 | |
| 98 | switch (event.type) { |
| 99 | case 'batch.succeeded': |
| 100 | console.log(`Batch succeeded: ${event.data.id}`); |
| 101 | break; |
| 102 | case 'batch.failed': |
| 103 | console.log(`Batch failed: ${event.data.id}`); |
| 104 | break; |
| 105 | case 'batch.cancelled': |
| 106 | console.log(`Batch cancelled: ${event.data.id}`); |
| 107 | break; |
| 108 | case 'batch.expired': |
| 109 | console.log(`Batch expired: ${event.data.id}`); |
| 110 | break; |
| 111 | case 'video.generated': |
| 112 | console.log(`Video generated: ${event.data.id}`); |
| 113 | break; |
| 114 | case 'interaction.completed': |
| 115 | console.log(`Interaction completed: ${event.data.id}`); |
| 116 | break; |
| 117 | case 'interaction.requires_action': |
| 118 | console.log(`Interaction requires action: ${event.data.id}`); |
| 119 | break; |
| 120 | case 'interaction.failed': |
| 121 | console.log(`Interaction failed: ${event.dat |