Metadata-Version: 2.1
Name: benvo
Version: 1.0.0
Summary: Official Python library for the Benvo API
Author: Coviola Group LLC
Author-email: Coviola Group LLC <support@benvo.io>
License: MIT
Project-URL: Homepage, https://developers.benvo.io
Project-URL: Documentation, https://developers.benvo.io
Keywords: benvo,api,sdk,hospitality,restaurants,members clubs,reservations,webhooks
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# Benvo Python library

The official Python library for the [Benvo API](https://developers.benvo.io). Typed resources, automatic pagination, idempotent writes, retries with backoff, webhook verification and request signing, using only the Python standard library.

- Python 3.9 or later, no dependencies
- Type hints included (`py.typed`)
- Full reference: [developers.benvo.io](https://developers.benvo.io)

## Install

```bash
pip install benvo
```

## Quickstart

```python
import os
from benvo import Benvo

client = Benvo(os.environ["BENVO_API_KEY"])

ping = client.ping()
print(ping["account"]["name"], ping["key"]["scopes"])

person = client.people.create(first_name="Sophie", last_name="Tan", email="sophie.tan@example.com")
print(person["id"], person.last_response["status"])
```

Objects come back as dictionaries (typed with `TypedDict`), so `person["id"]` works and they serialise with `json.dumps`. Each one also has `last_response` with `status`, `request_id`, `idempotent_replayed` and `headers`; `benvo.response_of(obj)` reads it with a type hint.

Keep the key on your server. The API refuses requests from browsers.

### Options

```python
client = Benvo(
    api_key,
    base_url="https://api.benvo.io",
    timeout=30,
    max_retries=2,
    signing_secret=os.environ.get("BENVO_SIGNING_SECRET"),
)
```

Every method also takes `request_options={"timeout": 5, "max_retries": 0, "headers": {...}}`, and writes take `idempotency_key`.

## Resources

| Resource | Methods |
|---|---|
| `client.ping()` | Check the key, its account and scopes |
| `client.people` | `list`, `retrieve`, `create`, `update` |
| `client.members` | `list`, `retrieve` |
| `client.membership_tiers` | `list` |
| `client.leads` | `list`, `create` |
| `client.reservations` | `list`, `retrieve`, `create`, `update`, `cancel` |
| `client.availability` | `check` |
| `client.events` | `list`, `retrieve` |
| `client.rsvps` | `list(event_id)`, `create(event_id, ...)`, `update(event_id, rsvp_id, ...)` |
| `client.orders` | `list`, `retrieve` |
| `client.invoices` | `list`, `retrieve` |
| `client.payments` | `list`, `retrieve` |
| `client.menus` | `list`, `retrieve` |
| `client.products` | `list` |
| `client.meta` | `ip_ranges` (no key needed) |

Writes take the body as keyword arguments, as a dictionary, or both:

```python
availability = client.availability.check(date="2026-11-12", party_size=4)
slot = next((s for s in availability["slots"] if s["available"]), None)

if slot:
    reservation = client.reservations.create(
        date=slot["date"],
        time=slot["time"],
        party_size=4,
        guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"},
    )
    client.reservations.update(reservation["id"], party_size=5)

client.rsvps.create("evt_8Hk2QmZ4", {"member_id": "mem_3fJ9xL2p", "guests": [{"name": "Rachel Lim"}]})
```

On `update`, a value of `None` is sent as `null` and clears the field.

Ids are opaque strings with a prefix per object (`per_`, `mem_`, `res_` and so on). Store them as strings and never parse them. Money is always `{"amount": 24860, "currency": "SGD"}` with `amount` in minor units. Every object carries `livemode`.

Contact details and sensitive fields are only present when the key has the matching scope (`people:read_contact`, `people:read_sensitive`, `orders:read_spend`), so read them with `.get()`.

## Pagination

`list()` returns an iterator that fetches the next page with `starting_after` until `has_more` is false.

```python
for reservation in client.reservations.list(date_from="2026-11-01", date_to="2026-11-30", limit=100):
    print(reservation["id"], reservation["status"])
```

One page at a time:

```python
page = client.people.list(limit=25).page()
print(len(page["data"]), page["has_more"], page["next_cursor"])

following = client.people.list(limit=25, starting_after=page["next_cursor"]).page()
```

Also available: `.pages()` and `.to_list(max_items)`. Timestamp filters such as `updated_since` accept a `datetime` (time zone aware values are sent in UTC) or an ISO 8601 string; date filters accept a `date`.

## Idempotency

Every create, update, cancel and RSVP write sends an `Idempotency-Key`. When you do not pass one, the library generates a uuid v4 and keeps the same key across its own retries, so a retried write never runs twice.

Pass your own key to make a write safe across restarts of your process:

```python
client.leads.create(
    email="marcus.wong@example.com",
    message="Dinner for 30 in December",
    idempotency_key=f"enquiry-{form_submission_id}",
)
```

A replayed result has `last_response["idempotent_replayed"] == True`. Reusing a key with a different body fails with `idempotency_key_reused`.

## Errors

Every API error is a `BenvoError` subclass with `status`, `type`, `code`, `param`, `request_id`, `headers` and `raw`.

| Exception | When |
|---|---|
| `BenvoAuthenticationError` | 401: missing, invalid, expired or revoked key |
| `BenvoPermissionError` | 403: missing scope, module not enabled, account inactive, consent required |
| `BenvoNotFoundError` | 404: no such record in this account |
| `BenvoInvalidRequestError` | 400, 409, 413, 422: invalid parameters or a conflict such as `slot_unavailable` |
| `BenvoRateLimitError` | 429 after the retries; `retry_after` holds the seconds to wait |
| `BenvoApiError` | 5xx after the retries, or a response that is not JSON |
| `BenvoConnectionError` | The API could not be reached; `timed_out` is true for timeouts |

```python
from benvo import BenvoInvalidRequestError

try:
    client.reservations.create(date="2026-11-12", time="19:00", party_size=4, guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"})
except BenvoInvalidRequestError as error:
    if error.code != "slot_unavailable":
        raise
    print("That time was just taken.")
```

Quote `request_id` when you contact Benvo support.

## Retries

The library retries up to `max_retries` times (default 2) on:

- 429 rate limits
- 409 `idempotency_in_progress`
- 5xx responses
- network errors and timeouts

It waits for `Retry-After` when the API sends it (up to 60 seconds) and otherwise backs off exponentially from 0.5 seconds up to 8 seconds with jitter. Set `max_retries=0` to turn retries off.

## Webhooks

Benvo signs every webhook with [Standard Webhooks](https://www.standardwebhooks.com) headers. Verify the raw body, before any JSON parsing:

```python
import os
from flask import Flask, request
from benvo import BenvoWebhookVerificationError, webhooks

app = Flask(__name__)

@app.post("/benvo/webhooks")
def benvo_webhook():
    try:
        event = webhooks.verify(request.get_data(), request.headers, os.environ["BENVO_WEBHOOK_SECRET"])
    except BenvoWebhookVerificationError:
        return "", 400

    if event["type"] == "reservation.created":
        print("New booking", event["data"]["object"]["id"])
    return "", 204
```

`webhooks.verify(payload, headers, secret, tolerance=300)` checks the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, refuses timestamps more than `tolerance` seconds away from now, compares signatures in constant time and returns the parsed event. `headers` can be any mapping (header names in any case) or a list of pairs. Store `event["id"]` and skip ids you have already processed.

Endpoints set to thin payloads receive only `id` and `object` in `data.object`; fetch the full object with your key.

For your own tests, `webhooks.sign(body, secret, msg_id, timestamp=None)` returns signed headers.

## Request signing

Keys marked to require signatures must sign every request. Pass the key's signing secret and the library adds the header:

```python
client = Benvo(api_key, signing_secret=os.environ["BENVO_SIGNING_SECRET"])
```

```
Benvo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.METHOD.path.body">
```

`path` is the path with its query string exactly as sent (for example `/v1/people?limit=25`) and `body` is the exact JSON sent, or empty for GET. A new signature is made for each retry. Requests older than 5 minutes are refused, so keep your server clock in sync. To sign by hand, use `benvo.sign_request(secret, method, path, body, timestamp=None)`.

## Test keys and live keys

| Key | Works on | `livemode` |
|---|---|---|
| `bvk_test_...` | Your sandbox account only, with fake data. Nothing reaches real guests | `False` |
| `bvk_live_...` | The venue account it was issued for, after the venue owner approves it | `True` |

The same code runs against both. `client.livemode` tells you which kind of key the client holds, and every object and webhook event carries `livemode`. Build and test with a test key, then swap in the live key.

## Development

Run from `packages/sdk-python`:

```bash
python3 -m unittest discover -s tests          # offline test suite
python3 scripts/build_dist.py                  # dist/benvo-1.0.0.tar.gz and dist/benvo-1.0.0-py3-none-any.whl, standard library only
python3 -m build                               # the same archives with the official builder, when the build package is installed
```

## Licence

MIT. Copyright Coviola Group LLC.
