deewan 0.2.0__py3-none-any.whl

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.
deewan/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Official server SDK for the Deewan API.
2
+
3
+ from deewan import Deewan
4
+
5
+ deewan = Deewan(api_key=os.environ["DEEWAN_API_KEY"])
6
+ room = deewan.rooms.create(name="Onboarding call")
7
+ link = deewan.rooms.create_join_link(room["id"], name="Sara", role="host")
8
+
9
+ Use it on your server only: an API key must never reach a browser or an app.
10
+ """
11
+
12
+ from ._client import Deewan, DeewanError
13
+ from ._webhooks import verify_webhook
14
+
15
+ __all__ = ["Deewan", "DeewanError", "verify_webhook"]
16
+ __version__ = "0.2.0"
deewan/_client.py ADDED
@@ -0,0 +1,402 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import random
6
+ import socket
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from typing import Any, Iterator, Optional
12
+
13
+ VERSION = "0.2.0"
14
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
15
+
16
+
17
+ class DeewanError(Exception):
18
+ """An API error. ``status`` is 0 when the request never got an answer."""
19
+
20
+ def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None, details: Any = None):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+ self.message = message
25
+ self.request_id = request_id
26
+ self.details = details
27
+
28
+ def __repr__(self) -> str:
29
+ return f"DeewanError(status={self.status}, code={self.code!r}, message={self.message!r})"
30
+
31
+
32
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
33
+ # urllib would follow a redirect with the Authorization header attached,
34
+ # even to another host. The API never redirects, so refuse all of them.
35
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
36
+ return None
37
+
38
+
39
+ def _never_sent(error: BaseException) -> bool:
40
+ """Failures before the request left: DNS, refused connection."""
41
+ reason = getattr(error, "reason", error)
42
+ return isinstance(reason, (ConnectionRefusedError, socket.gaierror))
43
+
44
+
45
+ def _backoff(attempt: int) -> float:
46
+ return min(8.0, 0.5 * 2**attempt) + random.random() * 0.25
47
+
48
+
49
+ def _q(value: str) -> str:
50
+ return urllib.parse.quote(str(value), safe="")
51
+
52
+
53
+ def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
54
+ return {k: v for k, v in values.items() if v is not None}
55
+
56
+
57
+ class Deewan:
58
+ """The Deewan API client.
59
+
60
+ ``timeout`` is per request, in seconds. Reads (GET, DELETE) are retried on
61
+ 429 and 5xx up to ``max_retries`` times; writes are retried only on 429 or
62
+ when the connection never opened, so nothing is applied twice.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ api_key: str,
68
+ *,
69
+ base_url: str = "https://deewan.io/api/v1",
70
+ timeout: float = 15.0,
71
+ max_retries: int = 2,
72
+ ):
73
+ if not isinstance(api_key, str) or not api_key.startswith("dwn_"):
74
+ raise ValueError("Deewan: pass a valid api_key (it starts with dwn_).")
75
+ parsed = urllib.parse.urlparse(base_url)
76
+ if parsed.scheme != "https" and parsed.hostname not in _LOCAL_HOSTS:
77
+ raise ValueError("Deewan: base_url must use https.")
78
+ self._key = api_key
79
+ self._base = base_url.rstrip("/")
80
+ self._timeout = timeout
81
+ self._retries = max_retries
82
+ self._opener = urllib.request.build_opener(_NoRedirect())
83
+ self.rooms = Rooms(self)
84
+ self.meetings = Meetings(self)
85
+ self.polls = Polls(self)
86
+ self.breakouts = Breakouts(self)
87
+ self.recordings = Recordings(self)
88
+ self.webhooks = Webhooks(self)
89
+
90
+ def me(self) -> dict[str, Any]:
91
+ """The key's workspace, scopes and environment."""
92
+ return self._request("GET", "/me")
93
+
94
+ # -------------------------------------------------------------- transport
95
+
96
+ def _request(self, method: str, path: str, body: Any = None, query: Optional[dict[str, Any]] = None) -> Any:
97
+ return json.loads(self._send(method, path, body, query) or b"null")
98
+
99
+ def _send(self, method: str, path: str, body: Any = None, query: Optional[dict[str, Any]] = None) -> bytes:
100
+ url = self._base + path
101
+ if query:
102
+ clean = _drop_none(query)
103
+ if clean:
104
+ url += "?" + urllib.parse.urlencode(clean)
105
+ data = json.dumps(body).encode("utf-8") if body is not None else None
106
+ headers = {
107
+ "Authorization": f"Bearer {self._key}",
108
+ "Accept": "application/json",
109
+ "User-Agent": f"deewan-sdk-python/{VERSION}",
110
+ }
111
+ if data is not None:
112
+ headers["Content-Type"] = "application/json"
113
+ safe = method in ("GET", "DELETE")
114
+
115
+ attempt = 0
116
+ while True:
117
+ request = urllib.request.Request(url, data=data, method=method, headers=headers)
118
+ try:
119
+ with self._opener.open(request, timeout=self._timeout) as response:
120
+ return response.read()
121
+ except urllib.error.HTTPError as error:
122
+ retryable = error.code == 429 or error.code >= 500
123
+ if retryable and (safe or error.code == 429) and attempt < self._retries:
124
+ after = error.headers.get("Retry-After") if error.headers else None
125
+ try:
126
+ wait = float(after) if after else _backoff(attempt)
127
+ except ValueError:
128
+ wait = _backoff(attempt)
129
+ time.sleep(wait if wait > 0 else _backoff(attempt))
130
+ attempt += 1
131
+ continue
132
+ raise self._to_error(error) from None
133
+ except (urllib.error.URLError, socket.timeout, ConnectionError) as error:
134
+ if (safe or _never_sent(error)) and attempt < self._retries:
135
+ time.sleep(_backoff(attempt))
136
+ attempt += 1
137
+ continue
138
+ raise DeewanError(0, "network_error", str(getattr(error, "reason", error))) from None
139
+
140
+ @staticmethod
141
+ def _to_error(error: urllib.error.HTTPError) -> DeewanError:
142
+ request_id = error.headers.get("x-request-id") if error.headers else None
143
+ try:
144
+ payload = json.loads(error.read() or b"null") or {}
145
+ except ValueError:
146
+ payload = {}
147
+ detail = payload.get("error") if isinstance(payload, dict) else None
148
+ detail = detail if isinstance(detail, dict) else {}
149
+ return DeewanError(
150
+ error.code,
151
+ detail.get("code") or "http_error",
152
+ detail.get("message") or f"HTTP {error.code}",
153
+ request_id,
154
+ detail.get("details"),
155
+ )
156
+
157
+
158
+ class Rooms:
159
+ def __init__(self, client: Deewan):
160
+ self._c = client
161
+
162
+ def list(self, *, limit: Optional[int] = None, starting_after: Optional[str] = None) -> dict[str, Any]:
163
+ return self._c._request("GET", "/rooms", query={"limit": limit, "starting_after": starting_after})
164
+
165
+ def all(self, page_size: int = 100) -> Iterator[dict[str, Any]]:
166
+ """Every room, fetching pages as needed."""
167
+ after: Optional[str] = None
168
+ while True:
169
+ page = self.list(limit=page_size, starting_after=after)
170
+ yield from page["data"]
171
+ if not page.get("has_more") or not page["data"]:
172
+ return
173
+ after = page["data"][-1]["id"]
174
+
175
+ def create(self, *, name: str, description: Optional[str] = None, settings: Optional[dict[str, Any]] = None) -> dict[str, Any]:
176
+ return self._c._request("POST", "/rooms", _drop_none({"name": name, "description": description, "settings": settings}))
177
+
178
+ def retrieve(self, room_id: str) -> dict[str, Any]:
179
+ return self._c._request("GET", f"/rooms/{_q(room_id)}")
180
+
181
+ def update(self, room_id: str, **changes: Any) -> dict[str, Any]:
182
+ """``name``, ``description``, ``locked`` or ``settings`` (a partial dict)."""
183
+ return self._c._request("PATCH", f"/rooms/{_q(room_id)}", changes)
184
+
185
+ def delete(self, room_id: str) -> dict[str, Any]:
186
+ return self._c._request("DELETE", f"/rooms/{_q(room_id)}")
187
+
188
+ def create_join_link(
189
+ self,
190
+ room_id: str,
191
+ *,
192
+ name: str,
193
+ role: Optional[str] = None,
194
+ user_id: Optional[str] = None,
195
+ expires_in: Optional[int] = None,
196
+ locale: Optional[str] = None,
197
+ redirect_url: Optional[str] = None,
198
+ branding: Optional[str] = None,
199
+ ) -> dict[str, Any]:
200
+ """A personal, expiring link into Deewan's hosted meeting page."""
201
+ body = _drop_none(
202
+ {"name": name, "role": role, "user_id": user_id, "expires_in": expires_in, "locale": locale, "redirect_url": redirect_url, "branding": branding}
203
+ )
204
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/join-links", body)
205
+
206
+ def create_token(
207
+ self,
208
+ room_id: str,
209
+ *,
210
+ name: str,
211
+ role: Optional[str] = None,
212
+ user_id: Optional[str] = None,
213
+ avatar: Optional[str] = None,
214
+ exchange_key: Optional[str] = None,
215
+ ) -> dict[str, Any]:
216
+ """Credentials for an interface you build yourself (web, React Native or Flutter SDK).
217
+
218
+ For a room with strict encryption, pass the participant's ``exchange_key``
219
+ (the client SDK makes it on their device); the response then has
220
+ ``e2ee == "strict"`` and no passphrase.
221
+ """
222
+ body = _drop_none({"name": name, "role": role, "user_id": user_id, "avatar": avatar, "exchange_key": exchange_key})
223
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/tokens", body)
224
+
225
+ def list_participants(self, room_id: str) -> dict[str, Any]:
226
+ return self._c._request("GET", f"/rooms/{_q(room_id)}/participants")
227
+
228
+ def remove_participant(self, room_id: str, identity: str) -> dict[str, Any]:
229
+ return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}")
230
+
231
+ def mute_participant(self, room_id: str, identity: str) -> dict[str, Any]:
232
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/mute")
233
+
234
+ def make_cohost(self, room_id: str, identity: str) -> dict[str, Any]:
235
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/cohost")
236
+
237
+ def remove_cohost(self, room_id: str, identity: str) -> dict[str, Any]:
238
+ return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/cohost")
239
+
240
+ def invite_to_speak(self, room_id: str, identity: str) -> dict[str, Any]:
241
+ """Webinars: lets someone in the audience turn on their microphone and camera."""
242
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/stage")
243
+
244
+ def move_to_audience(self, room_id: str, identity: str) -> dict[str, Any]:
245
+ """Webinars: sends someone back to the audience."""
246
+ return self._c._request("DELETE", f"/rooms/{_q(room_id)}/participants/{_q(identity)}/stage")
247
+
248
+ def list_waiting(self, room_id: str) -> dict[str, Any]:
249
+ return self._c._request("GET", f"/rooms/{_q(room_id)}/waiting")
250
+
251
+ def admit(self, room_id: str, identity: str) -> dict[str, Any]:
252
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/waiting/{_q(identity)}", {"decision": "admit"})
253
+
254
+ def deny(self, room_id: str, identity: str) -> dict[str, Any]:
255
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/waiting/{_q(identity)}", {"decision": "deny"})
256
+
257
+ def end(self, room_id: str) -> dict[str, Any]:
258
+ """Ends the meeting for everyone; the room remains."""
259
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/end")
260
+
261
+
262
+ class Meetings:
263
+ def __init__(self, client: Deewan):
264
+ self._c = client
265
+
266
+ def list(self, *, from_: Optional[str] = None, to: Optional[str] = None, limit: Optional[int] = None) -> dict[str, Any]:
267
+ return self._c._request("GET", "/meetings", query={"from": from_, "to": to, "limit": limit})
268
+
269
+ def create(self, *, title: str, starts_at: str, ends_at: str, **extra: Any) -> dict[str, Any]:
270
+ """Schedules a meeting. ``extra``: description, room_id, recurrence, recurrence_until, timezone."""
271
+ return self._c._request("POST", "/meetings", {"title": title, "starts_at": starts_at, "ends_at": ends_at, **extra})
272
+
273
+ def retrieve(self, meeting_id: str) -> dict[str, Any]:
274
+ return self._c._request("GET", f"/meetings/{_q(meeting_id)}")
275
+
276
+ def update(self, meeting_id: str, **changes: Any) -> dict[str, Any]:
277
+ return self._c._request("PATCH", f"/meetings/{_q(meeting_id)}", changes)
278
+
279
+ def cancel(self, meeting_id: str) -> dict[str, Any]:
280
+ return self._c._request("DELETE", f"/meetings/{_q(meeting_id)}")
281
+
282
+
283
+ class Polls:
284
+ def __init__(self, client: Deewan):
285
+ self._c = client
286
+
287
+ def list(self, room_id: str) -> dict[str, Any]:
288
+ return self._c._request("GET", f"/rooms/{_q(room_id)}/polls")
289
+
290
+ def create(self, room_id: str, *, question: str, options: list[str], **extra: Any) -> dict[str, Any]:
291
+ """``extra``: kind ("single"|"multiple"), anonymous, results ("live"|"after_vote"|"after_close")."""
292
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/polls", {"question": question, "options": options, **extra})
293
+
294
+ def close(self, poll_id: str) -> dict[str, Any]:
295
+ return self._c._request("PATCH", f"/polls/{_q(poll_id)}", {"status": "closed"})
296
+
297
+ def reopen(self, poll_id: str) -> dict[str, Any]:
298
+ return self._c._request("PATCH", f"/polls/{_q(poll_id)}", {"status": "open"})
299
+
300
+ def delete(self, poll_id: str) -> dict[str, Any]:
301
+ return self._c._request("DELETE", f"/polls/{_q(poll_id)}")
302
+
303
+
304
+ class Breakouts:
305
+ def __init__(self, client: Deewan):
306
+ self._c = client
307
+
308
+ def get(self, room_id: str) -> dict[str, Any]:
309
+ return self._c._request("GET", f"/rooms/{_q(room_id)}/breakouts")
310
+
311
+ def start(self, room_id: str, *, rooms: Any, **extra: Any) -> dict[str, Any]:
312
+ """``rooms``: a count (1–50) or a list of names. ``extra`` as in the API reference."""
313
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts", {"rooms": rooms, **extra})
314
+
315
+ def move(self, room_id: str, participant: str, breakout_id: Optional[str], *, can_return: Optional[bool] = None) -> dict[str, Any]:
316
+ body = _drop_none({"participant": participant, "can_return": can_return})
317
+ body["breakout_id"] = breakout_id
318
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/assignments", body)
319
+
320
+ def add_room(self, room_id: str, name: Optional[str] = None) -> dict[str, Any]:
321
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/rooms", _drop_none({"name": name}))
322
+
323
+ def rename_room(self, room_id: str, breakout_id: str, name: str) -> dict[str, Any]:
324
+ return self._c._request("PATCH", f"/rooms/{_q(room_id)}/breakouts/rooms/{_q(breakout_id)}", {"name": name})
325
+
326
+ def remove_room(self, room_id: str, breakout_id: str) -> dict[str, Any]:
327
+ return self._c._request("DELETE", f"/rooms/{_q(room_id)}/breakouts/rooms/{_q(breakout_id)}")
328
+
329
+ def announce(self, room_id: str, message: str) -> dict[str, Any]:
330
+ """Shows a short message in every room. Not end-to-end encrypted."""
331
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/announce", {"message": message})
332
+
333
+ def end(self, room_id: str, *, warn: Optional[bool] = None) -> dict[str, Any]:
334
+ return self._c._request("POST", f"/rooms/{_q(room_id)}/breakouts/end", _drop_none({"warn": warn}))
335
+
336
+
337
+ class Recordings:
338
+ def __init__(self, client: Deewan):
339
+ self._c = client
340
+
341
+ def list(self, *, room_id: Optional[str] = None, limit: Optional[int] = None) -> dict[str, Any]:
342
+ return self._c._request("GET", "/recordings", query={"room_id": room_id, "limit": limit})
343
+
344
+ def retrieve(self, recording_id: str) -> dict[str, Any]:
345
+ return self._c._request("GET", f"/recordings/{_q(recording_id)}")
346
+
347
+ def delete(self, recording_id: str) -> dict[str, Any]:
348
+ """Deletes the recording and its stored parts. Cannot be undone."""
349
+ return self._c._request("DELETE", f"/recordings/{_q(recording_id)}")
350
+
351
+ def download_info(self, recording_id: str) -> dict[str, Any]:
352
+ """The encrypted parts and the key for this one recording."""
353
+ return self._c._request("POST", f"/recordings/{_q(recording_id)}/download")
354
+
355
+ def download(self, recording_id: str) -> tuple[bytes, str]:
356
+ """Fetches a recording and decrypts it here. Returns ``(bytes, mime_type)``.
357
+
358
+ Needs ``pip install "deewan[recordings]"``. Recordings are encrypted
359
+ where they were made; this is the only way to get a playable file.
360
+ """
361
+ try:
362
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
363
+ except ImportError as error: # pragma: no cover
364
+ raise ImportError('Recordings.download needs: pip install "deewan[recordings]"') from error
365
+ info = self.download_info(recording_id)
366
+ enc = info["encryption"]
367
+ key = base64.urlsafe_b64decode(enc["key"] + "=" * (-len(enc["key"]) % 4))
368
+ iv_bytes = int(enc["iv_bytes"])
369
+ cipher = AESGCM(key)
370
+ chunks = []
371
+ for part in info["parts"]:
372
+ index = part["index"]
373
+ body = self._c._send("GET", f"/recordings/{_q(recording_id)}/parts/{int(index)}")
374
+ aad = f"deewan-recording:{recording_id}:{index}".encode("utf-8")
375
+ chunks.append(cipher.decrypt(body[:iv_bytes], body[iv_bytes:], aad))
376
+ return b"".join(chunks), info["mime_type"]
377
+
378
+
379
+ class Webhooks:
380
+ def __init__(self, client: Deewan):
381
+ self._c = client
382
+
383
+ def list(self) -> dict[str, Any]:
384
+ return self._c._request("GET", "/webhooks")
385
+
386
+ def create(self, *, url: str, events: list[str], description: Optional[str] = None, enabled: Optional[bool] = None) -> dict[str, Any]:
387
+ """The response's ``secret`` is shown only once — store it."""
388
+ body = _drop_none({"url": url, "events": events, "description": description, "enabled": enabled})
389
+ return self._c._request("POST", "/webhooks", body)
390
+
391
+ def update(self, webhook_id: str, **changes: Any) -> dict[str, Any]:
392
+ return self._c._request("PATCH", f"/webhooks/{_q(webhook_id)}", changes)
393
+
394
+ def delete(self, webhook_id: str) -> dict[str, Any]:
395
+ return self._c._request("DELETE", f"/webhooks/{_q(webhook_id)}")
396
+
397
+ def test(self, webhook_id: str) -> dict[str, Any]:
398
+ return self._c._request("POST", f"/webhooks/{_q(webhook_id)}/test")
399
+
400
+ def deliveries(self, webhook_id: str) -> dict[str, Any]:
401
+ """The 50 most recent delivery attempts to this endpoint, newest first."""
402
+ return self._c._request("GET", f"/webhooks/{_q(webhook_id)}/deliveries")
deewan/_webhooks.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any, Optional, Union
8
+
9
+ from ._client import DeewanError
10
+
11
+
12
+ def verify_webhook(
13
+ raw_body: Union[str, bytes],
14
+ signature_header: Optional[str],
15
+ secret: str,
16
+ tolerance_seconds: int = 300,
17
+ ) -> dict[str, Any]:
18
+ """Verifies a webhook request and returns the parsed event.
19
+
20
+ Pass the raw body exactly as received (not re-serialized JSON) and the
21
+ ``Deewan-Signature`` header. Signatures older than ``tolerance_seconds``
22
+ are rejected, to stop replays.
23
+ """
24
+ if not signature_header:
25
+ raise DeewanError(400, "missing_signature", "Missing Deewan-Signature header.")
26
+ parts: dict[str, str] = {}
27
+ for item in signature_header.split(","):
28
+ key, _, value = item.strip().partition("=")
29
+ parts[key] = value
30
+ try:
31
+ timestamp = int(parts.get("t", ""))
32
+ except ValueError:
33
+ raise DeewanError(400, "bad_signature", "Malformed Deewan-Signature header.") from None
34
+ if not parts.get("v1"):
35
+ raise DeewanError(400, "bad_signature", "Malformed Deewan-Signature header.")
36
+ if abs(time.time() - timestamp) > tolerance_seconds:
37
+ raise DeewanError(400, "stale_signature", "Webhook timestamp is outside the allowed tolerance.")
38
+ body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
39
+ expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.{body}".encode("utf-8"), hashlib.sha256).hexdigest()
40
+ if not hmac.compare_digest(expected, parts["v1"]):
41
+ raise DeewanError(400, "bad_signature", "Webhook signature does not match.")
42
+ return json.loads(body)
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: deewan
3
+ Version: 0.2.0
4
+ Summary: Official server SDK for the Deewan video meetings API: rooms, join links, meetings, polls, breakouts, recordings and webhooks.
5
+ Project-URL: Documentation, https://deewan.io/en/docs/sdk
6
+ Project-URL: Repository, https://github.com/deewanio/deewan-platform/tree/main/packages/python
7
+ Project-URL: Issues, https://github.com/deewanio/deewan-platform/issues
8
+ Author-email: Deewan <support@deewan.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: deewan,e2ee,meetings,video,webrtc
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: recordings
18
+ Requires-Dist: cryptography>=42; extra == 'recordings'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # deewan
22
+
23
+ The official Python server SDK for the [Deewan](https://deewan.io) video meetings API.
24
+
25
+ ```bash
26
+ pip install deewan
27
+ pip install "deewan[recordings]" # only to download (decrypt) recordings
28
+ ```
29
+
30
+ ```python
31
+ import os
32
+ from deewan import Deewan, DeewanError, verify_webhook
33
+
34
+ deewan = Deewan(os.environ["DEEWAN_API_KEY"])
35
+
36
+ room = deewan.rooms.create(name="Onboarding call")
37
+ link = deewan.rooms.create_join_link(room["id"], name="Sara", role="host")
38
+ token = deewan.rooms.create_token(room["id"], name="Omar") # for a custom interface
39
+
40
+ event = verify_webhook(raw_body, headers["Deewan-Signature"], os.environ["DEEWAN_WEBHOOK_SECRET"])
41
+ ```
42
+
43
+ Python 3.9+, no dependencies. Requests time out after 15 s; reads are retried
44
+ on 429 and 5xx, writes only when they can't have been applied. Redirects and
45
+ plain http are refused, so the key is only ever sent to Deewan. Errors are
46
+ `DeewanError` with `status`, `code`, `message` and `request_id`.
47
+
48
+ Server-side only — the API key must never reach a browser or app.
49
+ Full reference: https://deewan.io/en/docs/sdk
@@ -0,0 +1,7 @@
1
+ deewan/__init__.py,sha256=adJVPLKmXy_F5Oynm8B11ifDVL6ZfZukBR-gcLnlejw,506
2
+ deewan/_client.py,sha256=ySXYiwk_hrTzUcWTvECUzb-xB9LrC7FRJ5OLKzsrOlg,18074
3
+ deewan/_webhooks.py,sha256=Hp1FPLmy5EBWqP36HQHfRh0Wb8kF2NY6Z0gBqAKdXU4,1669
4
+ deewan-0.2.0.dist-info/METADATA,sha256=eVBVzzsRZ2yI-BSJ2IQlK4mzzJj1CueNjGS23CIDr-s,1916
5
+ deewan-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ deewan-0.2.0.dist-info/licenses/LICENSE,sha256=md0vUKCEZqoQHGXUNV8uYbE2IgDNvRH5fpTMJ5zGc2w,1063
7
+ deewan-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Deewan
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.