relaya 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.
relaya-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dhiraj Kumar
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.
relaya-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: relaya
3
+ Version: 0.1.0
4
+ Summary: Verify webhooks forwarded by Relaya and call the Relaya API.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Dhirajrai12/relaya-sdks/tree/main/python
7
+ Project-URL: Source, https://github.com/Dhirajrai12/relaya-sdks
8
+ Keywords: relaya,webhooks,signature,hmac,integrations
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # relaya (Python)
19
+
20
+ Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
21
+
22
+ ```sh
23
+ pip install relaya
24
+ ```
25
+
26
+ ## Receive events
27
+
28
+ Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
29
+
30
+ ### Django
31
+
32
+ ```python
33
+ from django.http import HttpResponse, JsonResponse
34
+ from django.views.decorators.csrf import csrf_exempt
35
+ from django.views.decorators.http import require_POST
36
+ from relaya import verify_delivery, WebhookVerificationError
37
+
38
+ @csrf_exempt
39
+ @require_POST
40
+ def relaya_webhook(request):
41
+ try:
42
+ delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
43
+ except WebhookVerificationError as e:
44
+ return JsonResponse({"error": e.reason}, status=400)
45
+ if already_processed(delivery.idempotency_key):
46
+ return HttpResponse()
47
+ handle(delivery.json())
48
+ return HttpResponse()
49
+ ```
50
+
51
+ ### Flask
52
+
53
+ ```python
54
+ @app.post("/webhooks/relaya")
55
+ def relaya_webhook():
56
+ try:
57
+ delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
58
+ except WebhookVerificationError as e:
59
+ return {"error": e.reason}, 400
60
+ handle(delivery.json())
61
+ return "", 200
62
+ ```
63
+
64
+ ### FastAPI
65
+
66
+ ```python
67
+ @app.post("/webhooks/relaya")
68
+ async def relaya_webhook(request: Request):
69
+ try:
70
+ delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
71
+ except WebhookVerificationError as e:
72
+ raise HTTPException(400, e.reason)
73
+ await handle(delivery.json())
74
+ ```
75
+
76
+ Return a 5xx and Relaya retries later with the same idempotency key.
77
+
78
+ | `Delivery` field | |
79
+ |---|---|
80
+ | `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
81
+ | `event_id`, `delivery_id` | Relaya's IDs. |
82
+ | `event_type` | e.g. `payment.captured`, when Relaya could tell. |
83
+ | `attempt` | 1, then 2, 3… on retries. |
84
+ | `replay_id` | Set when the request is part of an incident replay. |
85
+ | `body`, `json()` | The provider's original body, unchanged. |
86
+
87
+ `e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
88
+
89
+ ## Call the API
90
+
91
+ ```python
92
+ from datetime import datetime, timedelta, timezone
93
+ from relaya import Relaya
94
+
95
+ relaya = Relaya() # reads RELAYA_API_KEY
96
+
97
+ for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
98
+ print(event["type"], event["received_at"])
99
+
100
+ for d in relaya.deliveries.list(status="failed"):
101
+ relaya.deliveries.retry(d["id"])
102
+
103
+ for incident in relaya.incidents.list("open"):
104
+ plan = relaya.incidents.preview_replay(incident["id"]) # dry run
105
+ relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
106
+ ```
107
+
108
+ Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
109
+
110
+ Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
111
+
112
+ Errors raise `RelayaError` with `status`, `code` and `request_id`.
113
+
114
+ ## Development
115
+
116
+ ```sh
117
+ PYTHONPATH=src python -m unittest discover -s tests
118
+ RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
119
+ ```
relaya-0.1.0/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # relaya (Python)
2
+
3
+ Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
4
+
5
+ ```sh
6
+ pip install relaya
7
+ ```
8
+
9
+ ## Receive events
10
+
11
+ Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
12
+
13
+ ### Django
14
+
15
+ ```python
16
+ from django.http import HttpResponse, JsonResponse
17
+ from django.views.decorators.csrf import csrf_exempt
18
+ from django.views.decorators.http import require_POST
19
+ from relaya import verify_delivery, WebhookVerificationError
20
+
21
+ @csrf_exempt
22
+ @require_POST
23
+ def relaya_webhook(request):
24
+ try:
25
+ delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
26
+ except WebhookVerificationError as e:
27
+ return JsonResponse({"error": e.reason}, status=400)
28
+ if already_processed(delivery.idempotency_key):
29
+ return HttpResponse()
30
+ handle(delivery.json())
31
+ return HttpResponse()
32
+ ```
33
+
34
+ ### Flask
35
+
36
+ ```python
37
+ @app.post("/webhooks/relaya")
38
+ def relaya_webhook():
39
+ try:
40
+ delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
41
+ except WebhookVerificationError as e:
42
+ return {"error": e.reason}, 400
43
+ handle(delivery.json())
44
+ return "", 200
45
+ ```
46
+
47
+ ### FastAPI
48
+
49
+ ```python
50
+ @app.post("/webhooks/relaya")
51
+ async def relaya_webhook(request: Request):
52
+ try:
53
+ delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
54
+ except WebhookVerificationError as e:
55
+ raise HTTPException(400, e.reason)
56
+ await handle(delivery.json())
57
+ ```
58
+
59
+ Return a 5xx and Relaya retries later with the same idempotency key.
60
+
61
+ | `Delivery` field | |
62
+ |---|---|
63
+ | `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
64
+ | `event_id`, `delivery_id` | Relaya's IDs. |
65
+ | `event_type` | e.g. `payment.captured`, when Relaya could tell. |
66
+ | `attempt` | 1, then 2, 3… on retries. |
67
+ | `replay_id` | Set when the request is part of an incident replay. |
68
+ | `body`, `json()` | The provider's original body, unchanged. |
69
+
70
+ `e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
71
+
72
+ ## Call the API
73
+
74
+ ```python
75
+ from datetime import datetime, timedelta, timezone
76
+ from relaya import Relaya
77
+
78
+ relaya = Relaya() # reads RELAYA_API_KEY
79
+
80
+ for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
81
+ print(event["type"], event["received_at"])
82
+
83
+ for d in relaya.deliveries.list(status="failed"):
84
+ relaya.deliveries.retry(d["id"])
85
+
86
+ for incident in relaya.incidents.list("open"):
87
+ plan = relaya.incidents.preview_replay(incident["id"]) # dry run
88
+ relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
89
+ ```
90
+
91
+ Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
92
+
93
+ Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
94
+
95
+ Errors raise `RelayaError` with `status`, `code` and `request_id`.
96
+
97
+ ## Development
98
+
99
+ ```sh
100
+ PYTHONPATH=src python -m unittest discover -s tests
101
+ RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
102
+ ```
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "relaya"
7
+ version = "0.1.0"
8
+ description = "Verify webhooks forwarded by Relaya and call the Relaya API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+ keywords = ["relaya", "webhooks", "signature", "hmac", "integrations"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/Dhirajrai12/relaya-sdks/tree/main/python"
23
+ Source = "https://github.com/Dhirajrai12/relaya-sdks"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ relaya = ["py.typed"]
relaya-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,28 @@
1
+ """Verify webhooks forwarded by Relaya and call the Relaya API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import DEFAULT_BASE_URL, Relaya # noqa: E402
6
+ from .errors import RelayaError, WebhookVerificationError # noqa: E402
7
+ from .webhooks import ( # noqa: E402
8
+ DEFAULT_TOLERANCE,
9
+ Delivery,
10
+ is_valid_signature,
11
+ verify_alert,
12
+ verify_delivery,
13
+ verify_signature,
14
+ )
15
+
16
+ __all__ = [
17
+ "Relaya",
18
+ "RelayaError",
19
+ "WebhookVerificationError",
20
+ "Delivery",
21
+ "verify_signature",
22
+ "is_valid_signature",
23
+ "verify_delivery",
24
+ "verify_alert",
25
+ "DEFAULT_BASE_URL",
26
+ "DEFAULT_TOLERANCE",
27
+ "__version__",
28
+ ]
@@ -0,0 +1,289 @@
1
+ """Relaya API client. Responses are plain dicts with the API's field names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import socket
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Dict, Iterator, List, Optional
16
+
17
+ from . import __version__
18
+ from .errors import RelayaError
19
+
20
+ #: Where the API lives until the product has its own domain.
21
+ DEFAULT_BASE_URL = "https://server.aegonassett.com/api"
22
+
23
+ JSON = Dict[str, Any]
24
+
25
+
26
+ def _clean(params: Dict[str, Any]) -> Dict[str, str]:
27
+ out = {}
28
+ for k, v in params.items():
29
+ if v is None or v == "":
30
+ continue
31
+ if isinstance(v, datetime):
32
+ v = (v if v.tzinfo else v.replace(tzinfo=timezone.utc)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
33
+ out[k] = str(v)
34
+ return out
35
+
36
+
37
+ def _backoff(attempt: int, retry_after: Optional[str]) -> float:
38
+ if retry_after is not None:
39
+ try:
40
+ return min(max(float(retry_after), 0.0), 30.0)
41
+ except ValueError:
42
+ pass
43
+ return min(0.5 * 2**attempt, 8.0) * (0.8 + random.random() * 0.4)
44
+
45
+
46
+ class Relaya:
47
+ """Relaya API client.
48
+
49
+ >>> relaya = Relaya(api_key=os.environ["RELAYA_API_KEY"])
50
+ >>> for event in relaya.events.iterate(contract_status="breaking"):
51
+ ... print(event["type"])
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ api_key: Optional[str] = None,
57
+ *,
58
+ org_id: Optional[str] = None,
59
+ base_url: Optional[str] = None,
60
+ timeout: float = 30.0,
61
+ max_retries: int = 2,
62
+ ) -> None:
63
+ self.api_key = api_key or os.environ.get("RELAYA_API_KEY")
64
+ if not self.api_key:
65
+ raise ValueError("relaya: pass api_key or set RELAYA_API_KEY")
66
+ self.base_url = (base_url or os.environ.get("RELAYA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
67
+ self.timeout = timeout
68
+ self.max_retries = max_retries
69
+ self._org_id = org_id
70
+ self._org_lock = threading.Lock()
71
+
72
+ self.projects = Projects(self)
73
+ self.webhooks = Webhooks(self)
74
+ self.events = Events(self)
75
+ self.destinations = Destinations(self)
76
+ self.deliveries = Deliveries(self)
77
+ self.contracts = Contracts(self)
78
+ self.incidents = Incidents(self)
79
+ self.alerts = Alerts(self)
80
+
81
+ @property
82
+ def org_id(self) -> str:
83
+ """The org this client acts on, looked up from the API key on first use."""
84
+ with self._org_lock:
85
+ if not self._org_id:
86
+ me = self.request("GET", "/v1/me")
87
+ if not me.get("api_key"):
88
+ raise ValueError("relaya: pass org_id when using a session token instead of an API key")
89
+ self._org_id = me["api_key"]["org_id"]
90
+ return self._org_id
91
+
92
+ def request(self, method: str, path: str, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
93
+ """Low-level request; paths start with /v1. Returns the decoded JSON (None for 204)."""
94
+ url = self.base_url + path
95
+ query = _clean(params or {})
96
+ if query:
97
+ url += "?" + urllib.parse.urlencode(query)
98
+ data = None if body is None else json.dumps(body).encode()
99
+ headers = {
100
+ "Authorization": f"Bearer {self.api_key}",
101
+ "Accept": "application/json",
102
+ "User-Agent": f"relaya-python/{__version__}",
103
+ }
104
+ if data is not None:
105
+ headers["Content-Type"] = "application/json"
106
+ retries = self.max_retries if method == "GET" else 0
107
+
108
+ attempt = 0
109
+ while True:
110
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
111
+ try:
112
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
113
+ raw = res.read()
114
+ return json.loads(raw) if raw else None
115
+ except urllib.error.HTTPError as e:
116
+ raw = e.read()
117
+ if (e.code == 429 or e.code >= 500) and attempt < retries:
118
+ time.sleep(_backoff(attempt, e.headers.get("Retry-After")))
119
+ attempt += 1
120
+ continue
121
+ code, message = "http_error", f"HTTP {e.code}"
122
+ try:
123
+ err = json.loads(raw).get("error") or {}
124
+ code, message = err.get("code", code), err.get("message", message)
125
+ except (ValueError, AttributeError):
126
+ pass
127
+ raise RelayaError(e.code, code, message, e.headers.get("X-Request-Id")) from None
128
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as e:
129
+ if attempt < retries:
130
+ time.sleep(_backoff(attempt, None))
131
+ attempt += 1
132
+ continue
133
+ reason = getattr(e, "reason", e)
134
+ timed_out = isinstance(reason, (socket.timeout, TimeoutError))
135
+ raise RelayaError(
136
+ 0,
137
+ "timeout" if timed_out else "network_error",
138
+ f"Request timed out after {self.timeout}s" if timed_out else f"Could not reach Relaya: {reason}",
139
+ ) from None
140
+
141
+ def _org(self, method: str, path: str, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
142
+ return self.request(method, f"/v1/orgs/{self.org_id}{path}", body, params)
143
+
144
+
145
+ class _Resource:
146
+ def __init__(self, client: Relaya) -> None:
147
+ self._c = client
148
+
149
+
150
+ class Projects(_Resource):
151
+ def list(self) -> List[JSON]:
152
+ return self._c._org("GET", "/projects")["data"]
153
+
154
+ def get(self, id: str) -> JSON:
155
+ return self._c._org("GET", f"/projects/{id}")
156
+
157
+ def create(self, name: str) -> JSON:
158
+ return self._c._org("POST", "/projects", {"name": name})
159
+
160
+
161
+ class Webhooks(_Resource):
162
+ def list(self, project_id: Optional[str] = None) -> List[JSON]:
163
+ return self._c._org("GET", "/webhooks", params={"project_id": project_id})["data"]
164
+
165
+ def get(self, id: str) -> JSON:
166
+ return self._c._org("GET", f"/webhooks/{id}")
167
+
168
+ def create(self, project_id: str, name: str, provider: str = "generic", **fields: Any) -> JSON:
169
+ """Create an inbound webhook; give its ``ingest_url`` to the provider.
170
+ Optional: signing_secret, signature_header."""
171
+ return self._c._org("POST", "/webhooks", {"project_id": project_id, "name": name, "provider": provider, **fields})
172
+
173
+ def update(self, id: str, **fields: Any) -> JSON:
174
+ """Fields: name, status ("active"/"paused"), signing_secret, signature_header."""
175
+ return self._c._org("PATCH", f"/webhooks/{id}", fields)
176
+
177
+ def rotate_url(self, id: str) -> JSON:
178
+ """Issue a new ingest URL; the old one stops working."""
179
+ return self._c._org("POST", f"/webhooks/{id}/rotate-url")
180
+
181
+ def delete(self, id: str) -> None:
182
+ self._c._org("DELETE", f"/webhooks/{id}")
183
+
184
+
185
+ class Events(_Resource):
186
+ def list(self, **filters: Any) -> JSON:
187
+ """One page, newest first: ``{"data": [...], "next_cursor": ...}``.
188
+
189
+ Filters: project_id, webhook_id, type, status, signature, contract_status, dedup_key,
190
+ since, until (datetime or RFC 3339), limit (1-200), cursor.
191
+ """
192
+ return self._c._org("GET", "/events", params=filters)
193
+
194
+ def iterate(self, **filters: Any) -> Iterator[JSON]:
195
+ """Every matching event, newest first, fetching pages as you go."""
196
+ filters.pop("cursor", None)
197
+ cursor = None
198
+ while True:
199
+ page = self.list(**filters, cursor=cursor)
200
+ yield from page["data"]
201
+ cursor = page.get("next_cursor")
202
+ if not cursor:
203
+ return
204
+
205
+ def get(self, id: str) -> JSON:
206
+ """Full event: payload (sensitive fields masked), headers, deliveries and contract findings."""
207
+ return self._c._org("GET", f"/events/{id}")
208
+
209
+
210
+ class Destinations(_Resource):
211
+ def list(self, webhook_id: str) -> List[JSON]:
212
+ return self._c._org("GET", f"/webhooks/{webhook_id}/destinations")["data"]
213
+
214
+ def create(self, webhook_id: str, name: str, url: str, **fields: Any) -> JSON:
215
+ """Returns ``{"destination": ..., "signing_secret": ...}``. The secret is shown once.
216
+ Optional: max_attempts, timeout_ms, enabled."""
217
+ return self._c._org("POST", f"/webhooks/{webhook_id}/destinations", {"name": name, "url": url, **fields})
218
+
219
+ def update(self, id: str, **fields: Any) -> JSON:
220
+ return self._c._org("PATCH", f"/destinations/{id}", fields)
221
+
222
+ def delete(self, id: str) -> None:
223
+ self._c._org("DELETE", f"/destinations/{id}")
224
+
225
+ def rotate_secret(self, id: str) -> str:
226
+ return self._c._org("POST", f"/destinations/{id}/rotate-secret")["signing_secret"]
227
+
228
+ def test(self, id: str) -> JSON:
229
+ """Send a signed test request now; returns ``{ok, status_code, duration_ms, response_body, error}``."""
230
+ return self._c._org("POST", f"/destinations/{id}/test")
231
+
232
+
233
+ class Deliveries(_Resource):
234
+ def list(self, **filters: Any) -> List[JSON]:
235
+ """The latest 100 matching deliveries. Filters: event_id, destination_id, webhook_id, status."""
236
+ return self._c._org("GET", "/deliveries", params=filters)["data"]
237
+
238
+ def get(self, id: str) -> JSON:
239
+ """``{"delivery": ..., "attempts": [...]}``"""
240
+ return self._c._org("GET", f"/deliveries/{id}")
241
+
242
+ def retry(self, id: str) -> JSON:
243
+ """Send a failed or retrying delivery again now."""
244
+ return self._c._org("POST", f"/deliveries/{id}/retry")
245
+
246
+
247
+ class Contracts(_Resource):
248
+ def list(self, webhook_id: Optional[str] = None) -> List[JSON]:
249
+ return self._c._org("GET", "/contracts", params={"webhook_id": webhook_id})["data"]
250
+
251
+ def get(self, id: str) -> JSON:
252
+ return self._c._org("GET", f"/contracts/{id}")
253
+
254
+ def create_version(self, id: str, critical_fields: Optional[List[str]] = None, source: str = "observed") -> int:
255
+ """Activate a new version: from what was ``observed``, or the ``active`` one with new critical fields.
256
+ Returns the version number."""
257
+ body: JSON = {"source": source}
258
+ if critical_fields is not None:
259
+ body["critical_fields"] = critical_fields
260
+ return self._c._org("POST", f"/contracts/{id}/versions", body)["version"]
261
+
262
+ def relearn(self, id: str) -> None:
263
+ """Throw away what was learned and start learning again."""
264
+ self._c._org("POST", f"/contracts/{id}/relearn")
265
+
266
+
267
+ class Incidents(_Resource):
268
+ def list(self, status: Optional[str] = None) -> List[JSON]:
269
+ """status: "open" (default) or "resolved"."""
270
+ return self._c._org("GET", "/incidents", params={"status": status})["data"]
271
+
272
+ def resolve(self, id: str, resolution: str) -> None:
273
+ self._c._org("POST", f"/incidents/{id}/resolve", {"resolution": resolution})
274
+
275
+ def preview_replay(self, id: str) -> JSON:
276
+ """Dry run: which events and deliveries a replay would resend. Changes nothing."""
277
+ return self._c._org("GET", f"/incidents/{id}/replay")
278
+
279
+ def replay(self, id: str) -> JSON:
280
+ """Resend the incident's deliveries. The incident resolves itself if all of them succeed."""
281
+ return self._c._org("POST", f"/incidents/{id}/replay", {"confirm": True})
282
+
283
+
284
+ class Alerts(_Resource):
285
+ def channels(self) -> List[JSON]:
286
+ return self._c._org("GET", "/alert-channels")["data"]
287
+
288
+ def log(self) -> List[JSON]:
289
+ return self._c._org("GET", "/alerts")["data"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class RelayaError(Exception):
7
+ """An error response from the Relaya API."""
8
+
9
+ def __init__(self, status: int, code: str, message: str, request_id: Optional[str] = None) -> None:
10
+ super().__init__(message)
11
+ #: HTTP status, or 0 when no response arrived.
12
+ self.status = status
13
+ #: e.g. "not_found", "bad_request", "network_error", "timeout".
14
+ self.code = code
15
+ self.message = message
16
+ self.request_id = request_id
17
+
18
+ def __str__(self) -> str:
19
+ return self.message if self.status == 0 else f"{self.status} {self.code}: {self.message}"
20
+
21
+
22
+ class WebhookVerificationError(Exception):
23
+ """A request claiming to come from Relaya failed signature verification.
24
+
25
+ ``reason`` is one of: missing_signature, malformed_signature, timestamp_out_of_range, signature_mismatch.
26
+ """
27
+
28
+ def __init__(self, reason: str, message: str) -> None:
29
+ super().__init__(message)
30
+ self.reason = reason
File without changes
@@ -0,0 +1,164 @@
1
+ """Verify requests Relaya forwards to your endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Mapping, Optional, Sequence, Union
12
+
13
+ from .errors import WebhookVerificationError
14
+
15
+ HEADER_SIGNATURE = "Relaya-Signature"
16
+ HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"
17
+ HEADER_EVENT_ID = "Relaya-Event-Id"
18
+ HEADER_DELIVERY_ID = "Relaya-Delivery-Id"
19
+ HEADER_ATTEMPT = "Relaya-Attempt"
20
+ HEADER_REPLAY = "Relaya-Replay"
21
+ HEADER_EVENT_TYPE = "Relaya-Event-Type"
22
+
23
+ #: Reject signatures older (or newer) than this many seconds by default.
24
+ DEFAULT_TOLERANCE = 300
25
+
26
+ Body = Union[bytes, bytearray, memoryview, str]
27
+ Secrets = Union[str, Sequence[str]]
28
+
29
+
30
+ def _bytes(body: Body) -> bytes:
31
+ if isinstance(body, str):
32
+ return body.encode("utf-8")
33
+ if isinstance(body, (bytes, bytearray, memoryview)):
34
+ return bytes(body)
35
+ raise TypeError(
36
+ "relaya: pass the raw request body (bytes or str), not parsed JSON. "
37
+ "Django: request.body, Flask: request.get_data(), FastAPI: await request.body()"
38
+ )
39
+
40
+
41
+ def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
42
+ value = headers.get(name)
43
+ if value is None:
44
+ lower = name.lower()
45
+ for k, v in headers.items():
46
+ if k.lower() == lower or k.upper() == "HTTP_" + lower.upper().replace("-", "_"):
47
+ value = v
48
+ break
49
+ if isinstance(value, (list, tuple)):
50
+ value = value[0] if value else None
51
+ return None if value is None else str(value)
52
+
53
+
54
+ def verify_signature(
55
+ body: Body,
56
+ signature_header: Optional[str],
57
+ secret: Secrets,
58
+ tolerance: int = DEFAULT_TOLERANCE,
59
+ now: Optional[float] = None,
60
+ ) -> datetime:
61
+ """Check a ``Relaya-Signature`` header (``t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>``).
62
+
63
+ ``secret`` may be a list while rotating (any match passes). ``tolerance=0`` disables the age check.
64
+ Returns when the request was signed; raises :class:`WebhookVerificationError`.
65
+ """
66
+ secrets = [secret] if isinstance(secret, str) else [s for s in secret]
67
+ secrets = [s for s in secrets if s]
68
+ if not secrets:
69
+ raise ValueError("relaya: a signing secret is required")
70
+ if not signature_header:
71
+ raise WebhookVerificationError("missing_signature", "The Relaya-Signature header is missing")
72
+
73
+ timestamp: Optional[int] = None
74
+ signatures = []
75
+ for part in signature_header.split(","):
76
+ key, _, value = part.strip().partition("=")
77
+ if key == "t":
78
+ try:
79
+ timestamp = int(value)
80
+ except ValueError:
81
+ timestamp = None
82
+ elif key == "v1" and value:
83
+ signatures.append(value.lower())
84
+ if timestamp is None or not signatures:
85
+ raise WebhookVerificationError("malformed_signature", "The Relaya-Signature header is malformed")
86
+
87
+ current = time.time() if now is None else now
88
+ if tolerance > 0 and abs(current - timestamp) > tolerance:
89
+ raise WebhookVerificationError(
90
+ "timestamp_out_of_range", f"The signature is older than {tolerance} seconds (or from the future)"
91
+ )
92
+
93
+ signed = f"{timestamp}.".encode() + _bytes(body)
94
+ for s in secrets:
95
+ expected = hmac.new(s.encode("utf-8"), signed, hashlib.sha256).hexdigest()
96
+ if any(hmac.compare_digest(expected, sig) for sig in signatures):
97
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
98
+ raise WebhookVerificationError(
99
+ "signature_mismatch", "The signature does not match: check the signing secret and that you pass the raw body"
100
+ )
101
+
102
+
103
+ def is_valid_signature(body: Body, signature_header: Optional[str], secret: Secrets, **kwargs: Any) -> bool:
104
+ """Like :func:`verify_signature` but returns True/False."""
105
+ try:
106
+ verify_signature(body, signature_header, secret, **kwargs)
107
+ return True
108
+ except WebhookVerificationError:
109
+ return False
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Delivery:
114
+ """A verified request forwarded by Relaya."""
115
+
116
+ #: The same across retries and replays of one delivery: dedupe on this.
117
+ idempotency_key: str
118
+ delivery_id: str
119
+ event_id: str
120
+ #: e.g. "payment.captured", when Relaya could tell.
121
+ event_type: Optional[str]
122
+ #: 1 for the first try, then 2, 3… on retries.
123
+ attempt: int
124
+ #: Set when the request is part of an incident replay.
125
+ replay_id: Optional[str]
126
+ signed_at: datetime
127
+ #: The provider's original body, byte for byte.
128
+ body: bytes = field(repr=False)
129
+
130
+ def json(self) -> Any:
131
+ return json.loads(self.body)
132
+
133
+
134
+ def verify_delivery(body: Body, headers: Mapping[str, Any], secret: Secrets, **kwargs: Any) -> Delivery:
135
+ """Verify a request forwarded by Relaya and return its details.
136
+
137
+ ``headers`` can be Django's ``request.headers`` (or ``request.META``), Flask's or FastAPI's ``request.headers``,
138
+ or a plain dict.
139
+ """
140
+ raw = _bytes(body)
141
+ signed_at = verify_signature(raw, _header(headers, HEADER_SIGNATURE), secret, **kwargs)
142
+ delivery_id = _header(headers, HEADER_DELIVERY_ID) or ""
143
+ try:
144
+ attempt = max(1, int(_header(headers, HEADER_ATTEMPT) or 1))
145
+ except ValueError:
146
+ attempt = 1
147
+ return Delivery(
148
+ idempotency_key=_header(headers, HEADER_IDEMPOTENCY_KEY) or delivery_id,
149
+ delivery_id=delivery_id,
150
+ event_id=_header(headers, HEADER_EVENT_ID) or "",
151
+ event_type=_header(headers, HEADER_EVENT_TYPE),
152
+ attempt=attempt,
153
+ replay_id=_header(headers, HEADER_REPLAY),
154
+ signed_at=signed_at,
155
+ body=raw,
156
+ )
157
+
158
+
159
+ def verify_alert(body: Body, headers: Mapping[str, Any], secret: Secrets, **kwargs: Any) -> dict:
160
+ """Verify an alert sent to a webhook alert channel and return it:
161
+ ``{type, title, body, link, org_id, alert_id, sent_at}``."""
162
+ raw = _bytes(body)
163
+ verify_signature(raw, _header(headers, HEADER_SIGNATURE), secret, **kwargs)
164
+ return json.loads(raw)
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: relaya
3
+ Version: 0.1.0
4
+ Summary: Verify webhooks forwarded by Relaya and call the Relaya API.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Dhirajrai12/relaya-sdks/tree/main/python
7
+ Project-URL: Source, https://github.com/Dhirajrai12/relaya-sdks
8
+ Keywords: relaya,webhooks,signature,hmac,integrations
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # relaya (Python)
19
+
20
+ Verify requests that Relaya forwards to your endpoints, and call the Relaya API. No dependencies; Python 3.9+.
21
+
22
+ ```sh
23
+ pip install relaya
24
+ ```
25
+
26
+ ## Receive events
27
+
28
+ Relaya signs every request it forwards with your destination's signing secret (shown once when you add the destination). Always pass the **raw** body.
29
+
30
+ ### Django
31
+
32
+ ```python
33
+ from django.http import HttpResponse, JsonResponse
34
+ from django.views.decorators.csrf import csrf_exempt
35
+ from django.views.decorators.http import require_POST
36
+ from relaya import verify_delivery, WebhookVerificationError
37
+
38
+ @csrf_exempt
39
+ @require_POST
40
+ def relaya_webhook(request):
41
+ try:
42
+ delivery = verify_delivery(request.body, request.headers, settings.RELAYA_SIGNING_SECRET)
43
+ except WebhookVerificationError as e:
44
+ return JsonResponse({"error": e.reason}, status=400)
45
+ if already_processed(delivery.idempotency_key):
46
+ return HttpResponse()
47
+ handle(delivery.json())
48
+ return HttpResponse()
49
+ ```
50
+
51
+ ### Flask
52
+
53
+ ```python
54
+ @app.post("/webhooks/relaya")
55
+ def relaya_webhook():
56
+ try:
57
+ delivery = verify_delivery(request.get_data(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
58
+ except WebhookVerificationError as e:
59
+ return {"error": e.reason}, 400
60
+ handle(delivery.json())
61
+ return "", 200
62
+ ```
63
+
64
+ ### FastAPI
65
+
66
+ ```python
67
+ @app.post("/webhooks/relaya")
68
+ async def relaya_webhook(request: Request):
69
+ try:
70
+ delivery = verify_delivery(await request.body(), request.headers, os.environ["RELAYA_SIGNING_SECRET"])
71
+ except WebhookVerificationError as e:
72
+ raise HTTPException(400, e.reason)
73
+ await handle(delivery.json())
74
+ ```
75
+
76
+ Return a 5xx and Relaya retries later with the same idempotency key.
77
+
78
+ | `Delivery` field | |
79
+ |---|---|
80
+ | `idempotency_key` | The same across retries and replays of one delivery. Dedupe on this. |
81
+ | `event_id`, `delivery_id` | Relaya's IDs. |
82
+ | `event_type` | e.g. `payment.captured`, when Relaya could tell. |
83
+ | `attempt` | 1, then 2, 3… on retries. |
84
+ | `replay_id` | Set when the request is part of an incident replay. |
85
+ | `body`, `json()` | The provider's original body, unchanged. |
86
+
87
+ `e.reason` is one of `missing_signature`, `malformed_signature`, `timestamp_out_of_range`, `signature_mismatch`. Pass a list of secrets while rotating; `tolerance=` sets the maximum signature age in seconds (default 300, `0` disables). Webhook alert channels: `verify_alert(body, headers, secret)`.
88
+
89
+ ## Call the API
90
+
91
+ ```python
92
+ from datetime import datetime, timedelta, timezone
93
+ from relaya import Relaya
94
+
95
+ relaya = Relaya() # reads RELAYA_API_KEY
96
+
97
+ for event in relaya.events.iterate(contract_status="breaking", since=datetime.now(timezone.utc) - timedelta(days=7)):
98
+ print(event["type"], event["received_at"])
99
+
100
+ for d in relaya.deliveries.list(status="failed"):
101
+ relaya.deliveries.retry(d["id"])
102
+
103
+ for incident in relaya.incidents.list("open"):
104
+ plan = relaya.incidents.preview_replay(incident["id"]) # dry run
105
+ relaya.incidents.replay(incident["id"]) # resolves itself once every delivery succeeds
106
+ ```
107
+
108
+ Resources: `projects`, `webhooks`, `events` (`list`, `iterate`, `get`), `destinations`, `deliveries`, `contracts`, `incidents`, `alerts`. Responses are dicts with the API's field names. `relaya.request(method, path, body, params)` reaches anything else.
109
+
110
+ Options: `api_key` (or `RELAYA_API_KEY`), `base_url` (or `RELAYA_BASE_URL`), `org_id` (only with a session token), `timeout` (30 s), `max_retries` (2; GET only, on network errors, 429 and 5xx).
111
+
112
+ Errors raise `RelayaError` with `status`, `code` and `request_id`.
113
+
114
+ ## Development
115
+
116
+ ```sh
117
+ PYTHONPATH=src python -m unittest discover -s tests
118
+ RELAYA_IT_API_URL=http://127.0.0.1:18080 PYTHONPATH=src python -m unittest tests.test_integration # against a running dev stack
119
+ ```
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/relaya/__init__.py
5
+ src/relaya/client.py
6
+ src/relaya/errors.py
7
+ src/relaya/py.typed
8
+ src/relaya/webhooks.py
9
+ src/relaya.egg-info/PKG-INFO
10
+ src/relaya.egg-info/SOURCES.txt
11
+ src/relaya.egg-info/dependency_links.txt
12
+ src/relaya.egg-info/top_level.txt
13
+ tests/test_client.py
14
+ tests/test_integration.py
15
+ tests/test_webhooks.py
@@ -0,0 +1 @@
1
+ relaya
@@ -0,0 +1,106 @@
1
+ import json
2
+ import threading
3
+ import unittest
4
+ from datetime import datetime, timezone
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from relaya import Relaya, RelayaError
9
+
10
+
11
+ class FakeAPI:
12
+ """A tiny HTTP server: routes "METHOD /path" to (status, body, headers) callables."""
13
+
14
+ def __init__(self, routes):
15
+ self.calls = []
16
+ api = self
17
+
18
+ class Handler(BaseHTTPRequestHandler):
19
+ def log_message(self, *args):
20
+ pass
21
+
22
+ def handle_any(self):
23
+ u = urlparse(self.path)
24
+ length = int(self.headers.get("Content-Length") or 0)
25
+ body = json.loads(self.rfile.read(length)) if length else None
26
+ call = {"method": self.command, "path": u.path, "query": parse_qs(u.query), "body": body, "auth": self.headers.get("Authorization")}
27
+ api.calls.append(call)
28
+ route = routes.get(f"{self.command} {u.path}")
29
+ n = sum(1 for c in api.calls if c["method"] == self.command and c["path"] == u.path)
30
+ status, payload, headers = route(call, n) if route else (404, {"error": {"code": "not_found", "message": "no route"}}, {})
31
+ self.send_response(status)
32
+ for k, v in headers.items():
33
+ self.send_header(k, v)
34
+ data = b"" if payload is None else json.dumps(payload).encode()
35
+ self.send_header("Content-Length", str(len(data)))
36
+ self.end_headers()
37
+ self.wfile.write(data)
38
+
39
+ do_GET = do_POST = do_PATCH = do_DELETE = handle_any
40
+
41
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
42
+ threading.Thread(target=self.server.serve_forever, daemon=True).start()
43
+ self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
44
+
45
+ def close(self):
46
+ self.server.shutdown()
47
+
48
+
49
+ class ClientTest(unittest.TestCase):
50
+ def api(self, routes):
51
+ a = FakeAPI(routes)
52
+ self.addCleanup(a.close)
53
+ return a
54
+
55
+ def test_org_lookup_once_and_auth(self):
56
+ a = self.api({
57
+ "GET /v1/me": lambda c, n: (200, {"api_key": {"org_id": "org1"}}, {}),
58
+ "GET /v1/orgs/org1/projects": lambda c, n: (200, {"data": [{"id": "p1"}]}, {}),
59
+ })
60
+ r = Relaya("rk_test", base_url=a.url + "/")
61
+ self.assertEqual(r.projects.list()[0]["id"], "p1")
62
+ r.projects.list()
63
+ self.assertEqual(sum(1 for c in a.calls if c["path"] == "/v1/me"), 1)
64
+ self.assertTrue(all(c["auth"] == "Bearer rk_test" for c in a.calls))
65
+
66
+ def test_filters_and_iterate(self):
67
+ def events(c, n):
68
+ if c["query"].get("cursor") == ["c2"]:
69
+ return 200, {"data": [{"id": "e3"}], "next_cursor": None}, {}
70
+ return 200, {"data": [{"id": "e1"}, {"id": "e2"}], "next_cursor": "c2"}, {}
71
+
72
+ a = self.api({"GET /v1/orgs/o/events": events})
73
+ r = Relaya("rk", org_id="o", base_url=a.url)
74
+ ids = [e["id"] for e in r.events.iterate(contract_status="breaking", since=datetime(2026, 9, 1, tzinfo=timezone.utc), limit=2, type=None)]
75
+ self.assertEqual(ids, ["e1", "e2", "e3"])
76
+ q = a.calls[0]["query"]
77
+ self.assertEqual(q["contract_status"], ["breaking"])
78
+ self.assertEqual(q["since"], ["2026-09-01T00:00:00Z"])
79
+ self.assertNotIn("type", q)
80
+
81
+ def test_errors_and_retries(self):
82
+ a = self.api({
83
+ "GET /v1/orgs/o/incidents": lambda c, n: (503, None, {"Retry-After": "0"}) if n < 3 else (200, {"data": []}, {}),
84
+ "POST /v1/orgs/o/incidents/i1/replay": lambda c, n: (503, None, {"Retry-After": "0"}),
85
+ })
86
+ r = Relaya("rk", org_id="o", base_url=a.url)
87
+ self.assertEqual(r.incidents.list("open"), [])
88
+ with self.assertRaises(RelayaError) as cm:
89
+ r.incidents.replay("i1")
90
+ self.assertEqual(cm.exception.status, 503)
91
+ posts = [c for c in a.calls if c["method"] == "POST"]
92
+ self.assertEqual(len(posts), 1)
93
+ self.assertEqual(posts[0]["body"], {"confirm": True})
94
+ with self.assertRaises(RelayaError) as cm:
95
+ r.webhooks.get("nope")
96
+ self.assertEqual((cm.exception.status, cm.exception.code), (404, "not_found"))
97
+
98
+ def test_network_error(self):
99
+ r = Relaya("rk", org_id="o", base_url="http://127.0.0.1:1", max_retries=0)
100
+ with self.assertRaises(RelayaError) as cm:
101
+ r.projects.list()
102
+ self.assertEqual((cm.exception.status, cm.exception.code), (0, "network_error"))
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()
@@ -0,0 +1,136 @@
1
+ """End-to-end against a running Relaya (API + ingest + worker). Skipped unless RELAYA_IT_API_URL is set:
2
+
3
+ RELAYA_IT_API_URL=http://127.0.0.1:18080 python -m unittest tests.test_integration
4
+
5
+ The server must allow http://127.0.0.1 destinations (APP_ENV=dev) and use CONTRACT_MIN_SAMPLES=3.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ import unittest
13
+ import urllib.request
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+
16
+ from relaya import Relaya, RelayaError, WebhookVerificationError, verify_delivery
17
+
18
+ API = os.environ.get("RELAYA_IT_API_URL")
19
+
20
+
21
+ def wait_for(what, fn, timeout=15):
22
+ until = time.time() + timeout
23
+ while True:
24
+ v = fn()
25
+ if v:
26
+ return v
27
+ if time.time() > until:
28
+ raise AssertionError(f"timed out waiting for {what}")
29
+ time.sleep(0.15)
30
+
31
+
32
+ def post(path, body, token=None):
33
+ req = urllib.request.Request(API + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", **({"Authorization": f"Bearer {token}"} if token else {})})
34
+ with urllib.request.urlopen(req) as r:
35
+ return json.loads(r.read())
36
+
37
+
38
+ @unittest.skipUnless(API, "set RELAYA_IT_API_URL to run")
39
+ class IntegrationTest(unittest.TestCase):
40
+ def test_sdk_against_live_relaya(self):
41
+ session = post("/v1/auth/signup", {"email": f"py-sdk-{time.time_ns()}@example.com", "password": "sdk-test-password-1", "org_name": "Python SDK"})
42
+ org_id = Relaya(session["token"], base_url=API).request("GET", "/v1/me")["orgs"][0]["id"]
43
+ key = post(f"/v1/orgs/{org_id}/api-keys", {"name": "sdk", "role": "admin"}, session["token"])
44
+
45
+ relaya = Relaya(key["key"], base_url=API)
46
+ self.assertEqual(relaya.org_id, org_id)
47
+
48
+ # A customer endpoint verifying with the SDK.
49
+ state = {"secret": "", "fail_next": 0, "received": []}
50
+
51
+ class Endpoint(BaseHTTPRequestHandler):
52
+ def log_message(self, *a):
53
+ pass
54
+
55
+ def do_POST(self):
56
+ body = self.rfile.read(int(self.headers["Content-Length"]))
57
+ try:
58
+ d = verify_delivery(body, self.headers, state["secret"])
59
+ except WebhookVerificationError as e:
60
+ self.send_response(400)
61
+ self.end_headers()
62
+ self.wfile.write(e.reason.encode())
63
+ return
64
+ state["received"].append(d)
65
+ failing = state["fail_next"] > 0
66
+ state["fail_next"] -= 1
67
+ self.send_response(500 if failing else 200)
68
+ self.end_headers()
69
+
70
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Endpoint)
71
+ threading.Thread(target=server.serve_forever, daemon=True).start()
72
+ self.addCleanup(server.shutdown)
73
+ received = state["received"]
74
+
75
+ project = relaya.projects.create("SDK")
76
+ wh = relaya.webhooks.create(project["id"], "Payments", "generic")
77
+ dest = relaya.destinations.create(wh["id"], "My app", f"http://127.0.0.1:{server.server_address[1]}/hooks")
78
+ state["secret"] = dest["signing_secret"]
79
+
80
+ def send(event_id, body):
81
+ req = urllib.request.Request(wh["ingest_url"], data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json", "X-Event-Id": event_id})
82
+ with urllib.request.urlopen(req) as r:
83
+ self.assertEqual(r.status, 200)
84
+
85
+ for i in range(1, 4):
86
+ send(f"e{i}", {"type": "payment.captured", "amount": 100 * i})
87
+
88
+ wait_for("3 deliveries", lambda: len(received) >= 3)
89
+ first = received[0]
90
+ self.assertEqual(first.idempotency_key, first.delivery_id)
91
+ self.assertEqual(first.attempt, 1)
92
+ self.assertEqual(first.event_type, "payment.captured")
93
+ self.assertIsInstance(first.json()["amount"], int)
94
+
95
+ events = list(relaya.events.iterate(webhook_id=wh["id"], limit=2))
96
+ self.assertEqual(len(events), 3)
97
+ self.assertEqual(relaya.events.get(first.event_id)["payload_json"]["type"], "payment.captured")
98
+
99
+ ok = wait_for("succeeded deliveries", lambda: (lambda l: len(l) == 3 and l)(relaya.deliveries.list(webhook_id=wh["id"], status="succeeded")))
100
+ self.assertEqual(relaya.deliveries.get(ok[0]["id"])["attempts"][0]["outcome"], "succeeded")
101
+
102
+ # Failing endpoint, then a manual retry.
103
+ state["fail_next"] = 1
104
+ send("e4", {"type": "payment.captured", "amount": 400})
105
+ retrying = wait_for("a retrying delivery", lambda: relaya.deliveries.list(webhook_id=wh["id"], status="retrying"))[0]
106
+ relaya.deliveries.retry(retrying["id"])
107
+ wait_for("the retry to succeed", lambda: relaya.deliveries.get(retrying["id"])["delivery"]["status"] == "succeeded")
108
+ self.assertEqual(received[-1].attempt, 2)
109
+ self.assertEqual(received[-1].idempotency_key, retrying["id"])
110
+
111
+ # Contract -> incident -> replay.
112
+ contract = wait_for("a proposed contract", lambda: next((c for c in relaya.contracts.list(wh["id"]) if c["status"] != "learning"), None))
113
+ self.assertGreaterEqual(relaya.contracts.create_version(contract["id"], ["amount"], "observed"), 1)
114
+ send("e5", {"type": "payment.captured", "amount": "500"})
115
+ incident = wait_for("an open incident", lambda: relaya.incidents.list("open"))[0]
116
+ self.assertIn("amount changed type", incident["title"])
117
+ self.assertEqual(relaya.incidents.preview_replay(incident["id"])["events"], 1)
118
+ before = len(received)
119
+ replay = relaya.incidents.replay(incident["id"])
120
+ self.assertEqual(replay["total"], 1)
121
+ wait_for("the replayed delivery", lambda: len(received) > before)
122
+ self.assertEqual(received[-1].replay_id, replay["id"])
123
+ wait_for("the incident to resolve", lambda: relaya.incidents.list("open") == [])
124
+
125
+ # Destination test and API errors.
126
+ self.assertTrue(relaya.destinations.test(dest["destination"]["id"])["ok"])
127
+ with self.assertRaises(RelayaError) as cm:
128
+ relaya.webhooks.get("00000000-0000-0000-0000-000000000000")
129
+ self.assertEqual(cm.exception.status, 404)
130
+ with self.assertRaises(RelayaError) as cm:
131
+ relaya.deliveries.retry(ok[0]["id"])
132
+ self.assertEqual(cm.exception.status, 409)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ unittest.main()
@@ -0,0 +1,82 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+ import unittest
6
+
7
+ from relaya import WebhookVerificationError, is_valid_signature, verify_alert, verify_delivery, verify_signature
8
+
9
+ SECRET = "whsec_test_secret"
10
+ BODY = '{"type":"payment.captured","amount":100,"note":"héllo"}'.encode()
11
+
12
+
13
+ def sign(body: bytes, secret: str = SECRET, t: int = None) -> str:
14
+ """Same algorithm as the Relaya worker: hex HMAC-SHA256(secret, "<t>.<body>")."""
15
+ t = int(time.time()) if t is None else t
16
+ return f"t={t},v1=" + hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
17
+
18
+
19
+ class VerifySignatureTest(unittest.TestCase):
20
+ def reason(self, *args, **kwargs):
21
+ with self.assertRaises(WebhookVerificationError) as cm:
22
+ verify_signature(*args, **kwargs)
23
+ return cm.exception.reason
24
+
25
+ def test_valid_bytes_and_str(self):
26
+ h = sign(BODY)
27
+ verify_signature(BODY, h, SECRET)
28
+ verify_signature(BODY.decode(), h, SECRET)
29
+ verify_signature(bytearray(BODY), h, SECRET)
30
+ self.assertTrue(is_valid_signature(BODY, h, SECRET))
31
+
32
+ def test_rejections(self):
33
+ self.assertEqual(self.reason(BODY, sign(BODY, "other"), SECRET), "signature_mismatch")
34
+ self.assertEqual(self.reason(BODY + b" ", sign(BODY), SECRET), "signature_mismatch")
35
+ self.assertEqual(self.reason(BODY, None, SECRET), "missing_signature")
36
+ self.assertEqual(self.reason(BODY, "v1=abc", SECRET), "malformed_signature")
37
+ self.assertEqual(self.reason(BODY, "t=x,v1=abc", SECRET), "malformed_signature")
38
+ self.assertFalse(is_valid_signature(BODY, sign(BODY, "other"), SECRET))
39
+
40
+ def test_tolerance(self):
41
+ old = int(time.time()) - 301
42
+ self.assertEqual(self.reason(BODY, sign(BODY, t=old), SECRET), "timestamp_out_of_range")
43
+ verify_signature(BODY, sign(BODY, t=old), SECRET, tolerance=600)
44
+ verify_signature(BODY, sign(BODY, t=old), SECRET, tolerance=0)
45
+ at = verify_signature(BODY, sign(BODY, t=1_700_000_000), SECRET, now=1_700_000_005)
46
+ self.assertEqual(at.timestamp(), 1_700_000_000)
47
+
48
+ def test_rotation_and_bad_input(self):
49
+ verify_signature(BODY, sign(BODY, "new"), ["old", "new"])
50
+ with self.assertRaises(ValueError):
51
+ verify_signature(BODY, sign(BODY), [])
52
+ with self.assertRaisesRegex(TypeError, "raw request body"):
53
+ verify_signature(json.loads(BODY), sign(BODY), SECRET)
54
+
55
+
56
+ class VerifyDeliveryTest(unittest.TestCase):
57
+ def test_headers_any_case_and_django_meta(self):
58
+ plain = {
59
+ "relaya-signature": sign(BODY),
60
+ "Idempotency-Key": "dlv_1",
61
+ "RELAYA-DELIVERY-ID": "dlv_1",
62
+ "Relaya-Event-Id": "evt_1",
63
+ "Relaya-Attempt": "3",
64
+ "Relaya-Event-Type": "payment.captured",
65
+ }
66
+ meta = {"HTTP_" + k.upper().replace("-", "_"): v for k, v in plain.items()} # Django request.META
67
+ for headers in (plain, meta):
68
+ d = verify_delivery(BODY, headers, SECRET)
69
+ self.assertEqual(d.idempotency_key, "dlv_1")
70
+ self.assertEqual(d.event_id, "evt_1")
71
+ self.assertEqual(d.attempt, 3)
72
+ self.assertEqual(d.event_type, "payment.captured")
73
+ self.assertIsNone(d.replay_id)
74
+ self.assertEqual(d.json()["note"], "héllo")
75
+
76
+ def test_alert(self):
77
+ body = json.dumps({"type": "test", "title": "Test alert", "alert_id": 1}).encode()
78
+ self.assertEqual(verify_alert(body, {"Relaya-Signature": sign(body)}, SECRET)["title"], "Test alert")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ unittest.main()