Webhooks tell your server when something changes, so you don't have to poll: a user connected a calendar, a connection stopped working, or events changed in someone's calendar.
Set up an endpoint
- Open your application in the console and go to the Webhooks tab.
- Under Endpoint, enter your Endpoint URL, tick the Events you want and click Save endpoint.
- A signing secret is generated the first time you save an endpoint. Under Signing secret, click Reveal and store it with your other secrets.
- Click Send test webhook and check it appears under Recent deliveries.
Endpoint URLs must use https. Development applications may also use http://localhost and http://127.0.0.1. Private, link-local and cloud metadata addresses are rejected, and redirects are not followed. Saving an empty URL turns webhooks off.
Events
| Type | Sent when |
|---|---|
connection.created | A user connected a calendar account for the first time. |
connection.relinked | A user reconnected an account: after a relink session, by connecting the same account again, or by bringing back a revoked connection. |
connection.relink_required | Google or Microsoft stopped accepting the connection's access. Ask the user to relink. |
connection.revoked | A connection was revoked: through the API, by deleting its user or the application, or from the console. |
calendar.changed | Events changed in the primary calendar of a connection. |
Payloads
Every event has the same envelope:
{
"id": "evt_2Lq8Xv4Nw7Tz1KpR",
"type": "connection.created",
"created_at": "2026-10-15T03:12:47Z",
"application_id": "app_7Yc2Mf9Qa1Hs4BxD",
"data": {
"user_id": "user_123",
"connection_id": "conn_4kT9pQ2vXa7LmN3s",
"provider": "google",
"email": "sam@example.com",
"status": "active"
}
}The four connection.* events carry the same data: user_id, connection_id, provider, email and the connection's new status.
calendar.changed says which calendar changed, not what changed:
{
"id": "evt_9Fd3Rk6Bm2Yw8QsL",
"type": "calendar.changed",
"created_at": "2026-10-15T03:20:31Z",
"application_id": "app_7Yc2Mf9Qa1Hs4BxD",
"data": {
"user_id": "user_123",
"connection_id": "conn_4kT9pQ2vXa7LmN3s",
"calendar_id": "sam@example.com",
"provider": "google",
"changed_at": "2026-10-15T03:20:00Z"
}
}When you get one, call free/busy or availability again for that user. Changes are grouped over 30 seconds, so a burst of edits sends one webhook. Only each connection's primary calendar is watched.
The Send test webhook button sends type test with data: { "message": "Test webhook from BentoCal." }. Return 2xx for event types you don't handle, so new types never break your endpoint.
Verify the signature
Every delivery has a BentoCal-Signature header:
BentoCal-Signature: t=1760498567,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is the hex HMAC-SHA256 of {t}.{raw request body}, keyed with your full signing secret, including its whsec_ prefix. To verify a delivery:
- Read the raw request body before any JSON parsing. Re-serialised JSON won't match.
- Compute the HMAC and compare it, in constant time, with each
v1value. Accept the delivery if any matches. - Reject timestamps more than 5 minutes from your clock, so a captured request can't be replayed later.
Node.js
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 300;
export function verifyBentoCalSignature(rawBody, header, secret) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase());
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((sig) => crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}
const app = express();
app.post('/webhooks/bentocal', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyBentoCalSignature(req.body, req.get('BentoCal-Signature'), process.env.BENTOCAL_WEBHOOK_SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Skip event.id if you've already handled it, then queue the work and reply quickly.
res.sendStatus(200);
});Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
TOLERANCE_SECONDS = 300
def verify_bentocal_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and len(value) == 64:
signatures.append(value.lower())
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
app = Flask(__name__)
@app.post("/webhooks/bentocal")
def bentocal_webhook():
if not verify_bentocal_signature(request.get_data(), request.headers.get("BentoCal-Signature"), os.environ["BENTOCAL_WEBHOOK_SECRET"]):
return "Invalid signature", 400
event = json.loads(request.get_data())
# Skip event["id"] if you've already handled it, then queue the work and reply quickly.
return "", 200Rotate the signing secret
Click Rotate under Signing secret. For the next 24 hours every delivery carries two v1= values, one made with the new secret and one with the old, so deliveries keep verifying while you deploy the new secret. After 24 hours only the new secret is used. Only one previous secret is kept, so rotating again during the overlap ends the old one early.
Delivery and retries
- Deliveries are
POSTrequests with a JSON body and these headers:Content-Type: application/json,User-Agent: BentoCal-Webhooks/1.0,BentoCal-SignatureandBentoCal-Event-Id. - Reply with any
2xxstatus within 10 seconds. Do slow work after replying, for example on a queue. - Anything else (another status, a timeout, a redirect) is retried with exponential backoff, from 30 seconds up to an hour between attempts, for about 24 hours.
- A delivery can arrive more than once, and events can arrive out of order. De-duplicate on
BentoCal-Event-Id(the same as the body'sid; retries reuse it), and fetch current state from the API instead of relying on event order.
Delivery history
Recent deliveries on the Webhooks tab lists each delivery with its status (Succeeded, Failed or Pending), attempts and your endpoint's response. Click Resend to send a delivery again, for example after fixing a bug in your handler. Logs → Webhook deliveries shows deliveries across all your applications.