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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ httpx>=0.27
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ mypy>=1.0
@@ -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