sendclipper 0.1.0__tar.gz → 0.2.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.
- sendclipper-0.2.0/CHANGELOG.md +23 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/PKG-INFO +23 -1
- {sendclipper-0.1.0 → sendclipper-0.2.0}/README.md +22 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_core.py +25 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_version.py +1 -1
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/emails.py +9 -3
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/sandboxes.py +4 -3
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/types.py +20 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/test_client.py +72 -0
- sendclipper-0.1.0/CHANGELOG.md +0 -12
- {sendclipper-0.1.0 → sendclipper-0.2.0}/.gitignore +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/LICENSE +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/pyproject.toml +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/__init__.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_client.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_errors.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_webhooks.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/py.typed +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/__init__.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/_base.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/audience.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/domains.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/mailboxes.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/templates.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/webhooks.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/workspace.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/__init__.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/conftest.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/test_webhooks.py +0 -0
- {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/wheel/smoke.py +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-18
|
|
4
|
+
|
|
5
|
+
- `emails.send` and `sandboxes.send` take `replyTo` (one address or a list of up to 10) and `attachments` (a list of `{"filename", "content", "contentType"}`). Give `content` as `bytes`, `bytearray` or `memoryview` and the SDK base64-encodes it for you; a `str` is taken to be base64 already and sent as is. `contentType` is optional.
|
|
6
|
+
- `batch.send` items take `replyTo` too. An item carrying `attachments` is failed by the server with `attachments_not_batchable`; send those messages with `emails.send`.
|
|
7
|
+
- New type `sendclipper.types.Attachment`.
|
|
8
|
+
- Needs the SendClipper API from 2026-10-18 on; an older API ignores both fields.
|
|
9
|
+
|
|
10
|
+
## 0.1.1 — 2026-10-17
|
|
11
|
+
|
|
12
|
+
- README: how to install in a uv workspace whose root has no `[project]` (`uv add --package`). No code change.
|
|
13
|
+
|
|
14
|
+
## 0.1.0 — 2026-10-16
|
|
15
|
+
|
|
16
|
+
First release.
|
|
17
|
+
|
|
18
|
+
- `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.iterate`, `emails.stats`.
|
|
19
|
+
- Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
|
|
20
|
+
- Every method returns a `Result` with `data`, `error` and `headers`; it unpacks to `(data, error)`.
|
|
21
|
+
- Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
|
|
22
|
+
- `verify_webhook()` for `X-SendClipper-Signature-V2`, rotation-aware.
|
|
23
|
+
- No runtime dependencies; typed (`py.typed`), Python 3.9 or newer.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendclipper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Official Python SDK for the SendClipper email API: send mail, manage domains, templates, sandboxes, webhooks, contacts and suppressions.
|
|
5
5
|
Project-URL: Homepage, https://sendclipper.com
|
|
6
6
|
Project-URL: Documentation, https://sendclipper.com/docs#sdk-python
|
|
@@ -39,6 +39,8 @@ uv add sendclipper
|
|
|
39
39
|
poetry add sendclipper
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
In a uv workspace whose root has no `[project]` table, `uv add` refuses at the root: name the member that sends mail with `uv add --package <member> sendclipper`.
|
|
43
|
+
|
|
42
44
|
Python 3.9 or newer. No runtime dependencies. Fully typed.
|
|
43
45
|
|
|
44
46
|
## Setup
|
|
@@ -73,6 +75,26 @@ Parameters and responses are plain dicts whose keys are the API's own field name
|
|
|
73
75
|
|
|
74
76
|
`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
|
|
75
77
|
|
|
78
|
+
## Reply-To and attachments
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
with open("booking-42.pdf", "rb") as f:
|
|
82
|
+
pdf = f.read()
|
|
83
|
+
|
|
84
|
+
data, error = sc.emails.send({
|
|
85
|
+
"from": "bookings@acme.io",
|
|
86
|
+
"to": "ada@lumen.io",
|
|
87
|
+
"subject": "Your booking",
|
|
88
|
+
"html": "<p>Your confirmation is attached.</p>",
|
|
89
|
+
"replyTo": "support@acme.io",
|
|
90
|
+
"attachments": [
|
|
91
|
+
{"filename": "booking-42.pdf", "content": pdf},
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`replyTo` is one address or a list of up to 10. `content` is the file's bytes (`bytes`, `bytearray` or `memoryview`, which the SDK base64-encodes for you) or a `str` that is already base64; `contentType` is guessed from the filename when you leave it out. Limits: 10 MiB per file, 25 MiB of files per message, and your sending provider's own. Attachments are not accepted on a batch item; send those emails on their own. `sandboxes.send` takes the same two fields.
|
|
97
|
+
|
|
76
98
|
## Send with a template
|
|
77
99
|
|
|
78
100
|
Design and publish a template in the dashboard, then send it by id or by its send name:
|
|
@@ -10,6 +10,8 @@ uv add sendclipper
|
|
|
10
10
|
poetry add sendclipper
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
In a uv workspace whose root has no `[project]` table, `uv add` refuses at the root: name the member that sends mail with `uv add --package <member> sendclipper`.
|
|
14
|
+
|
|
13
15
|
Python 3.9 or newer. No runtime dependencies. Fully typed.
|
|
14
16
|
|
|
15
17
|
## Setup
|
|
@@ -44,6 +46,26 @@ Parameters and responses are plain dicts whose keys are the API's own field name
|
|
|
44
46
|
|
|
45
47
|
`from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
|
|
46
48
|
|
|
49
|
+
## Reply-To and attachments
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
with open("booking-42.pdf", "rb") as f:
|
|
53
|
+
pdf = f.read()
|
|
54
|
+
|
|
55
|
+
data, error = sc.emails.send({
|
|
56
|
+
"from": "bookings@acme.io",
|
|
57
|
+
"to": "ada@lumen.io",
|
|
58
|
+
"subject": "Your booking",
|
|
59
|
+
"html": "<p>Your confirmation is attached.</p>",
|
|
60
|
+
"replyTo": "support@acme.io",
|
|
61
|
+
"attachments": [
|
|
62
|
+
{"filename": "booking-42.pdf", "content": pdf},
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`replyTo` is one address or a list of up to 10. `content` is the file's bytes (`bytes`, `bytearray` or `memoryview`, which the SDK base64-encodes for you) or a `str` that is already base64; `contentType` is guessed from the filename when you leave it out. Limits: 10 MiB per file, 25 MiB of files per message, and your sending provider's own. Attachments are not accepted on a batch item; send those emails on their own. `sandboxes.send` takes the same two fields.
|
|
68
|
+
|
|
47
69
|
## Send with a template
|
|
48
70
|
|
|
49
71
|
Design and publish a template in the dashboard, then send it by id or by its send name:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import random
|
|
@@ -427,6 +428,30 @@ def strip_none(params: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
|
427
428
|
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
428
429
|
|
|
429
430
|
|
|
431
|
+
def send_body(params: Mapping[str, Any]) -> Dict[str, Any]:
|
|
432
|
+
"""
|
|
433
|
+
The JSON body of a send (``emails.send``, ``sandboxes.send``, each batch
|
|
434
|
+
item): a copy of ``params`` whose attachment ``content`` given as bytes is
|
|
435
|
+
base64-encoded. A ``str`` content is taken to be base64 already and sent as
|
|
436
|
+
is. The caller's dicts are never changed.
|
|
437
|
+
"""
|
|
438
|
+
body = dict(params)
|
|
439
|
+
attachments = body.get("attachments")
|
|
440
|
+
if isinstance(attachments, (list, tuple)):
|
|
441
|
+
body["attachments"] = [_encode_attachment(a) for a in attachments]
|
|
442
|
+
return body
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _encode_attachment(attachment: Any) -> Any:
|
|
446
|
+
if not isinstance(attachment, Mapping):
|
|
447
|
+
return attachment
|
|
448
|
+
out = dict(attachment)
|
|
449
|
+
content = out.get("content")
|
|
450
|
+
if isinstance(content, (bytes, bytearray, memoryview)):
|
|
451
|
+
out["content"] = base64.b64encode(bytes(content)).decode("ascii")
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
430
455
|
def multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, str, bytes]]) -> Tuple[bytes, str]:
|
|
431
456
|
"""``multipart/form-data``: ``fields`` are (name, value), ``files`` are (name, filename, type, bytes)."""
|
|
432
457
|
boundary = f"----sendclipper{uuid.uuid4().hex}"
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, Iterator, List, Optional, cast
|
|
4
4
|
|
|
5
|
-
from .._core import Result, seg, strip_none
|
|
5
|
+
from .._core import Result, send_body, seg, strip_none
|
|
6
6
|
from ._base import Resource
|
|
7
7
|
|
|
8
8
|
if TYPE_CHECKING:
|
|
@@ -20,8 +20,11 @@ class Emails(Resource):
|
|
|
20
20
|
|
|
21
21
|
Every call carries an ``Idempotency-Key``, so the SDK's own retries never
|
|
22
22
|
send twice. Pass ``idempotency_key=`` yourself to make YOUR retries safe too.
|
|
23
|
+
|
|
24
|
+
``replyTo`` takes one address or a list of up to 10. An attachment's
|
|
25
|
+
``content`` may be ``bytes`` (encoded for you) or a base64 ``str``.
|
|
23
26
|
"""
|
|
24
|
-
return self._http.call("POST", "/emails", body=
|
|
27
|
+
return self._http.call("POST", "/emails", body=send_body(params), **options)
|
|
25
28
|
|
|
26
29
|
def send_batch(self, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
|
|
27
30
|
"""Same as ``batch.send()``."""
|
|
@@ -60,9 +63,12 @@ class Batch(Resource):
|
|
|
60
63
|
"""
|
|
61
64
|
Up to 500 messages in one request (one recipient each). Every item is
|
|
62
65
|
accepted or refused on its own; a refused item costs its siblings nothing.
|
|
66
|
+
|
|
67
|
+
An item may carry ``replyTo``; one carrying ``attachments`` fails with
|
|
68
|
+
``attachments_not_batchable``, so send those messages with ``emails.send()``.
|
|
63
69
|
"""
|
|
64
70
|
return _send_batch(self, emails, **options)
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
def _send_batch(resource: Resource, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
|
|
68
|
-
return resource._http.call("POST", "/emails/batch", body={"emails": [
|
|
74
|
+
return resource._http.call("POST", "/emails/batch", body={"emails": [send_body(e) for e in emails]}, **options)
|
|
@@ -4,7 +4,7 @@ import time
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
|
6
6
|
|
|
7
|
-
from .._core import ErrorResponse, Result, seg, strip_none
|
|
7
|
+
from .._core import ErrorResponse, Result, send_body, seg, strip_none
|
|
8
8
|
from ._base import Resource
|
|
9
9
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
@@ -125,9 +125,10 @@ class Sandboxes(Resource):
|
|
|
125
125
|
def send(self, sandbox_token: str, params: SendEmailParams, **options: Unpack[RequestOptions]) -> Result[SandboxSendResult]:
|
|
126
126
|
"""
|
|
127
127
|
Send to a sandbox through its JSON door, with the sandbox's own
|
|
128
|
-
``sc_sbx_...`` token. Same fields as ``emails.send()
|
|
128
|
+
``sc_sbx_...`` token. Same fields as ``emails.send()`` (``replyTo`` and
|
|
129
|
+
``attachments`` included, bytes encoded for you); nothing is delivered.
|
|
129
130
|
"""
|
|
130
|
-
return self._http.call("POST", "/sandbox/emails", body=
|
|
131
|
+
return self._http.call("POST", "/sandbox/emails", body=send_body(params), bearer=sandbox_token, **options)
|
|
131
132
|
|
|
132
133
|
|
|
133
134
|
def _epoch(iso: str) -> float:
|
|
@@ -41,6 +41,21 @@ _SendEmailRequired = TypedDict(
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class _AttachmentRequired(TypedDict):
|
|
45
|
+
#: The name the recipient sees, e.g. ``"booking-42.pdf"``.
|
|
46
|
+
filename: str
|
|
47
|
+
#: The file's bytes (``bytes``, ``bytearray`` or ``memoryview``, which the SDK
|
|
48
|
+
#: base64-encodes for you) or a ``str`` that is already standard base64 (sent as is).
|
|
49
|
+
content: Union[str, bytes, bytearray, memoryview]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Attachment(_AttachmentRequired, total=False):
|
|
53
|
+
"""One file on a message. Limits: 10 MiB per file, 25 MiB of files per message, 20 files."""
|
|
54
|
+
|
|
55
|
+
#: A MIME type such as ``"application/pdf"``. Guessed from ``filename`` when left out.
|
|
56
|
+
contentType: str
|
|
57
|
+
|
|
58
|
+
|
|
44
59
|
class SendEmailParams(_SendEmailRequired, total=False):
|
|
45
60
|
"""
|
|
46
61
|
One message. Give it a body (``subject`` + ``html``) or a published template
|
|
@@ -63,6 +78,11 @@ class SendEmailParams(_SendEmailRequired, total=False):
|
|
|
63
78
|
trackOpens: bool
|
|
64
79
|
#: ``False`` leaves links unwrapped. Default ``True``.
|
|
65
80
|
trackClicks: bool
|
|
81
|
+
#: The Reply-To header: one bare address, or a list of 1-10.
|
|
82
|
+
replyTo: Union[str, List[str]]
|
|
83
|
+
#: Files to attach. Not accepted on a batch item: the server fails that item
|
|
84
|
+
#: with ``attachments_not_batchable`` (a batch item does accept ``replyTo``).
|
|
85
|
+
attachments: List[Attachment]
|
|
66
86
|
|
|
67
87
|
|
|
68
88
|
EmailStatus = Literal["queued", "sent", "delivered", "opened", "clicked", "bounced", "complained", "failed"]
|
|
@@ -233,6 +233,78 @@ def test_sandboxes_send_uses_the_sandbox_token_instead_of_the_api_key(make_clien
|
|
|
233
233
|
assert t.calls[0].headers["Authorization"] == "Bearer sc_sbx_abc"
|
|
234
234
|
|
|
235
235
|
|
|
236
|
+
PDF = b"%PDF-1.7\n\x00\xff binary"
|
|
237
|
+
PDF_B64 = "JVBERi0xLjcKAP8gYmluYXJ5"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_send_carries_reply_to_and_encodes_bytes_attachments_while_a_base64_str_is_sent_as_is(make_client: Any) -> None:
|
|
241
|
+
sc, t = make_client([json(201, SENT)])
|
|
242
|
+
params: Any = {
|
|
243
|
+
**MSG,
|
|
244
|
+
"replyTo": ["support@acme.io", "billing@acme.io"],
|
|
245
|
+
"attachments": [
|
|
246
|
+
{"filename": "booking-42.pdf", "content": PDF, "contentType": "application/pdf"},
|
|
247
|
+
{"filename": "notes.txt", "content": "aGVsbG8="},
|
|
248
|
+
],
|
|
249
|
+
}
|
|
250
|
+
sc.emails.send(params)
|
|
251
|
+
assert t.calls[0].body == {
|
|
252
|
+
**MSG,
|
|
253
|
+
"replyTo": ["support@acme.io", "billing@acme.io"],
|
|
254
|
+
"attachments": [
|
|
255
|
+
{"filename": "booking-42.pdf", "content": PDF_B64, "contentType": "application/pdf"},
|
|
256
|
+
{"filename": "notes.txt", "content": "aGVsbG8="},
|
|
257
|
+
],
|
|
258
|
+
}
|
|
259
|
+
# The caller's own dicts are left alone.
|
|
260
|
+
assert params["attachments"][0]["content"] == PDF
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_bytearray_and_memoryview_content_are_encoded_too(make_client: Any) -> None:
|
|
264
|
+
sc, t = make_client([json(201, SENT)])
|
|
265
|
+
sc.emails.send(
|
|
266
|
+
{
|
|
267
|
+
**MSG,
|
|
268
|
+
"replyTo": "support@acme.io",
|
|
269
|
+
"attachments": [
|
|
270
|
+
{"filename": "a.bin", "content": bytearray(PDF)},
|
|
271
|
+
{"filename": "b.bin", "content": memoryview(PDF)},
|
|
272
|
+
],
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
assert t.calls[0].body["replyTo"] == "support@acme.io"
|
|
276
|
+
assert [a["content"] for a in t.calls[0].body["attachments"]] == [PDF_B64, PDF_B64]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_sandboxes_send_encodes_attachments_like_emails_send(make_client: Any) -> None:
|
|
280
|
+
sc, t = make_client([json(201, {"id": "sbx-msg"})])
|
|
281
|
+
sc.sandboxes.send(
|
|
282
|
+
"sc_sbx_abc",
|
|
283
|
+
{**MSG, "replyTo": "support@acme.io", "attachments": [{"filename": "booking-42.pdf", "content": PDF}]},
|
|
284
|
+
)
|
|
285
|
+
assert t.calls[0].body == {
|
|
286
|
+
**MSG,
|
|
287
|
+
"replyTo": "support@acme.io",
|
|
288
|
+
"attachments": [{"filename": "booking-42.pdf", "content": PDF_B64}],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_batch_items_carry_reply_to_and_any_attachments_are_encoded(make_client: Any) -> None:
|
|
293
|
+
sc, t = make_client([json(200, {"data": [], "summary": {"submitted": 2, "accepted": 0, "failed": 0}})])
|
|
294
|
+
sc.batch.send(
|
|
295
|
+
[
|
|
296
|
+
{**MSG, "replyTo": "support@acme.io"},
|
|
297
|
+
{**MSG, "attachments": [{"filename": "x.pdf", "content": PDF}]},
|
|
298
|
+
]
|
|
299
|
+
)
|
|
300
|
+
assert t.calls[0].body == {
|
|
301
|
+
"emails": [
|
|
302
|
+
{**MSG, "replyTo": "support@acme.io"},
|
|
303
|
+
{**MSG, "attachments": [{"filename": "x.pdf", "content": PDF_B64}]},
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
236
308
|
def test_contacts_import_is_multipart_carries_no_idempotency_key_and_is_never_retried(make_client: Any) -> None:
|
|
237
309
|
sc, t = make_client([json(503, {"error": {"code": "unavailable", "message": "down"}})])
|
|
238
310
|
_, error = sc.contacts.import_({"file": "name,email\nAda,ada@lumen.io\n", "filename": "contacts.csv", "onDuplicate": "update"})
|
sendclipper-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 — 2026-10-16
|
|
4
|
-
|
|
5
|
-
First release.
|
|
6
|
-
|
|
7
|
-
- `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.iterate`, `emails.stats`.
|
|
8
|
-
- Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
|
|
9
|
-
- Every method returns a `Result` with `data`, `error` and `headers`; it unpacks to `(data, error)`.
|
|
10
|
-
- Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
|
|
11
|
-
- `verify_webhook()` for `X-SendClipper-Signature-V2`, rotation-aware.
|
|
12
|
-
- No runtime dependencies; typed (`py.typed`), Python 3.9 or newer.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|