zoplio 0.2.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.
- zoplio-0.2.0/PKG-INFO +121 -0
- zoplio-0.2.0/README.md +96 -0
- zoplio-0.2.0/pyproject.toml +43 -0
- zoplio-0.2.0/setup.cfg +4 -0
- zoplio-0.2.0/src/zoplio/__init__.py +30 -0
- zoplio-0.2.0/src/zoplio/client.py +261 -0
- zoplio-0.2.0/src/zoplio/py.typed +0 -0
- zoplio-0.2.0/src/zoplio/types.py +106 -0
- zoplio-0.2.0/src/zoplio.egg-info/PKG-INFO +121 -0
- zoplio-0.2.0/src/zoplio.egg-info/SOURCES.txt +12 -0
- zoplio-0.2.0/src/zoplio.egg-info/dependency_links.txt +1 -0
- zoplio-0.2.0/src/zoplio.egg-info/requires.txt +5 -0
- zoplio-0.2.0/src/zoplio.egg-info/top_level.txt +1 -0
- zoplio-0.2.0/tests/test_webhook_signature.py +35 -0
zoplio-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zoplio
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Zoplio Python SDK for the Zoplio API v1
|
|
5
|
+
Author-email: Zoplio <hello@zoplio.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Zoplio/zoplio-sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/Zoplio/zoplio-sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/Zoplio/zoplio-sdk/issues
|
|
10
|
+
Keywords: zoplio,scheduling,ai-agent,meetings,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# zoplio (Python SDK)
|
|
27
|
+
|
|
28
|
+
Official Zoplio Python SDK for the [Zoplio API v1](../../docs/quickstart.md). MIT licensed.
|
|
29
|
+
|
|
30
|
+
Zoplio schedules meetings for you: you say who and roughly when, Zoplio negotiates with every participant over WhatsApp/email and confirms a slot.
|
|
31
|
+
|
|
32
|
+
Requires Python >= 3.10. Depends on `httpx`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not yet published to PyPI — install from this monorepo:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e packages/sdk-python
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from zoplio import ZoplioClient, ZoplioError
|
|
46
|
+
|
|
47
|
+
zoplio = ZoplioClient(api_key="zpl_...") # base_url defaults to https://api.zoplio.com
|
|
48
|
+
|
|
49
|
+
# Create a meeting — Zoplio reaches out to participants and negotiates.
|
|
50
|
+
created = zoplio.schedule_meeting(
|
|
51
|
+
participants=[
|
|
52
|
+
{"email": "petr@example.com", "name": "Petr"},
|
|
53
|
+
{"phone": "+420777123456", "name": "Jana"},
|
|
54
|
+
],
|
|
55
|
+
title="Intro call",
|
|
56
|
+
duration_minutes=30,
|
|
57
|
+
preferred_date="2026-06-15",
|
|
58
|
+
preferred_time="14:00",
|
|
59
|
+
timezone="Europe/Prague",
|
|
60
|
+
idempotency_key="order-42-intro-call", # optional, safe retries
|
|
61
|
+
)
|
|
62
|
+
print(created["meetingId"], created["status"], created["proposedSlots"])
|
|
63
|
+
|
|
64
|
+
# Poll status (or use webhooks instead).
|
|
65
|
+
meeting = zoplio.get_meeting(created["meetingId"])
|
|
66
|
+
|
|
67
|
+
# List / reschedule / cancel.
|
|
68
|
+
zoplio.list_meetings(status="confirmed", limit=10)
|
|
69
|
+
zoplio.reschedule_meeting(
|
|
70
|
+
created["meetingId"],
|
|
71
|
+
preferred_date="2026-06-16",
|
|
72
|
+
preferred_time="10:00",
|
|
73
|
+
timezone="Europe/Prague",
|
|
74
|
+
)
|
|
75
|
+
zoplio.cancel_meeting(created["meetingId"])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
Every non-2xx response raises `ZoplioError` with the contract envelope:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
try:
|
|
84
|
+
zoplio.get_meeting("nope")
|
|
85
|
+
except ZoplioError as err:
|
|
86
|
+
err.status_code # 404
|
|
87
|
+
err.code # 'not_found' | 'unauthorized' | 'rate_limited' | 'validation_failed' | 'conflict' | 'upstream_error'
|
|
88
|
+
str(err) # human-readable message
|
|
89
|
+
err.details # [{"field", "message"}] on validation_failed
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Webhooks
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Subscribe — the secret is returned exactly once.
|
|
96
|
+
hook = zoplio.create_webhook(
|
|
97
|
+
url="https://example.com/zoplio-hook",
|
|
98
|
+
events=["meeting.confirmed", "meeting.cancelled"],
|
|
99
|
+
)
|
|
100
|
+
save_secret(hook["secret"]) # whsec_...
|
|
101
|
+
|
|
102
|
+
zoplio.list_webhooks()
|
|
103
|
+
zoplio.delete_webhook(hook["id"])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Verify deliveries with the static helper — pass the RAW request body:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# e.g. Flask
|
|
110
|
+
@app.post("/zoplio-hook")
|
|
111
|
+
def zoplio_hook():
|
|
112
|
+
ok = ZoplioClient.verify_webhook_signature(
|
|
113
|
+
request.get_data(), # raw bytes
|
|
114
|
+
request.headers.get("X-Zoplio-Signature", ""),
|
|
115
|
+
os.environ["ZOPLIO_WEBHOOK_SECRET"], # whsec_...
|
|
116
|
+
)
|
|
117
|
+
if not ok:
|
|
118
|
+
return "", 401
|
|
119
|
+
delivery = request.get_json() # {"event", "payload", "timestamp"}
|
|
120
|
+
return "", 200
|
|
121
|
+
```
|
zoplio-0.2.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# zoplio (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Zoplio Python SDK for the [Zoplio API v1](../../docs/quickstart.md). MIT licensed.
|
|
4
|
+
|
|
5
|
+
Zoplio schedules meetings for you: you say who and roughly when, Zoplio negotiates with every participant over WhatsApp/email and confirms a slot.
|
|
6
|
+
|
|
7
|
+
Requires Python >= 3.10. Depends on `httpx`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Not yet published to PyPI — install from this monorepo:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e packages/sdk-python
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from zoplio import ZoplioClient, ZoplioError
|
|
21
|
+
|
|
22
|
+
zoplio = ZoplioClient(api_key="zpl_...") # base_url defaults to https://api.zoplio.com
|
|
23
|
+
|
|
24
|
+
# Create a meeting — Zoplio reaches out to participants and negotiates.
|
|
25
|
+
created = zoplio.schedule_meeting(
|
|
26
|
+
participants=[
|
|
27
|
+
{"email": "petr@example.com", "name": "Petr"},
|
|
28
|
+
{"phone": "+420777123456", "name": "Jana"},
|
|
29
|
+
],
|
|
30
|
+
title="Intro call",
|
|
31
|
+
duration_minutes=30,
|
|
32
|
+
preferred_date="2026-06-15",
|
|
33
|
+
preferred_time="14:00",
|
|
34
|
+
timezone="Europe/Prague",
|
|
35
|
+
idempotency_key="order-42-intro-call", # optional, safe retries
|
|
36
|
+
)
|
|
37
|
+
print(created["meetingId"], created["status"], created["proposedSlots"])
|
|
38
|
+
|
|
39
|
+
# Poll status (or use webhooks instead).
|
|
40
|
+
meeting = zoplio.get_meeting(created["meetingId"])
|
|
41
|
+
|
|
42
|
+
# List / reschedule / cancel.
|
|
43
|
+
zoplio.list_meetings(status="confirmed", limit=10)
|
|
44
|
+
zoplio.reschedule_meeting(
|
|
45
|
+
created["meetingId"],
|
|
46
|
+
preferred_date="2026-06-16",
|
|
47
|
+
preferred_time="10:00",
|
|
48
|
+
timezone="Europe/Prague",
|
|
49
|
+
)
|
|
50
|
+
zoplio.cancel_meeting(created["meetingId"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
Every non-2xx response raises `ZoplioError` with the contract envelope:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
try:
|
|
59
|
+
zoplio.get_meeting("nope")
|
|
60
|
+
except ZoplioError as err:
|
|
61
|
+
err.status_code # 404
|
|
62
|
+
err.code # 'not_found' | 'unauthorized' | 'rate_limited' | 'validation_failed' | 'conflict' | 'upstream_error'
|
|
63
|
+
str(err) # human-readable message
|
|
64
|
+
err.details # [{"field", "message"}] on validation_failed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Webhooks
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Subscribe — the secret is returned exactly once.
|
|
71
|
+
hook = zoplio.create_webhook(
|
|
72
|
+
url="https://example.com/zoplio-hook",
|
|
73
|
+
events=["meeting.confirmed", "meeting.cancelled"],
|
|
74
|
+
)
|
|
75
|
+
save_secret(hook["secret"]) # whsec_...
|
|
76
|
+
|
|
77
|
+
zoplio.list_webhooks()
|
|
78
|
+
zoplio.delete_webhook(hook["id"])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Verify deliveries with the static helper — pass the RAW request body:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# e.g. Flask
|
|
85
|
+
@app.post("/zoplio-hook")
|
|
86
|
+
def zoplio_hook():
|
|
87
|
+
ok = ZoplioClient.verify_webhook_signature(
|
|
88
|
+
request.get_data(), # raw bytes
|
|
89
|
+
request.headers.get("X-Zoplio-Signature", ""),
|
|
90
|
+
os.environ["ZOPLIO_WEBHOOK_SECRET"], # whsec_...
|
|
91
|
+
)
|
|
92
|
+
if not ok:
|
|
93
|
+
return "", 401
|
|
94
|
+
delivery = request.get_json() # {"event", "payload", "timestamp"}
|
|
95
|
+
return "", 200
|
|
96
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zoplio"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Official Zoplio Python SDK for the Zoplio API v1"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Zoplio", email = "hello@zoplio.com" }]
|
|
13
|
+
keywords = ["zoplio", "scheduling", "ai-agent", "meetings", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"mypy>=1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
zoplio = ["py.typed"]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/Zoplio/zoplio-sdk"
|
|
42
|
+
Repository = "https://github.com/Zoplio/zoplio-sdk"
|
|
43
|
+
Issues = "https://github.com/Zoplio/zoplio-sdk/issues"
|
zoplio-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .client import ZoplioClient, ZoplioError
|
|
2
|
+
from .types import (
|
|
3
|
+
MEETING_STATUSES,
|
|
4
|
+
WEBHOOK_EVENTS,
|
|
5
|
+
MeetingDetail,
|
|
6
|
+
MeetingParticipant,
|
|
7
|
+
MeetingSummary,
|
|
8
|
+
ParticipantInput,
|
|
9
|
+
Slot,
|
|
10
|
+
WebhookCreated,
|
|
11
|
+
WebhookDelivery,
|
|
12
|
+
WebhookSummary,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ZoplioClient",
|
|
19
|
+
"ZoplioError",
|
|
20
|
+
"MEETING_STATUSES",
|
|
21
|
+
"WEBHOOK_EVENTS",
|
|
22
|
+
"MeetingDetail",
|
|
23
|
+
"MeetingParticipant",
|
|
24
|
+
"MeetingSummary",
|
|
25
|
+
"ParticipantInput",
|
|
26
|
+
"Slot",
|
|
27
|
+
"WebhookCreated",
|
|
28
|
+
"WebhookDelivery",
|
|
29
|
+
"WebhookSummary",
|
|
30
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Client for the Zoplio public API v1 (api-gateway /v1)."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ZoplioError(Exception):
|
|
12
|
+
"""Error raised for every non-2xx API response.
|
|
13
|
+
|
|
14
|
+
Carries the contract error envelope ``{"error": {"code", "message",
|
|
15
|
+
"details"?}}``:
|
|
16
|
+
|
|
17
|
+
- ``code``: one of ``unauthorized``, ``rate_limited``, ``validation_failed``,
|
|
18
|
+
``not_found``, ``conflict``, ``quota_exceeded``, ``upstream_error``
|
|
19
|
+
- ``status_code``: HTTP status of the response
|
|
20
|
+
- ``details``: list of ``{"field", "message"}`` on ``validation_failed``
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
code: str = "upstream_error",
|
|
27
|
+
status_code: int = 0,
|
|
28
|
+
details: Optional[list] = None,
|
|
29
|
+
):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.code = code
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.details = details or []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ZoplioClient:
|
|
37
|
+
"""Official Zoplio Python SDK client.
|
|
38
|
+
|
|
39
|
+
Usage::
|
|
40
|
+
|
|
41
|
+
from zoplio import ZoplioClient
|
|
42
|
+
|
|
43
|
+
zoplio = ZoplioClient(api_key="zpl_...")
|
|
44
|
+
created = zoplio.schedule_meeting(
|
|
45
|
+
participants=[{"email": "petr@example.com", "name": "Petr"}],
|
|
46
|
+
title="Intro call",
|
|
47
|
+
)
|
|
48
|
+
print(created["meetingId"], created["status"])
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, api_key: str, base_url: str = "https://api.zoplio.com"):
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self._client = httpx.Client(
|
|
55
|
+
base_url=self.base_url,
|
|
56
|
+
headers={
|
|
57
|
+
"Authorization": f"Bearer {api_key}",
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
},
|
|
60
|
+
timeout=30.0,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
json: Optional[dict] = None,
|
|
68
|
+
params: Optional[dict] = None,
|
|
69
|
+
headers: Optional[dict] = None,
|
|
70
|
+
) -> Any:
|
|
71
|
+
response = self._client.request(
|
|
72
|
+
method, path, json=json, params=params, headers=headers
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
data = response.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
data = None
|
|
78
|
+
if not response.is_success:
|
|
79
|
+
err = (data or {}).get("error") or {}
|
|
80
|
+
raise ZoplioError(
|
|
81
|
+
err.get("message", f"Zoplio API error: HTTP {response.status_code}"),
|
|
82
|
+
code=err.get("code", "upstream_error"),
|
|
83
|
+
status_code=response.status_code,
|
|
84
|
+
details=err.get("details"),
|
|
85
|
+
)
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
# ── Meetings ────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def schedule_meeting(
|
|
91
|
+
self,
|
|
92
|
+
participants: list[dict],
|
|
93
|
+
title: Optional[str] = None,
|
|
94
|
+
duration_minutes: Optional[int] = None,
|
|
95
|
+
preferred_date: Optional[str] = None,
|
|
96
|
+
preferred_time: Optional[str] = None,
|
|
97
|
+
timezone: Optional[str] = None,
|
|
98
|
+
earliest_date: Optional[str] = None,
|
|
99
|
+
latest_date: Optional[str] = None,
|
|
100
|
+
open_ask: Optional[bool] = None,
|
|
101
|
+
location: Optional[str] = None,
|
|
102
|
+
organizer_attending: Optional[bool] = None,
|
|
103
|
+
idempotency_key: Optional[str] = None,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Create a meeting and start negotiating with the participants.
|
|
106
|
+
|
|
107
|
+
``POST /v1/meetings``
|
|
108
|
+
|
|
109
|
+
:param participants: 1-8 dicts, each with ``phone`` (E.164) or
|
|
110
|
+
``email``, plus optional ``name``.
|
|
111
|
+
:param preferred_date: ``YYYY-MM-DD`` (required when
|
|
112
|
+
``preferred_time`` is set).
|
|
113
|
+
:param preferred_time: ``HH:MM`` 24-hour.
|
|
114
|
+
:param timezone: IANA timezone the date/time are expressed in.
|
|
115
|
+
:param idempotency_key: sent as ``X-Idempotency-Key`` — replaying the
|
|
116
|
+
same key returns the originally created meeting.
|
|
117
|
+
:returns: ``{"meetingId", "negotiationId", "status", "proposedSlots"}``
|
|
118
|
+
"""
|
|
119
|
+
body: dict[str, Any] = {"participants": participants}
|
|
120
|
+
if title is not None:
|
|
121
|
+
body["title"] = title
|
|
122
|
+
if duration_minutes is not None:
|
|
123
|
+
body["durationMinutes"] = duration_minutes
|
|
124
|
+
if preferred_date is not None:
|
|
125
|
+
body["preferredDate"] = preferred_date
|
|
126
|
+
if preferred_time is not None:
|
|
127
|
+
body["preferredTime"] = preferred_time
|
|
128
|
+
if timezone is not None:
|
|
129
|
+
body["timezone"] = timezone
|
|
130
|
+
if earliest_date is not None:
|
|
131
|
+
body["earliestDate"] = earliest_date
|
|
132
|
+
if latest_date is not None:
|
|
133
|
+
body["latestDate"] = latest_date
|
|
134
|
+
if open_ask is not None:
|
|
135
|
+
body["openAsk"] = open_ask
|
|
136
|
+
if location is not None:
|
|
137
|
+
body["location"] = location
|
|
138
|
+
if organizer_attending is not None:
|
|
139
|
+
body["organizerAttending"] = organizer_attending
|
|
140
|
+
|
|
141
|
+
headers = {"X-Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
142
|
+
return self._request("POST", "/v1/meetings", json=body, headers=headers)
|
|
143
|
+
|
|
144
|
+
def get_meeting(self, meeting_id: str) -> dict:
|
|
145
|
+
"""Fetch one meeting you organize, with per-participant status.
|
|
146
|
+
|
|
147
|
+
``GET /v1/meetings/:id`` →
|
|
148
|
+
``{"id", "title", "status", "confirmedSlot"?, "participants"}``
|
|
149
|
+
"""
|
|
150
|
+
return self._request("GET", f"/v1/meetings/{quote(meeting_id, safe='')}")
|
|
151
|
+
|
|
152
|
+
def list_meetings(
|
|
153
|
+
self, status: Optional[str] = None, limit: Optional[int] = None
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""List meetings you organize, newest first.
|
|
156
|
+
|
|
157
|
+
``GET /v1/meetings?status=&limit=`` → ``{"meetings": [...]}``
|
|
158
|
+
|
|
159
|
+
:param status: one of ``draft``, ``negotiating``, ``confirmed``,
|
|
160
|
+
``cancelled``, ``rescheduling``.
|
|
161
|
+
:param limit: 1-50, default 20.
|
|
162
|
+
"""
|
|
163
|
+
params: dict[str, Any] = {}
|
|
164
|
+
if status is not None:
|
|
165
|
+
params["status"] = status
|
|
166
|
+
if limit is not None:
|
|
167
|
+
params["limit"] = limit
|
|
168
|
+
return self._request("GET", "/v1/meetings", params=params or None)
|
|
169
|
+
|
|
170
|
+
def cancel_meeting(self, meeting_id: str) -> dict:
|
|
171
|
+
"""Cancel a meeting (idempotent).
|
|
172
|
+
|
|
173
|
+
``POST /v1/meetings/:id/cancel`` → ``{"status": "cancelled"}``
|
|
174
|
+
"""
|
|
175
|
+
return self._request(
|
|
176
|
+
"POST", f"/v1/meetings/{quote(meeting_id, safe='')}/cancel"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def reschedule_meeting(
|
|
180
|
+
self,
|
|
181
|
+
meeting_id: str,
|
|
182
|
+
preferred_date: str,
|
|
183
|
+
preferred_time: str,
|
|
184
|
+
timezone: Optional[str] = None,
|
|
185
|
+
) -> dict:
|
|
186
|
+
"""Propose a new exact date+time to all participants.
|
|
187
|
+
|
|
188
|
+
``POST /v1/meetings/:id/reschedule`` →
|
|
189
|
+
``{"meetingId", "status": "negotiating", "proposedSlots"}``
|
|
190
|
+
|
|
191
|
+
Raises :class:`ZoplioError` with code ``conflict`` when the requested
|
|
192
|
+
time collides with a participant's availability or the negotiation
|
|
193
|
+
state does not allow re-proposing.
|
|
194
|
+
"""
|
|
195
|
+
body: dict[str, Any] = {
|
|
196
|
+
"preferredDate": preferred_date,
|
|
197
|
+
"preferredTime": preferred_time,
|
|
198
|
+
}
|
|
199
|
+
if timezone is not None:
|
|
200
|
+
body["timezone"] = timezone
|
|
201
|
+
return self._request(
|
|
202
|
+
"POST", f"/v1/meetings/{quote(meeting_id, safe='')}/reschedule", json=body
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# ── Webhooks ────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def create_webhook(self, url: str, events: Optional[list[str]] = None) -> dict:
|
|
208
|
+
"""Subscribe a URL to meeting lifecycle events.
|
|
209
|
+
|
|
210
|
+
``POST /v1/webhooks`` → ``{"id", "url", "events", "secret"}``
|
|
211
|
+
|
|
212
|
+
The ``whsec_`` secret is returned exactly once — store it to verify
|
|
213
|
+
deliveries. ``events`` defaults to all of ``meeting.created``,
|
|
214
|
+
``meeting.confirmed``, ``meeting.cancelled``, ``negotiation.failed``.
|
|
215
|
+
"""
|
|
216
|
+
body: dict[str, Any] = {"url": url}
|
|
217
|
+
if events is not None:
|
|
218
|
+
body["events"] = events
|
|
219
|
+
return self._request("POST", "/v1/webhooks", json=body)
|
|
220
|
+
|
|
221
|
+
def list_webhooks(self) -> dict:
|
|
222
|
+
"""List your webhook subscriptions (without secrets).
|
|
223
|
+
|
|
224
|
+
``GET /v1/webhooks`` → ``{"webhooks": [...]}``
|
|
225
|
+
"""
|
|
226
|
+
return self._request("GET", "/v1/webhooks")
|
|
227
|
+
|
|
228
|
+
def delete_webhook(self, webhook_id: str) -> dict:
|
|
229
|
+
"""Delete one of your webhook subscriptions.
|
|
230
|
+
|
|
231
|
+
``DELETE /v1/webhooks/:id`` → ``{"deleted": true}``
|
|
232
|
+
"""
|
|
233
|
+
return self._request(
|
|
234
|
+
"DELETE", f"/v1/webhooks/{quote(webhook_id, safe='')}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def verify_webhook_signature(
|
|
239
|
+
raw_body: Union[str, bytes], signature_header: str, secret: str
|
|
240
|
+
) -> bool:
|
|
241
|
+
"""Verify a webhook delivery signature.
|
|
242
|
+
|
|
243
|
+
Constant-time comparison of the ``X-Zoplio-Signature`` header against
|
|
244
|
+
``HMAC-SHA256(secret, raw_body)`` hex-encoded — exactly how Zoplio
|
|
245
|
+
signs deliveries. Pass the RAW request body bytes (before JSON
|
|
246
|
+
parsing — re-serializing the parsed body may not be byte-identical).
|
|
247
|
+
"""
|
|
248
|
+
if not signature_header or not secret:
|
|
249
|
+
return False
|
|
250
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
251
|
+
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
252
|
+
return hmac.compare_digest(expected, signature_header.strip().lower())
|
|
253
|
+
|
|
254
|
+
def close(self) -> None:
|
|
255
|
+
self._client.close()
|
|
256
|
+
|
|
257
|
+
def __enter__(self) -> "ZoplioClient":
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
def __exit__(self, *args: Any) -> None:
|
|
261
|
+
self.close()
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Reference shapes for the Zoplio public API v1 wire format.
|
|
2
|
+
|
|
3
|
+
:class:`~zoplio.client.ZoplioClient` returns plain dicts straight off the
|
|
4
|
+
wire; these dataclasses document the shapes (mirroring the TypeScript SDK's
|
|
5
|
+
types) for IDE help and type-checked application code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
MEETING_STATUSES = ("draft", "negotiating", "confirmed", "cancelled", "rescheduling")
|
|
12
|
+
|
|
13
|
+
WEBHOOK_EVENTS = (
|
|
14
|
+
"meeting.created",
|
|
15
|
+
"meeting.confirmed",
|
|
16
|
+
"meeting.cancelled",
|
|
17
|
+
"meeting.rescheduled",
|
|
18
|
+
"negotiation.failed",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Slot:
|
|
24
|
+
"""A concrete time slot. ISO 8601 datetimes (UTC)."""
|
|
25
|
+
|
|
26
|
+
start: str
|
|
27
|
+
end: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ParticipantInput:
|
|
32
|
+
"""Meeting participant input — needs ``phone`` (E.164) or ``email``."""
|
|
33
|
+
|
|
34
|
+
phone: Optional[str] = None
|
|
35
|
+
email: Optional[str] = None
|
|
36
|
+
name: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class MeetingParticipant:
|
|
41
|
+
"""Participant as returned by ``GET /v1/meetings/:id``."""
|
|
42
|
+
|
|
43
|
+
status: str # pending, accepted, declined, counter-proposed, ...
|
|
44
|
+
attending: bool
|
|
45
|
+
name: Optional[str] = None
|
|
46
|
+
email: Optional[str] = None
|
|
47
|
+
phone: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class MeetingDetail:
|
|
52
|
+
"""``GET /v1/meetings/:id`` response."""
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
title: str
|
|
56
|
+
status: str # one of MEETING_STATUSES
|
|
57
|
+
participants: list[MeetingParticipant] = field(default_factory=list)
|
|
58
|
+
confirmed_slot: Optional[Slot] = None # wire key: confirmedSlot
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class MeetingSummary:
|
|
63
|
+
"""Entry of ``GET /v1/meetings`` → ``{"meetings": [...]}``."""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
title: str
|
|
67
|
+
status: str
|
|
68
|
+
confirmed_slot: Optional[Slot] = None # wire key: confirmedSlot
|
|
69
|
+
created_at: Optional[str] = None # wire key: createdAt
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class WebhookCreated:
|
|
74
|
+
"""``POST /v1/webhooks`` response. ``secret`` is returned exactly once."""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
url: str
|
|
78
|
+
events: list[str]
|
|
79
|
+
secret: str # whsec_...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class WebhookSummary:
|
|
84
|
+
"""Entry of ``GET /v1/webhooks`` → ``{"webhooks": [...]}`` (no secret)."""
|
|
85
|
+
|
|
86
|
+
id: str
|
|
87
|
+
url: str
|
|
88
|
+
events: list[str]
|
|
89
|
+
active: bool
|
|
90
|
+
created_at: Optional[str] = None # wire key: createdAt
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class WebhookDelivery:
|
|
95
|
+
"""Body POSTed to a subscribed URL.
|
|
96
|
+
|
|
97
|
+
Verify ``X-Zoplio-Signature`` over the raw body with
|
|
98
|
+
:meth:`zoplio.ZoplioClient.verify_webhook_signature` before trusting it.
|
|
99
|
+
``payload`` carries ``meetingId``, ``organizerUserId``, ``title`` and — on
|
|
100
|
+
confirmed/cancelled/failed events — ``negotiationId``,
|
|
101
|
+
``participantEmails`` and (when confirmed) ``confirmedSlot``.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
event: str # one of WEBHOOK_EVENTS, also in the X-Zoplio-Event header
|
|
105
|
+
payload: dict
|
|
106
|
+
timestamp: str # ISO 8601
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zoplio
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Zoplio Python SDK for the Zoplio API v1
|
|
5
|
+
Author-email: Zoplio <hello@zoplio.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Zoplio/zoplio-sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/Zoplio/zoplio-sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/Zoplio/zoplio-sdk/issues
|
|
10
|
+
Keywords: zoplio,scheduling,ai-agent,meetings,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# zoplio (Python SDK)
|
|
27
|
+
|
|
28
|
+
Official Zoplio Python SDK for the [Zoplio API v1](../../docs/quickstart.md). MIT licensed.
|
|
29
|
+
|
|
30
|
+
Zoplio schedules meetings for you: you say who and roughly when, Zoplio negotiates with every participant over WhatsApp/email and confirms a slot.
|
|
31
|
+
|
|
32
|
+
Requires Python >= 3.10. Depends on `httpx`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Not yet published to PyPI — install from this monorepo:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e packages/sdk-python
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from zoplio import ZoplioClient, ZoplioError
|
|
46
|
+
|
|
47
|
+
zoplio = ZoplioClient(api_key="zpl_...") # base_url defaults to https://api.zoplio.com
|
|
48
|
+
|
|
49
|
+
# Create a meeting — Zoplio reaches out to participants and negotiates.
|
|
50
|
+
created = zoplio.schedule_meeting(
|
|
51
|
+
participants=[
|
|
52
|
+
{"email": "petr@example.com", "name": "Petr"},
|
|
53
|
+
{"phone": "+420777123456", "name": "Jana"},
|
|
54
|
+
],
|
|
55
|
+
title="Intro call",
|
|
56
|
+
duration_minutes=30,
|
|
57
|
+
preferred_date="2026-06-15",
|
|
58
|
+
preferred_time="14:00",
|
|
59
|
+
timezone="Europe/Prague",
|
|
60
|
+
idempotency_key="order-42-intro-call", # optional, safe retries
|
|
61
|
+
)
|
|
62
|
+
print(created["meetingId"], created["status"], created["proposedSlots"])
|
|
63
|
+
|
|
64
|
+
# Poll status (or use webhooks instead).
|
|
65
|
+
meeting = zoplio.get_meeting(created["meetingId"])
|
|
66
|
+
|
|
67
|
+
# List / reschedule / cancel.
|
|
68
|
+
zoplio.list_meetings(status="confirmed", limit=10)
|
|
69
|
+
zoplio.reschedule_meeting(
|
|
70
|
+
created["meetingId"],
|
|
71
|
+
preferred_date="2026-06-16",
|
|
72
|
+
preferred_time="10:00",
|
|
73
|
+
timezone="Europe/Prague",
|
|
74
|
+
)
|
|
75
|
+
zoplio.cancel_meeting(created["meetingId"])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
Every non-2xx response raises `ZoplioError` with the contract envelope:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
try:
|
|
84
|
+
zoplio.get_meeting("nope")
|
|
85
|
+
except ZoplioError as err:
|
|
86
|
+
err.status_code # 404
|
|
87
|
+
err.code # 'not_found' | 'unauthorized' | 'rate_limited' | 'validation_failed' | 'conflict' | 'upstream_error'
|
|
88
|
+
str(err) # human-readable message
|
|
89
|
+
err.details # [{"field", "message"}] on validation_failed
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Webhooks
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Subscribe — the secret is returned exactly once.
|
|
96
|
+
hook = zoplio.create_webhook(
|
|
97
|
+
url="https://example.com/zoplio-hook",
|
|
98
|
+
events=["meeting.confirmed", "meeting.cancelled"],
|
|
99
|
+
)
|
|
100
|
+
save_secret(hook["secret"]) # whsec_...
|
|
101
|
+
|
|
102
|
+
zoplio.list_webhooks()
|
|
103
|
+
zoplio.delete_webhook(hook["id"])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Verify deliveries with the static helper — pass the RAW request body:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# e.g. Flask
|
|
110
|
+
@app.post("/zoplio-hook")
|
|
111
|
+
def zoplio_hook():
|
|
112
|
+
ok = ZoplioClient.verify_webhook_signature(
|
|
113
|
+
request.get_data(), # raw bytes
|
|
114
|
+
request.headers.get("X-Zoplio-Signature", ""),
|
|
115
|
+
os.environ["ZOPLIO_WEBHOOK_SECRET"], # whsec_...
|
|
116
|
+
)
|
|
117
|
+
if not ok:
|
|
118
|
+
return "", 401
|
|
119
|
+
delivery = request.get_json() # {"event", "payload", "timestamp"}
|
|
120
|
+
return "", 200
|
|
121
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/zoplio/__init__.py
|
|
4
|
+
src/zoplio/client.py
|
|
5
|
+
src/zoplio/py.typed
|
|
6
|
+
src/zoplio/types.py
|
|
7
|
+
src/zoplio.egg-info/PKG-INFO
|
|
8
|
+
src/zoplio.egg-info/SOURCES.txt
|
|
9
|
+
src/zoplio.egg-info/dependency_links.txt
|
|
10
|
+
src/zoplio.egg-info/requires.txt
|
|
11
|
+
src/zoplio.egg-info/top_level.txt
|
|
12
|
+
tests/test_webhook_signature.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zoplio
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Webhook HMAC verification — pinned against the SAME vector the JS SDK and
|
|
2
|
+
the webhooks service use (packages/sdk-js/src/__tests__/client.test.ts and
|
|
3
|
+
apps/webhooks/src/services/__tests__/webhookSignature.test.ts). A drift here
|
|
4
|
+
silently breaks every Python consumer's signature check, so this must match
|
|
5
|
+
byte-for-byte."""
|
|
6
|
+
|
|
7
|
+
from zoplio.client import ZoplioClient
|
|
8
|
+
|
|
9
|
+
SECRET = "whsec_0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
10
|
+
BODY = (
|
|
11
|
+
'{"event":"meeting.confirmed","payload":{"meetingId":"meet_1",'
|
|
12
|
+
'"title":"Standup"},"timestamp":"2026-06-11T08:00:00.000Z"}'
|
|
13
|
+
)
|
|
14
|
+
SIG = "700d795ed0445ce5a1fb659ef36f540684c5595b2818f96368e4d7062a1f9433"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_accepts_known_good_vector_str():
|
|
18
|
+
assert ZoplioClient.verify_webhook_signature(BODY, SIG, SECRET) is True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_accepts_known_good_vector_bytes():
|
|
22
|
+
assert ZoplioClient.verify_webhook_signature(BODY.encode("utf-8"), SIG, SECRET) is True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_rejects_wrong_signature():
|
|
26
|
+
assert ZoplioClient.verify_webhook_signature(BODY, "0" * 64, SECRET) is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_rejects_tampered_body():
|
|
30
|
+
tampered = BODY.replace("Standup", "Tampered")
|
|
31
|
+
assert ZoplioClient.verify_webhook_signature(tampered, SIG, SECRET) is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_rejects_wrong_secret():
|
|
35
|
+
assert ZoplioClient.verify_webhook_signature(BODY, SIG, "whsec_wrong") is False
|