The bentocal package on PyPI wraps every API endpoint in a method. It takes the region from your API key, retries rate limits and provider hiccups for you, raises errors with a stable code, and checks webhook signatures in one call.
- Python 3.11+, with a sync client and an asyncio client
- One dependency:
httpx - Responses are
TypedDicts generated from the API's OpenAPI document, checked under mypy strict
pip install bentocal works.Install
pip install bentocalOr with uv: uv add bentocal.
Create a client
The client reads BENTOCAL_API_KEY from the environment, or takes the key as its first argument. The key already says which region your application lives in, so there's no base URL to set. See Authentication and API keys.
from bentocal import BentoCal
client = BentoCal() # reads BENTOCAL_API_KEYCreate one client and reuse it: it holds a connection pool. Call client.close() when you're done, or use it as a context manager (with BentoCal() as client:).
Fields are keyword arguments with the API's own names (user_id, slot_duration_minutes), so the API reference applies as written. Responses are dicts with the same keys as the API's JSON.
asyncio
AsyncBentoCal has the same methods; await them:
from bentocal import AsyncBentoCal
async with AsyncBentoCal() as client:
user = await client.users.get("user_123")Outside async with, close it with await client.aclose().
Connect a calendar
Create a connect session for one of your users and send them to its url:
session = client.connect_sessions.create(
user_id="user_123", # your own ID for the user
redirect_uri="https://app.example.com/calendar/connected",
)
# Redirect your user to session["url"]They sign in to Google or Microsoft and come back to your redirect_uri with connect_session_id and status in the query string. Read the session to get the connection:
# GET /calendar/connected?connect_session_id=cs_...&status=success
session = client.connect_sessions.get(connect_session_id)
if session["status"] == "completed":
print("Connected", session["connection"]["email"])To relink a connection whose token stopped working, pass its connection_id when you create the session. See Connecting calendars.
Find a time
start and end take a timezone-aware datetime or an ISO 8601 string. A naive datetime (no tzinfo) is rejected, because it doesn't say which moment it means.
from datetime import datetime
from zoneinfo import ZoneInfo
sydney = ZoneInfo("Australia/Sydney")
result = client.availability.query(
participants=[{"user_id": "user_123"}, {"user_id": "user_456"}],
periods=[{"start": datetime(2026, 11, 2, 9, tzinfo=sydney), "end": datetime(2026, 11, 2, 17, tzinfo=sydney)}],
slot_duration_minutes=30,
)
for slot in result["slots"]:
print(slot["start"], slot["user_ids"])For raw busy times instead of slots:
busy = client.free_busy.query(
user_ids=["user_123"],
start=datetime(2026, 11, 2, tzinfo=sydney),
end=datetime(2026, 11, 3, tzinfo=sydney),
)See Free/busy and availability.
Book it
Events are keyed by your own ID, so the same call creates the event and later updates it, and a retry never makes a duplicate:
slot = result["slots"][0]
client.events.upsert(
"user_123",
"booking_789",
summary="Intro call",
start=slot["start"],
end=slot["end"],
time_zone="Australia/Sydney",
)
client.events.delete("user_123", "booking_789")See Writing events for every field.
Verify webhooks
Pass the raw request body, before any JSON parsing, plus the request headers. The client reads the signing secret from BENTOCAL_WEBHOOK_SECRET, or you can pass secret=. verify raises WebhookVerificationError if the signature is missing, wrong or more than five minutes old. During a secret rotation both the old and the new secret are accepted.
from bentocal import WebhookVerificationError
@app.post("/webhooks/bentocal") # Flask
def bentocal_webhook():
try:
event = client.webhooks.verify(request.get_data(), request.headers)
except WebhookVerificationError:
return "", 400
if event["type"] == "connection.relink_required":
ask_user_to_reconnect(event["data"]["user_id"], event["data"]["connection_id"])
elif event["type"] == "calendar.changed":
resync(event["data"]["user_id"], event["data"]["calendar_id"])
return "", 204The raw body and headers in other frameworks:
| Framework | Body | Headers |
|---|---|---|
| Flask | request.get_data() | request.headers |
| Django | request.body | request.headers |
| FastAPI / Starlette | await request.body() | request.headers |
Deliveries are retried, so use event["id"] to skip ones you've already handled. See Webhooks for the event types.
bentocal.sign_webhook(body, "whsec_test") and send it in the BentoCal-Signature header.Errors
API errors raise BentoCalApiError. Branch on its code, which is stable; the message is for people.
from bentocal import BentoCalApiError
try:
client.events.upsert(user_id, booking_id, summary="Intro call", start=start, end=end)
except BentoCalApiError as err:
if err.code == "relink_required":
... # Send the user through a new connect session with connection_id
raiseBentoCalApiError has status, code, message, request_id (the same ID as in the console's logs; quote it to support), details (validation issues) and retry_after. Network failures and timeouts raise BentoCalConnectionError. Both extend BentoCalError. See Errors for every code.
Retries and timeouts
Network errors, 429 and 502–504 are retried twice with exponential backoff. When the API sends Retry-After, the SDK waits that long, up to 60 seconds; a longer wait raises instead. Each attempt times out after 30 seconds. See Limits and rate limits.
client = BentoCal(max_retries=4, timeout=10) # secondsConfiguration
| Argument | Environment variable | Default |
|---|---|---|
api_key | BENTOCAL_API_KEY | Required |
webhook_secret | BENTOCAL_WEBHOOK_SECRET | None |
base_url | BENTOCAL_BASE_URL | https://api.bentocal.com/{region}/v1, region from the key |
timeout | 30 seconds | |
max_retries | 2 | |
http_client | A new httpx.Client / httpx.AsyncClient |
All methods
| Method | Endpoint |
|---|---|
connect_sessions.create(user_id=, redirect_uri=, provider=, connection_id=) | POST /connect_sessions |
connect_sessions.get(id) | GET /connect_sessions/{id} |
users.get(user_id) | GET /users/{user_id} |
users.update(user_id, default_connection_id=) | PATCH /users/{user_id} |
users.delete(user_id) | DELETE /users/{user_id} |
connections.get(id) | GET /connections/{id} |
connections.delete(id) | DELETE /connections/{id} |
connections.list_calendars(id) | GET /connections/{id}/calendars |
free_busy.query(user_ids=, start=, end=, calendar_ids=) | POST /free_busy |
availability.query(participants=, periods=, slot_duration_minutes=, ...) | POST /availability |
events.upsert(user_id, event_id, summary=, start=, end=, ...) | PUT /users/{user_id}/events/{event_id} |
events.delete(user_id, event_id) | DELETE /users/{user_id}/events/{event_id} |
webhooks.verify(payload, headers, secret=) | Checks a webhook locally |
The package is MIT licensed.