otok 0.1.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.
otok-0.1.0/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .venv/
otok-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SlikkDev
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.
otok-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: otok
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the oToK marketing platform public API (/v1) — contacts, deals, transactional email, webhooks, and a high-level e-commerce layer.
5
+ Project-URL: Homepage, https://github.com/SlikkDev/otok-api/tree/main/sdk/python#readme
6
+ Project-URL: Repository, https://github.com/SlikkDev/otok-api
7
+ Project-URL: Issues, https://github.com/SlikkDev/otok-api/issues
8
+ Author: oToK
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api-client,crm,e-commerce,marketing,otok,sdk,transactional-email,webhooks
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy<2,>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest<9,>=7.4; extra == 'dev'
28
+ Requires-Dist: ruff<0.16,>=0.5; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # otok (Python)
32
+
33
+ Official Python SDK for the [oToK](https://github.com/SlikkDev/otok-api) marketing platform public API (`/v1`).
34
+
35
+ Gives bespoke websites and e-commerce stores out-of-the-box integration with oToK: contact upserts, sales deals, transactional email, WhatsApp templates, campaigns, payments, bookings — plus signed-webhook verification and a high-level e-commerce layer that is safe to retry by design.
36
+
37
+ - **Python 3.9+**, zero runtime dependencies (stdlib `urllib` behind an injectable transport)
38
+ - Full type hints (`py.typed`) derived from the real API contract
39
+ - Automatic retries with exponential backoff + jitter on `429`/`5xx` (honors `Retry-After`)
40
+ - Constant-time webhook signature verification
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install otok
46
+ ```
47
+
48
+ ## Quickstart
49
+
50
+ Create an API key in **Settings → Developers → API keys** in your oToK workspace (keys look like `otok_live_…` and are shown once). All requests go to the oToK API at `https://app.otok.io/api`.
51
+
52
+ ```python
53
+ import os
54
+
55
+ from otok import OtokClient
56
+
57
+ client = OtokClient(api_key=os.environ["OTOK_API_KEY"])
58
+ ```
59
+
60
+ ### Upsert a contact
61
+
62
+ `POST /v1/contacts` upserts by phone (canonicalized to E.164), falling back to email. `tags` and `groups` are **names** — missing ones are created automatically, and on upsert they are *added* (never removed).
63
+
64
+ ```python
65
+ contact = client.contacts.upsert(
66
+ {
67
+ "email": "jane@example.com",
68
+ "phone": "+12025551234",
69
+ "first_name": "Jane",
70
+ "last_name": "Doe",
71
+ "tags": ["VIP", "Newsletter"],
72
+ "custom_fields": {"plan": "gold"},
73
+ }
74
+ )
75
+ ```
76
+
77
+ ### Create a deal from an order (idempotent)
78
+
79
+ `external_reference` maps one order to one deal — a repeat `POST` with the same reference updates that deal instead of creating a duplicate, so retries are always safe.
80
+
81
+ ```python
82
+ pipelines = client.pipelines.list() # map stage ids once
83
+
84
+ deal = client.deals.create(
85
+ {
86
+ "email": "jane@example.com", # contact matched or created
87
+ "title": "Order A-1001",
88
+ "amount": 249.9,
89
+ "currency": "USD",
90
+ "external_reference": "order:A-1001", # <- idempotency key
91
+ }
92
+ )
93
+
94
+ # Later: mark it won when the order is fulfilled
95
+ client.deals.set_status(deal["id"], {"status": "won"})
96
+ ```
97
+
98
+ Or use the high-level e-commerce layer, which does the contact upsert + idempotent deal (+ optional receipt email) in one call:
99
+
100
+ ```python
101
+ result = client.commerce.track_order(
102
+ {
103
+ "order_id": "A-1001",
104
+ "customer": {"email": "jane@example.com", "name": "Jane Doe", "tags": ["Customer"]},
105
+ "total": 249.9,
106
+ "currency": "USD",
107
+ "receipt": {"subject": "Your order A-1001", "html": "<p>Thanks for your order!</p>"},
108
+ }
109
+ )
110
+ result.contact, result.deal, result.receipt
111
+ ```
112
+
113
+ `track_order` is safe to call from at-least-once webhook handlers (e.g. a store's `order.created` event): replays converge on the same contact, deal (`order:<id>`), and receipt (`order:<id>:receipt` email idempotency key).
114
+
115
+ ### Send a transactional email
116
+
117
+ Content passes through verbatim — no footer, tracking, or `List-Unsubscribe` injection unless you opt in. The `idempotency_key` is required; a repeat call returns the original send (`duplicate: true`) and never sends twice.
118
+
119
+ ```python
120
+ result = client.emails.send(
121
+ {
122
+ "to": "jane@example.com",
123
+ "subject": "Your password reset link",
124
+ "html": '<p>Click <a href="https://shop.example.com/reset">here</a>.</p>',
125
+ "idempotency_key": "pwreset:user-42:2026-07-14",
126
+ "tracking": {"opens": True, "clicks": True}, # optional, default off
127
+ "metadata": {"user_id": "42"}, # echoed in webhook events
128
+ }
129
+ )
130
+ # result["status"]: "sent" | "suppressed"; result["duplicate"]: bool
131
+ ```
132
+
133
+ ### Receive delivery webhooks
134
+
135
+ Register an endpoint (max 3 per workspace). The `whsec_…` signing secret is returned **once** — store it.
136
+
137
+ ```python
138
+ endpoint = client.webhook_endpoints.create(
139
+ {
140
+ "url": "https://shop.example.com/api/otok-events",
141
+ # Defaults to the four delivery events; engagement events are opt-in:
142
+ "events": [
143
+ "email.delivered",
144
+ "email.bounced",
145
+ "email.complained",
146
+ "email.failed",
147
+ "email.opened",
148
+ "email.clicked",
149
+ ],
150
+ }
151
+ )
152
+ print(endpoint["secret"]) # whsec_… — shown only now
153
+ ```
154
+
155
+ Events are POSTed with an `X-Otok-Signature: t=<unix>,v1=<hex>` header (HMAC-SHA256 of `"{t}.{body}"` with your secret). Failed deliveries retry for ≈16 hours. **Always verify against the raw request body** — parsing and re-serializing changes the bytes.
156
+
157
+ #### Flask
158
+
159
+ ```python
160
+ import os
161
+
162
+ from flask import Flask, request
163
+
164
+ from otok import OtokWebhookVerificationError, construct_event
165
+
166
+ app = Flask(__name__)
167
+
168
+ @app.post("/api/otok-events")
169
+ def otok_events():
170
+ try:
171
+ event = construct_event(
172
+ request.get_data(), # raw body — keep the exact bytes!
173
+ request.headers.get("X-Otok-Signature"),
174
+ os.environ["OTOK_WEBHOOK_SECRET"],
175
+ )
176
+ except OtokWebhookVerificationError:
177
+ return "bad signature", 400
178
+ if event["type"] == "email.bounced":
179
+ print("bounced:", event["data"]["to"], event["data"].get("bounce_type"))
180
+ elif event["type"] == "email.clicked":
181
+ print("clicked:", event["data"]["url"])
182
+ return "ok", 200 # 2xx stops retries; dedupe on event["id"]
183
+ ```
184
+
185
+ #### FastAPI
186
+
187
+ ```python
188
+ import os
189
+
190
+ from fastapi import FastAPI, Request, Response
191
+
192
+ from otok import OtokWebhookVerificationError, construct_event
193
+
194
+ app = FastAPI()
195
+
196
+ @app.post("/api/otok-events")
197
+ async def otok_events(request: Request) -> Response:
198
+ raw_body = await request.body() # raw bytes — do not parse first
199
+ try:
200
+ event = construct_event(
201
+ raw_body,
202
+ request.headers.get("x-otok-signature"),
203
+ os.environ["OTOK_WEBHOOK_SECRET"],
204
+ )
205
+ except OtokWebhookVerificationError:
206
+ return Response(content="bad signature", status_code=400)
207
+ # ...handle event...
208
+ return Response(content="ok", status_code=200)
209
+ ```
210
+
211
+ #### Django
212
+
213
+ ```python
214
+ import os
215
+
216
+ from django.http import HttpResponse
217
+ from django.views.decorators.csrf import csrf_exempt
218
+
219
+ from otok import OtokWebhookVerificationError, construct_event
220
+
221
+ @csrf_exempt # webhooks carry no CSRF token — the HMAC signature authenticates
222
+ def otok_events(request):
223
+ try:
224
+ event = construct_event(
225
+ request.body, # raw bytes — do not parse first
226
+ request.headers.get("X-Otok-Signature"),
227
+ os.environ["OTOK_WEBHOOK_SECRET"],
228
+ )
229
+ except OtokWebhookVerificationError:
230
+ return HttpResponse("bad signature", status=400)
231
+ # ...handle event...
232
+ return HttpResponse("ok", status=200)
233
+ ```
234
+
235
+ You can also call `verify_webhook_signature(payload, header, secret, tolerance_seconds=300)` directly when you only need a boolean (default timestamp tolerance: 5 minutes).
236
+
237
+ ## API coverage
238
+
239
+ | Namespace | Endpoints |
240
+ |---|---|
241
+ | `client.contacts` | `GET/POST /v1/contacts`, `GET/PATCH /v1/contacts/:id` (POST = upsert by phone/email); notes: `GET/POST /v1/contacts/:id/notes`, `PATCH/DELETE /v1/notes/:id` |
242
+ | `client.tags` | `GET/POST /v1/tags`, `GET/PATCH /v1/tags/:id` |
243
+ | `client.contact_groups` | `GET/POST /v1/contact-groups`, `GET/PATCH /v1/contact-groups/:id` |
244
+ | `client.pipelines` | `GET /v1/pipelines` (with ordered stages) |
245
+ | `client.deals` | `GET/POST /v1/deals`, `GET/PATCH /v1/deals/:id`, `POST /v1/deals/:id/stage`, `POST /v1/deals/:id/status` |
246
+ | `client.emails` | `POST /v1/emails` (transactional, idempotent) |
247
+ | `client.campaigns` | `GET/POST /v1/campaigns`, `GET/PATCH /v1/campaigns/:id`, `POST /v1/campaigns/:id/execute` |
248
+ | `client.templates` | `GET /v1/templates`, `GET /v1/templates/:id`, `POST /v1/templates/:id/send` (WhatsApp) |
249
+ | `client.payments` | `GET/POST /v1/payments`, `GET/PATCH /v1/payments/:id`, `POST …/cancel`, `POST …/entries/:entryId/mark`, `POST …/refund` |
250
+ | `client.meeting_types` | `GET /v1/meeting-types`, `GET /v1/meeting-types/:id`, `GET /v1/meeting-types/:id/slots` |
251
+ | `client.bookings` | `GET/POST /v1/bookings`, `GET /v1/bookings/:id`, `POST …/cancel`, `POST …/reschedule`, `POST …/reassign` |
252
+ | `client.webhook_endpoints` | `GET/POST /v1/webhook-endpoints`, `DELETE /v1/webhook-endpoints/:id` |
253
+ | `client.commerce` | High-level: `identify_customer(customer)`, `track_order(order)` |
254
+
255
+ Request/response field names match the wire contract (snake_case) exactly, so the interactive API reference at `https://app.otok.io/api/v1/docs` applies 1:1. The `commerce` layer accepts friendlier flat dicts and maps them for you.
256
+
257
+ ## Errors, timeouts, retries
258
+
259
+ - Non-2xx responses raise **`OtokAPIError`** with `status`, `code` (machine-readable, when the endpoint uses the `{"error": {"code", "message"}}` envelope, e.g. `endpoint_not_found`, `SLOT_TAKEN`), and the parsed `body`.
260
+ - Slow requests raise **`OtokTimeoutError`** — with the default urllib transport the `timeout` option (default 30 s) bounds each socket operation (connect, each read) rather than a whole attempt's wall-clock time.
261
+ - Redirects are never followed: a 3xx comes back as an `OtokAPIError`, so the bearer API key is never re-sent to a redirect target.
262
+ - `429` and `5xx` responses are retried up to `max_retries` times (default 2) with exponential backoff + full jitter, honoring the `Retry-After` header (both delta-seconds and HTTP-date forms). Network errors are **not** retried automatically in v0.1 — use idempotency keys (`external_reference`, `idempotency_key`) and retry at the call site.
263
+ - Rate limits are enforced per API key (default 100 requests/min; `POST /v1/emails` allows 300/min).
264
+
265
+ ```python
266
+ from otok import OtokAPIError
267
+
268
+ try:
269
+ client.bookings.create({...})
270
+ except OtokAPIError as err:
271
+ if err.code == "SLOT_TAKEN":
272
+ ... # offer another slot
273
+ else:
274
+ raise
275
+ ```
276
+
277
+ ## Examples
278
+
279
+ Runnable scripts live in [`examples/`](./examples):
280
+
281
+ - [`track_order.py`](./examples/track_order.py) — contact upsert + idempotent deal + receipt for a store order
282
+ - [`flask_webhook_receiver.py`](./examples/flask_webhook_receiver.py) — verified webhook receiver (Flask)
283
+ - [`fastapi_webhook_receiver.py`](./examples/fastapi_webhook_receiver.py) — verified webhook receiver (FastAPI)
284
+ - [`django_webhook_receiver.py`](./examples/django_webhook_receiver.py) — verified webhook receiver (Django, single file)
285
+
286
+ ## Development
287
+
288
+ ```bash
289
+ pip install -e ".[dev]"
290
+ pytest
291
+ ruff check .
292
+ mypy
293
+ ```
294
+
295
+ ## Versioning & scope (v0.1)
296
+
297
+ Covered: the e-commerce path end to end (contacts + notes, tags/groups, pipelines/deals, transactional email + webhooks, payments), plus campaigns, WhatsApp templates, and bookings. Sync client only; not covered yet: an async client, list-endpoint `$where` advanced filter helpers, and automatic pagination iterators — planned for a later release.
otok-0.1.0/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # otok (Python)
2
+
3
+ Official Python SDK for the [oToK](https://github.com/SlikkDev/otok-api) marketing platform public API (`/v1`).
4
+
5
+ Gives bespoke websites and e-commerce stores out-of-the-box integration with oToK: contact upserts, sales deals, transactional email, WhatsApp templates, campaigns, payments, bookings — plus signed-webhook verification and a high-level e-commerce layer that is safe to retry by design.
6
+
7
+ - **Python 3.9+**, zero runtime dependencies (stdlib `urllib` behind an injectable transport)
8
+ - Full type hints (`py.typed`) derived from the real API contract
9
+ - Automatic retries with exponential backoff + jitter on `429`/`5xx` (honors `Retry-After`)
10
+ - Constant-time webhook signature verification
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install otok
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ Create an API key in **Settings → Developers → API keys** in your oToK workspace (keys look like `otok_live_…` and are shown once). All requests go to the oToK API at `https://app.otok.io/api`.
21
+
22
+ ```python
23
+ import os
24
+
25
+ from otok import OtokClient
26
+
27
+ client = OtokClient(api_key=os.environ["OTOK_API_KEY"])
28
+ ```
29
+
30
+ ### Upsert a contact
31
+
32
+ `POST /v1/contacts` upserts by phone (canonicalized to E.164), falling back to email. `tags` and `groups` are **names** — missing ones are created automatically, and on upsert they are *added* (never removed).
33
+
34
+ ```python
35
+ contact = client.contacts.upsert(
36
+ {
37
+ "email": "jane@example.com",
38
+ "phone": "+12025551234",
39
+ "first_name": "Jane",
40
+ "last_name": "Doe",
41
+ "tags": ["VIP", "Newsletter"],
42
+ "custom_fields": {"plan": "gold"},
43
+ }
44
+ )
45
+ ```
46
+
47
+ ### Create a deal from an order (idempotent)
48
+
49
+ `external_reference` maps one order to one deal — a repeat `POST` with the same reference updates that deal instead of creating a duplicate, so retries are always safe.
50
+
51
+ ```python
52
+ pipelines = client.pipelines.list() # map stage ids once
53
+
54
+ deal = client.deals.create(
55
+ {
56
+ "email": "jane@example.com", # contact matched or created
57
+ "title": "Order A-1001",
58
+ "amount": 249.9,
59
+ "currency": "USD",
60
+ "external_reference": "order:A-1001", # <- idempotency key
61
+ }
62
+ )
63
+
64
+ # Later: mark it won when the order is fulfilled
65
+ client.deals.set_status(deal["id"], {"status": "won"})
66
+ ```
67
+
68
+ Or use the high-level e-commerce layer, which does the contact upsert + idempotent deal (+ optional receipt email) in one call:
69
+
70
+ ```python
71
+ result = client.commerce.track_order(
72
+ {
73
+ "order_id": "A-1001",
74
+ "customer": {"email": "jane@example.com", "name": "Jane Doe", "tags": ["Customer"]},
75
+ "total": 249.9,
76
+ "currency": "USD",
77
+ "receipt": {"subject": "Your order A-1001", "html": "<p>Thanks for your order!</p>"},
78
+ }
79
+ )
80
+ result.contact, result.deal, result.receipt
81
+ ```
82
+
83
+ `track_order` is safe to call from at-least-once webhook handlers (e.g. a store's `order.created` event): replays converge on the same contact, deal (`order:<id>`), and receipt (`order:<id>:receipt` email idempotency key).
84
+
85
+ ### Send a transactional email
86
+
87
+ Content passes through verbatim — no footer, tracking, or `List-Unsubscribe` injection unless you opt in. The `idempotency_key` is required; a repeat call returns the original send (`duplicate: true`) and never sends twice.
88
+
89
+ ```python
90
+ result = client.emails.send(
91
+ {
92
+ "to": "jane@example.com",
93
+ "subject": "Your password reset link",
94
+ "html": '<p>Click <a href="https://shop.example.com/reset">here</a>.</p>',
95
+ "idempotency_key": "pwreset:user-42:2026-07-14",
96
+ "tracking": {"opens": True, "clicks": True}, # optional, default off
97
+ "metadata": {"user_id": "42"}, # echoed in webhook events
98
+ }
99
+ )
100
+ # result["status"]: "sent" | "suppressed"; result["duplicate"]: bool
101
+ ```
102
+
103
+ ### Receive delivery webhooks
104
+
105
+ Register an endpoint (max 3 per workspace). The `whsec_…` signing secret is returned **once** — store it.
106
+
107
+ ```python
108
+ endpoint = client.webhook_endpoints.create(
109
+ {
110
+ "url": "https://shop.example.com/api/otok-events",
111
+ # Defaults to the four delivery events; engagement events are opt-in:
112
+ "events": [
113
+ "email.delivered",
114
+ "email.bounced",
115
+ "email.complained",
116
+ "email.failed",
117
+ "email.opened",
118
+ "email.clicked",
119
+ ],
120
+ }
121
+ )
122
+ print(endpoint["secret"]) # whsec_… — shown only now
123
+ ```
124
+
125
+ Events are POSTed with an `X-Otok-Signature: t=<unix>,v1=<hex>` header (HMAC-SHA256 of `"{t}.{body}"` with your secret). Failed deliveries retry for ≈16 hours. **Always verify against the raw request body** — parsing and re-serializing changes the bytes.
126
+
127
+ #### Flask
128
+
129
+ ```python
130
+ import os
131
+
132
+ from flask import Flask, request
133
+
134
+ from otok import OtokWebhookVerificationError, construct_event
135
+
136
+ app = Flask(__name__)
137
+
138
+ @app.post("/api/otok-events")
139
+ def otok_events():
140
+ try:
141
+ event = construct_event(
142
+ request.get_data(), # raw body — keep the exact bytes!
143
+ request.headers.get("X-Otok-Signature"),
144
+ os.environ["OTOK_WEBHOOK_SECRET"],
145
+ )
146
+ except OtokWebhookVerificationError:
147
+ return "bad signature", 400
148
+ if event["type"] == "email.bounced":
149
+ print("bounced:", event["data"]["to"], event["data"].get("bounce_type"))
150
+ elif event["type"] == "email.clicked":
151
+ print("clicked:", event["data"]["url"])
152
+ return "ok", 200 # 2xx stops retries; dedupe on event["id"]
153
+ ```
154
+
155
+ #### FastAPI
156
+
157
+ ```python
158
+ import os
159
+
160
+ from fastapi import FastAPI, Request, Response
161
+
162
+ from otok import OtokWebhookVerificationError, construct_event
163
+
164
+ app = FastAPI()
165
+
166
+ @app.post("/api/otok-events")
167
+ async def otok_events(request: Request) -> Response:
168
+ raw_body = await request.body() # raw bytes — do not parse first
169
+ try:
170
+ event = construct_event(
171
+ raw_body,
172
+ request.headers.get("x-otok-signature"),
173
+ os.environ["OTOK_WEBHOOK_SECRET"],
174
+ )
175
+ except OtokWebhookVerificationError:
176
+ return Response(content="bad signature", status_code=400)
177
+ # ...handle event...
178
+ return Response(content="ok", status_code=200)
179
+ ```
180
+
181
+ #### Django
182
+
183
+ ```python
184
+ import os
185
+
186
+ from django.http import HttpResponse
187
+ from django.views.decorators.csrf import csrf_exempt
188
+
189
+ from otok import OtokWebhookVerificationError, construct_event
190
+
191
+ @csrf_exempt # webhooks carry no CSRF token — the HMAC signature authenticates
192
+ def otok_events(request):
193
+ try:
194
+ event = construct_event(
195
+ request.body, # raw bytes — do not parse first
196
+ request.headers.get("X-Otok-Signature"),
197
+ os.environ["OTOK_WEBHOOK_SECRET"],
198
+ )
199
+ except OtokWebhookVerificationError:
200
+ return HttpResponse("bad signature", status=400)
201
+ # ...handle event...
202
+ return HttpResponse("ok", status=200)
203
+ ```
204
+
205
+ You can also call `verify_webhook_signature(payload, header, secret, tolerance_seconds=300)` directly when you only need a boolean (default timestamp tolerance: 5 minutes).
206
+
207
+ ## API coverage
208
+
209
+ | Namespace | Endpoints |
210
+ |---|---|
211
+ | `client.contacts` | `GET/POST /v1/contacts`, `GET/PATCH /v1/contacts/:id` (POST = upsert by phone/email); notes: `GET/POST /v1/contacts/:id/notes`, `PATCH/DELETE /v1/notes/:id` |
212
+ | `client.tags` | `GET/POST /v1/tags`, `GET/PATCH /v1/tags/:id` |
213
+ | `client.contact_groups` | `GET/POST /v1/contact-groups`, `GET/PATCH /v1/contact-groups/:id` |
214
+ | `client.pipelines` | `GET /v1/pipelines` (with ordered stages) |
215
+ | `client.deals` | `GET/POST /v1/deals`, `GET/PATCH /v1/deals/:id`, `POST /v1/deals/:id/stage`, `POST /v1/deals/:id/status` |
216
+ | `client.emails` | `POST /v1/emails` (transactional, idempotent) |
217
+ | `client.campaigns` | `GET/POST /v1/campaigns`, `GET/PATCH /v1/campaigns/:id`, `POST /v1/campaigns/:id/execute` |
218
+ | `client.templates` | `GET /v1/templates`, `GET /v1/templates/:id`, `POST /v1/templates/:id/send` (WhatsApp) |
219
+ | `client.payments` | `GET/POST /v1/payments`, `GET/PATCH /v1/payments/:id`, `POST …/cancel`, `POST …/entries/:entryId/mark`, `POST …/refund` |
220
+ | `client.meeting_types` | `GET /v1/meeting-types`, `GET /v1/meeting-types/:id`, `GET /v1/meeting-types/:id/slots` |
221
+ | `client.bookings` | `GET/POST /v1/bookings`, `GET /v1/bookings/:id`, `POST …/cancel`, `POST …/reschedule`, `POST …/reassign` |
222
+ | `client.webhook_endpoints` | `GET/POST /v1/webhook-endpoints`, `DELETE /v1/webhook-endpoints/:id` |
223
+ | `client.commerce` | High-level: `identify_customer(customer)`, `track_order(order)` |
224
+
225
+ Request/response field names match the wire contract (snake_case) exactly, so the interactive API reference at `https://app.otok.io/api/v1/docs` applies 1:1. The `commerce` layer accepts friendlier flat dicts and maps them for you.
226
+
227
+ ## Errors, timeouts, retries
228
+
229
+ - Non-2xx responses raise **`OtokAPIError`** with `status`, `code` (machine-readable, when the endpoint uses the `{"error": {"code", "message"}}` envelope, e.g. `endpoint_not_found`, `SLOT_TAKEN`), and the parsed `body`.
230
+ - Slow requests raise **`OtokTimeoutError`** — with the default urllib transport the `timeout` option (default 30 s) bounds each socket operation (connect, each read) rather than a whole attempt's wall-clock time.
231
+ - Redirects are never followed: a 3xx comes back as an `OtokAPIError`, so the bearer API key is never re-sent to a redirect target.
232
+ - `429` and `5xx` responses are retried up to `max_retries` times (default 2) with exponential backoff + full jitter, honoring the `Retry-After` header (both delta-seconds and HTTP-date forms). Network errors are **not** retried automatically in v0.1 — use idempotency keys (`external_reference`, `idempotency_key`) and retry at the call site.
233
+ - Rate limits are enforced per API key (default 100 requests/min; `POST /v1/emails` allows 300/min).
234
+
235
+ ```python
236
+ from otok import OtokAPIError
237
+
238
+ try:
239
+ client.bookings.create({...})
240
+ except OtokAPIError as err:
241
+ if err.code == "SLOT_TAKEN":
242
+ ... # offer another slot
243
+ else:
244
+ raise
245
+ ```
246
+
247
+ ## Examples
248
+
249
+ Runnable scripts live in [`examples/`](./examples):
250
+
251
+ - [`track_order.py`](./examples/track_order.py) — contact upsert + idempotent deal + receipt for a store order
252
+ - [`flask_webhook_receiver.py`](./examples/flask_webhook_receiver.py) — verified webhook receiver (Flask)
253
+ - [`fastapi_webhook_receiver.py`](./examples/fastapi_webhook_receiver.py) — verified webhook receiver (FastAPI)
254
+ - [`django_webhook_receiver.py`](./examples/django_webhook_receiver.py) — verified webhook receiver (Django, single file)
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ pip install -e ".[dev]"
260
+ pytest
261
+ ruff check .
262
+ mypy
263
+ ```
264
+
265
+ ## Versioning & scope (v0.1)
266
+
267
+ Covered: the e-commerce path end to end (contacts + notes, tags/groups, pipelines/deals, transactional email + webhooks, payments), plus campaigns, WhatsApp templates, and bookings. Sync client only; not covered yet: an async client, list-endpoint `$where` advanced filter helpers, and automatic pagination iterators — planned for a later release.
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "otok"
7
+ dynamic = ["version"]
8
+ description = "Official Python SDK for the oToK marketing platform public API (/v1) — contacts, deals, transactional email, webhooks, and a high-level e-commerce layer."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "oToK" }]
13
+ keywords = [
14
+ "otok",
15
+ "marketing",
16
+ "crm",
17
+ "sdk",
18
+ "api-client",
19
+ "e-commerce",
20
+ "webhooks",
21
+ "transactional-email",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.9",
30
+ "Programming Language :: Python :: 3.10",
31
+ "Programming Language :: Python :: 3.11",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Programming Language :: Python :: 3.13",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ "Typing :: Typed",
36
+ ]
37
+ dependencies = []
38
+
39
+ [project.optional-dependencies]
40
+ # Pins keep dev tooling compatible with the package's 3.9 floor:
41
+ # mypy 2.x cannot target python_version = 3.9, and pytest 9.x sources use
42
+ # 3.10+ syntax that mypy would refuse to analyze under the 3.9 target.
43
+ # ruff is bounded so a future release adding rules cannot turn CI red
44
+ # without a code change.
45
+ dev = ["pytest>=7.4,<9", "ruff>=0.5,<0.16", "mypy>=1.10,<2"]
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/SlikkDev/otok-api/tree/main/sdk/python#readme"
49
+ Repository = "https://github.com/SlikkDev/otok-api"
50
+ Issues = "https://github.com/SlikkDev/otok-api/issues"
51
+
52
+ [tool.hatch.version]
53
+ path = "src/otok/_version.py"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/otok"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ # Patterns are anchored ("/...") so stray files at other depths in a
60
+ # maintainer's working tree can never leak into a published sdist.
61
+ include = ["/src/otok", "/tests", "/README.md", "/LICENSE", "/pyproject.toml"]
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+
66
+ [tool.ruff]
67
+ target-version = "py39"
68
+ line-length = 100
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "F", "W", "I", "UP", "B", "C4"]
72
+
73
+ [tool.ruff.lint.pyupgrade]
74
+ # Keep typing.List/Optional/Union spellings: annotations here are also read
75
+ # at runtime (TypedDict introspection, casts) and the package supports 3.9.
76
+ keep-runtime-typing = true
77
+
78
+ [tool.ruff.lint.isort]
79
+ known-first-party = ["otok"]
80
+
81
+ [tool.mypy]
82
+ python_version = "3.9"
83
+ strict = true
84
+ files = ["src/otok", "tests"]