inboxjot 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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
inboxjot-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) Arik Chakma
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1,340 @@
1
+ Metadata-Version: 2.5
2
+ Name: inboxjot
3
+ Version: 0.1.0
4
+ Summary: The official Python SDK for the Inboxjot email API
5
+ Project-URL: Homepage, https://inboxjot.com
6
+ Project-URL: Documentation, https://inboxjot.com/docs
7
+ Project-URL: Repository, https://github.com/inboxjot/inboxjot-python
8
+ Project-URL: Issues, https://github.com/inboxjot/inboxjot-python/issues
9
+ Author-email: Arik Chakma <arikchangma@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: email,email-api,inboxjot,sdk,transactional-email
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Communications :: Email
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx<1,>=0.27
26
+ Description-Content-Type: text/markdown
27
+
28
+ ![Inboxjot](https://inboxjot.com/og.png)
29
+
30
+ <p align="center">The official Python SDK for the Inboxjot email API. <br />Email for builders and their agents.</p>
31
+
32
+ <p align="center">
33
+ <a href="https://pypi.org/project/inboxjot/"><img src="https://inboxjot.com/badges/pypi.svg?v=2" alt="PyPI" height="24" /></a>
34
+ <a href="./LICENSE"><img src="https://inboxjot.com/badges/license.svg?v=2" alt="License" height="24" /></a>
35
+ <a href="https://inboxjot.com/docs"><img src="https://inboxjot.com/badges/docs.svg?v=2" alt="Docs" height="24" /></a>
36
+ </p>
37
+
38
+ Send email, manage contacts, campaigns, templates and domains, and verify webhooks from your Python app. It needs Python 3.10+ and has one runtime dependency, [`httpx`](https://www.python-httpx.org). It has a sync client, `Inboxjot`, and an async client, `AsyncInboxjot`, with the same methods.
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ pip install inboxjot
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from inboxjot import Inboxjot
50
+
51
+ inboxjot = Inboxjot(api_key="ibx_...")
52
+
53
+ email = inboxjot.emails.send(
54
+ {
55
+ "from": "Acme <hi@acme.dev>",
56
+ "to": "jane@example.com",
57
+ "subject": "Welcome",
58
+ "html": "<p>Hi Jane</p>",
59
+ }
60
+ )
61
+ print(email["id"])
62
+ ```
63
+
64
+ If you do not give an API key, the client reads `INBOXJOT_API_KEY` from the environment.
65
+
66
+ ```python
67
+ inboxjot = Inboxjot(
68
+ base_url="https://api.inboxjot.com",
69
+ timeout=60.0,
70
+ max_retries=2,
71
+ http_client=httpx.Client(),
72
+ )
73
+ ```
74
+
75
+ The client keeps a connection pool. Use it as a context manager, or call `inboxjot.close()` when you are done.
76
+
77
+ The async client takes the same options, and an `httpx.AsyncClient` as `http_client`:
78
+
79
+ ```python
80
+ from inboxjot import AsyncInboxjot
81
+
82
+ async with AsyncInboxjot() as inboxjot:
83
+ email = await inboxjot.emails.get("em_...")
84
+ ```
85
+
86
+ ### Emails
87
+
88
+ ```python
89
+ inboxjot.emails.send(
90
+ {
91
+ "from": sender,
92
+ "to": ["a@example.com"],
93
+ "subject": subject,
94
+ "text": text,
95
+ "scheduled_at": "2026-10-20T09:00:00Z",
96
+ }
97
+ )
98
+ inboxjot.emails.batch(
99
+ [
100
+ {"from": sender, "to": "a@example.com", "subject": subject, "html": html},
101
+ {"from": sender, "to": "b@example.com", "subject": subject, "html": html},
102
+ ]
103
+ )
104
+ inboxjot.emails.list({"status": "delivered", "per_page": 20})
105
+ inboxjot.emails.get("em_...")
106
+ inboxjot.emails.update("em_...", {"scheduled_at": "2026-10-21T09:00:00Z"})
107
+ inboxjot.emails.cancel("em_...")
108
+ inboxjot.emails.list_attachments("em_...")
109
+ inboxjot.emails.get_attachment("em_...", "att_...")
110
+ ```
111
+
112
+ ### Contacts and events
113
+
114
+ ```python
115
+ contact = inboxjot.contacts.create({"email": "jane@example.com", "first_name": "Jane"})
116
+ inboxjot.contacts.list({"status": "subscribed", "per_page": 50})
117
+ inboxjot.contacts.get(contact["id"])
118
+ inboxjot.contacts.get("jane@example.com")
119
+ inboxjot.contacts.update(contact["id"], {"topics": [{"id": "tp_...", "status": "unsubscribed"}]})
120
+ inboxjot.contacts.delete(contact["id"])
121
+ inboxjot.contacts.erase({"email": "jane@example.com"})
122
+
123
+ inboxjot.events.send({"name": "signed_up", "email": "jane@example.com", "data": {"plan": "pro"}})
124
+ ```
125
+
126
+ `contacts.remove` deletes the contact. `contacts.erase` queues a privacy erasure of the address, its emails, events and logs.
127
+
128
+ ### Campaigns
129
+
130
+ ```python
131
+ campaign = inboxjot.campaigns.create({"name": "October", "subject": "News"})
132
+ inboxjot.campaigns.list({"status": "draft,scheduled", "per_page": 20})
133
+ inboxjot.campaigns.get(campaign["id"])
134
+ inboxjot.campaigns.update(campaign["id"], {"subject": "October news"})
135
+ inboxjot.campaigns.send_test(campaign["id"], {"to": ["jane@example.com"]})
136
+ inboxjot.campaigns.send(campaign["id"])
137
+ inboxjot.campaigns.send(campaign["id"], {"scheduled_at": "2026-10-20T09:00:00Z"})
138
+ inboxjot.campaigns.cancel(campaign["id"])
139
+ inboxjot.campaigns.delete(campaign["id"])
140
+ ```
141
+
142
+ ### Templates
143
+
144
+ ```python
145
+ template = inboxjot.templates.create({"name": "Welcome", "starter_id": "welcome"})
146
+ inboxjot.templates.list({"sort": "name"})
147
+ inboxjot.templates.get(template["id"])
148
+ inboxjot.templates.update(template["id"], {"subject": "Welcome"})
149
+ inboxjot.templates.publish(template["id"])
150
+ inboxjot.templates.delete(template["id"])
151
+ ```
152
+
153
+ ### Versions
154
+
155
+ Campaigns and templates have the same version methods. `update_version` always takes `name`, and `None` clears it.
156
+
157
+ ```python
158
+ version = inboxjot.templates.create_version(template["id"], {"name": "Launch"})
159
+ inboxjot.templates.list_versions(template["id"], {"filter": "saved"})
160
+ inboxjot.templates.get_version(template["id"], version["id"])
161
+ inboxjot.templates.update_version(template["id"], version["id"], {"name": None})
162
+ inboxjot.templates.restore_version(template["id"], version["id"])
163
+ ```
164
+
165
+ ### Domains
166
+
167
+ ```python
168
+ domain = inboxjot.domains.create({"name": "acme.dev"})
169
+ inboxjot.domains.list()
170
+ inboxjot.domains.get(domain["id"])
171
+ inboxjot.domains.verify(domain["id"])
172
+ inboxjot.domains.delete(domain["id"])
173
+ ```
174
+
175
+ ### Images
176
+
177
+ An image that is already online goes straight into email content as `<img src="https://…">`. To host a file on Inboxjot, upload it. The SDK accepts PNG, JPEG and GIF files.
178
+
179
+ ```python
180
+ with open("logo.png", "rb") as file:
181
+ image = inboxjot.images.upload({"file": file.read(), "file_name": "logo.png"})
182
+
183
+ inboxjot.images.list({"type": "png"})
184
+ inboxjot.images.get(image["id"])
185
+ inboxjot.images.delete(image["id"])
186
+ ```
187
+
188
+ ### Segments and topics
189
+
190
+ ```python
191
+ segment = inboxjot.segments.create({"name": "Beta", "type": "static"})
192
+ inboxjot.segments.list({"type": "static"})
193
+ inboxjot.segments.get(segment["id"])
194
+ inboxjot.segments.update(segment["id"], {"name": "Early Access"})
195
+ inboxjot.segments.add_members(segment["id"], {"contact_ids": ["ct_..."]})
196
+ inboxjot.segments.list_members(segment["id"], {"query": "jane"})
197
+ inboxjot.segments.remove_members(segment["id"], {"contact_ids": ["ct_..."]})
198
+ inboxjot.segments.delete(segment["id"])
199
+
200
+ topic = inboxjot.topics.create({"name": "Product News"})
201
+ inboxjot.topics.list()
202
+ inboxjot.topics.get(topic["id"])
203
+ inboxjot.topics.update(topic["id"], {"visibility": "private"})
204
+ inboxjot.topics.delete(topic["id"])
205
+ ```
206
+
207
+ ### Pagination
208
+
209
+ Lists page with a cursor. Pass `next_cursor` back as `after`. It is `None` on the last page, and the client leaves a `None` cursor out of the request.
210
+
211
+ ```python
212
+ after = None
213
+ while True:
214
+ page = inboxjot.contacts.list({"per_page": 100, "after": after})
215
+ after = page["next_cursor"]
216
+ if after is None:
217
+ break
218
+ ```
219
+
220
+ ### Idempotency and retries
221
+
222
+ Every method takes request options as keyword arguments:
223
+
224
+ ```python
225
+ inboxjot.emails.send(email, idempotency_key=f"welcome-{user.id}")
226
+ ```
227
+
228
+ | Option | Use |
229
+ | ----------------- | ------------------------------------------- |
230
+ | `idempotency_key` | Sends the `Idempotency-Key` header. |
231
+ | `timeout` | Overrides the client timeout, in seconds. |
232
+ | `max_retries` | Overrides the client retry count. |
233
+
234
+ The client retries with exponential backoff, and it honors `Retry-After`. A `Retry-After` over 60 seconds raises the error with no retry:
235
+
236
+ - `429` and `409 concurrent_idempotent_requests` always retry. The API did not run these requests.
237
+ - `5xx`, timeouts and network errors retry only when the retry is safe: a `GET` or `DELETE`, or a request with an `idempotency_key`.
238
+
239
+ An email send without a key is never retried after a network error, because that can send the email twice. Give a key to make it safe.
240
+
241
+ ### Errors
242
+
243
+ A failed request raises. An API error is an `InboxjotError` with the fields of the API error body. A network error or timeout is an `InboxjotConnectionError`.
244
+
245
+ ```python
246
+ from inboxjot import InboxjotError
247
+
248
+ try:
249
+ inboxjot.emails.get("em_...")
250
+ except InboxjotError as error:
251
+ if error.name == "not_found":
252
+ ... # error.status, error.message, error.errors, error.request_id, error.headers
253
+ ```
254
+
255
+ The SDK raises instead of returning `(data, error)`. A result tuple makes you check every call, and an unchecked error turns into `None` data later in the code. A raised error stops the flow at the failed call, and it works with `asyncio.gather` and with the retry loop. `error.name` is typed as a `Literal` of the API error names.
256
+
257
+ ### Webhooks
258
+
259
+ ```python
260
+ webhook = inboxjot.webhooks.create(
261
+ {"name": "Production", "url": "https://acme.dev/hook", "events": ["email.bounced"]}
262
+ )
263
+ print(webhook["secret"])
264
+ inboxjot.webhooks.list()
265
+ inboxjot.webhooks.get(webhook["id"])
266
+ inboxjot.webhooks.update(webhook["id"], {"status": "disabled"})
267
+ inboxjot.webhooks.delete(webhook["id"])
268
+ ```
269
+
270
+ Inboxjot signs webhooks with the [Standard Webhooks](https://www.standardwebhooks.com) spec. Verify the raw request body before you parse it:
271
+
272
+ ```python
273
+ import os
274
+
275
+ from fastapi import FastAPI, Request, Response
276
+ from inboxjot import verify_webhook
277
+
278
+ app = FastAPI()
279
+
280
+
281
+ @app.post("/webhooks/inboxjot")
282
+ async def handle(request: Request):
283
+ event = verify_webhook(
284
+ payload=await request.body(),
285
+ headers=request.headers,
286
+ secret=os.environ["INBOXJOT_WEBHOOK_SECRET"],
287
+ )
288
+ if event["type"] == "email.bounced":
289
+ print(event["data"]["email_id"], event["data"]["bounce"]["type"])
290
+ elif event["type"] == "contact.created":
291
+ print(event["data"]["contact_id"])
292
+ return Response(status_code=204)
293
+ ```
294
+
295
+ `inboxjot.webhooks.verify` is the same function. The payload can be `str` or `bytes`, and the headers can be any mapping, such as a `dict` or a framework's headers object. A bad signature, a missing header or a timestamp more than 5 minutes off raises a `WebhookVerificationError`.
296
+
297
+ ## Types
298
+
299
+ `inboxjot.types` has a `TypedDict` or `Literal` for each schema in the Inboxjot OpenAPI spec, with the names the TypeScript SDK uses (`SendEmailOptions`, `Email`, `Contact`, `WebhookEvent` and the others). Request and response bodies are plain dicts with the API's own keys, so `scheduled_at` stays `scheduled_at`. `WebhookEvent` is a union that a type checker narrows on `event["type"]`.
300
+
301
+ ```python
302
+ from inboxjot.types import Email, SendEmailOptions
303
+
304
+ email: SendEmailOptions = {"to": "jane@example.com", "subject": "Hi", "text": "Hi"}
305
+ ```
306
+
307
+ The client does not validate responses, so a new enum value or field reaches your code as is.
308
+
309
+ If a type is wrong, fix the spec in the API and update `src/inboxjot/types.py` to match.
310
+
311
+ ## Layout
312
+
313
+ ```
314
+ src/inboxjot/_client.py Inboxjot, AsyncInboxjot and request(): auth, idempotency, timeout, retries
315
+ src/inboxjot/_emails.py emails.list, send, batch, get, update, cancel, list_attachments, get_attachment
316
+ src/inboxjot/_contacts.py contacts.list, create, get, update, remove, erase
317
+ src/inboxjot/_events.py events.send
318
+ src/inboxjot/_campaigns.py campaigns.list, create, get, update, remove, send, cancel, send_test, versions
319
+ src/inboxjot/_templates.py templates.list, create, get, update, remove, publish, versions
320
+ src/inboxjot/_domains.py domains.list, create, get, remove, verify
321
+ src/inboxjot/_images.py images.list, upload, get, remove
322
+ src/inboxjot/_segments.py segments.list, create, get, update, remove, list_members, add_members, remove_members
323
+ src/inboxjot/_topics.py topics.list, create, get, update, remove
324
+ src/inboxjot/_webhooks.py webhooks.list, create, get, update, remove, verify, verify_webhook
325
+ src/inboxjot/_errors.py InboxjotError, InboxjotConnectionError, WebhookVerificationError
326
+ src/inboxjot/types.py the request and response types
327
+ ```
328
+
329
+ ## Development
330
+
331
+ ```sh
332
+ uv sync
333
+ uv run pytest
334
+ uv run ruff check
335
+ uv run ruff format
336
+ uv run pyright
337
+ uv build
338
+ ```
339
+
340
+ To release, run `uv version --bump patch` (or `minor`), commit `chore: release vX.Y.Z`, then tag `vX.Y.Z` and push the tag. The release workflow checks, builds and publishes to PyPI.
@@ -0,0 +1,313 @@
1
+ ![Inboxjot](https://inboxjot.com/og.png)
2
+
3
+ <p align="center">The official Python SDK for the Inboxjot email API. <br />Email for builders and their agents.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://pypi.org/project/inboxjot/"><img src="https://inboxjot.com/badges/pypi.svg?v=2" alt="PyPI" height="24" /></a>
7
+ <a href="./LICENSE"><img src="https://inboxjot.com/badges/license.svg?v=2" alt="License" height="24" /></a>
8
+ <a href="https://inboxjot.com/docs"><img src="https://inboxjot.com/badges/docs.svg?v=2" alt="Docs" height="24" /></a>
9
+ </p>
10
+
11
+ Send email, manage contacts, campaigns, templates and domains, and verify webhooks from your Python app. It needs Python 3.10+ and has one runtime dependency, [`httpx`](https://www.python-httpx.org). It has a sync client, `Inboxjot`, and an async client, `AsyncInboxjot`, with the same methods.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pip install inboxjot
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from inboxjot import Inboxjot
23
+
24
+ inboxjot = Inboxjot(api_key="ibx_...")
25
+
26
+ email = inboxjot.emails.send(
27
+ {
28
+ "from": "Acme <hi@acme.dev>",
29
+ "to": "jane@example.com",
30
+ "subject": "Welcome",
31
+ "html": "<p>Hi Jane</p>",
32
+ }
33
+ )
34
+ print(email["id"])
35
+ ```
36
+
37
+ If you do not give an API key, the client reads `INBOXJOT_API_KEY` from the environment.
38
+
39
+ ```python
40
+ inboxjot = Inboxjot(
41
+ base_url="https://api.inboxjot.com",
42
+ timeout=60.0,
43
+ max_retries=2,
44
+ http_client=httpx.Client(),
45
+ )
46
+ ```
47
+
48
+ The client keeps a connection pool. Use it as a context manager, or call `inboxjot.close()` when you are done.
49
+
50
+ The async client takes the same options, and an `httpx.AsyncClient` as `http_client`:
51
+
52
+ ```python
53
+ from inboxjot import AsyncInboxjot
54
+
55
+ async with AsyncInboxjot() as inboxjot:
56
+ email = await inboxjot.emails.get("em_...")
57
+ ```
58
+
59
+ ### Emails
60
+
61
+ ```python
62
+ inboxjot.emails.send(
63
+ {
64
+ "from": sender,
65
+ "to": ["a@example.com"],
66
+ "subject": subject,
67
+ "text": text,
68
+ "scheduled_at": "2026-10-20T09:00:00Z",
69
+ }
70
+ )
71
+ inboxjot.emails.batch(
72
+ [
73
+ {"from": sender, "to": "a@example.com", "subject": subject, "html": html},
74
+ {"from": sender, "to": "b@example.com", "subject": subject, "html": html},
75
+ ]
76
+ )
77
+ inboxjot.emails.list({"status": "delivered", "per_page": 20})
78
+ inboxjot.emails.get("em_...")
79
+ inboxjot.emails.update("em_...", {"scheduled_at": "2026-10-21T09:00:00Z"})
80
+ inboxjot.emails.cancel("em_...")
81
+ inboxjot.emails.list_attachments("em_...")
82
+ inboxjot.emails.get_attachment("em_...", "att_...")
83
+ ```
84
+
85
+ ### Contacts and events
86
+
87
+ ```python
88
+ contact = inboxjot.contacts.create({"email": "jane@example.com", "first_name": "Jane"})
89
+ inboxjot.contacts.list({"status": "subscribed", "per_page": 50})
90
+ inboxjot.contacts.get(contact["id"])
91
+ inboxjot.contacts.get("jane@example.com")
92
+ inboxjot.contacts.update(contact["id"], {"topics": [{"id": "tp_...", "status": "unsubscribed"}]})
93
+ inboxjot.contacts.delete(contact["id"])
94
+ inboxjot.contacts.erase({"email": "jane@example.com"})
95
+
96
+ inboxjot.events.send({"name": "signed_up", "email": "jane@example.com", "data": {"plan": "pro"}})
97
+ ```
98
+
99
+ `contacts.remove` deletes the contact. `contacts.erase` queues a privacy erasure of the address, its emails, events and logs.
100
+
101
+ ### Campaigns
102
+
103
+ ```python
104
+ campaign = inboxjot.campaigns.create({"name": "October", "subject": "News"})
105
+ inboxjot.campaigns.list({"status": "draft,scheduled", "per_page": 20})
106
+ inboxjot.campaigns.get(campaign["id"])
107
+ inboxjot.campaigns.update(campaign["id"], {"subject": "October news"})
108
+ inboxjot.campaigns.send_test(campaign["id"], {"to": ["jane@example.com"]})
109
+ inboxjot.campaigns.send(campaign["id"])
110
+ inboxjot.campaigns.send(campaign["id"], {"scheduled_at": "2026-10-20T09:00:00Z"})
111
+ inboxjot.campaigns.cancel(campaign["id"])
112
+ inboxjot.campaigns.delete(campaign["id"])
113
+ ```
114
+
115
+ ### Templates
116
+
117
+ ```python
118
+ template = inboxjot.templates.create({"name": "Welcome", "starter_id": "welcome"})
119
+ inboxjot.templates.list({"sort": "name"})
120
+ inboxjot.templates.get(template["id"])
121
+ inboxjot.templates.update(template["id"], {"subject": "Welcome"})
122
+ inboxjot.templates.publish(template["id"])
123
+ inboxjot.templates.delete(template["id"])
124
+ ```
125
+
126
+ ### Versions
127
+
128
+ Campaigns and templates have the same version methods. `update_version` always takes `name`, and `None` clears it.
129
+
130
+ ```python
131
+ version = inboxjot.templates.create_version(template["id"], {"name": "Launch"})
132
+ inboxjot.templates.list_versions(template["id"], {"filter": "saved"})
133
+ inboxjot.templates.get_version(template["id"], version["id"])
134
+ inboxjot.templates.update_version(template["id"], version["id"], {"name": None})
135
+ inboxjot.templates.restore_version(template["id"], version["id"])
136
+ ```
137
+
138
+ ### Domains
139
+
140
+ ```python
141
+ domain = inboxjot.domains.create({"name": "acme.dev"})
142
+ inboxjot.domains.list()
143
+ inboxjot.domains.get(domain["id"])
144
+ inboxjot.domains.verify(domain["id"])
145
+ inboxjot.domains.delete(domain["id"])
146
+ ```
147
+
148
+ ### Images
149
+
150
+ An image that is already online goes straight into email content as `<img src="https://…">`. To host a file on Inboxjot, upload it. The SDK accepts PNG, JPEG and GIF files.
151
+
152
+ ```python
153
+ with open("logo.png", "rb") as file:
154
+ image = inboxjot.images.upload({"file": file.read(), "file_name": "logo.png"})
155
+
156
+ inboxjot.images.list({"type": "png"})
157
+ inboxjot.images.get(image["id"])
158
+ inboxjot.images.delete(image["id"])
159
+ ```
160
+
161
+ ### Segments and topics
162
+
163
+ ```python
164
+ segment = inboxjot.segments.create({"name": "Beta", "type": "static"})
165
+ inboxjot.segments.list({"type": "static"})
166
+ inboxjot.segments.get(segment["id"])
167
+ inboxjot.segments.update(segment["id"], {"name": "Early Access"})
168
+ inboxjot.segments.add_members(segment["id"], {"contact_ids": ["ct_..."]})
169
+ inboxjot.segments.list_members(segment["id"], {"query": "jane"})
170
+ inboxjot.segments.remove_members(segment["id"], {"contact_ids": ["ct_..."]})
171
+ inboxjot.segments.delete(segment["id"])
172
+
173
+ topic = inboxjot.topics.create({"name": "Product News"})
174
+ inboxjot.topics.list()
175
+ inboxjot.topics.get(topic["id"])
176
+ inboxjot.topics.update(topic["id"], {"visibility": "private"})
177
+ inboxjot.topics.delete(topic["id"])
178
+ ```
179
+
180
+ ### Pagination
181
+
182
+ Lists page with a cursor. Pass `next_cursor` back as `after`. It is `None` on the last page, and the client leaves a `None` cursor out of the request.
183
+
184
+ ```python
185
+ after = None
186
+ while True:
187
+ page = inboxjot.contacts.list({"per_page": 100, "after": after})
188
+ after = page["next_cursor"]
189
+ if after is None:
190
+ break
191
+ ```
192
+
193
+ ### Idempotency and retries
194
+
195
+ Every method takes request options as keyword arguments:
196
+
197
+ ```python
198
+ inboxjot.emails.send(email, idempotency_key=f"welcome-{user.id}")
199
+ ```
200
+
201
+ | Option | Use |
202
+ | ----------------- | ------------------------------------------- |
203
+ | `idempotency_key` | Sends the `Idempotency-Key` header. |
204
+ | `timeout` | Overrides the client timeout, in seconds. |
205
+ | `max_retries` | Overrides the client retry count. |
206
+
207
+ The client retries with exponential backoff, and it honors `Retry-After`. A `Retry-After` over 60 seconds raises the error with no retry:
208
+
209
+ - `429` and `409 concurrent_idempotent_requests` always retry. The API did not run these requests.
210
+ - `5xx`, timeouts and network errors retry only when the retry is safe: a `GET` or `DELETE`, or a request with an `idempotency_key`.
211
+
212
+ An email send without a key is never retried after a network error, because that can send the email twice. Give a key to make it safe.
213
+
214
+ ### Errors
215
+
216
+ A failed request raises. An API error is an `InboxjotError` with the fields of the API error body. A network error or timeout is an `InboxjotConnectionError`.
217
+
218
+ ```python
219
+ from inboxjot import InboxjotError
220
+
221
+ try:
222
+ inboxjot.emails.get("em_...")
223
+ except InboxjotError as error:
224
+ if error.name == "not_found":
225
+ ... # error.status, error.message, error.errors, error.request_id, error.headers
226
+ ```
227
+
228
+ The SDK raises instead of returning `(data, error)`. A result tuple makes you check every call, and an unchecked error turns into `None` data later in the code. A raised error stops the flow at the failed call, and it works with `asyncio.gather` and with the retry loop. `error.name` is typed as a `Literal` of the API error names.
229
+
230
+ ### Webhooks
231
+
232
+ ```python
233
+ webhook = inboxjot.webhooks.create(
234
+ {"name": "Production", "url": "https://acme.dev/hook", "events": ["email.bounced"]}
235
+ )
236
+ print(webhook["secret"])
237
+ inboxjot.webhooks.list()
238
+ inboxjot.webhooks.get(webhook["id"])
239
+ inboxjot.webhooks.update(webhook["id"], {"status": "disabled"})
240
+ inboxjot.webhooks.delete(webhook["id"])
241
+ ```
242
+
243
+ Inboxjot signs webhooks with the [Standard Webhooks](https://www.standardwebhooks.com) spec. Verify the raw request body before you parse it:
244
+
245
+ ```python
246
+ import os
247
+
248
+ from fastapi import FastAPI, Request, Response
249
+ from inboxjot import verify_webhook
250
+
251
+ app = FastAPI()
252
+
253
+
254
+ @app.post("/webhooks/inboxjot")
255
+ async def handle(request: Request):
256
+ event = verify_webhook(
257
+ payload=await request.body(),
258
+ headers=request.headers,
259
+ secret=os.environ["INBOXJOT_WEBHOOK_SECRET"],
260
+ )
261
+ if event["type"] == "email.bounced":
262
+ print(event["data"]["email_id"], event["data"]["bounce"]["type"])
263
+ elif event["type"] == "contact.created":
264
+ print(event["data"]["contact_id"])
265
+ return Response(status_code=204)
266
+ ```
267
+
268
+ `inboxjot.webhooks.verify` is the same function. The payload can be `str` or `bytes`, and the headers can be any mapping, such as a `dict` or a framework's headers object. A bad signature, a missing header or a timestamp more than 5 minutes off raises a `WebhookVerificationError`.
269
+
270
+ ## Types
271
+
272
+ `inboxjot.types` has a `TypedDict` or `Literal` for each schema in the Inboxjot OpenAPI spec, with the names the TypeScript SDK uses (`SendEmailOptions`, `Email`, `Contact`, `WebhookEvent` and the others). Request and response bodies are plain dicts with the API's own keys, so `scheduled_at` stays `scheduled_at`. `WebhookEvent` is a union that a type checker narrows on `event["type"]`.
273
+
274
+ ```python
275
+ from inboxjot.types import Email, SendEmailOptions
276
+
277
+ email: SendEmailOptions = {"to": "jane@example.com", "subject": "Hi", "text": "Hi"}
278
+ ```
279
+
280
+ The client does not validate responses, so a new enum value or field reaches your code as is.
281
+
282
+ If a type is wrong, fix the spec in the API and update `src/inboxjot/types.py` to match.
283
+
284
+ ## Layout
285
+
286
+ ```
287
+ src/inboxjot/_client.py Inboxjot, AsyncInboxjot and request(): auth, idempotency, timeout, retries
288
+ src/inboxjot/_emails.py emails.list, send, batch, get, update, cancel, list_attachments, get_attachment
289
+ src/inboxjot/_contacts.py contacts.list, create, get, update, remove, erase
290
+ src/inboxjot/_events.py events.send
291
+ src/inboxjot/_campaigns.py campaigns.list, create, get, update, remove, send, cancel, send_test, versions
292
+ src/inboxjot/_templates.py templates.list, create, get, update, remove, publish, versions
293
+ src/inboxjot/_domains.py domains.list, create, get, remove, verify
294
+ src/inboxjot/_images.py images.list, upload, get, remove
295
+ src/inboxjot/_segments.py segments.list, create, get, update, remove, list_members, add_members, remove_members
296
+ src/inboxjot/_topics.py topics.list, create, get, update, remove
297
+ src/inboxjot/_webhooks.py webhooks.list, create, get, update, remove, verify, verify_webhook
298
+ src/inboxjot/_errors.py InboxjotError, InboxjotConnectionError, WebhookVerificationError
299
+ src/inboxjot/types.py the request and response types
300
+ ```
301
+
302
+ ## Development
303
+
304
+ ```sh
305
+ uv sync
306
+ uv run pytest
307
+ uv run ruff check
308
+ uv run ruff format
309
+ uv run pyright
310
+ uv build
311
+ ```
312
+
313
+ To release, run `uv version --bump patch` (or `minor`), commit `chore: release vX.Y.Z`, then tag `vX.Y.Z` and push the tag. The release workflow checks, builds and publishes to PyPI.