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 +9 -0
- otok-0.1.0/LICENSE +21 -0
- otok-0.1.0/PKG-INFO +297 -0
- otok-0.1.0/README.md +267 -0
- otok-0.1.0/pyproject.toml +84 -0
- otok-0.1.0/src/otok/__init__.py +187 -0
- otok-0.1.0/src/otok/_http.py +316 -0
- otok-0.1.0/src/otok/_version.py +1 -0
- otok-0.1.0/src/otok/client.py +86 -0
- otok-0.1.0/src/otok/commerce.py +205 -0
- otok-0.1.0/src/otok/errors.py +43 -0
- otok-0.1.0/src/otok/py.typed +0 -0
- otok-0.1.0/src/otok/resources.py +477 -0
- otok-0.1.0/src/otok/types.py +703 -0
- otok-0.1.0/src/otok/webhooks.py +163 -0
- otok-0.1.0/tests/__init__.py +0 -0
- otok-0.1.0/tests/helpers.py +52 -0
- otok-0.1.0/tests/test_commerce.py +194 -0
- otok-0.1.0/tests/test_http.py +342 -0
- otok-0.1.0/tests/test_resources.py +383 -0
- otok-0.1.0/tests/test_webhooks.py +202 -0
otok-0.1.0/.gitignore
ADDED
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"]
|