vaartix 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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ .env
13
+ .env.*
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.1.0
6
+
7
+ - First release: `emails.send`, `get`, `list`, `cancel`, `batch.send`, webhook endpoints, and
8
+ `verify_webhook` for signed webhooks (Standard Webhooks).
9
+ - Idempotency keys, scheduled sending and test mode.
10
+ - No dependencies; Python 3.10 or newer; fully typed.
vaartix-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NAP Designs
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.
vaartix-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: vaartix
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Vaartix email API
5
+ Project-URL: Homepage, https://vaartix.com
6
+ Project-URL: Documentation, https://vaartix.com/docs/
7
+ Project-URL: Source, https://github.com/vaartix-mail/vaartix-python
8
+ Project-URL: Issues, https://github.com/vaartix-mail/vaartix-python/issues
9
+ Project-URL: Changelog, https://github.com/vaartix-mail/vaartix-python/blob/main/CHANGELOG.md
10
+ Author-email: NAP Designs <support@vaartix.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,email,india,sdk,ses,transactional-email,vaartix
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Communications :: Email
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+
29
+ # vaartix
30
+
31
+ Official Python SDK for [Vaartix](https://vaartix.com), the email API for Indian developers.
32
+
33
+ > Vaartix is in private beta. [Request early access](https://vaartix.com/#early-access).
34
+
35
+ ```sh
36
+ pip install vaartix
37
+ ```
38
+
39
+ Python 3.10 or newer. No dependencies.
40
+
41
+ ## Send an email
42
+
43
+ ```python
44
+ from vaartix import Vaartix
45
+
46
+ vaartix = Vaartix() # reads the VAARTIX_API_KEY environment variable
47
+
48
+ email = vaartix.emails.send(
49
+ {
50
+ "from": "Acme <onboarding@acme.in>",
51
+ "to": ["priya@example.com"],
52
+ "subject": "Hello from Vaartix",
53
+ "html": "<strong>It works!</strong>",
54
+ }
55
+ )
56
+ print(email["id"]) # em_...
57
+ ```
58
+
59
+ Emails are plain dicts because `from` is a Python keyword. Keys can be `snake_case`
60
+ (`reply_to`, `scheduled_at`) or the API's camelCase (`replyTo`, `scheduledAt`). Responses are the
61
+ API's JSON as dicts, exactly as in the [API reference](https://vaartix.com/docs/).
62
+
63
+ ```python
64
+ # Retry safely: the same key within 24 hours returns the first email instead of sending twice.
65
+ vaartix.emails.send(email, idempotency_key=f"welcome-{user.id}")
66
+
67
+ # Send later (up to 30 days ahead), and cancel if plans change.
68
+ scheduled = vaartix.emails.send({**email, "scheduled_at": "2026-11-01T09:00:00+05:30"})
69
+ vaartix.emails.cancel(scheduled["id"])
70
+
71
+ # Up to 100 emails in one request.
72
+ vaartix.batch.send([email_1, email_2])
73
+
74
+ # Look up an email and its delivery events, or list recent bounces.
75
+ vaartix.emails.get("em_...")
76
+ vaartix.emails.list(status="bounced", days=7)
77
+
78
+ # Try it without a domain: test addresses simulate delivery, bounces and complaints.
79
+ vaartix.emails.send(
80
+ {
81
+ "from": "onboarding@test.vaartix.com",
82
+ "to": "delivered@test.vaartix.com",
83
+ "subject": "Hello",
84
+ "text": "It works!",
85
+ }
86
+ )
87
+ ```
88
+
89
+ ## Errors
90
+
91
+ API and network errors raise `VaartixError`:
92
+
93
+ ```python
94
+ from vaartix import VaartixError
95
+
96
+ try:
97
+ vaartix.emails.send(email)
98
+ except VaartixError as err:
99
+ print(err.type, err.status_code, err.message) # e.g. rate_limited 429 Too many requests...
100
+ ```
101
+
102
+ `err.type` is a stable code; the full list is in [Errors](https://vaartix.com/docs/concepts/errors/).
103
+ Network failures have `type == "network_error"` and `status_code is None`.
104
+
105
+ ## Webhooks
106
+
107
+ Check that a webhook request really came from Vaartix before trusting it. Pass the **raw** body.
108
+
109
+ ```python
110
+ import os
111
+
112
+ from flask import Flask, request
113
+ from vaartix import verify_webhook, WebhookVerificationError
114
+
115
+ app = Flask(__name__)
116
+
117
+
118
+ @app.post("/webhooks/vaartix")
119
+ def vaartix_webhook():
120
+ try:
121
+ event = verify_webhook(
122
+ request.get_data(), # raw bytes, not request.json
123
+ request.headers,
124
+ os.environ["VAARTIX_WEBHOOK_SECRET"],
125
+ )
126
+ except WebhookVerificationError:
127
+ return "", 400
128
+ if event["type"] == "email.bounced":
129
+ ... # stop emailing event["data"]["recipient"]
130
+ return "", 200
131
+ ```
132
+
133
+ In Django use `request.body` and `request.headers`. Signatures follow the
134
+ [Standard Webhooks](https://www.standardwebhooks.com) spec.
135
+
136
+ Manage endpoints with `vaartix.webhooks.create(url=..., events=[...])`, `list()`, `get()`,
137
+ `update()`, `remove()`, `rotate_secret()`, `deliveries()` and `retry_delivery()`.
138
+
139
+ ## Docs
140
+
141
+ Full API reference and guides: [vaartix.com/docs](https://vaartix.com/docs/).
142
+
143
+ ## Contributing and security
144
+
145
+ Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report
146
+ security problems privately as described in [SECURITY.md](SECURITY.md).
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,122 @@
1
+ # vaartix
2
+
3
+ Official Python SDK for [Vaartix](https://vaartix.com), the email API for Indian developers.
4
+
5
+ > Vaartix is in private beta. [Request early access](https://vaartix.com/#early-access).
6
+
7
+ ```sh
8
+ pip install vaartix
9
+ ```
10
+
11
+ Python 3.10 or newer. No dependencies.
12
+
13
+ ## Send an email
14
+
15
+ ```python
16
+ from vaartix import Vaartix
17
+
18
+ vaartix = Vaartix() # reads the VAARTIX_API_KEY environment variable
19
+
20
+ email = vaartix.emails.send(
21
+ {
22
+ "from": "Acme <onboarding@acme.in>",
23
+ "to": ["priya@example.com"],
24
+ "subject": "Hello from Vaartix",
25
+ "html": "<strong>It works!</strong>",
26
+ }
27
+ )
28
+ print(email["id"]) # em_...
29
+ ```
30
+
31
+ Emails are plain dicts because `from` is a Python keyword. Keys can be `snake_case`
32
+ (`reply_to`, `scheduled_at`) or the API's camelCase (`replyTo`, `scheduledAt`). Responses are the
33
+ API's JSON as dicts, exactly as in the [API reference](https://vaartix.com/docs/).
34
+
35
+ ```python
36
+ # Retry safely: the same key within 24 hours returns the first email instead of sending twice.
37
+ vaartix.emails.send(email, idempotency_key=f"welcome-{user.id}")
38
+
39
+ # Send later (up to 30 days ahead), and cancel if plans change.
40
+ scheduled = vaartix.emails.send({**email, "scheduled_at": "2026-11-01T09:00:00+05:30"})
41
+ vaartix.emails.cancel(scheduled["id"])
42
+
43
+ # Up to 100 emails in one request.
44
+ vaartix.batch.send([email_1, email_2])
45
+
46
+ # Look up an email and its delivery events, or list recent bounces.
47
+ vaartix.emails.get("em_...")
48
+ vaartix.emails.list(status="bounced", days=7)
49
+
50
+ # Try it without a domain: test addresses simulate delivery, bounces and complaints.
51
+ vaartix.emails.send(
52
+ {
53
+ "from": "onboarding@test.vaartix.com",
54
+ "to": "delivered@test.vaartix.com",
55
+ "subject": "Hello",
56
+ "text": "It works!",
57
+ }
58
+ )
59
+ ```
60
+
61
+ ## Errors
62
+
63
+ API and network errors raise `VaartixError`:
64
+
65
+ ```python
66
+ from vaartix import VaartixError
67
+
68
+ try:
69
+ vaartix.emails.send(email)
70
+ except VaartixError as err:
71
+ print(err.type, err.status_code, err.message) # e.g. rate_limited 429 Too many requests...
72
+ ```
73
+
74
+ `err.type` is a stable code; the full list is in [Errors](https://vaartix.com/docs/concepts/errors/).
75
+ Network failures have `type == "network_error"` and `status_code is None`.
76
+
77
+ ## Webhooks
78
+
79
+ Check that a webhook request really came from Vaartix before trusting it. Pass the **raw** body.
80
+
81
+ ```python
82
+ import os
83
+
84
+ from flask import Flask, request
85
+ from vaartix import verify_webhook, WebhookVerificationError
86
+
87
+ app = Flask(__name__)
88
+
89
+
90
+ @app.post("/webhooks/vaartix")
91
+ def vaartix_webhook():
92
+ try:
93
+ event = verify_webhook(
94
+ request.get_data(), # raw bytes, not request.json
95
+ request.headers,
96
+ os.environ["VAARTIX_WEBHOOK_SECRET"],
97
+ )
98
+ except WebhookVerificationError:
99
+ return "", 400
100
+ if event["type"] == "email.bounced":
101
+ ... # stop emailing event["data"]["recipient"]
102
+ return "", 200
103
+ ```
104
+
105
+ In Django use `request.body` and `request.headers`. Signatures follow the
106
+ [Standard Webhooks](https://www.standardwebhooks.com) spec.
107
+
108
+ Manage endpoints with `vaartix.webhooks.create(url=..., events=[...])`, `list()`, `get()`,
109
+ `update()`, `remove()`, `rotate_secret()`, `deliveries()` and `retry_delivery()`.
110
+
111
+ ## Docs
112
+
113
+ Full API reference and guides: [vaartix.com/docs](https://vaartix.com/docs/).
114
+
115
+ ## Contributing and security
116
+
117
+ Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report
118
+ security problems privately as described in [SECURITY.md](SECURITY.md).
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vaartix"
7
+ dynamic = ["version"]
8
+ description = "Official Python SDK for the Vaartix email API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "NAP Designs", email = "support@vaartix.com" }]
14
+ keywords = ["email", "transactional-email", "api", "india", "vaartix", "ses", "sdk"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Communications :: Email",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://vaartix.com"
33
+ Documentation = "https://vaartix.com/docs/"
34
+ Source = "https://github.com/vaartix-mail/vaartix-python"
35
+ Issues = "https://github.com/vaartix-mail/vaartix-python/issues"
36
+ Changelog = "https://github.com/vaartix-mail/vaartix-python/blob/main/CHANGELOG.md"
37
+
38
+ [tool.hatch.version]
39
+ path = "src/vaartix/_version.py"
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py310"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
53
+ ignore = ["UP006", "UP007", "UP035", "UP045"]
54
+
55
+ [tool.ruff.lint.per-file-ignores]
56
+ "tests/*" = ["E501"]
57
+
58
+ [tool.mypy]
59
+ strict = true
60
+ python_version = "3.10"
61
+ files = ["src"]
@@ -0,0 +1,14 @@
1
+ """Official Python SDK for Vaartix, the email API for Indian developers."""
2
+
3
+ from ._client import Vaartix
4
+ from ._errors import VaartixError, WebhookVerificationError
5
+ from ._verify import verify_webhook
6
+ from ._version import __version__
7
+
8
+ __all__ = [
9
+ "Vaartix",
10
+ "VaartixError",
11
+ "WebhookVerificationError",
12
+ "__version__",
13
+ "verify_webhook",
14
+ ]
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
9
+
10
+ from ._errors import VaartixError
11
+ from ._types import (
12
+ SendEmailParams,
13
+ SendEmailResponse,
14
+ Webhook,
15
+ WebhookEventType,
16
+ WebhookWithSecret,
17
+ )
18
+ from ._verify import verify_webhook
19
+ from ._version import __version__
20
+
21
+ DEFAULT_BASE_URL = "https://api.vaartix.com"
22
+
23
+ # (method, url, headers, body, timeout) -> (status code, response body)
24
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, bytes]]
25
+
26
+ # snake_case keys Python developers expect, mapped to the API's names.
27
+ _KEY_NAMES = {
28
+ "reply_to": "replyTo",
29
+ "scheduled_at": "scheduledAt",
30
+ "content_type": "contentType",
31
+ "content_id": "contentId",
32
+ }
33
+
34
+
35
+ def _api_keys(value: Any) -> Any:
36
+ if isinstance(value, Mapping):
37
+ return {_KEY_NAMES.get(k, k): _api_keys(v) for k, v in value.items()}
38
+ if isinstance(value, (list, tuple)):
39
+ return [_api_keys(v) for v in value]
40
+ return value
41
+
42
+
43
+ def _urllib_transport(
44
+ method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float
45
+ ) -> Tuple[int, bytes]:
46
+ request = urllib.request.Request(url, data=body, headers=headers, method=method)
47
+ try:
48
+ with urllib.request.urlopen(request, timeout=timeout) as response:
49
+ return response.status, response.read()
50
+ except urllib.error.HTTPError as err:
51
+ return err.code, err.read()
52
+
53
+
54
+ def _trim_trailing_slashes(url: str) -> str:
55
+ end = len(url)
56
+ while end > 0 and url[end - 1] == "/":
57
+ end -= 1
58
+ return url[:end]
59
+
60
+
61
+ class Vaartix:
62
+ """Client for the Vaartix email API.
63
+
64
+ >>> vaartix = Vaartix() # reads VAARTIX_API_KEY
65
+ >>> vaartix.emails.send({"from": "Acme <hi@acme.in>", "to": "priya@example.com",
66
+ ... "subject": "Hello", "html": "<p>Hi</p>"})
67
+ {'id': 'em_...'}
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ api_key: Optional[str] = None,
73
+ *,
74
+ base_url: Optional[str] = None,
75
+ timeout: float = 30.0,
76
+ transport: Optional[Transport] = None,
77
+ ) -> None:
78
+ key = api_key or os.environ.get("VAARTIX_API_KEY")
79
+ if not key:
80
+ raise ValueError(
81
+ 'Missing API key. Pass it as Vaartix("vx_...") or set the VAARTIX_API_KEY '
82
+ "environment variable."
83
+ )
84
+ self._key = key
85
+ self._base_url = _trim_trailing_slashes(base_url or DEFAULT_BASE_URL)
86
+ self._timeout = timeout
87
+ self._transport = transport or _urllib_transport
88
+ self.emails = Emails(self)
89
+ self.batch = Batch(self)
90
+ self.webhooks = Webhooks(self)
91
+
92
+ def _request(
93
+ self,
94
+ method: str,
95
+ path: str,
96
+ body: Any = None,
97
+ *,
98
+ query: Optional[Mapping[str, Any]] = None,
99
+ idempotency_key: Optional[str] = None,
100
+ ) -> Any:
101
+ url = self._base_url + path
102
+ if query:
103
+ params = {k: v for k, v in query.items() if v is not None}
104
+ if params:
105
+ url += "?" + urllib.parse.urlencode(params)
106
+ headers = {
107
+ "Authorization": f"Bearer {self._key}",
108
+ "User-Agent": f"vaartix-python/{__version__}",
109
+ "Accept": "application/json",
110
+ }
111
+ data = None
112
+ if body is not None:
113
+ headers["Content-Type"] = "application/json"
114
+ data = json.dumps(body).encode()
115
+ if idempotency_key:
116
+ headers["Idempotency-Key"] = idempotency_key
117
+
118
+ try:
119
+ status, raw = self._transport(method, url, headers, data, self._timeout)
120
+ except (TimeoutError, urllib.error.URLError, OSError) as err:
121
+ reason = getattr(err, "reason", err)
122
+ raise VaartixError("network_error", str(reason)) from err
123
+
124
+ payload: Any = None
125
+ if raw:
126
+ try:
127
+ payload = json.loads(raw)
128
+ except ValueError:
129
+ payload = None
130
+ if status >= 400:
131
+ error = payload.get("error") if isinstance(payload, dict) else None
132
+ error = error if isinstance(error, dict) else {}
133
+ raise VaartixError(
134
+ str(error.get("type") or "api_error"),
135
+ str(error.get("message") or f"Request failed with status {status}"),
136
+ status,
137
+ )
138
+ return payload
139
+
140
+
141
+ class Emails:
142
+ def __init__(self, client: Vaartix) -> None:
143
+ self._client = client
144
+
145
+ def send(
146
+ self, params: SendEmailParams, *, idempotency_key: Optional[str] = None
147
+ ) -> SendEmailResponse:
148
+ """Send an email. Returns ``{"id": "em_..."}`` once it is queued.
149
+
150
+ With ``idempotency_key``, retrying within 24 hours returns the first email instead of
151
+ sending again.
152
+ """
153
+ result: SendEmailResponse = self._client._request(
154
+ "POST", "/emails", _api_keys(params), idempotency_key=idempotency_key
155
+ )
156
+ return result
157
+
158
+ def get(self, email_id: str) -> Dict[str, Any]:
159
+ """An email with its status and event timeline. Needs a full-access key."""
160
+ result: Dict[str, Any] = self._client._request(
161
+ "GET", f"/emails/{urllib.parse.quote(email_id, safe='')}"
162
+ )
163
+ return result
164
+
165
+ def list(
166
+ self,
167
+ *,
168
+ limit: Optional[int] = None,
169
+ before: Optional[str] = None,
170
+ status: Optional[str] = None,
171
+ search: Optional[str] = None,
172
+ days: Optional[int] = None,
173
+ api_key_id: Optional[str] = None,
174
+ ) -> Dict[str, Any]:
175
+ """Recent emails, newest first: ``{"data": [...], "hasMore": bool}``.
176
+
177
+ Page with ``before=<last email ID>``. Needs a full-access key.
178
+ """
179
+ result: Dict[str, Any] = self._client._request(
180
+ "GET",
181
+ "/emails",
182
+ query={
183
+ "limit": limit,
184
+ "before": before,
185
+ "status": status,
186
+ "search": search,
187
+ "days": days,
188
+ "apiKeyId": api_key_id,
189
+ },
190
+ )
191
+ return result
192
+
193
+ def cancel(self, email_id: str) -> Dict[str, Any]:
194
+ """Cancel a scheduled email before it is sent. Needs a full-access key."""
195
+ result: Dict[str, Any] = self._client._request(
196
+ "POST", f"/emails/{urllib.parse.quote(email_id, safe='')}/cancel"
197
+ )
198
+ return result
199
+
200
+
201
+ class Batch:
202
+ def __init__(self, client: Vaartix) -> None:
203
+ self._client = client
204
+
205
+ def send(self, emails: Sequence[SendEmailParams]) -> Dict[str, List[SendEmailResponse]]:
206
+ """Send up to 100 emails in one request (no attachments or scheduling). All or nothing."""
207
+ result: Dict[str, List[SendEmailResponse]] = self._client._request(
208
+ "POST", "/emails/batch", [_api_keys(e) for e in emails]
209
+ )
210
+ return result
211
+
212
+
213
+ class Webhooks:
214
+ """Webhook endpoints. Managing them needs a full-access key."""
215
+
216
+ def __init__(self, client: Vaartix) -> None:
217
+ self._client = client
218
+
219
+ def _path(self, webhook_id: str, suffix: str = "") -> str:
220
+ return f"/webhooks/{urllib.parse.quote(webhook_id, safe='')}{suffix}"
221
+
222
+ def create(self, *, url: str, events: Sequence[WebhookEventType]) -> WebhookWithSecret:
223
+ """Create an endpoint. The response includes the signing secret, shown only this once."""
224
+ result: WebhookWithSecret = self._client._request(
225
+ "POST", "/webhooks", {"url": url, "events": list(events)}
226
+ )
227
+ return result
228
+
229
+ def list(self) -> Dict[str, List[Webhook]]:
230
+ result: Dict[str, List[Webhook]] = self._client._request("GET", "/webhooks")
231
+ return result
232
+
233
+ def get(self, webhook_id: str) -> Webhook:
234
+ result: Webhook = self._client._request("GET", self._path(webhook_id))
235
+ return result
236
+
237
+ def update(
238
+ self,
239
+ webhook_id: str,
240
+ *,
241
+ url: Optional[str] = None,
242
+ events: Optional[Sequence[WebhookEventType]] = None,
243
+ status: Optional[str] = None,
244
+ ) -> Webhook:
245
+ """Change the URL or events, or turn the endpoint off (``status="disabled"``) and on."""
246
+ body: Dict[str, Any] = {}
247
+ if url is not None:
248
+ body["url"] = url
249
+ if events is not None:
250
+ body["events"] = list(events)
251
+ if status is not None:
252
+ body["status"] = status
253
+ result: Webhook = self._client._request("PATCH", self._path(webhook_id), body)
254
+ return result
255
+
256
+ def remove(self, webhook_id: str) -> None:
257
+ self._client._request("DELETE", self._path(webhook_id))
258
+
259
+ def rotate_secret(self, webhook_id: str) -> WebhookWithSecret:
260
+ """Replace the signing secret. The old one stops working immediately."""
261
+ result: WebhookWithSecret = self._client._request(
262
+ "POST", self._path(webhook_id, "/rotate-secret")
263
+ )
264
+ return result
265
+
266
+ def deliveries(self, webhook_id: str, *, limit: Optional[int] = None) -> Dict[str, Any]:
267
+ """Recent deliveries to an endpoint, newest first, with your server's responses."""
268
+ result: Dict[str, Any] = self._client._request(
269
+ "GET", self._path(webhook_id, "/deliveries"), query={"limit": limit}
270
+ )
271
+ return result
272
+
273
+ def retry_delivery(self, webhook_id: str, delivery_id: str) -> Dict[str, Any]:
274
+ """Send a delivery again, with the same ``webhook-id``."""
275
+ path = self._path(
276
+ webhook_id, f"/deliveries/{urllib.parse.quote(delivery_id, safe='')}/retry"
277
+ )
278
+ result: Dict[str, Any] = self._client._request("POST", path)
279
+ return result
280
+
281
+ @staticmethod
282
+ def verify(
283
+ payload: Any, headers: Mapping[str, Any], secret: str, tolerance_seconds: int = 300
284
+ ) -> Dict[str, Any]:
285
+ """Same as the standalone ``verify_webhook``."""
286
+ return verify_webhook(payload, headers, secret, tolerance_seconds)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class VaartixError(Exception):
7
+ """Raised when the API returns an error or cannot be reached.
8
+
9
+ ``type`` is a stable code such as ``validation_error`` or ``rate_limited`` (see
10
+ https://vaartix.com/docs/concepts/errors/). ``status_code`` is ``None`` for network errors.
11
+ """
12
+
13
+ def __init__(self, type: str, message: str, status_code: Optional[int] = None) -> None:
14
+ super().__init__(message)
15
+ self.type = type
16
+ self.message = message
17
+ self.status_code = status_code
18
+
19
+ def __repr__(self) -> str:
20
+ return (
21
+ f"VaartixError(type={self.type!r}, message={self.message!r}, "
22
+ f"status_code={self.status_code!r})"
23
+ )
24
+
25
+
26
+ class WebhookVerificationError(Exception):
27
+ """Raised by ``verify_webhook`` when a request's signature or timestamp is not valid."""
@@ -0,0 +1,83 @@
1
+ """Shapes of the data the API accepts and returns. Responses are the API's JSON, unchanged."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
6
+
7
+ Addresses = Union[str, List[str]]
8
+
9
+
10
+ class Tag(TypedDict):
11
+ name: str
12
+ value: str
13
+
14
+
15
+ class _AttachmentRequired(TypedDict):
16
+ filename: str
17
+ content: str
18
+ """Base64-encoded file content."""
19
+
20
+
21
+ class Attachment(_AttachmentRequired, total=False):
22
+ content_type: str
23
+ content_id: str
24
+ """For inline images: reference it in your HTML as ``cid:<content_id>``."""
25
+
26
+
27
+ # "from" is a Python keyword, so emails are passed as plain dicts:
28
+ # {"from": "...", "to": "...", "subject": "...", "html": "..."}
29
+ # Keys can be snake_case (reply_to, scheduled_at) or the API's camelCase (replyTo, scheduledAt).
30
+ SendEmailParams = Dict[str, Any]
31
+
32
+ EmailStatus = Literal[
33
+ "queued",
34
+ "scheduled",
35
+ "sending",
36
+ "sent",
37
+ "delivered",
38
+ "delivery_delayed",
39
+ "bounced",
40
+ "complained",
41
+ "suppressed",
42
+ "failed",
43
+ "canceled",
44
+ ]
45
+
46
+ WebhookEventType = Literal[
47
+ "email.sent",
48
+ "email.delivered",
49
+ "email.delivery_delayed",
50
+ "email.bounced",
51
+ "email.complained",
52
+ "email.rejected",
53
+ "email.rendering_failure",
54
+ "email.suppressed",
55
+ ]
56
+
57
+
58
+ class SendEmailResponse(TypedDict):
59
+ id: str
60
+
61
+
62
+ class EmailSummary(TypedDict):
63
+ id: str
64
+ status: EmailStatus
65
+ subject: str
66
+ to: List[str]
67
+ createdAt: str
68
+ sentAt: Optional[str]
69
+ scheduledAt: Optional[str]
70
+ error: Optional[str]
71
+
72
+
73
+ class Webhook(TypedDict):
74
+ id: str
75
+ url: str
76
+ events: List[WebhookEventType]
77
+ status: Literal["enabled", "disabled"]
78
+ createdAt: str
79
+
80
+
81
+ class WebhookWithSecret(Webhook):
82
+ secret: str
83
+ """The signing secret (``whsec_...``). Only returned when created or rotated."""
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from typing import Any, Dict, Mapping, Optional, Union
10
+
11
+ from ._errors import WebhookVerificationError
12
+
13
+ _PREFIX = "whsec_"
14
+
15
+
16
+ def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
17
+ # Works with dicts, Django's request.headers, Flask/Werkzeug and Starlette headers.
18
+ getter = getattr(headers, "get", None)
19
+ value = getter(name) if getter else None
20
+ if value is None:
21
+ for key in headers:
22
+ if str(key).lower() == name:
23
+ value = headers[key]
24
+ break
25
+ if isinstance(value, (list, tuple)):
26
+ value = value[0] if value else None
27
+ return None if value is None else str(value)
28
+
29
+
30
+ def verify_webhook(
31
+ payload: Union[str, bytes],
32
+ headers: Mapping[str, Any],
33
+ secret: str,
34
+ tolerance_seconds: int = 300,
35
+ *,
36
+ now: Optional[float] = None,
37
+ ) -> Dict[str, Any]:
38
+ """Check that a webhook request came from Vaartix and return its parsed JSON body.
39
+
40
+ ``payload`` must be the raw request body, exactly as received (for example
41
+ ``request.get_data()`` in Flask or ``request.body`` in Django), not parsed JSON.
42
+ Raises ``WebhookVerificationError`` if the signature or timestamp is wrong.
43
+
44
+ Follows the Standard Webhooks spec, so any Standard Webhooks library works too.
45
+ """
46
+ msg_id = _header(headers, "webhook-id")
47
+ timestamp = _header(headers, "webhook-timestamp")
48
+ signatures = _header(headers, "webhook-signature")
49
+ if not msg_id or not timestamp or not signatures:
50
+ raise WebhookVerificationError(
51
+ "Missing webhook-id, webhook-timestamp or webhook-signature header."
52
+ )
53
+
54
+ try:
55
+ sent_at = int(timestamp)
56
+ except ValueError:
57
+ raise WebhookVerificationError("The webhook timestamp is not a number.") from None
58
+ current = time.time() if now is None else now
59
+ if abs(current - sent_at) > tolerance_seconds:
60
+ raise WebhookVerificationError("The webhook timestamp is too old or in the future.")
61
+
62
+ if not secret.startswith(_PREFIX):
63
+ raise WebhookVerificationError(f"The secret should start with {_PREFIX}.")
64
+ try:
65
+ key = base64.b64decode(secret[len(_PREFIX) :], validate=True)
66
+ except (binascii.Error, ValueError):
67
+ raise WebhookVerificationError("The secret is not valid base64.") from None
68
+
69
+ body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
70
+ signed = f"{msg_id}.{timestamp}.{body}".encode()
71
+ expected = hmac.new(key, signed, hashlib.sha256).digest()
72
+
73
+ # The header can hold several space-separated signatures, e.g. while a secret is rotated.
74
+ for entry in signatures.split(" "):
75
+ version, _, signature = entry.partition(",")
76
+ if version != "v1" or not signature:
77
+ continue
78
+ try:
79
+ given = base64.b64decode(signature)
80
+ except (binascii.Error, ValueError):
81
+ continue
82
+ if hmac.compare_digest(given, expected):
83
+ parsed: Dict[str, Any] = json.loads(body)
84
+ return parsed
85
+ raise WebhookVerificationError("The webhook signature does not match.")
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,164 @@
1
+ import json
2
+ import urllib.error
3
+
4
+ import pytest
5
+
6
+ from vaartix import Vaartix, VaartixError, __version__
7
+
8
+
9
+ class FakeTransport:
10
+ def __init__(self, status=200, body=None):
11
+ self.status = status
12
+ self.body = body if body is not None else {"id": "em_1"}
13
+ self.calls = []
14
+
15
+ def __call__(self, method, url, headers, data, timeout):
16
+ self.calls.append(
17
+ {
18
+ "method": method,
19
+ "url": url,
20
+ "headers": headers,
21
+ "json": json.loads(data) if data else None,
22
+ "timeout": timeout,
23
+ }
24
+ )
25
+ raw = self.body if isinstance(self.body, bytes) else json.dumps(self.body).encode()
26
+ return self.status, raw
27
+
28
+
29
+ def client(transport, **kwargs):
30
+ return Vaartix("vx_test", transport=transport, **kwargs)
31
+
32
+
33
+ def test_sends_with_auth_json_and_idempotency_key():
34
+ t = FakeTransport()
35
+ result = client(t, base_url="https://api.example.test/").emails.send(
36
+ {"from": "a@acme.in", "to": "b@example.com", "subject": "Hi", "html": "<p>Hi</p>"},
37
+ idempotency_key="order-42",
38
+ )
39
+ assert result == {"id": "em_1"}
40
+ call = t.calls[0]
41
+ assert call["method"] == "POST"
42
+ assert call["url"] == "https://api.example.test/emails"
43
+ assert call["headers"]["Authorization"] == "Bearer vx_test"
44
+ assert call["headers"]["Idempotency-Key"] == "order-42"
45
+ assert call["headers"]["User-Agent"] == f"vaartix-python/{__version__}"
46
+ assert call["json"]["subject"] == "Hi"
47
+
48
+
49
+ def test_snake_case_keys_become_api_names():
50
+ t = FakeTransport()
51
+ client(t).emails.send(
52
+ {
53
+ "from": "a@acme.in",
54
+ "to": ["b@example.com"],
55
+ "subject": "Invoice",
56
+ "text": "Attached",
57
+ "reply_to": "billing@acme.in",
58
+ "scheduled_at": "2026-11-01T09:00:00+05:30",
59
+ "attachments": [
60
+ {"filename": "a.pdf", "content": "QQ==", "content_type": "application/pdf"}
61
+ ],
62
+ }
63
+ )
64
+ sent = t.calls[0]["json"]
65
+ assert sent["replyTo"] == "billing@acme.in"
66
+ assert sent["scheduledAt"] == "2026-11-01T09:00:00+05:30"
67
+ assert sent["attachments"][0]["contentType"] == "application/pdf"
68
+ assert "reply_to" not in sent
69
+
70
+
71
+ def test_api_errors_raise_vaartix_error():
72
+ t = FakeTransport(422, {"error": {"type": "validation_error", "message": "from is required"}})
73
+ with pytest.raises(VaartixError) as info:
74
+ client(t).emails.send({"to": "b@example.com", "subject": "Hi"})
75
+ assert info.value.type == "validation_error"
76
+ assert info.value.message == "from is required"
77
+ assert info.value.status_code == 422
78
+
79
+
80
+ def test_non_json_errors_still_raise():
81
+ t = FakeTransport(502, b"<html>Bad gateway</html>")
82
+ with pytest.raises(VaartixError) as info:
83
+ client(t).emails.get("em_1")
84
+ assert info.value.type == "api_error"
85
+ assert info.value.status_code == 502
86
+
87
+
88
+ def test_network_errors_raise_with_no_status():
89
+ def broken(*args):
90
+ raise urllib.error.URLError("connection refused")
91
+
92
+ with pytest.raises(VaartixError) as info:
93
+ client(broken).emails.get("em_1")
94
+ assert info.value.type == "network_error"
95
+ assert info.value.status_code is None
96
+
97
+
98
+ def test_list_builds_query_and_skips_empty_filters():
99
+ t = FakeTransport(body={"data": [], "hasMore": False})
100
+ client(t).emails.list(limit=5, status="bounced", api_key_id="key_1")
101
+ assert (
102
+ t.calls[0]["url"] == "https://api.vaartix.com/emails?limit=5&status=bounced&apiKeyId=key_1"
103
+ )
104
+
105
+
106
+ def test_ids_are_escaped_in_paths():
107
+ t = FakeTransport(body={"id": "em_1", "status": "canceled"})
108
+ client(t).emails.cancel("em_1/../x")
109
+ assert t.calls[0]["url"] == "https://api.vaartix.com/emails/em_1%2F..%2Fx/cancel"
110
+
111
+
112
+ def test_batch_sends_a_list():
113
+ t = FakeTransport(body={"data": [{"id": "em_1"}, {"id": "em_2"}]})
114
+ result = client(t).batch.send(
115
+ [
116
+ {"from": "a@acme.in", "to": "b@example.com", "subject": "1", "text": "x"},
117
+ {
118
+ "from": "a@acme.in",
119
+ "to": "c@example.com",
120
+ "subject": "2",
121
+ "text": "y",
122
+ "reply_to": "r@acme.in",
123
+ },
124
+ ]
125
+ )
126
+ assert [e["id"] for e in result["data"]] == ["em_1", "em_2"]
127
+ assert t.calls[0]["url"].endswith("/emails/batch")
128
+ assert t.calls[0]["json"][1]["replyTo"] == "r@acme.in"
129
+
130
+
131
+ def test_webhook_management():
132
+ t = FakeTransport(body={"id": "wh_1", "status": "disabled"})
133
+ hooks = client(t).webhooks
134
+ hooks.create(url="https://acme.in/hooks", events=["email.bounced"])
135
+ hooks.update("wh_1", status="disabled")
136
+ hooks.deliveries("wh_1", limit=10)
137
+ hooks.retry_delivery("wh_1", "whd_1")
138
+ assert [(c["method"], c["url"].replace("https://api.vaartix.com", "")) for c in t.calls] == [
139
+ ("POST", "/webhooks"),
140
+ ("PATCH", "/webhooks/wh_1"),
141
+ ("GET", "/webhooks/wh_1/deliveries?limit=10"),
142
+ ("POST", "/webhooks/wh_1/deliveries/whd_1/retry"),
143
+ ]
144
+ assert t.calls[0]["json"] == {"url": "https://acme.in/hooks", "events": ["email.bounced"]}
145
+ assert t.calls[1]["json"] == {"status": "disabled"}
146
+
147
+
148
+ def test_api_key_from_environment(monkeypatch):
149
+ monkeypatch.setenv("VAARTIX_API_KEY", "vx_env")
150
+ t = FakeTransport()
151
+ Vaartix(transport=t).emails.get("em_1")
152
+ assert t.calls[0]["headers"]["Authorization"] == "Bearer vx_env"
153
+
154
+
155
+ def test_missing_api_key(monkeypatch):
156
+ monkeypatch.delenv("VAARTIX_API_KEY", raising=False)
157
+ with pytest.raises(ValueError, match="VAARTIX_API_KEY"):
158
+ Vaartix()
159
+
160
+
161
+ def test_many_trailing_slashes_in_base_url():
162
+ t = FakeTransport()
163
+ client(t, base_url="https://api.example.test" + "/" * 50_000).emails.get("em_1")
164
+ assert t.calls[0]["url"] == "https://api.example.test/emails/em_1"
@@ -0,0 +1,79 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from vaartix import Vaartix, WebhookVerificationError, verify_webhook
9
+
10
+ # Test-only secrets, built from a separate prefix so secret scanners don't flag them.
11
+ PREFIX = "whsec_"
12
+
13
+ # The worked example from the Standard Webhooks specification.
14
+ SPEC_SECRET = f"{PREFIX}MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
15
+ SPEC_HEADERS = {
16
+ "webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek",
17
+ "webhook-timestamp": "1614265330",
18
+ "webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
19
+ }
20
+ SPEC_BODY = '{"test": 2432232314}'
21
+ SPEC_NOW = 1614265330
22
+
23
+
24
+ def sign(secret, msg_id, timestamp, body):
25
+ key = base64.b64decode(secret[len(PREFIX) :])
26
+ digest = hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
27
+ return "v1," + base64.b64encode(digest).decode()
28
+
29
+
30
+ def test_matches_the_standard_webhooks_example():
31
+ assert verify_webhook(SPEC_BODY, SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW) == {
32
+ "test": 2432232314
33
+ }
34
+
35
+
36
+ def test_accepts_bytes_and_any_header_case():
37
+ headers = {k.title(): v for k, v in SPEC_HEADERS.items()}
38
+ assert (
39
+ verify_webhook(SPEC_BODY.encode(), headers, SPEC_SECRET, now=SPEC_NOW)["test"] == 2432232314
40
+ )
41
+
42
+
43
+ def test_accepts_one_valid_signature_among_several():
44
+ headers = dict(SPEC_HEADERS)
45
+ headers["webhook-signature"] = "v1,AAAA " + SPEC_HEADERS["webhook-signature"]
46
+ verify_webhook(SPEC_BODY, headers, SPEC_SECRET, now=SPEC_NOW)
47
+
48
+
49
+ def test_rejects_a_changed_body():
50
+ with pytest.raises(WebhookVerificationError, match="does not match"):
51
+ verify_webhook('{"test": 1}', SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW)
52
+
53
+
54
+ def test_rejects_old_timestamps():
55
+ with pytest.raises(WebhookVerificationError, match="too old"):
56
+ verify_webhook(SPEC_BODY, SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW + 301)
57
+
58
+
59
+ def test_rejects_missing_headers_and_bad_secrets():
60
+ with pytest.raises(WebhookVerificationError, match="Missing"):
61
+ verify_webhook(SPEC_BODY, {}, SPEC_SECRET, now=SPEC_NOW)
62
+ with pytest.raises(WebhookVerificationError, match="start with"):
63
+ verify_webhook(SPEC_BODY, SPEC_HEADERS, "not-a-secret", now=SPEC_NOW)
64
+
65
+
66
+ def test_round_trip_with_a_vaartix_style_payload():
67
+ secret = f"{PREFIX}" + base64.b64encode(b"x" * 24).decode()
68
+ body = json.dumps({"type": "email.bounced", "data": {"recipient": "priya@example.com"}})
69
+ headers = {
70
+ "webhook-id": "whd_1",
71
+ "webhook-timestamp": "1793181600",
72
+ "webhook-signature": sign(secret, "whd_1", "1793181600", body),
73
+ }
74
+ result = verify_webhook(body, headers, secret, now=1793181600)
75
+ assert result["data"]["recipient"] == "priya@example.com"
76
+ # The client exposes the same check.
77
+ client = Vaartix("vx_test", transport=lambda *a: (200, b"{}"))
78
+ with pytest.raises(WebhookVerificationError):
79
+ client.webhooks.verify(body, headers, secret) # real clock: timestamp is far off