ody-sdk 0.6.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.
ody_sdk-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: ody-sdk
3
+ Version: 0.6.0
4
+ Summary: Official Python SDK for the Ody REST API (contacts, conversations, messaging, calls, numbers, AI agent, webhooks) with webhook verification and event parsing.
5
+ License: MIT
6
+ Project-URL: Homepage, https://ody.co
7
+ Project-URL: Documentation, https://dev.ody.co/sdks/python
8
+ Project-URL: API reference, https://dev.ody.co/api/overview
9
+ Project-URL: Source, https://github.com/CosmicHero2/ody-platform/tree/main/sdks/python
10
+ Keywords: ody,sms,mms,voice,ai-agent,contacts,webhooks,api,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Communications :: Telephony
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # ody-sdk
22
+
23
+ Official Python SDK for the [Ody REST API](https://dev.ody.co/api/overview) — contacts, conversations, SMS/MMS sending, calls and the AI agent, phone numbers, and outbound webhooks. Docs: [dev.ody.co/sdks/python](https://dev.ody.co/sdks/python).
24
+
25
+ - **Standard library only** — no dependencies (`urllib.request`, `hmac`, `hashlib`).
26
+ - Python ≥ 3.9, type hints throughout.
27
+ - Auto-pagination generators, typed errors, idempotent sends, Standard-Webhooks verification and webhook event parsing built in.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install ody-sdk
33
+ ```
34
+
35
+ Until the first PyPI release is live (see `sdks/README.md` → Publishing), install from this repo instead:
36
+
37
+ ```bash
38
+ pip install /path/to/ody-platform/sdks/python
39
+ # or for development:
40
+ pip install -e sdks/python
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ ```python
46
+ from ody import Ody, OdyError
47
+
48
+ # Keys come from Settings → Developer API. ody_test_... keys behave identically
49
+ # but message sends are simulated (no real SMS).
50
+ client = Ody("ody_live_...")
51
+ # Override the origin if you call the Cloud Run URL directly:
52
+ # client = Ody(key, base_url="https://ody-api-rtvqk5tg2a-uc.a.run.app")
53
+
54
+ # Contacts
55
+ page = client.contacts.list(q="acme", limit=50) # {"contacts": [...], "nextCursor": ...}
56
+ contact = client.contacts.create(
57
+ first_name="Ada",
58
+ last_name="Lovelace",
59
+ phone="+15125550123",
60
+ tags=["vip"],
61
+ )
62
+
63
+ # Auto-pagination: iterate every contact, following nextCursor for you.
64
+ for c in client.contacts.list_all():
65
+ print(c["id"], c["firstName"])
66
+
67
+ # Conversations
68
+ open_page = client.conversations.list(status="open")
69
+ thread = client.conversations.get(open_page["conversations"][0]["id"])
70
+ client.conversations.set_status(thread["conversation"]["id"], "done")
71
+
72
+ # Send an SMS (idempotent — safe to retry with the same key for 24h)
73
+ sent = client.messages.send(
74
+ to="+15125550123",
75
+ body="Your order shipped!",
76
+ idempotency_key="order-1042-shipped",
77
+ )
78
+ print(sent["conversationId"], sent.get("simulated", False))
79
+
80
+ # Search, calls, numbers
81
+ hits = client.messages.search("refund", limit=10)
82
+ calls = client.calls.list(limit=100)
83
+ numbers = client.numbers.list()
84
+ ```
85
+
86
+ ## Error handling
87
+
88
+ Every non-2xx response raises `OdyError` carrying the structured body plus HTTP status:
89
+
90
+ ```python
91
+ try:
92
+ client.contacts.get("00000000-0000-0000-0000-000000000000")
93
+ except OdyError as err:
94
+ print(err.code) # "not_found", "resource_exhausted", ...
95
+ print(err.status) # 404, 429, ...
96
+ print(err.request_id) # quote this to support
97
+ if err.status == 429:
98
+ print(f"retry in {err.retry_after}s") # from Retry-After
99
+ ```
100
+
101
+ Rate limit: 600 requests/minute per key; every response carries `RateLimit-*` headers and a 429 adds `Retry-After` (surfaced as `err.retry_after`).
102
+
103
+ ## Webhooks
104
+
105
+ Register endpoints and verify deliveries per [Standard Webhooks](https://www.standardwebhooks.com). The signing secret (`whsec_...`) is returned **only** by `create` and `rotate` — store it immediately.
106
+
107
+ ```python
108
+ catalog = client.webhooks.event_types()
109
+ hook = client.webhooks.create(
110
+ url="https://example.com/ody-webhook",
111
+ events=["*"],
112
+ label="prod",
113
+ )
114
+ print(hook["secret"]) # whsec_... — shown once!
115
+
116
+ client.webhooks.test(hook["id"]) # signed {"type": "ping"} delivery
117
+ deliveries = client.webhooks.deliveries(hook["id"]) # debugging view
118
+ ```
119
+
120
+ ### Verifying deliveries
121
+
122
+ `verify_webhook_signature` checks the `v1,<base64 HMAC-SHA256>` signature over `<webhook-id>.<webhook-timestamp>.<raw body>` in constant time and rejects timestamps older than 5 minutes (replay guard). **Pass the raw body bytes** — re-serializing parsed JSON breaks the signature.
123
+
124
+ ```python
125
+ import json
126
+ import os
127
+
128
+ from flask import Flask, request
129
+ from ody import verify_webhook_signature
130
+
131
+ app = Flask(__name__)
132
+
133
+ @app.post("/ody-webhook")
134
+ def ody_webhook():
135
+ ok = verify_webhook_signature(
136
+ os.environ["ODY_WEBHOOK_SECRET"],
137
+ request.headers, # any case-insensitive mapping works
138
+ request.get_data(), # RAW bytes, not request.json
139
+ )
140
+ if not ok:
141
+ return "", 401
142
+
143
+ event = json.loads(request.get_data())
144
+ print("verified event:", event["type"])
145
+ return "", 200
146
+ ```
147
+
148
+ ### One step: `parse_webhook_event`
149
+
150
+ Verify and parse together. Returns the envelope dict (`id`, `apiVersion`, `createdAt`, `type`, `data`); raises `WebhookVerificationError` on a bad or stale signature or a malformed body.
151
+
152
+ ```python
153
+ from ody import parse_webhook_event, WebhookVerificationError, WEBHOOK_EVENT_TYPES, is_known_webhook_event_type
154
+
155
+ @app.post("/ody-webhook")
156
+ def ody_webhook():
157
+ try:
158
+ event = parse_webhook_event(os.environ["ODY_WEBHOOK_SECRET"], request.headers, request.get_data())
159
+ except WebhookVerificationError:
160
+ return "", 401
161
+ if event["type"] == "call.ringing":
162
+ screen_pop(event["data"]["from"], event["data"]["callerName"])
163
+ elif event["type"] in ("contact.updated", "contact.deleted"):
164
+ sync_contact(event["data"]["contactId"])
165
+ elif not is_known_webhook_event_type(event["type"]):
166
+ app.logger.info("new Ody event type %s", event["type"])
167
+ return "", 200
168
+ ```
169
+
170
+ `WEBHOOK_EVENT_TYPES` is the server catalog as a tuple (kept in step by `tests/events/sdkCatalogParity.test.ts` in the platform repo). Every event and its `data` fields: [dev.ody.co/webhooks/events](https://dev.ody.co/webhooks/events).
171
+
172
+ ## API surface
173
+
174
+ | Group | Methods |
175
+ | --- | --- |
176
+ | `client.contacts` | `list`, `list_all` (generator), `create`, `get`, `update`, `delete` |
177
+ | `client.conversations` | `list`, `list_all`, `get`, `set_status` |
178
+ | `client.messages` | `send` (supports `idempotency_key`), `search` |
179
+ | `client.calls` | `list`, `place` (outbound AI call, scope `calls:place`) |
180
+ | `client.numbers` | `list`, `search_available`, `buy` (supports `idempotency_key`), `connect_agent`, `disconnect_agent` |
181
+ | `client.messaging` | `status`, `register` (10DLC brand + campaign), `refresh` |
182
+ | `client.agent` | `get`, `update`, `publish` |
183
+ | `client.knowledge` | `get`, `search`, `list_articles`, `get_article`, `create_article`, `update_article`, `delete_article`, `import_website`, `refresh_article`, `create_folder`, `update_folder`, `delete_folder` |
184
+ | `client.webhooks` | `list`, `create`, `get`, `update`, `delete`, `rotate`, `test`, `deliveries`, `event_types` |
185
+
186
+ ### Agent Phone
187
+
188
+ Provision a working AI phone line end to end — everything an agent buys or
189
+ configures through the API appears in the customer's account exactly as if
190
+ they did it in the app (same billing, same gates, same timeline), and a human
191
+ can take over at any moment:
192
+
193
+ ```python
194
+ candidate = client.numbers.search_available(starts_with="512")[0]
195
+ client.numbers.buy(candidate["phone_number"], idempotency_key="onboard-1")
196
+
197
+ client.messaging.register(business={
198
+ "displayName": "Acme Plumbing", "email": "ops@acme.com", "vertical": "PROFESSIONAL",
199
+ "companyName": "Acme Plumbing LLC", "ein": "12-3456789",
200
+ "street": "600 Congress Ave", "city": "Austin", "state": "TX", "postalCode": "78701",
201
+ })
202
+
203
+ client.agent.update(name="Front Desk", greeting="Thanks for calling Acme!")
204
+ client.agent.publish()
205
+ client.numbers.connect_agent(candidate["phone_number"])
206
+
207
+ placed = client.calls.place(to="+15125550123")
208
+ ```
209
+
210
+ Use an ``ody_test_...`` key to dry-run the whole flow: purchases, filings, and
211
+ calls are simulated — nothing is bought, filed, or dialed.
212
+
213
+ ### Knowledge hub
214
+
215
+ Everything the AI agents (and the inbox's AI suggested replies) answer from
216
+ lives in the workspace's Knowledge hub — Markdown articles in nested folders.
217
+ Articles are live the moment they are saved; published agents are updated
218
+ automatically. Scopes ``knowledge:read`` / ``knowledge:write``.
219
+
220
+ ```python
221
+ tree = client.knowledge.get() # folders, articles, consumers
222
+ sales = client.knowledge.create_folder("Sales line")
223
+
224
+ client.knowledge.import_website("https://acme.com", folder_id=sales["id"])
225
+ client.knowledge.create_article(
226
+ title="Pricing",
227
+ content="## Hourly rate\n$120/h, first hour minimum…",
228
+ folder_id=sales["id"],
229
+ audience="selected",
230
+ consumers=["texting"] + [c["key"] for c in tree["consumers"] if c["name"] == "Sales desk"],
231
+ )
232
+
233
+ hits = client.knowledge.search("how much per hour", consumer="texting")
234
+ ```
235
+
236
+ ## Development
237
+
238
+ ```bash
239
+ python3 -m py_compile ody/__init__.py ody/client.py ody/webhooks.py # syntax check
240
+ python3 -m build # build sdist + wheel
241
+ ```
@@ -0,0 +1,221 @@
1
+ # ody-sdk
2
+
3
+ Official Python SDK for the [Ody REST API](https://dev.ody.co/api/overview) — contacts, conversations, SMS/MMS sending, calls and the AI agent, phone numbers, and outbound webhooks. Docs: [dev.ody.co/sdks/python](https://dev.ody.co/sdks/python).
4
+
5
+ - **Standard library only** — no dependencies (`urllib.request`, `hmac`, `hashlib`).
6
+ - Python ≥ 3.9, type hints throughout.
7
+ - Auto-pagination generators, typed errors, idempotent sends, Standard-Webhooks verification and webhook event parsing built in.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install ody-sdk
13
+ ```
14
+
15
+ Until the first PyPI release is live (see `sdks/README.md` → Publishing), install from this repo instead:
16
+
17
+ ```bash
18
+ pip install /path/to/ody-platform/sdks/python
19
+ # or for development:
20
+ pip install -e sdks/python
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ from ody import Ody, OdyError
27
+
28
+ # Keys come from Settings → Developer API. ody_test_... keys behave identically
29
+ # but message sends are simulated (no real SMS).
30
+ client = Ody("ody_live_...")
31
+ # Override the origin if you call the Cloud Run URL directly:
32
+ # client = Ody(key, base_url="https://ody-api-rtvqk5tg2a-uc.a.run.app")
33
+
34
+ # Contacts
35
+ page = client.contacts.list(q="acme", limit=50) # {"contacts": [...], "nextCursor": ...}
36
+ contact = client.contacts.create(
37
+ first_name="Ada",
38
+ last_name="Lovelace",
39
+ phone="+15125550123",
40
+ tags=["vip"],
41
+ )
42
+
43
+ # Auto-pagination: iterate every contact, following nextCursor for you.
44
+ for c in client.contacts.list_all():
45
+ print(c["id"], c["firstName"])
46
+
47
+ # Conversations
48
+ open_page = client.conversations.list(status="open")
49
+ thread = client.conversations.get(open_page["conversations"][0]["id"])
50
+ client.conversations.set_status(thread["conversation"]["id"], "done")
51
+
52
+ # Send an SMS (idempotent — safe to retry with the same key for 24h)
53
+ sent = client.messages.send(
54
+ to="+15125550123",
55
+ body="Your order shipped!",
56
+ idempotency_key="order-1042-shipped",
57
+ )
58
+ print(sent["conversationId"], sent.get("simulated", False))
59
+
60
+ # Search, calls, numbers
61
+ hits = client.messages.search("refund", limit=10)
62
+ calls = client.calls.list(limit=100)
63
+ numbers = client.numbers.list()
64
+ ```
65
+
66
+ ## Error handling
67
+
68
+ Every non-2xx response raises `OdyError` carrying the structured body plus HTTP status:
69
+
70
+ ```python
71
+ try:
72
+ client.contacts.get("00000000-0000-0000-0000-000000000000")
73
+ except OdyError as err:
74
+ print(err.code) # "not_found", "resource_exhausted", ...
75
+ print(err.status) # 404, 429, ...
76
+ print(err.request_id) # quote this to support
77
+ if err.status == 429:
78
+ print(f"retry in {err.retry_after}s") # from Retry-After
79
+ ```
80
+
81
+ Rate limit: 600 requests/minute per key; every response carries `RateLimit-*` headers and a 429 adds `Retry-After` (surfaced as `err.retry_after`).
82
+
83
+ ## Webhooks
84
+
85
+ Register endpoints and verify deliveries per [Standard Webhooks](https://www.standardwebhooks.com). The signing secret (`whsec_...`) is returned **only** by `create` and `rotate` — store it immediately.
86
+
87
+ ```python
88
+ catalog = client.webhooks.event_types()
89
+ hook = client.webhooks.create(
90
+ url="https://example.com/ody-webhook",
91
+ events=["*"],
92
+ label="prod",
93
+ )
94
+ print(hook["secret"]) # whsec_... — shown once!
95
+
96
+ client.webhooks.test(hook["id"]) # signed {"type": "ping"} delivery
97
+ deliveries = client.webhooks.deliveries(hook["id"]) # debugging view
98
+ ```
99
+
100
+ ### Verifying deliveries
101
+
102
+ `verify_webhook_signature` checks the `v1,<base64 HMAC-SHA256>` signature over `<webhook-id>.<webhook-timestamp>.<raw body>` in constant time and rejects timestamps older than 5 minutes (replay guard). **Pass the raw body bytes** — re-serializing parsed JSON breaks the signature.
103
+
104
+ ```python
105
+ import json
106
+ import os
107
+
108
+ from flask import Flask, request
109
+ from ody import verify_webhook_signature
110
+
111
+ app = Flask(__name__)
112
+
113
+ @app.post("/ody-webhook")
114
+ def ody_webhook():
115
+ ok = verify_webhook_signature(
116
+ os.environ["ODY_WEBHOOK_SECRET"],
117
+ request.headers, # any case-insensitive mapping works
118
+ request.get_data(), # RAW bytes, not request.json
119
+ )
120
+ if not ok:
121
+ return "", 401
122
+
123
+ event = json.loads(request.get_data())
124
+ print("verified event:", event["type"])
125
+ return "", 200
126
+ ```
127
+
128
+ ### One step: `parse_webhook_event`
129
+
130
+ Verify and parse together. Returns the envelope dict (`id`, `apiVersion`, `createdAt`, `type`, `data`); raises `WebhookVerificationError` on a bad or stale signature or a malformed body.
131
+
132
+ ```python
133
+ from ody import parse_webhook_event, WebhookVerificationError, WEBHOOK_EVENT_TYPES, is_known_webhook_event_type
134
+
135
+ @app.post("/ody-webhook")
136
+ def ody_webhook():
137
+ try:
138
+ event = parse_webhook_event(os.environ["ODY_WEBHOOK_SECRET"], request.headers, request.get_data())
139
+ except WebhookVerificationError:
140
+ return "", 401
141
+ if event["type"] == "call.ringing":
142
+ screen_pop(event["data"]["from"], event["data"]["callerName"])
143
+ elif event["type"] in ("contact.updated", "contact.deleted"):
144
+ sync_contact(event["data"]["contactId"])
145
+ elif not is_known_webhook_event_type(event["type"]):
146
+ app.logger.info("new Ody event type %s", event["type"])
147
+ return "", 200
148
+ ```
149
+
150
+ `WEBHOOK_EVENT_TYPES` is the server catalog as a tuple (kept in step by `tests/events/sdkCatalogParity.test.ts` in the platform repo). Every event and its `data` fields: [dev.ody.co/webhooks/events](https://dev.ody.co/webhooks/events).
151
+
152
+ ## API surface
153
+
154
+ | Group | Methods |
155
+ | --- | --- |
156
+ | `client.contacts` | `list`, `list_all` (generator), `create`, `get`, `update`, `delete` |
157
+ | `client.conversations` | `list`, `list_all`, `get`, `set_status` |
158
+ | `client.messages` | `send` (supports `idempotency_key`), `search` |
159
+ | `client.calls` | `list`, `place` (outbound AI call, scope `calls:place`) |
160
+ | `client.numbers` | `list`, `search_available`, `buy` (supports `idempotency_key`), `connect_agent`, `disconnect_agent` |
161
+ | `client.messaging` | `status`, `register` (10DLC brand + campaign), `refresh` |
162
+ | `client.agent` | `get`, `update`, `publish` |
163
+ | `client.knowledge` | `get`, `search`, `list_articles`, `get_article`, `create_article`, `update_article`, `delete_article`, `import_website`, `refresh_article`, `create_folder`, `update_folder`, `delete_folder` |
164
+ | `client.webhooks` | `list`, `create`, `get`, `update`, `delete`, `rotate`, `test`, `deliveries`, `event_types` |
165
+
166
+ ### Agent Phone
167
+
168
+ Provision a working AI phone line end to end — everything an agent buys or
169
+ configures through the API appears in the customer's account exactly as if
170
+ they did it in the app (same billing, same gates, same timeline), and a human
171
+ can take over at any moment:
172
+
173
+ ```python
174
+ candidate = client.numbers.search_available(starts_with="512")[0]
175
+ client.numbers.buy(candidate["phone_number"], idempotency_key="onboard-1")
176
+
177
+ client.messaging.register(business={
178
+ "displayName": "Acme Plumbing", "email": "ops@acme.com", "vertical": "PROFESSIONAL",
179
+ "companyName": "Acme Plumbing LLC", "ein": "12-3456789",
180
+ "street": "600 Congress Ave", "city": "Austin", "state": "TX", "postalCode": "78701",
181
+ })
182
+
183
+ client.agent.update(name="Front Desk", greeting="Thanks for calling Acme!")
184
+ client.agent.publish()
185
+ client.numbers.connect_agent(candidate["phone_number"])
186
+
187
+ placed = client.calls.place(to="+15125550123")
188
+ ```
189
+
190
+ Use an ``ody_test_...`` key to dry-run the whole flow: purchases, filings, and
191
+ calls are simulated — nothing is bought, filed, or dialed.
192
+
193
+ ### Knowledge hub
194
+
195
+ Everything the AI agents (and the inbox's AI suggested replies) answer from
196
+ lives in the workspace's Knowledge hub — Markdown articles in nested folders.
197
+ Articles are live the moment they are saved; published agents are updated
198
+ automatically. Scopes ``knowledge:read`` / ``knowledge:write``.
199
+
200
+ ```python
201
+ tree = client.knowledge.get() # folders, articles, consumers
202
+ sales = client.knowledge.create_folder("Sales line")
203
+
204
+ client.knowledge.import_website("https://acme.com", folder_id=sales["id"])
205
+ client.knowledge.create_article(
206
+ title="Pricing",
207
+ content="## Hourly rate\n$120/h, first hour minimum…",
208
+ folder_id=sales["id"],
209
+ audience="selected",
210
+ consumers=["texting"] + [c["key"] for c in tree["consumers"] if c["name"] == "Sales desk"],
211
+ )
212
+
213
+ hits = client.knowledge.search("how much per hour", consumer="texting")
214
+ ```
215
+
216
+ ## Development
217
+
218
+ ```bash
219
+ python3 -m py_compile ody/__init__.py ody/client.py ody/webhooks.py # syntax check
220
+ python3 -m build # build sdist + wheel
221
+ ```
@@ -0,0 +1,39 @@
1
+ """Official Python SDK for the Ody REST API.
2
+
3
+ Hand-written against the OpenAPI 3.1 contract served at
4
+ ``GET https://api.ody.co/v1/api/openapi.json``. Standard library only —
5
+ no third-party dependencies.
6
+
7
+ Docs: https://dev.ody.co/sdks/python · Events: https://dev.ody.co/webhooks/events
8
+
9
+ Quickstart::
10
+
11
+ from ody import Ody
12
+
13
+ client = Ody("ody_live_...")
14
+ page = client.contacts.list(q="acme")
15
+ for contact in page["contacts"]:
16
+ print(contact["id"])
17
+ """
18
+
19
+ from .client import Ody, OdyError
20
+ from .webhooks import (
21
+ WEBHOOK_EVENT_TYPES,
22
+ WebhookVerificationError,
23
+ is_known_webhook_event_type,
24
+ parse_webhook_event,
25
+ verify_webhook_signature,
26
+ )
27
+
28
+ __version__ = "0.6.0"
29
+
30
+ __all__ = [
31
+ "Ody",
32
+ "OdyError",
33
+ "WEBHOOK_EVENT_TYPES",
34
+ "WebhookVerificationError",
35
+ "is_known_webhook_event_type",
36
+ "parse_webhook_event",
37
+ "verify_webhook_signature",
38
+ "__version__",
39
+ ]