zerelay 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.
- zerelay-0.1.0/LICENSE +21 -0
- zerelay-0.1.0/PKG-INFO +96 -0
- zerelay-0.1.0/README.md +79 -0
- zerelay-0.1.0/pyproject.toml +29 -0
- zerelay-0.1.0/setup.cfg +4 -0
- zerelay-0.1.0/src/zerelay/__init__.py +8 -0
- zerelay-0.1.0/src/zerelay/_client.py +215 -0
- zerelay-0.1.0/src/zerelay/_errors.py +16 -0
- zerelay-0.1.0/src/zerelay/_version.py +1 -0
- zerelay-0.1.0/src/zerelay/_webhooks.py +58 -0
- zerelay-0.1.0/src/zerelay/py.typed +0 -0
- zerelay-0.1.0/src/zerelay.egg-info/PKG-INFO +96 -0
- zerelay-0.1.0/src/zerelay.egg-info/SOURCES.txt +14 -0
- zerelay-0.1.0/src/zerelay.egg-info/dependency_links.txt +1 -0
- zerelay-0.1.0/src/zerelay.egg-info/top_level.txt +1 -0
- zerelay-0.1.0/tests/test_client.py +141 -0
zerelay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerelay
|
|
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.
|
zerelay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zerelay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Zerelay email and SMS API
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://zerelay.com
|
|
7
|
+
Project-URL: Documentation, https://zerelay.com/developer/docs
|
|
8
|
+
Keywords: zerelay,email,sms,transactional-email,smtp,nigeria,webhooks
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Communications :: Email
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Zerelay for Python
|
|
19
|
+
|
|
20
|
+
Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install zerelay
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Send an email
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from zerelay import Zerelay
|
|
30
|
+
|
|
31
|
+
zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
|
|
32
|
+
|
|
33
|
+
result = zerelay.emails.send(
|
|
34
|
+
to="jane@example.com",
|
|
35
|
+
subject="Your order has shipped",
|
|
36
|
+
body="<p>It will arrive on Friday.</p>",
|
|
37
|
+
)
|
|
38
|
+
print(result["task_id"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
zerelay.emails.mail_send({
|
|
45
|
+
"personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
|
|
46
|
+
"from": {"email": "orders@yourdomain.com", "name": "Your Store"},
|
|
47
|
+
"subject": "Order #1001",
|
|
48
|
+
"content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Send an SMS
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
|
|
56
|
+
print(message["status"], message["cost_kobo"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Test mode
|
|
60
|
+
|
|
61
|
+
Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
|
|
62
|
+
|
|
63
|
+
## Verify webhooks
|
|
64
|
+
|
|
65
|
+
Verify the **raw** request body:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from zerelay import verify_webhook, ZerelayWebhookError
|
|
69
|
+
|
|
70
|
+
# Flask
|
|
71
|
+
@app.post("/webhooks/zerelay")
|
|
72
|
+
def zerelay_webhook():
|
|
73
|
+
try:
|
|
74
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
|
|
75
|
+
except ZerelayWebhookError as exc:
|
|
76
|
+
return str(exc), 400
|
|
77
|
+
# event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
|
|
78
|
+
return "", 200
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In Django use `request.body`. In FastAPI use `await request.body()`.
|
|
82
|
+
|
|
83
|
+
## Errors and retries
|
|
84
|
+
|
|
85
|
+
Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
|
|
86
|
+
|
|
87
|
+
- **Rate limits (429)** are always retried, after the `Retry-After` delay.
|
|
88
|
+
- **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
|
|
89
|
+
|
|
90
|
+
Change this with `Zerelay(max_retries=0)`.
|
|
91
|
+
|
|
92
|
+
Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
zerelay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Zerelay for Python
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install zerelay
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Send an email
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from zerelay import Zerelay
|
|
13
|
+
|
|
14
|
+
zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
|
|
15
|
+
|
|
16
|
+
result = zerelay.emails.send(
|
|
17
|
+
to="jane@example.com",
|
|
18
|
+
subject="Your order has shipped",
|
|
19
|
+
body="<p>It will arrive on Friday.</p>",
|
|
20
|
+
)
|
|
21
|
+
print(result["task_id"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
zerelay.emails.mail_send({
|
|
28
|
+
"personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
|
|
29
|
+
"from": {"email": "orders@yourdomain.com", "name": "Your Store"},
|
|
30
|
+
"subject": "Order #1001",
|
|
31
|
+
"content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Send an SMS
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
|
|
39
|
+
print(message["status"], message["cost_kobo"])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Test mode
|
|
43
|
+
|
|
44
|
+
Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
|
|
45
|
+
|
|
46
|
+
## Verify webhooks
|
|
47
|
+
|
|
48
|
+
Verify the **raw** request body:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from zerelay import verify_webhook, ZerelayWebhookError
|
|
52
|
+
|
|
53
|
+
# Flask
|
|
54
|
+
@app.post("/webhooks/zerelay")
|
|
55
|
+
def zerelay_webhook():
|
|
56
|
+
try:
|
|
57
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
|
|
58
|
+
except ZerelayWebhookError as exc:
|
|
59
|
+
return str(exc), 400
|
|
60
|
+
# event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
|
|
61
|
+
return "", 200
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In Django use `request.body`. In FastAPI use `await request.body()`.
|
|
65
|
+
|
|
66
|
+
## Errors and retries
|
|
67
|
+
|
|
68
|
+
Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
|
|
69
|
+
|
|
70
|
+
- **Rate limits (429)** are always retried, after the `Retry-After` delay.
|
|
71
|
+
- **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
|
|
72
|
+
|
|
73
|
+
Change this with `Zerelay(max_retries=0)`.
|
|
74
|
+
|
|
75
|
+
Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zerelay"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Zerelay email and SMS API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["zerelay", "email", "sms", "transactional-email", "smtp", "nigeria", "webhooks"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Communications :: Email",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://zerelay.com"
|
|
23
|
+
Documentation = "https://zerelay.com/developer/docs"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
zerelay = ["py.typed"]
|
zerelay-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Official Python SDK for the Zerelay email and SMS API. https://zerelay.com/developer/docs"""
|
|
2
|
+
from ._client import DEFAULT_BASE_URL, Zerelay
|
|
3
|
+
from ._errors import ZerelayError, ZerelayWebhookError
|
|
4
|
+
from ._version import VERSION
|
|
5
|
+
from ._webhooks import verify_webhook
|
|
6
|
+
|
|
7
|
+
__version__ = VERSION
|
|
8
|
+
__all__ = ["Zerelay", "ZerelayError", "ZerelayWebhookError", "verify_webhook", "DEFAULT_BASE_URL", "VERSION"]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
from urllib import error as urlerror
|
|
8
|
+
from urllib import parse, request
|
|
9
|
+
|
|
10
|
+
from ._errors import ZerelayError
|
|
11
|
+
from ._version import VERSION
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.zerelay.com"
|
|
14
|
+
_RETRYABLE = {429, 502, 503, 504}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _detail(body: Any, fallback: str) -> str:
|
|
18
|
+
d = body.get("detail") if isinstance(body, dict) else None
|
|
19
|
+
if isinstance(d, list):
|
|
20
|
+
parts = []
|
|
21
|
+
for item in d:
|
|
22
|
+
if isinstance(item, dict) and item.get("msg"):
|
|
23
|
+
parts.append(f"{'.'.join(str(x) for x in item.get('loc', []))}: {item['msg']}")
|
|
24
|
+
else:
|
|
25
|
+
parts.append(str(item))
|
|
26
|
+
return "; ".join(parts)
|
|
27
|
+
return str(d) if d else fallback
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Zerelay:
|
|
31
|
+
"""Zerelay API client.
|
|
32
|
+
|
|
33
|
+
zr = Zerelay("sk_...") # or set ZERELAY_API_KEY
|
|
34
|
+
zr.emails.send(to="jane@example.com", subject="Hi", body="<p>Hello</p>")
|
|
35
|
+
zr.sms.send(to="08031234567", body="Your code is 1234")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: Optional[str] = None,
|
|
41
|
+
*,
|
|
42
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
43
|
+
timeout: float = 30.0,
|
|
44
|
+
max_retries: int = 2,
|
|
45
|
+
) -> None:
|
|
46
|
+
api_key = api_key or os.environ.get("ZERELAY_API_KEY")
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise ValueError("Missing Zerelay API key: pass it to Zerelay(api_key) or set ZERELAY_API_KEY")
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self.max_retries = max_retries
|
|
53
|
+
self.emails = Emails(self)
|
|
54
|
+
self.sms = Sms(self)
|
|
55
|
+
|
|
56
|
+
def key_info(self) -> Dict[str, Any]:
|
|
57
|
+
"""What the configured key is: live/test, full/send-only, and the account's rate limits."""
|
|
58
|
+
return self.request("GET", "/api-keys/current")
|
|
59
|
+
|
|
60
|
+
def request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
query: Optional[Dict[str, Any]] = None,
|
|
66
|
+
body: Any = None,
|
|
67
|
+
headers: Optional[Dict[str, str]] = None,
|
|
68
|
+
idempotent: bool = False,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Call any endpoint (see https://api.zerelay.com/docs). 429s are always retried;
|
|
71
|
+
network errors and 502-504 only for GETs and requests carrying an Idempotency-Key."""
|
|
72
|
+
url = self.base_url + path
|
|
73
|
+
if query:
|
|
74
|
+
clean = {k: ("true" if v is True else "false" if v is False else v) for k, v in query.items() if v is not None}
|
|
75
|
+
if clean:
|
|
76
|
+
url += "?" + parse.urlencode(clean)
|
|
77
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
78
|
+
all_headers = {
|
|
79
|
+
"X-API-Key": self.api_key,
|
|
80
|
+
"User-Agent": f"zerelay-python/{VERSION}",
|
|
81
|
+
"Accept": "application/json",
|
|
82
|
+
}
|
|
83
|
+
if data is not None:
|
|
84
|
+
all_headers["Content-Type"] = "application/json"
|
|
85
|
+
all_headers.update(headers or {})
|
|
86
|
+
can_retry_failures = method.upper() == "GET" or idempotent
|
|
87
|
+
|
|
88
|
+
attempt = 0
|
|
89
|
+
while True:
|
|
90
|
+
req = request.Request(url, data=data, method=method.upper(), headers=all_headers)
|
|
91
|
+
try:
|
|
92
|
+
with request.urlopen(req, timeout=self.timeout) as resp:
|
|
93
|
+
raw = resp.read()
|
|
94
|
+
status = resp.status
|
|
95
|
+
test_mode = resp.headers.get("X-Zerelay-Test-Mode") == "true"
|
|
96
|
+
if not raw:
|
|
97
|
+
return {"status": status, "test_mode": test_mode}
|
|
98
|
+
return json.loads(raw)
|
|
99
|
+
except urlerror.HTTPError as exc:
|
|
100
|
+
raw = exc.read()
|
|
101
|
+
try:
|
|
102
|
+
parsed: Any = json.loads(raw) if raw else None
|
|
103
|
+
except ValueError:
|
|
104
|
+
parsed = raw.decode("utf-8", "replace")
|
|
105
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
106
|
+
retry_after_s = float(retry_after) if retry_after and retry_after.replace(".", "", 1).isdigit() else None
|
|
107
|
+
retryable = exc.code == 429 or (can_retry_failures and exc.code in _RETRYABLE)
|
|
108
|
+
if retryable and attempt < self.max_retries:
|
|
109
|
+
time.sleep(min(retry_after_s, 60) if retry_after_s else self._backoff(attempt))
|
|
110
|
+
attempt += 1
|
|
111
|
+
continue
|
|
112
|
+
raise ZerelayError(_detail(parsed, f"HTTP {exc.code}"), status=exc.code, body=parsed, retry_after=retry_after_s) from None
|
|
113
|
+
except (urlerror.URLError, TimeoutError, ConnectionError, OSError) as exc:
|
|
114
|
+
if can_retry_failures and attempt < self.max_retries:
|
|
115
|
+
time.sleep(self._backoff(attempt))
|
|
116
|
+
attempt += 1
|
|
117
|
+
continue
|
|
118
|
+
raise ZerelayError(f"Request to {method.upper()} {path} failed: {exc}") from None
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _backoff(attempt: int) -> float:
|
|
122
|
+
return min(8.0, 0.5 * 2 ** attempt) * random.uniform(0.8, 1.2)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _drop_none(values: Dict[str, Any]) -> Dict[str, Any]:
|
|
126
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Emails:
|
|
130
|
+
def __init__(self, client: Zerelay) -> None:
|
|
131
|
+
self._c = client
|
|
132
|
+
|
|
133
|
+
def send(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
to: str,
|
|
137
|
+
subject: str,
|
|
138
|
+
body: str,
|
|
139
|
+
sender_identity_id: Optional[int] = None,
|
|
140
|
+
attachments: Optional[List[Dict[str, str]]] = None,
|
|
141
|
+
idempotency_key: Optional[str] = None,
|
|
142
|
+
) -> Dict[str, Any]:
|
|
143
|
+
"""Send one email. Retries are safe: an Idempotency-Key is generated unless you pass one."""
|
|
144
|
+
return self._c.request(
|
|
145
|
+
"POST", "/send-email",
|
|
146
|
+
body=_drop_none({"to": to, "subject": subject, "body": body, "sender_identity_id": sender_identity_id, "attachments": attachments}),
|
|
147
|
+
headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
|
|
148
|
+
idempotent=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def send_bulk(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
recipients: List[str],
|
|
155
|
+
subject: str,
|
|
156
|
+
body: str,
|
|
157
|
+
sender_identity_id: Optional[int] = None,
|
|
158
|
+
attachments: Optional[List[Dict[str, str]]] = None,
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""One message to many recipients (each gets their own copy)."""
|
|
161
|
+
return self._c.request(
|
|
162
|
+
"POST", "/send-bulk-emails",
|
|
163
|
+
body=_drop_none({"recipients": recipients, "subject": subject, "body": body, "sender_identity_id": sender_identity_id, "attachments": attachments}),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def mail_send(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
167
|
+
"""Full control (cc, bcc, separate text/html, templates, categories): SendGrid v3 format."""
|
|
168
|
+
return self._c.request("POST", "/v3/mail/send", body=payload)
|
|
169
|
+
|
|
170
|
+
def get(self, task_id: str) -> Dict[str, Any]:
|
|
171
|
+
return self._c.request("GET", f"/email-status/{parse.quote(task_id, safe='')}")
|
|
172
|
+
|
|
173
|
+
def list(self, **query: Any) -> List[Dict[str, Any]]:
|
|
174
|
+
return self._c.request("GET", "/emails", query=query)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Sms:
|
|
178
|
+
def __init__(self, client: Zerelay) -> None:
|
|
179
|
+
self._c = client
|
|
180
|
+
|
|
181
|
+
def send(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
to: Union[str, List[str]],
|
|
185
|
+
body: str,
|
|
186
|
+
sender_id: Optional[str] = None,
|
|
187
|
+
route: Optional[str] = None,
|
|
188
|
+
schedule_at: Optional[str] = None,
|
|
189
|
+
reference: Optional[str] = None,
|
|
190
|
+
idempotency_key: Optional[str] = None,
|
|
191
|
+
) -> Dict[str, Any]:
|
|
192
|
+
"""Send an SMS to one or more Nigerian numbers, paid from your SMS wallet."""
|
|
193
|
+
return self._c.request(
|
|
194
|
+
"POST", "/sms/send",
|
|
195
|
+
body=_drop_none({"to": to, "body": body, "sender_id": sender_id, "route": route, "schedule_at": schedule_at, "reference": reference}),
|
|
196
|
+
headers={"Idempotency-Key": idempotency_key or str(uuid.uuid4())},
|
|
197
|
+
idempotent=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def quote(self, *, to: Union[str, List[str]], body: str, sender_id: Optional[str] = None, route: Optional[str] = None) -> Dict[str, Any]:
|
|
201
|
+
"""Price a send without sending or charging."""
|
|
202
|
+
return self._c.request("POST", "/sms/quote", body=_drop_none({"to": to, "body": body, "sender_id": sender_id, "route": route}), idempotent=True)
|
|
203
|
+
|
|
204
|
+
def get(self, message_id: str, *, refresh: bool = False) -> Dict[str, Any]:
|
|
205
|
+
return self._c.request("GET", f"/sms/messages/{parse.quote(message_id, safe='')}", query={"refresh": refresh})
|
|
206
|
+
|
|
207
|
+
def list(self, **query: Any) -> List[Dict[str, Any]]:
|
|
208
|
+
return self._c.request("GET", "/sms/messages", query=query)
|
|
209
|
+
|
|
210
|
+
def cancel(self, message_id: str) -> Dict[str, Any]:
|
|
211
|
+
"""Cancel a scheduled message (fully refunded)."""
|
|
212
|
+
return self._c.request("POST", f"/sms/messages/{parse.quote(message_id, safe='')}/cancel")
|
|
213
|
+
|
|
214
|
+
def wallet(self) -> Dict[str, Any]:
|
|
215
|
+
return self._c.request("GET", "/sms/wallet")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ZerelayError(Exception):
|
|
5
|
+
"""An API error. `status` is the HTTP status (None for network errors)."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, *, status: Optional[int] = None, body: Any = None, retry_after: Optional[float] = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
self.status = status
|
|
11
|
+
self.body = body
|
|
12
|
+
self.retry_after = retry_after
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ZerelayWebhookError(Exception):
|
|
16
|
+
"""A webhook failed verification (bad signature, stale timestamp, missing headers)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = "0.1.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ._errors import ZerelayWebhookError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
|
11
|
+
getter = getattr(headers, "get", None)
|
|
12
|
+
if getter is not None:
|
|
13
|
+
value = getter(name)
|
|
14
|
+
if value is None:
|
|
15
|
+
value = getter(name.lower())
|
|
16
|
+
if value is not None:
|
|
17
|
+
return value[0] if isinstance(value, (list, tuple)) else str(value)
|
|
18
|
+
lower = name.lower()
|
|
19
|
+
for key, value in dict(headers).items():
|
|
20
|
+
if str(key).lower() == lower:
|
|
21
|
+
return value[0] if isinstance(value, (list, tuple)) else str(value)
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_webhook(
|
|
26
|
+
payload: Union[bytes, str],
|
|
27
|
+
headers: Mapping[str, Any],
|
|
28
|
+
secret: str,
|
|
29
|
+
tolerance_seconds: int = 300,
|
|
30
|
+
now: Optional[float] = None,
|
|
31
|
+
) -> Any:
|
|
32
|
+
"""Verify a Zerelay webhook and return the parsed event.
|
|
33
|
+
|
|
34
|
+
Pass the RAW request body (e.g. Flask `request.get_data()`, Django `request.body`,
|
|
35
|
+
FastAPI `await request.body()`), not re-serialised JSON. Raises ZerelayWebhookError if
|
|
36
|
+
the signature is wrong or the timestamp is older than `tolerance_seconds` (0 disables).
|
|
37
|
+
"""
|
|
38
|
+
if not secret:
|
|
39
|
+
raise ZerelayWebhookError("Missing webhook signing secret")
|
|
40
|
+
raw = payload if isinstance(payload, bytes) else str(payload).encode("utf-8")
|
|
41
|
+
signature = _header(headers, "X-Zerelay-Signature") or _header(headers, "X-SMTP-Signature")
|
|
42
|
+
timestamp = _header(headers, "X-Zerelay-Timestamp") or _header(headers, "X-SMTP-Timestamp")
|
|
43
|
+
if not signature or not timestamp:
|
|
44
|
+
raise ZerelayWebhookError("Missing X-Zerelay-Signature or X-Zerelay-Timestamp header")
|
|
45
|
+
try:
|
|
46
|
+
ts = int(timestamp)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise ZerelayWebhookError("Invalid X-Zerelay-Timestamp header") from None
|
|
49
|
+
current = time.time() if now is None else now
|
|
50
|
+
if tolerance_seconds and abs(current - ts) > tolerance_seconds:
|
|
51
|
+
raise ZerelayWebhookError("Webhook timestamp is outside the allowed tolerance (possible replay)")
|
|
52
|
+
expected = "sha256=" + hmac.new(secret.encode("utf-8"), timestamp.encode("utf-8") + b"." + raw, hashlib.sha256).hexdigest()
|
|
53
|
+
if not hmac.compare_digest(expected, signature):
|
|
54
|
+
raise ZerelayWebhookError("Invalid webhook signature")
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(raw)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise ZerelayWebhookError("Webhook body is not valid JSON") from None
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zerelay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Zerelay email and SMS API
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://zerelay.com
|
|
7
|
+
Project-URL: Documentation, https://zerelay.com/developer/docs
|
|
8
|
+
Keywords: zerelay,email,sms,transactional-email,smtp,nigeria,webhooks
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Communications :: Email
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Zerelay for Python
|
|
19
|
+
|
|
20
|
+
Official Python SDK for the [Zerelay](https://zerelay.com) email and SMS API. No dependencies; Python 3.8+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install zerelay
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Send an email
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from zerelay import Zerelay
|
|
30
|
+
|
|
31
|
+
zerelay = Zerelay() # reads ZERELAY_API_KEY, or pass the key: Zerelay("sk_...")
|
|
32
|
+
|
|
33
|
+
result = zerelay.emails.send(
|
|
34
|
+
to="jane@example.com",
|
|
35
|
+
subject="Your order has shipped",
|
|
36
|
+
body="<p>It will arrive on Friday.</p>",
|
|
37
|
+
)
|
|
38
|
+
print(result["task_id"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For cc/bcc, separate text and HTML parts, templates or categories, `emails.mail_send()` takes the SendGrid v3 `/mail/send` format:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
zerelay.emails.mail_send({
|
|
45
|
+
"personalizations": [{"to": [{"email": "jane@example.com"}], "cc": [{"email": "ops@example.com"}]}],
|
|
46
|
+
"from": {"email": "orders@yourdomain.com", "name": "Your Store"},
|
|
47
|
+
"subject": "Order #1001",
|
|
48
|
+
"content": [{"type": "text/html", "value": "<p>Thanks for your order</p>"}],
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Send an SMS
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
message = zerelay.sms.send(to="08031234567", body="Your code is 4821")
|
|
56
|
+
print(message["status"], message["cost_kobo"])
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Test mode
|
|
60
|
+
|
|
61
|
+
Create a **test key** (`sk_test_...`) under **API Keys** in the dashboard. Test keys check every request exactly like a live key does, but nothing is sent, charged or counted toward your quota. Webhooks still fire, marked `"test_mode": true`. Send to `bounce@simulator.zerelay.com` to simulate a bounce.
|
|
62
|
+
|
|
63
|
+
## Verify webhooks
|
|
64
|
+
|
|
65
|
+
Verify the **raw** request body:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from zerelay import verify_webhook, ZerelayWebhookError
|
|
69
|
+
|
|
70
|
+
# Flask
|
|
71
|
+
@app.post("/webhooks/zerelay")
|
|
72
|
+
def zerelay_webhook():
|
|
73
|
+
try:
|
|
74
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["ZERELAY_WEBHOOK_SECRET"])
|
|
75
|
+
except ZerelayWebhookError as exc:
|
|
76
|
+
return str(exc), 400
|
|
77
|
+
# event["event"] is e.g. "delivered", "bounce", "sms.delivered"; de-duplicate on event["id"]
|
|
78
|
+
return "", 200
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In Django use `request.body`. In FastAPI use `await request.body()`.
|
|
82
|
+
|
|
83
|
+
## Errors and retries
|
|
84
|
+
|
|
85
|
+
Failed requests raise `ZerelayError` with `status`, `message` (the API's explanation) and `body`. The client retries automatically:
|
|
86
|
+
|
|
87
|
+
- **Rate limits (429)** are always retried, after the `Retry-After` delay.
|
|
88
|
+
- **Network errors and 502/503/504** are retried only when repeating the request is safe. That covers GETs, plus `emails.send()` and `sms.send()`, which send an automatic `Idempotency-Key`.
|
|
89
|
+
|
|
90
|
+
Change this with `Zerelay(max_retries=0)`.
|
|
91
|
+
|
|
92
|
+
Any other endpoint can be called with `zerelay.request("GET", "/path", query={...}, body={...})`. The full reference is at <https://api.zerelay.com/docs>.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/zerelay/__init__.py
|
|
5
|
+
src/zerelay/_client.py
|
|
6
|
+
src/zerelay/_errors.py
|
|
7
|
+
src/zerelay/_version.py
|
|
8
|
+
src/zerelay/_webhooks.py
|
|
9
|
+
src/zerelay/py.typed
|
|
10
|
+
src/zerelay.egg-info/PKG-INFO
|
|
11
|
+
src/zerelay.egg-info/SOURCES.txt
|
|
12
|
+
src/zerelay.egg-info/dependency_links.txt
|
|
13
|
+
src/zerelay.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zerelay
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
12
|
+
|
|
13
|
+
from zerelay import Zerelay, ZerelayError, ZerelayWebhookError, verify_webhook # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Mock:
|
|
17
|
+
def __init__(self, responder):
|
|
18
|
+
self.calls = []
|
|
19
|
+
mock = self
|
|
20
|
+
|
|
21
|
+
class H(BaseHTTPRequestHandler):
|
|
22
|
+
def log_message(self, *a):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def _handle(self):
|
|
26
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
27
|
+
body = self.rfile.read(length) if length else b""
|
|
28
|
+
mock.calls.append({"method": self.command, "path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": json.loads(body) if body else None})
|
|
29
|
+
status, data, headers = responder(len(mock.calls))
|
|
30
|
+
self.send_response(status)
|
|
31
|
+
for k, v in (headers or {}).items():
|
|
32
|
+
self.send_header(k, v)
|
|
33
|
+
payload = b"" if data is None else json.dumps(data).encode()
|
|
34
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
35
|
+
self.end_headers()
|
|
36
|
+
self.wfile.write(payload)
|
|
37
|
+
|
|
38
|
+
do_GET = do_POST = _handle
|
|
39
|
+
|
|
40
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
41
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
42
|
+
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
|
|
43
|
+
|
|
44
|
+
def close(self):
|
|
45
|
+
self.server.shutdown()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ClientTests(unittest.TestCase):
|
|
49
|
+
def test_send_email_headers_and_idempotency(self):
|
|
50
|
+
m = Mock(lambda n: (200, {"task_id": "t1", "status": "queued", "message": "ok", "test_mode": False}, None))
|
|
51
|
+
zr = Zerelay("sk_test_x", base_url=m.url)
|
|
52
|
+
out = zr.emails.send(to="a@example.com", subject="s", body="b")
|
|
53
|
+
self.assertEqual(out["task_id"], "t1")
|
|
54
|
+
call = m.calls[0]
|
|
55
|
+
self.assertEqual(call["path"], "/send-email")
|
|
56
|
+
self.assertEqual(call["headers"]["x-api-key"], "sk_test_x")
|
|
57
|
+
self.assertTrue(call["headers"]["user-agent"].startswith("zerelay-python/"))
|
|
58
|
+
self.assertEqual(len(call["headers"]["idempotency-key"]), 36)
|
|
59
|
+
self.assertEqual(call["body"], {"to": "a@example.com", "subject": "s", "body": "b"})
|
|
60
|
+
m.close()
|
|
61
|
+
|
|
62
|
+
def test_429_retry_after_keeps_idempotency_key(self):
|
|
63
|
+
m = Mock(lambda n: (429, {"detail": "Rate limit exceeded"}, {"Retry-After": "1"}) if n == 1 else (200, {"id": "sms_1"}, None))
|
|
64
|
+
zr = Zerelay("sk_x", base_url=m.url)
|
|
65
|
+
t0 = time.time()
|
|
66
|
+
self.assertEqual(zr.sms.send(to="08031234567", body="hi")["id"], "sms_1")
|
|
67
|
+
self.assertGreaterEqual(time.time() - t0, 0.9)
|
|
68
|
+
self.assertEqual(m.calls[0]["headers"]["idempotency-key"], m.calls[1]["headers"]["idempotency-key"])
|
|
69
|
+
m.close()
|
|
70
|
+
|
|
71
|
+
def test_503_only_retried_when_safe(self):
|
|
72
|
+
m = Mock(lambda n: (503, {"detail": "Worker unavailable"}, None))
|
|
73
|
+
zr = Zerelay("sk_x", base_url=m.url, max_retries=1)
|
|
74
|
+
with self.assertRaises(ZerelayError) as ctx:
|
|
75
|
+
zr.emails.send_bulk(recipients=["a@example.com"], subject="s", body="b")
|
|
76
|
+
self.assertEqual(ctx.exception.status, 503)
|
|
77
|
+
self.assertEqual(str(ctx.exception), "Worker unavailable")
|
|
78
|
+
self.assertEqual(len(m.calls), 1)
|
|
79
|
+
with self.assertRaises(ZerelayError):
|
|
80
|
+
zr.emails.get("t1")
|
|
81
|
+
self.assertEqual(len(m.calls), 3)
|
|
82
|
+
m.close()
|
|
83
|
+
|
|
84
|
+
def test_validation_detail(self):
|
|
85
|
+
m = Mock(lambda n: (422, {"detail": [{"loc": ["body", "to"], "msg": "value is not a valid email address"}]}, None))
|
|
86
|
+
with self.assertRaisesRegex(ZerelayError, r"body\.to: value is not a valid email address"):
|
|
87
|
+
Zerelay("sk_x", base_url=m.url).emails.send(to="x", subject="s", body="b")
|
|
88
|
+
m.close()
|
|
89
|
+
|
|
90
|
+
def test_202_no_body_and_query(self):
|
|
91
|
+
m = Mock(lambda n: (202, None, {"X-Zerelay-Test-Mode": "true"}) if n == 1 else (200, [], None))
|
|
92
|
+
zr = Zerelay("sk_x", base_url=m.url + "/")
|
|
93
|
+
self.assertEqual(zr.emails.mail_send({"personalizations": []}), {"status": 202, "test_mode": True})
|
|
94
|
+
zr.sms.get("sms_a/b", refresh=True)
|
|
95
|
+
self.assertEqual(m.calls[1]["path"], "/sms/messages/sms_a%2Fb?refresh=true")
|
|
96
|
+
m.close()
|
|
97
|
+
|
|
98
|
+
def test_network_error(self):
|
|
99
|
+
zr = Zerelay("sk_x", base_url="http://127.0.0.1:1", max_retries=0, timeout=2)
|
|
100
|
+
with self.assertRaisesRegex(ZerelayError, "failed"):
|
|
101
|
+
zr.sms.wallet()
|
|
102
|
+
|
|
103
|
+
def test_missing_key(self):
|
|
104
|
+
saved = os.environ.pop("ZERELAY_API_KEY", None)
|
|
105
|
+
with self.assertRaises(ValueError):
|
|
106
|
+
Zerelay()
|
|
107
|
+
if saved:
|
|
108
|
+
os.environ["ZERELAY_API_KEY"] = saved
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WebhookTests(unittest.TestCase):
|
|
112
|
+
secret = "whsec_test"
|
|
113
|
+
|
|
114
|
+
def sign(self, body: bytes, ts: str) -> str:
|
|
115
|
+
return "sha256=" + hmac.new(self.secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
116
|
+
|
|
117
|
+
def test_valid_and_legacy(self):
|
|
118
|
+
body = json.dumps({"id": 1, "event": "delivered"}).encode()
|
|
119
|
+
ts = str(int(time.time()))
|
|
120
|
+
sig = self.sign(body, ts)
|
|
121
|
+
self.assertEqual(verify_webhook(body, {"X-Zerelay-Signature": sig, "X-Zerelay-Timestamp": ts}, self.secret)["event"], "delivered")
|
|
122
|
+
self.assertEqual(verify_webhook(body.decode(), {"x-smtp-signature": sig, "x-smtp-timestamp": ts}, self.secret)["id"], 1)
|
|
123
|
+
|
|
124
|
+
def test_rejections(self):
|
|
125
|
+
body = b'{"id":1}'
|
|
126
|
+
ts = str(int(time.time()))
|
|
127
|
+
sig = self.sign(body, ts)
|
|
128
|
+
with self.assertRaisesRegex(ZerelayWebhookError, "Invalid webhook signature"):
|
|
129
|
+
verify_webhook(body + b" ", {"x-zerelay-signature": sig, "x-zerelay-timestamp": ts}, self.secret)
|
|
130
|
+
with self.assertRaisesRegex(ZerelayWebhookError, "Invalid webhook signature"):
|
|
131
|
+
verify_webhook(body, {"x-zerelay-signature": sig, "x-zerelay-timestamp": ts}, "whsec_other")
|
|
132
|
+
old = str(int(time.time()) - 3600)
|
|
133
|
+
with self.assertRaisesRegex(ZerelayWebhookError, "tolerance"):
|
|
134
|
+
verify_webhook(body, {"x-zerelay-signature": self.sign(body, old), "x-zerelay-timestamp": old}, self.secret)
|
|
135
|
+
self.assertEqual(verify_webhook(body, {"x-zerelay-signature": self.sign(body, old), "x-zerelay-timestamp": old}, self.secret, tolerance_seconds=0)["id"], 1)
|
|
136
|
+
with self.assertRaisesRegex(ZerelayWebhookError, "Missing"):
|
|
137
|
+
verify_webhook(body, {}, self.secret)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
unittest.main()
|