Metadata-Version: 2.5
Name: bentocal
Version: 0.1.0
Summary: The BentoCal API client for Python: connect Google and Microsoft calendars, find availability, write events, verify webhooks.
Project-URL: Homepage, https://bentocal.com
License-Expression: MIT
License-File: LICENSE
Keywords: availability,bentocal,calendar,free busy,google calendar,microsoft outlook,scheduling
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# BentoCal for Python

The official client for the [BentoCal](https://bentocal.com) API: connect your users' Google and Microsoft calendars, find when they're free, and write events, from one API.

- Sync and asyncio clients
- Fully typed: responses are `TypedDict`s generated from the API's OpenAPI document
- Retries rate limits and provider hiccups for you
- Webhook signature verification built in
- One dependency (`httpx`), Python 3.11+

```sh
pip install bentocal
```

## Quickstart

```python
from bentocal import BentoCal

client = BentoCal()  # reads BENTOCAL_API_KEY
```

Your API key is in the console under **Application → API keys**. It already says which region your application lives in, so there's nothing else to configure.

### 1. Connect a calendar

Create a connect session for one of your users and send them to its `url`:

```python
session = client.connect_sessions.create(
    user_id="user_123",  # your own id for the user
    redirect_uri="https://app.example.com/calendar/connected",
)
return redirect(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:

```python
# GET /calendar/connected?connect_session_id=cs_...&status=success
session = client.connect_sessions.get(request.args["connect_session_id"])
if session["status"] == "completed":
    print("Connected", session["connection"]["email"])
```

### 2. Find a time

```python
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"])
```

`start` and `end` take a timezone-aware `datetime` or an ISO 8601 string. For raw busy blocks instead of slots, use `client.free_busy.query(user_ids=[...], start=..., end=...)`.

### 3. Book it

Events are keyed by your own id, so the same call creates and later updates the event, and a retry never makes a duplicate:

```python
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")
```

### 4. Handle webhooks

BentoCal tells you when a connection is created, needs relinking or is revoked, and when a calendar changes. Verify the signature against the **raw** request body:

```python
from bentocal import WebhookVerificationError

# Flask
@app.post("/webhooks/bentocal")
def bentocal_webhook():
    try:
        event = client.webhooks.verify(request.get_data(), request.headers)  # secret from BENTOCAL_WEBHOOK_SECRET
    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 "", 204
```

In Django pass `request.body` and `request.headers`; in FastAPI, `await request.body()` and `request.headers`. Verification fails if the signature is missing, wrong or older than five minutes. Deliveries are retried, so use `event["id"]` to skip ones you've already handled. During a signing-secret rotation both the old and new secret are accepted.

To test your handler, sign your own payload with `bentocal.sign_webhook(body, "whsec_test")` and send it as the `BentoCal-Signature` header.

## Async

```python
from bentocal import AsyncBentoCal

async with AsyncBentoCal() as client:
    user = await client.users.get("user_123")
```

Every method is the same, awaited.

## All methods

| Method | API |
|---|---|
| `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 |

Arguments and response keys use the API's names, so the [API reference](https://api.bentocal.com/openapi.json) applies as written.

## Errors

API errors raise `BentoCalApiError`. Branch on `code`, which is stable; the message is for people.

```python
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":
        # The user revoked access or changed their password: send them through a new connect
        # session with connection_id to relink.
        ...
    raise
```

`BentoCalApiError` has `status`, `code`, `message`, `request_id` (quote it to support), `details` (validation issues) and `retry_after`. Network failures and timeouts raise `BentoCalConnectionError`. Both extend `BentoCalError`.

## Retries and timeouts

Network errors, `429` and `502`–`504` are retried twice with exponential backoff, waiting as long as the API's `Retry-After` asks (up to 60 seconds; longer waits raise instead). Each attempt times out after 30 seconds.

```python
client = BentoCal(max_retries=4, timeout=10)
```

## Configuration

| 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` |

## License

MIT
