webhooks-cc 0.1.0__py3-none-any.whl

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,144 @@
1
+ """webhooks.cc SDK for Python.
2
+
3
+ Capture webhooks and email in tests, wait for them, send signed webhooks to
4
+ your own handlers, and verify provider and forwarding signatures.
5
+
6
+ .. code-block:: python
7
+
8
+ from webhooks_cc import WebhooksCC, match_body_path
9
+
10
+ with WebhooksCC() as client: # reads WHK_API_KEY
11
+ endpoint = client.endpoints.create(name="checkout test")
12
+ request = client.requests.wait_for(
13
+ endpoint.slug, timeout="30s", match=match_body_path("type", "invoice.paid")
14
+ )
15
+
16
+ Every signature check for each provider is in :mod:`webhooks_cc.verify`.
17
+ """
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ from .client import Emails, Endpoints, Forwarding, Requests, RetryOptions, WebhooksCC
22
+ from .durations import Duration, parse_duration
23
+ from .email import (
24
+ DEFAULT_EMAIL_DOMAIN,
25
+ email_address,
26
+ extract_code,
27
+ extract_link,
28
+ is_valid_email_tag,
29
+ )
30
+ from .errors import (
31
+ NotFoundError,
32
+ RateLimitError,
33
+ TimeoutError,
34
+ UnauthorizedError,
35
+ WebhooksCCError,
36
+ WebhookVerificationError,
37
+ )
38
+ from .matchers import (
39
+ Matcher,
40
+ is_email_request,
41
+ match_all,
42
+ match_any,
43
+ match_body_path,
44
+ match_body_subset,
45
+ match_content_type,
46
+ match_email,
47
+ match_header,
48
+ match_method,
49
+ match_path,
50
+ match_query_param,
51
+ match_unverified,
52
+ match_verified,
53
+ )
54
+ from .models import (
55
+ BuiltRequest,
56
+ EmailAddress,
57
+ EmailAttachment,
58
+ EmailCapture,
59
+ EmailExtracts,
60
+ Endpoint,
61
+ ExtractedLink,
62
+ Request,
63
+ RequestPage,
64
+ SendTestEmailResult,
65
+ TeamShare,
66
+ UsageInfo,
67
+ )
68
+ from .templates import (
69
+ TEMPLATE_METADATA,
70
+ TEMPLATE_PROVIDERS,
71
+ VERIFY_PROVIDERS,
72
+ TemplateProviderInfo,
73
+ build_template_request,
74
+ )
75
+ from .testing import capture_during, capture_email_during, temporary_endpoint
76
+ from .verify import (
77
+ RelayedRequestInfo,
78
+ verify_forwarded_email,
79
+ verify_forwarded_request,
80
+ verify_relayed_request,
81
+ verify_signature,
82
+ )
83
+
84
+ __all__ = [
85
+ "DEFAULT_EMAIL_DOMAIN",
86
+ "TEMPLATE_METADATA",
87
+ "TEMPLATE_PROVIDERS",
88
+ "VERIFY_PROVIDERS",
89
+ "BuiltRequest",
90
+ "Duration",
91
+ "EmailAddress",
92
+ "EmailAttachment",
93
+ "EmailCapture",
94
+ "EmailExtracts",
95
+ "Emails",
96
+ "Endpoint",
97
+ "Endpoints",
98
+ "ExtractedLink",
99
+ "Forwarding",
100
+ "Matcher",
101
+ "NotFoundError",
102
+ "RateLimitError",
103
+ "RelayedRequestInfo",
104
+ "Request",
105
+ "RequestPage",
106
+ "Requests",
107
+ "RetryOptions",
108
+ "SendTestEmailResult",
109
+ "TeamShare",
110
+ "TemplateProviderInfo",
111
+ "TimeoutError",
112
+ "UnauthorizedError",
113
+ "UsageInfo",
114
+ "WebhookVerificationError",
115
+ "WebhooksCC",
116
+ "WebhooksCCError",
117
+ "__version__",
118
+ "build_template_request",
119
+ "capture_during",
120
+ "capture_email_during",
121
+ "email_address",
122
+ "extract_code",
123
+ "extract_link",
124
+ "is_email_request",
125
+ "is_valid_email_tag",
126
+ "match_all",
127
+ "match_any",
128
+ "match_body_path",
129
+ "match_body_subset",
130
+ "match_content_type",
131
+ "match_email",
132
+ "match_header",
133
+ "match_method",
134
+ "match_path",
135
+ "match_query_param",
136
+ "match_unverified",
137
+ "match_verified",
138
+ "parse_duration",
139
+ "temporary_endpoint",
140
+ "verify_forwarded_email",
141
+ "verify_forwarded_request",
142
+ "verify_relayed_request",
143
+ "verify_signature",
144
+ ]
webhooks_cc/_crypto.py ADDED
@@ -0,0 +1,255 @@
1
+ """Signing primitives shared by request building and signature verification.
2
+
3
+ These mirror the TypeScript SDK byte for byte; the conformance vectors in
4
+ ``packages/sdk/conformance`` check that they do.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import binascii
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import math
15
+ import re
16
+ from collections.abc import Mapping, Sequence
17
+ from typing import Any, Literal
18
+
19
+ HashName = Literal["sha256", "sha1"]
20
+
21
+ _BASE64_ALPHABET = re.compile(r"^[A-Za-z0-9+/]*$")
22
+ _ASCII_WHITESPACE = re.compile(r"[\t\n\f\r ]")
23
+ _HEX = re.compile(r"^[a-fA-F0-9]+$")
24
+
25
+
26
+ def utf8(value: str | bytes) -> bytes:
27
+ return value if isinstance(value, bytes) else value.encode("utf-8")
28
+
29
+
30
+ def hmac_digest(algorithm: HashName, key: str | bytes, payload: str | bytes) -> bytes:
31
+ """HMAC of ``payload``; a ``str`` key or payload is taken as UTF-8."""
32
+ digest = hashlib.sha256 if algorithm == "sha256" else hashlib.sha1
33
+ return hmac.new(utf8(key), utf8(payload), digest).digest()
34
+
35
+
36
+ def to_hex(data: bytes) -> str:
37
+ return data.hex()
38
+
39
+
40
+ def to_base64(data: bytes) -> str:
41
+ return base64.b64encode(data).decode("ascii")
42
+
43
+
44
+ def to_base64url(data: bytes) -> str:
45
+ """Base64url without padding."""
46
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
47
+
48
+
49
+ def from_base64(value: str) -> bytes:
50
+ """Decode base64 the way browsers' ``atob`` does (WHATWG forgiving-base64).
51
+
52
+ ASCII whitespace is ignored and padding is optional. Raises ``ValueError``
53
+ for a character outside ``A-Z a-z 0-9 + /``, an ``=`` anywhere but as one
54
+ or two final characters that make the length a multiple of 4, or 4n+1
55
+ characters once padding is removed.
56
+ """
57
+ data = _ASCII_WHITESPACE.sub("", value)
58
+ if len(data) % 4 == 0:
59
+ if data.endswith("=="):
60
+ data = data[:-2]
61
+ elif data.endswith("="):
62
+ data = data[:-1]
63
+ if len(data) % 4 == 1 or not _BASE64_ALPHABET.match(data):
64
+ raise ValueError("invalid base64")
65
+ try:
66
+ return base64.b64decode(data + "=" * (-len(data) % 4))
67
+ except binascii.Error as error: # pragma: no cover - excluded by the checks above
68
+ raise ValueError("invalid base64") from error
69
+
70
+
71
+ def hex_to_bytes(value: str) -> bytes:
72
+ normalized = value.strip()
73
+ if not _HEX.match(normalized) or len(normalized) % 2 != 0:
74
+ raise ValueError("Expected a hex-encoded value")
75
+ return bytes.fromhex(normalized)
76
+
77
+
78
+ def decode_standard_webhook_secret(secret: str) -> bytes:
79
+ """The HMAC key for a Standard Webhooks style secret.
80
+
81
+ Standard Webhooks, Clerk, Resend and webhooks.cc forwarding use them.
82
+
83
+ A leading ``whsec_`` is stripped and the rest decoded as base64. When that
84
+ fails, the key is the UTF-8 bytes of the whole secret, prefix included.
85
+ """
86
+ raw = secret[6:] if secret.startswith("whsec_") else secret
87
+ try:
88
+ return from_base64(raw)
89
+ except ValueError:
90
+ return secret.encode("utf-8")
91
+
92
+
93
+ _HUBSPOT_ESCAPES = {
94
+ "%3A": ":",
95
+ "%2F": "/",
96
+ "%3F": "?",
97
+ "%40": "@",
98
+ "%21": "!",
99
+ "%24": "$",
100
+ "%27": "'",
101
+ "%28": "(",
102
+ "%29": ")",
103
+ "%2A": "*",
104
+ "%2C": ",",
105
+ "%3B": ";",
106
+ }
107
+ _HUBSPOT_ESCAPE_PATTERN = re.compile(r"%(?:3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)")
108
+
109
+
110
+ def decode_hubspot_uri(uri: str) -> str:
111
+ """Decode exactly the escapes HubSpot decodes before signing, uppercase only."""
112
+ return _HUBSPOT_ESCAPE_PATTERN.sub(lambda match: _HUBSPOT_ESCAPES[match.group(0)], uri)
113
+
114
+
115
+ def _utf16_key(value: str) -> bytes:
116
+ # JavaScript compares strings by UTF-16 code unit; big-endian UTF-16 bytes sort the same way.
117
+ return value.encode("utf-16-be", "surrogatepass")
118
+
119
+
120
+ def build_twilio_signature_payload(url: str, params: Sequence[tuple[str, str]]) -> str:
121
+ """The URL followed by each parameter's name and value, sorted by name, then value."""
122
+ ordered = sorted(params, key=lambda item: (_utf16_key(item[0]), _utf16_key(item[1])))
123
+ return url + "".join(f"{key}{value}" for key, value in ordered)
124
+
125
+
126
+ def _adyen_field(value: Any) -> str:
127
+ if value is None:
128
+ return ""
129
+ if isinstance(value, str):
130
+ return value
131
+ return js_string(value)
132
+
133
+
134
+ def adyen_data_to_sign(item: Mapping[str, Any]) -> str:
135
+ """The fields of an Adyen notification item that its HMAC covers, joined with ``:``."""
136
+ amount = item.get("amount")
137
+ amount = amount if isinstance(amount, Mapping) else {}
138
+ return ":".join(
139
+ _adyen_field(value)
140
+ for value in (
141
+ item.get("pspReference"),
142
+ item.get("originalReference"),
143
+ item.get("merchantAccountCode"),
144
+ item.get("merchantReference"),
145
+ amount.get("value"),
146
+ amount.get("currency"),
147
+ item.get("eventCode"),
148
+ item.get("success"),
149
+ )
150
+ )
151
+
152
+
153
+ def adyen_hmac(item: Mapping[str, Any], hex_key: str) -> str:
154
+ """Base64 HMAC-SHA256 of an Adyen notification item, keyed with the hex HMAC key."""
155
+ return to_base64(hmac_digest("sha256", hex_to_bytes(hex_key), adyen_data_to_sign(item)))
156
+
157
+
158
+ # --- JavaScript-compatible JSON -------------------------------------------------------
159
+
160
+
161
+ def js_number(value: float) -> str:
162
+ """Format a number as JavaScript's ``String(number)`` does."""
163
+ if isinstance(value, bool):
164
+ raise TypeError("bool is not a number")
165
+ if isinstance(value, int):
166
+ return str(value)
167
+ if math.isnan(value):
168
+ return "NaN"
169
+ if math.isinf(value):
170
+ return "Infinity" if value > 0 else "-Infinity"
171
+ if value == 0:
172
+ return "0"
173
+ sign = "-" if value < 0 else ""
174
+ # repr() gives the shortest round-tripping digits, as ECMAScript requires.
175
+ mantissa, _, exp_part = repr(abs(value)).partition("e")
176
+ exponent = int(exp_part) if exp_part else 0
177
+ int_part, _, frac_part = mantissa.partition(".")
178
+ digits = (int_part + frac_part).lstrip("0")
179
+ # Position of the decimal point relative to the start of `digits` (ECMAScript's n).
180
+ leading_zeros = len(int_part + frac_part) - len((int_part + frac_part).lstrip("0"))
181
+ n = len(int_part) - leading_zeros + exponent
182
+ digits = digits.rstrip("0") or "0"
183
+ k = len(digits)
184
+ if k <= n <= 21:
185
+ return sign + digits + "0" * (n - k)
186
+ if 0 < n <= 21:
187
+ return sign + digits[:n] + "." + digits[n:]
188
+ if -6 < n <= 0:
189
+ return sign + "0." + "0" * (-n) + digits
190
+ e = n - 1
191
+ exp = f"e{'+' if e >= 0 else '-'}{abs(e)}"
192
+ if k == 1:
193
+ return sign + digits + exp
194
+ return sign + digits[0] + "." + digits[1:] + exp
195
+
196
+
197
+ def js_string(value: Any) -> str:
198
+ """JavaScript's ``String(value)`` for the primitive types JSON can hold."""
199
+ if value is None:
200
+ return "null"
201
+ if value is True:
202
+ return "true"
203
+ if value is False:
204
+ return "false"
205
+ if isinstance(value, (int, float)):
206
+ return js_number(value)
207
+ return str(value)
208
+
209
+
210
+ def js_json(value: Any) -> str:
211
+ """Serialize like JavaScript's ``JSON.stringify(value)``.
212
+
213
+ Compact, keys in their order, non-ASCII characters left as they are.
214
+ """
215
+ parts: list[str] = []
216
+ _write_json(value, parts)
217
+ return "".join(parts)
218
+
219
+
220
+ def _write_json(value: Any, out: list[str]) -> None:
221
+ if value is None or value is True or value is False:
222
+ out.append(js_string(value))
223
+ elif isinstance(value, str):
224
+ out.append(json.dumps(value, ensure_ascii=False))
225
+ elif isinstance(value, (int, float)):
226
+ out.append(
227
+ "null"
228
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value))
229
+ else js_number(value)
230
+ )
231
+ elif isinstance(value, Mapping):
232
+ out.append("{")
233
+ first = True
234
+ for key, item in value.items():
235
+ if not first:
236
+ out.append(",")
237
+ first = False
238
+ out.append(json.dumps(str(key), ensure_ascii=False))
239
+ out.append(":")
240
+ _write_json(item, out)
241
+ out.append("}")
242
+ elif isinstance(value, (list, tuple)):
243
+ out.append("[")
244
+ for index, item in enumerate(value):
245
+ if index:
246
+ out.append(",")
247
+ _write_json(item, out)
248
+ out.append("]")
249
+ else:
250
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
251
+
252
+
253
+ def json_body(value: Any) -> str:
254
+ """A request body: strings are sent as they are, anything else as JSON."""
255
+ return value if isinstance(value, str) else js_json(value)
@@ -0,0 +1,16 @@
1
+ """A marker for arguments that were not given, where ``None`` has a meaning of its own."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class _Unset:
9
+ def __repr__(self) -> str:
10
+ return "UNSET"
11
+
12
+ def __bool__(self) -> bool:
13
+ return False
14
+
15
+
16
+ UNSET: Any = _Unset()
webhooks_cc/_sse.py ADDED
@@ -0,0 +1,45 @@
1
+ """Server-sent events, as the request stream sends them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SSEFrame:
11
+ event: str
12
+ data: str
13
+
14
+
15
+ def parse_sse(lines: Iterable[str]) -> Iterator[SSEFrame]:
16
+ """Frames from the lines of an event stream (line endings already removed).
17
+
18
+ ``data:`` lines join with newlines, a blank line ends a frame, and a
19
+ comment (``: ...``) is yielded as a frame with event ``"comment"``, which
20
+ is how the server's keepalives show up. Other fields are ignored.
21
+ """
22
+ event = "message"
23
+ data: list[str] = []
24
+ for line in lines:
25
+ if line == "":
26
+ if data:
27
+ yield SSEFrame(event, "\n".join(data))
28
+ data = []
29
+ event = "message"
30
+ continue
31
+ if line.startswith(":"):
32
+ comment = line[1:]
33
+ yield SSEFrame("comment", comment[1:] if comment.startswith(" ") else comment)
34
+ continue
35
+ name, colon, value = line.partition(":")
36
+ if not colon:
37
+ continue
38
+ if value.startswith(" "):
39
+ value = value[1:]
40
+ if name == "event":
41
+ event = value
42
+ elif name == "data":
43
+ data.append(value)
44
+ if data:
45
+ yield SSEFrame(event, "\n".join(data))