benvo 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benvo-1.0.0/LICENSE +21 -0
- benvo-1.0.0/PKG-INFO +257 -0
- benvo-1.0.0/README.md +230 -0
- benvo-1.0.0/pyproject.toml +40 -0
- benvo-1.0.0/src/benvo/__init__.py +43 -0
- benvo-1.0.0/src/benvo/_client.py +80 -0
- benvo-1.0.0/src/benvo/_endpoints.py +59 -0
- benvo-1.0.0/src/benvo/_pagination.py +39 -0
- benvo-1.0.0/src/benvo/_resources.py +297 -0
- benvo-1.0.0/src/benvo/_transport.py +224 -0
- benvo-1.0.0/src/benvo/_version.py +1 -0
- benvo-1.0.0/src/benvo/errors.py +110 -0
- benvo-1.0.0/src/benvo/py.typed +0 -0
- benvo-1.0.0/src/benvo/signing.py +16 -0
- benvo-1.0.0/src/benvo/types.py +564 -0
- benvo-1.0.0/src/benvo/webhooks.py +108 -0
- benvo-1.0.0/tests/test_sdk.py +483 -0
benvo-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coviola Group LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
benvo-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: benvo
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python library for the Benvo API
|
|
5
|
+
Author: Coviola Group LLC
|
|
6
|
+
Author-email: Coviola Group LLC <support@benvo.io>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://developers.benvo.io
|
|
9
|
+
Project-URL: Documentation, https://developers.benvo.io
|
|
10
|
+
Keywords: benvo,api,sdk,hospitality,restaurants,members clubs,reservations,webhooks
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Benvo Python library
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
- Python 3.9 or later, no dependencies
|
|
33
|
+
- Type hints included (`py.typed`)
|
|
34
|
+
- Full reference: [developers.benvo.io](https://developers.benvo.io)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install benvo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
from benvo import Benvo
|
|
47
|
+
|
|
48
|
+
client = Benvo(os.environ["BENVO_API_KEY"])
|
|
49
|
+
|
|
50
|
+
ping = client.ping()
|
|
51
|
+
print(ping["account"]["name"], ping["key"]["scopes"])
|
|
52
|
+
|
|
53
|
+
person = client.people.create(first_name="Sophie", last_name="Tan", email="sophie.tan@example.com")
|
|
54
|
+
print(person["id"], person.last_response["status"])
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
Keep the key on your server. The API refuses requests from browsers.
|
|
60
|
+
|
|
61
|
+
### Options
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
client = Benvo(
|
|
65
|
+
api_key,
|
|
66
|
+
base_url="https://api.benvo.io",
|
|
67
|
+
timeout=30,
|
|
68
|
+
max_retries=2,
|
|
69
|
+
signing_secret=os.environ.get("BENVO_SIGNING_SECRET"),
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every method also takes `request_options={"timeout": 5, "max_retries": 0, "headers": {...}}`, and writes take `idempotency_key`.
|
|
74
|
+
|
|
75
|
+
## Resources
|
|
76
|
+
|
|
77
|
+
| Resource | Methods |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `client.ping()` | Check the key, its account and scopes |
|
|
80
|
+
| `client.people` | `list`, `retrieve`, `create`, `update` |
|
|
81
|
+
| `client.members` | `list`, `retrieve` |
|
|
82
|
+
| `client.membership_tiers` | `list` |
|
|
83
|
+
| `client.leads` | `list`, `create` |
|
|
84
|
+
| `client.reservations` | `list`, `retrieve`, `create`, `update`, `cancel` |
|
|
85
|
+
| `client.availability` | `check` |
|
|
86
|
+
| `client.events` | `list`, `retrieve` |
|
|
87
|
+
| `client.rsvps` | `list(event_id)`, `create(event_id, ...)`, `update(event_id, rsvp_id, ...)` |
|
|
88
|
+
| `client.orders` | `list`, `retrieve` |
|
|
89
|
+
| `client.invoices` | `list`, `retrieve` |
|
|
90
|
+
| `client.payments` | `list`, `retrieve` |
|
|
91
|
+
| `client.menus` | `list`, `retrieve` |
|
|
92
|
+
| `client.products` | `list` |
|
|
93
|
+
| `client.meta` | `ip_ranges` (no key needed) |
|
|
94
|
+
|
|
95
|
+
Writes take the body as keyword arguments, as a dictionary, or both:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
availability = client.availability.check(date="2026-11-12", party_size=4)
|
|
99
|
+
slot = next((s for s in availability["slots"] if s["available"]), None)
|
|
100
|
+
|
|
101
|
+
if slot:
|
|
102
|
+
reservation = client.reservations.create(
|
|
103
|
+
date=slot["date"],
|
|
104
|
+
time=slot["time"],
|
|
105
|
+
party_size=4,
|
|
106
|
+
guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"},
|
|
107
|
+
)
|
|
108
|
+
client.reservations.update(reservation["id"], party_size=5)
|
|
109
|
+
|
|
110
|
+
client.rsvps.create("evt_8Hk2QmZ4", {"member_id": "mem_3fJ9xL2p", "guests": [{"name": "Rachel Lim"}]})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
On `update`, a value of `None` is sent as `null` and clears the field.
|
|
114
|
+
|
|
115
|
+
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`.
|
|
116
|
+
|
|
117
|
+
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()`.
|
|
118
|
+
|
|
119
|
+
## Pagination
|
|
120
|
+
|
|
121
|
+
`list()` returns an iterator that fetches the next page with `starting_after` until `has_more` is false.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
for reservation in client.reservations.list(date_from="2026-11-01", date_to="2026-11-30", limit=100):
|
|
125
|
+
print(reservation["id"], reservation["status"])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
One page at a time:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
page = client.people.list(limit=25).page()
|
|
132
|
+
print(len(page["data"]), page["has_more"], page["next_cursor"])
|
|
133
|
+
|
|
134
|
+
following = client.people.list(limit=25, starting_after=page["next_cursor"]).page()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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`.
|
|
138
|
+
|
|
139
|
+
## Idempotency
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
Pass your own key to make a write safe across restarts of your process:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
client.leads.create(
|
|
147
|
+
email="marcus.wong@example.com",
|
|
148
|
+
message="Dinner for 30 in December",
|
|
149
|
+
idempotency_key=f"enquiry-{form_submission_id}",
|
|
150
|
+
)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A replayed result has `last_response["idempotent_replayed"] == True`. Reusing a key with a different body fails with `idempotency_key_reused`.
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
Every API error is a `BenvoError` subclass with `status`, `type`, `code`, `param`, `request_id`, `headers` and `raw`.
|
|
158
|
+
|
|
159
|
+
| Exception | When |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `BenvoAuthenticationError` | 401: missing, invalid, expired or revoked key |
|
|
162
|
+
| `BenvoPermissionError` | 403: missing scope, module not enabled, account inactive, consent required |
|
|
163
|
+
| `BenvoNotFoundError` | 404: no such record in this account |
|
|
164
|
+
| `BenvoInvalidRequestError` | 400, 409, 413, 422: invalid parameters or a conflict such as `slot_unavailable` |
|
|
165
|
+
| `BenvoRateLimitError` | 429 after the retries; `retry_after` holds the seconds to wait |
|
|
166
|
+
| `BenvoApiError` | 5xx after the retries, or a response that is not JSON |
|
|
167
|
+
| `BenvoConnectionError` | The API could not be reached; `timed_out` is true for timeouts |
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from benvo import BenvoInvalidRequestError
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
client.reservations.create(date="2026-11-12", time="19:00", party_size=4, guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"})
|
|
174
|
+
except BenvoInvalidRequestError as error:
|
|
175
|
+
if error.code != "slot_unavailable":
|
|
176
|
+
raise
|
|
177
|
+
print("That time was just taken.")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Quote `request_id` when you contact Benvo support.
|
|
181
|
+
|
|
182
|
+
## Retries
|
|
183
|
+
|
|
184
|
+
The library retries up to `max_retries` times (default 2) on:
|
|
185
|
+
|
|
186
|
+
- 429 rate limits
|
|
187
|
+
- 409 `idempotency_in_progress`
|
|
188
|
+
- 5xx responses
|
|
189
|
+
- network errors and timeouts
|
|
190
|
+
|
|
191
|
+
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.
|
|
192
|
+
|
|
193
|
+
## Webhooks
|
|
194
|
+
|
|
195
|
+
Benvo signs every webhook with [Standard Webhooks](https://www.standardwebhooks.com) headers. Verify the raw body, before any JSON parsing:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
import os
|
|
199
|
+
from flask import Flask, request
|
|
200
|
+
from benvo import BenvoWebhookVerificationError, webhooks
|
|
201
|
+
|
|
202
|
+
app = Flask(__name__)
|
|
203
|
+
|
|
204
|
+
@app.post("/benvo/webhooks")
|
|
205
|
+
def benvo_webhook():
|
|
206
|
+
try:
|
|
207
|
+
event = webhooks.verify(request.get_data(), request.headers, os.environ["BENVO_WEBHOOK_SECRET"])
|
|
208
|
+
except BenvoWebhookVerificationError:
|
|
209
|
+
return "", 400
|
|
210
|
+
|
|
211
|
+
if event["type"] == "reservation.created":
|
|
212
|
+
print("New booking", event["data"]["object"]["id"])
|
|
213
|
+
return "", 204
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`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.
|
|
217
|
+
|
|
218
|
+
Endpoints set to thin payloads receive only `id` and `object` in `data.object`; fetch the full object with your key.
|
|
219
|
+
|
|
220
|
+
For your own tests, `webhooks.sign(body, secret, msg_id, timestamp=None)` returns signed headers.
|
|
221
|
+
|
|
222
|
+
## Request signing
|
|
223
|
+
|
|
224
|
+
Keys marked to require signatures must sign every request. Pass the key's signing secret and the library adds the header:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
client = Benvo(api_key, signing_secret=os.environ["BENVO_SIGNING_SECRET"])
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Benvo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.METHOD.path.body">
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`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)`.
|
|
235
|
+
|
|
236
|
+
## Test keys and live keys
|
|
237
|
+
|
|
238
|
+
| Key | Works on | `livemode` |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `bvk_test_...` | Your sandbox account only, with fake data. Nothing reaches real guests | `False` |
|
|
241
|
+
| `bvk_live_...` | The venue account it was issued for, after the venue owner approves it | `True` |
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
Run from `packages/sdk-python`:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
python3 -m unittest discover -s tests # offline test suite
|
|
251
|
+
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
|
|
252
|
+
python3 -m build # the same archives with the official builder, when the build package is installed
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Licence
|
|
256
|
+
|
|
257
|
+
MIT. Copyright Coviola Group LLC.
|
benvo-1.0.0/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Benvo Python library
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- Python 3.9 or later, no dependencies
|
|
6
|
+
- Type hints included (`py.typed`)
|
|
7
|
+
- Full reference: [developers.benvo.io](https://developers.benvo.io)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install benvo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from benvo import Benvo
|
|
20
|
+
|
|
21
|
+
client = Benvo(os.environ["BENVO_API_KEY"])
|
|
22
|
+
|
|
23
|
+
ping = client.ping()
|
|
24
|
+
print(ping["account"]["name"], ping["key"]["scopes"])
|
|
25
|
+
|
|
26
|
+
person = client.people.create(first_name="Sophie", last_name="Tan", email="sophie.tan@example.com")
|
|
27
|
+
print(person["id"], person.last_response["status"])
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
Keep the key on your server. The API refuses requests from browsers.
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
client = Benvo(
|
|
38
|
+
api_key,
|
|
39
|
+
base_url="https://api.benvo.io",
|
|
40
|
+
timeout=30,
|
|
41
|
+
max_retries=2,
|
|
42
|
+
signing_secret=os.environ.get("BENVO_SIGNING_SECRET"),
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Every method also takes `request_options={"timeout": 5, "max_retries": 0, "headers": {...}}`, and writes take `idempotency_key`.
|
|
47
|
+
|
|
48
|
+
## Resources
|
|
49
|
+
|
|
50
|
+
| Resource | Methods |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `client.ping()` | Check the key, its account and scopes |
|
|
53
|
+
| `client.people` | `list`, `retrieve`, `create`, `update` |
|
|
54
|
+
| `client.members` | `list`, `retrieve` |
|
|
55
|
+
| `client.membership_tiers` | `list` |
|
|
56
|
+
| `client.leads` | `list`, `create` |
|
|
57
|
+
| `client.reservations` | `list`, `retrieve`, `create`, `update`, `cancel` |
|
|
58
|
+
| `client.availability` | `check` |
|
|
59
|
+
| `client.events` | `list`, `retrieve` |
|
|
60
|
+
| `client.rsvps` | `list(event_id)`, `create(event_id, ...)`, `update(event_id, rsvp_id, ...)` |
|
|
61
|
+
| `client.orders` | `list`, `retrieve` |
|
|
62
|
+
| `client.invoices` | `list`, `retrieve` |
|
|
63
|
+
| `client.payments` | `list`, `retrieve` |
|
|
64
|
+
| `client.menus` | `list`, `retrieve` |
|
|
65
|
+
| `client.products` | `list` |
|
|
66
|
+
| `client.meta` | `ip_ranges` (no key needed) |
|
|
67
|
+
|
|
68
|
+
Writes take the body as keyword arguments, as a dictionary, or both:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
availability = client.availability.check(date="2026-11-12", party_size=4)
|
|
72
|
+
slot = next((s for s in availability["slots"] if s["available"]), None)
|
|
73
|
+
|
|
74
|
+
if slot:
|
|
75
|
+
reservation = client.reservations.create(
|
|
76
|
+
date=slot["date"],
|
|
77
|
+
time=slot["time"],
|
|
78
|
+
party_size=4,
|
|
79
|
+
guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"},
|
|
80
|
+
)
|
|
81
|
+
client.reservations.update(reservation["id"], party_size=5)
|
|
82
|
+
|
|
83
|
+
client.rsvps.create("evt_8Hk2QmZ4", {"member_id": "mem_3fJ9xL2p", "guests": [{"name": "Rachel Lim"}]})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
On `update`, a value of `None` is sent as `null` and clears the field.
|
|
87
|
+
|
|
88
|
+
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`.
|
|
89
|
+
|
|
90
|
+
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()`.
|
|
91
|
+
|
|
92
|
+
## Pagination
|
|
93
|
+
|
|
94
|
+
`list()` returns an iterator that fetches the next page with `starting_after` until `has_more` is false.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
for reservation in client.reservations.list(date_from="2026-11-01", date_to="2026-11-30", limit=100):
|
|
98
|
+
print(reservation["id"], reservation["status"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
One page at a time:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
page = client.people.list(limit=25).page()
|
|
105
|
+
print(len(page["data"]), page["has_more"], page["next_cursor"])
|
|
106
|
+
|
|
107
|
+
following = client.people.list(limit=25, starting_after=page["next_cursor"]).page()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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`.
|
|
111
|
+
|
|
112
|
+
## Idempotency
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
Pass your own key to make a write safe across restarts of your process:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
client.leads.create(
|
|
120
|
+
email="marcus.wong@example.com",
|
|
121
|
+
message="Dinner for 30 in December",
|
|
122
|
+
idempotency_key=f"enquiry-{form_submission_id}",
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A replayed result has `last_response["idempotent_replayed"] == True`. Reusing a key with a different body fails with `idempotency_key_reused`.
|
|
127
|
+
|
|
128
|
+
## Errors
|
|
129
|
+
|
|
130
|
+
Every API error is a `BenvoError` subclass with `status`, `type`, `code`, `param`, `request_id`, `headers` and `raw`.
|
|
131
|
+
|
|
132
|
+
| Exception | When |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `BenvoAuthenticationError` | 401: missing, invalid, expired or revoked key |
|
|
135
|
+
| `BenvoPermissionError` | 403: missing scope, module not enabled, account inactive, consent required |
|
|
136
|
+
| `BenvoNotFoundError` | 404: no such record in this account |
|
|
137
|
+
| `BenvoInvalidRequestError` | 400, 409, 413, 422: invalid parameters or a conflict such as `slot_unavailable` |
|
|
138
|
+
| `BenvoRateLimitError` | 429 after the retries; `retry_after` holds the seconds to wait |
|
|
139
|
+
| `BenvoApiError` | 5xx after the retries, or a response that is not JSON |
|
|
140
|
+
| `BenvoConnectionError` | The API could not be reached; `timed_out` is true for timeouts |
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from benvo import BenvoInvalidRequestError
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
client.reservations.create(date="2026-11-12", time="19:00", party_size=4, guest={"name": "Sophie Tan", "email": "sophie.tan@example.com"})
|
|
147
|
+
except BenvoInvalidRequestError as error:
|
|
148
|
+
if error.code != "slot_unavailable":
|
|
149
|
+
raise
|
|
150
|
+
print("That time was just taken.")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Quote `request_id` when you contact Benvo support.
|
|
154
|
+
|
|
155
|
+
## Retries
|
|
156
|
+
|
|
157
|
+
The library retries up to `max_retries` times (default 2) on:
|
|
158
|
+
|
|
159
|
+
- 429 rate limits
|
|
160
|
+
- 409 `idempotency_in_progress`
|
|
161
|
+
- 5xx responses
|
|
162
|
+
- network errors and timeouts
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
## Webhooks
|
|
167
|
+
|
|
168
|
+
Benvo signs every webhook with [Standard Webhooks](https://www.standardwebhooks.com) headers. Verify the raw body, before any JSON parsing:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import os
|
|
172
|
+
from flask import Flask, request
|
|
173
|
+
from benvo import BenvoWebhookVerificationError, webhooks
|
|
174
|
+
|
|
175
|
+
app = Flask(__name__)
|
|
176
|
+
|
|
177
|
+
@app.post("/benvo/webhooks")
|
|
178
|
+
def benvo_webhook():
|
|
179
|
+
try:
|
|
180
|
+
event = webhooks.verify(request.get_data(), request.headers, os.environ["BENVO_WEBHOOK_SECRET"])
|
|
181
|
+
except BenvoWebhookVerificationError:
|
|
182
|
+
return "", 400
|
|
183
|
+
|
|
184
|
+
if event["type"] == "reservation.created":
|
|
185
|
+
print("New booking", event["data"]["object"]["id"])
|
|
186
|
+
return "", 204
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`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.
|
|
190
|
+
|
|
191
|
+
Endpoints set to thin payloads receive only `id` and `object` in `data.object`; fetch the full object with your key.
|
|
192
|
+
|
|
193
|
+
For your own tests, `webhooks.sign(body, secret, msg_id, timestamp=None)` returns signed headers.
|
|
194
|
+
|
|
195
|
+
## Request signing
|
|
196
|
+
|
|
197
|
+
Keys marked to require signatures must sign every request. Pass the key's signing secret and the library adds the header:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
client = Benvo(api_key, signing_secret=os.environ["BENVO_SIGNING_SECRET"])
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
Benvo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.METHOD.path.body">
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`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)`.
|
|
208
|
+
|
|
209
|
+
## Test keys and live keys
|
|
210
|
+
|
|
211
|
+
| Key | Works on | `livemode` |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `bvk_test_...` | Your sandbox account only, with fake data. Nothing reaches real guests | `False` |
|
|
214
|
+
| `bvk_live_...` | The venue account it was issued for, after the venue owner approves it | `True` |
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
218
|
+
## Development
|
|
219
|
+
|
|
220
|
+
Run from `packages/sdk-python`:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
python3 -m unittest discover -s tests # offline test suite
|
|
224
|
+
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
|
|
225
|
+
python3 -m build # the same archives with the official builder, when the build package is installed
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Licence
|
|
229
|
+
|
|
230
|
+
MIT. Copyright Coviola Group LLC.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "benvo"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python library for the Benvo API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Coviola Group LLC", email = "support@benvo.io" }]
|
|
13
|
+
keywords = ["benvo", "api", "sdk", "hospitality", "restaurants", "members clubs", "reservations", "webhooks"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
dependencies = []
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://developers.benvo.io"
|
|
34
|
+
Documentation = "https://developers.benvo.io"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
benvo = ["py.typed"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from . import webhooks
|
|
2
|
+
from ._client import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Benvo, response_of
|
|
3
|
+
from ._endpoints import ENDPOINTS, Endpoint
|
|
4
|
+
from ._pagination import ListIterator
|
|
5
|
+
from ._transport import BenvoObject
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .errors import (
|
|
8
|
+
BenvoApiError,
|
|
9
|
+
BenvoAuthenticationError,
|
|
10
|
+
BenvoConnectionError,
|
|
11
|
+
BenvoError,
|
|
12
|
+
BenvoInvalidRequestError,
|
|
13
|
+
BenvoNotFoundError,
|
|
14
|
+
BenvoPermissionError,
|
|
15
|
+
BenvoRateLimitError,
|
|
16
|
+
BenvoWebhookVerificationError,
|
|
17
|
+
)
|
|
18
|
+
from .signing import SIGNATURE_HEADER, sign_request
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_BASE_URL",
|
|
22
|
+
"DEFAULT_MAX_RETRIES",
|
|
23
|
+
"DEFAULT_TIMEOUT",
|
|
24
|
+
"ENDPOINTS",
|
|
25
|
+
"SIGNATURE_HEADER",
|
|
26
|
+
"Benvo",
|
|
27
|
+
"BenvoApiError",
|
|
28
|
+
"BenvoAuthenticationError",
|
|
29
|
+
"BenvoConnectionError",
|
|
30
|
+
"BenvoError",
|
|
31
|
+
"BenvoInvalidRequestError",
|
|
32
|
+
"BenvoNotFoundError",
|
|
33
|
+
"BenvoObject",
|
|
34
|
+
"BenvoPermissionError",
|
|
35
|
+
"BenvoRateLimitError",
|
|
36
|
+
"BenvoWebhookVerificationError",
|
|
37
|
+
"Endpoint",
|
|
38
|
+
"ListIterator",
|
|
39
|
+
"__version__",
|
|
40
|
+
"response_of",
|
|
41
|
+
"sign_request",
|
|
42
|
+
"webhooks",
|
|
43
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Dict, Mapping, Optional
|
|
3
|
+
|
|
4
|
+
from ._resources import (
|
|
5
|
+
AvailabilityResource,
|
|
6
|
+
Events,
|
|
7
|
+
Invoices,
|
|
8
|
+
Leads,
|
|
9
|
+
Members,
|
|
10
|
+
MembershipTiers,
|
|
11
|
+
Menus,
|
|
12
|
+
Meta,
|
|
13
|
+
Orders,
|
|
14
|
+
Payments,
|
|
15
|
+
People,
|
|
16
|
+
Products,
|
|
17
|
+
Reservations,
|
|
18
|
+
Rsvps,
|
|
19
|
+
)
|
|
20
|
+
from ._transport import Transport, default_user_agent
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .types import Ping
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.benvo.io"
|
|
25
|
+
DEFAULT_TIMEOUT = 30.0
|
|
26
|
+
DEFAULT_MAX_RETRIES = 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Benvo:
|
|
30
|
+
VERSION = __version__
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_key: str,
|
|
35
|
+
*,
|
|
36
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
37
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
38
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
39
|
+
signing_secret: Optional[str] = None,
|
|
40
|
+
user_agent: Optional[str] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
43
|
+
raise ValueError("A Benvo API key is required")
|
|
44
|
+
base = base_url.rstrip("/")
|
|
45
|
+
if not re.match(r"^https?://", base):
|
|
46
|
+
raise ValueError("base_url must start with https:// or http://")
|
|
47
|
+
self.base_url = base
|
|
48
|
+
self.livemode = not api_key.startswith("bvk_test_")
|
|
49
|
+
self._transport = Transport(
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
base_url=base,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
max_retries=max_retries,
|
|
54
|
+
signing_secret=signing_secret,
|
|
55
|
+
user_agent=user_agent or default_user_agent(),
|
|
56
|
+
)
|
|
57
|
+
self.people = People(self._transport)
|
|
58
|
+
self.members = Members(self._transport)
|
|
59
|
+
self.membership_tiers = MembershipTiers(self._transport)
|
|
60
|
+
self.leads = Leads(self._transport)
|
|
61
|
+
self.reservations = Reservations(self._transport)
|
|
62
|
+
self.availability = AvailabilityResource(self._transport)
|
|
63
|
+
self.events = Events(self._transport)
|
|
64
|
+
self.rsvps = Rsvps(self._transport)
|
|
65
|
+
self.orders = Orders(self._transport)
|
|
66
|
+
self.invoices = Invoices(self._transport)
|
|
67
|
+
self.payments = Payments(self._transport)
|
|
68
|
+
self.menus = Menus(self._transport)
|
|
69
|
+
self.products = Products(self._transport)
|
|
70
|
+
self.meta = Meta(self._transport)
|
|
71
|
+
|
|
72
|
+
def ping(self, *, request_options: Optional[Mapping[str, Any]] = None) -> Ping:
|
|
73
|
+
return self._transport.call("ping", **dict(request_options or {}))
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"Benvo(base_url={self.base_url!r}, livemode={self.livemode!r})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def response_of(obj: Any) -> Optional[Dict[str, Any]]:
|
|
80
|
+
return getattr(obj, "last_response", None)
|