linkbridge 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkbridge
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Linkbridge e-invoicing API
5
+ Author: Linkbridge
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://docs.linkbridge.ng
8
+ Project-URL: Documentation, https://docs.linkbridge.ng
9
+ Project-URL: Source, https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python
10
+ Project-URL: Issues, https://github.com/Linkbridge-Systems/linkbridge-sdks/issues
11
+ Keywords: linkbridge,e-invoicing,nrs,fbr,fiscalisation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Requires-Dist: pytest-cov>=4; extra == "test"
23
+
24
+ # linkbridge (Python SDK)
25
+
26
+ Official Python client for the [Linkbridge](https://linkbridge.ng)
27
+ e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
28
+ [Node](../sdk-node) SDKs against the same OpenAPI contract
29
+ (`tools/openapi/openapi.yaml`).
30
+
31
+ * **Zero runtime dependencies** — uses only the Python standard library
32
+ so the package drops cleanly into Lambda layers, vendored POS
33
+ firmware, and air-gapped merchant ERPs.
34
+ * **Sync API** with explicit OAuth2 client-credentials handling and
35
+ automatic token refresh.
36
+ * **Webhook signature verification** with constant-time comparison and
37
+ the same 5-minute clock-skew window enforced server-side.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install linkbridge
43
+ ```
44
+
45
+ Requires Python 3.9 or newer.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from linkbridge import LinkbridgeClient
51
+
52
+ client = LinkbridgeClient(
53
+ base_url="https://api.linkbridge.ng",
54
+ client_id="your-client-id",
55
+ client_secret="your-client-secret",
56
+ scopes=["invoices:write", "invoices:read"],
57
+ )
58
+
59
+ accepted = client.invoices.submit({
60
+ "irn": "INV001-SVC01-20260601",
61
+ "invoice_kind": "Standard",
62
+ # …rest of the canonical NRS payload — see
63
+ # packages/schema/invoice.schema.json
64
+ })
65
+ print(accepted["irn"], accepted["status"])
66
+
67
+ # Read back, paginate, retry, mutate payment status:
68
+ record = client.invoices.get(accepted["irn"])
69
+ page = client.invoices.list(limit=20, status="failed")
70
+ requeued = client.invoices.transmit(accepted["irn"])
71
+ paid = client.invoices.update_status(accepted["irn"],
72
+ payment_status="PAID", reference="RCPT-001")
73
+ ```
74
+
75
+ ## Webhook verification
76
+
77
+ ```python
78
+ from linkbridge import verify_webhook, SignatureError
79
+
80
+ @app.post("/hooks/linkbridge")
81
+ def hook(request):
82
+ try:
83
+ verify_webhook(
84
+ secret=os.environ["WEBHOOK_SECRET"].encode(),
85
+ body=request.body, # raw bytes off the wire
86
+ header=request.headers["X-Linkbridge-Signature"],
87
+ )
88
+ except SignatureError:
89
+ return 401
90
+ # …handle the event
91
+ ```
92
+
93
+ ## Errors
94
+
95
+ All non-2xx responses raise `linkbridge.APIError`, which exposes the
96
+ HTTP status, the canonical `error.code`, the human `error.message`, and
97
+ the `trace_id` so that operators can correlate against server logs.
98
+
99
+ ## Versioning
100
+
101
+ The package follows the same `0.MINOR.PATCH` cadence as the API surface
102
+ during the beta. Breaking changes will be confined to MINOR bumps until
103
+ the API freezes at `1.0.0`.
@@ -0,0 +1,80 @@
1
+ # linkbridge (Python SDK)
2
+
3
+ Official Python client for the [Linkbridge](https://linkbridge.ng)
4
+ e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
5
+ [Node](../sdk-node) SDKs against the same OpenAPI contract
6
+ (`tools/openapi/openapi.yaml`).
7
+
8
+ * **Zero runtime dependencies** — uses only the Python standard library
9
+ so the package drops cleanly into Lambda layers, vendored POS
10
+ firmware, and air-gapped merchant ERPs.
11
+ * **Sync API** with explicit OAuth2 client-credentials handling and
12
+ automatic token refresh.
13
+ * **Webhook signature verification** with constant-time comparison and
14
+ the same 5-minute clock-skew window enforced server-side.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install linkbridge
20
+ ```
21
+
22
+ Requires Python 3.9 or newer.
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from linkbridge import LinkbridgeClient
28
+
29
+ client = LinkbridgeClient(
30
+ base_url="https://api.linkbridge.ng",
31
+ client_id="your-client-id",
32
+ client_secret="your-client-secret",
33
+ scopes=["invoices:write", "invoices:read"],
34
+ )
35
+
36
+ accepted = client.invoices.submit({
37
+ "irn": "INV001-SVC01-20260601",
38
+ "invoice_kind": "Standard",
39
+ # …rest of the canonical NRS payload — see
40
+ # packages/schema/invoice.schema.json
41
+ })
42
+ print(accepted["irn"], accepted["status"])
43
+
44
+ # Read back, paginate, retry, mutate payment status:
45
+ record = client.invoices.get(accepted["irn"])
46
+ page = client.invoices.list(limit=20, status="failed")
47
+ requeued = client.invoices.transmit(accepted["irn"])
48
+ paid = client.invoices.update_status(accepted["irn"],
49
+ payment_status="PAID", reference="RCPT-001")
50
+ ```
51
+
52
+ ## Webhook verification
53
+
54
+ ```python
55
+ from linkbridge import verify_webhook, SignatureError
56
+
57
+ @app.post("/hooks/linkbridge")
58
+ def hook(request):
59
+ try:
60
+ verify_webhook(
61
+ secret=os.environ["WEBHOOK_SECRET"].encode(),
62
+ body=request.body, # raw bytes off the wire
63
+ header=request.headers["X-Linkbridge-Signature"],
64
+ )
65
+ except SignatureError:
66
+ return 401
67
+ # …handle the event
68
+ ```
69
+
70
+ ## Errors
71
+
72
+ All non-2xx responses raise `linkbridge.APIError`, which exposes the
73
+ HTTP status, the canonical `error.code`, the human `error.message`, and
74
+ the `trace_id` so that operators can correlate against server logs.
75
+
76
+ ## Versioning
77
+
78
+ The package follows the same `0.MINOR.PATCH` cadence as the API surface
79
+ during the beta. Breaking changes will be confined to MINOR bumps until
80
+ the API freezes at `1.0.0`.
@@ -0,0 +1,25 @@
1
+ """Linkbridge Python SDK — public surface.
2
+
3
+ Stable imports for downstream callers; everything else is implementation
4
+ detail and may change without a major version bump.
5
+ """
6
+
7
+ from .client import LinkbridgeClient, SDK_VERSION
8
+ from .errors import APIError, SignatureError
9
+ from .webhook import (
10
+ MAX_WEBHOOK_SKEW_SECONDS,
11
+ SIGNATURE_HEADER,
12
+ verify_webhook,
13
+ )
14
+
15
+ __all__ = [
16
+ "LinkbridgeClient",
17
+ "APIError",
18
+ "SignatureError",
19
+ "verify_webhook",
20
+ "SIGNATURE_HEADER",
21
+ "MAX_WEBHOOK_SKEW_SECONDS",
22
+ "SDK_VERSION",
23
+ ]
24
+
25
+ __version__ = SDK_VERSION
@@ -0,0 +1,322 @@
1
+ """LinkbridgeClient — the SDK entry point.
2
+
3
+ Design notes
4
+ ------------
5
+
6
+ * Stdlib-only HTTP via ``urllib.request``. We deliberately avoid the
7
+ ``requests``/``httpx`` dependency so the package installs cleanly
8
+ into AWS Lambda layers and air-gapped POS firmware.
9
+ * OAuth2 client-credentials flow with lazy, thread-safe token refresh.
10
+ Tokens are refreshed 60 seconds before expiry; concurrent callers
11
+ block on a single in-flight refresh via a threading.Lock.
12
+ * Mirrors the resource grouping of the Node and Go SDKs:
13
+ ``client.invoices.{submit, get, list, transmit, update_status}``,
14
+ ``client.webhooks.{create, list, delete}``, ``client.lookups.*``.
15
+ * The ``transport`` constructor argument lets tests inject a fake
16
+ request handler (see ``tests/test_client.py``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import secrets
23
+ import threading
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple
29
+
30
+ from .errors import APIError
31
+
32
+ SDK_VERSION = "0.1.0"
33
+
34
+ # A Transport is a pluggable HTTP function. Returning the tuple
35
+ # (status, headers, body_bytes) keeps the interface trivially mockable.
36
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, Mapping[str, str], bytes]]
37
+
38
+
39
+ def _stdlib_transport(
40
+ method: str,
41
+ url: str,
42
+ headers: Mapping[str, str],
43
+ body: Optional[bytes],
44
+ ) -> Tuple[int, Mapping[str, str], bytes]:
45
+ """Default transport built on urllib. Captures error responses so
46
+ callers (and ``APIError.from_response``) can decode the body even
47
+ on 4xx/5xx — urllib normally treats those as exceptions."""
48
+ req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — trusted scheme
51
+ return resp.status, dict(resp.headers), resp.read()
52
+ except urllib.error.HTTPError as exc:
53
+ return exc.code, dict(exc.headers or {}), exc.read()
54
+
55
+
56
+ class LinkbridgeClient:
57
+ """Synchronous Linkbridge API client."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ base_url: str,
63
+ client_id: Optional[str] = None,
64
+ client_secret: Optional[str] = None,
65
+ static_token: Optional[str] = None,
66
+ scopes: Optional[Iterable[str]] = None,
67
+ user_agent: Optional[str] = None,
68
+ transport: Optional[Transport] = None,
69
+ ) -> None:
70
+ if not base_url:
71
+ raise ValueError("linkbridge: base_url is required")
72
+ if not static_token and not (client_id and client_secret):
73
+ raise ValueError(
74
+ "linkbridge: either static_token or client_id+client_secret is required"
75
+ )
76
+
77
+ self._base_url = base_url.rstrip("/")
78
+ self._client_id = client_id
79
+ self._client_secret = client_secret
80
+ self._static_token = static_token
81
+ self._scopes = list(scopes) if scopes else ["invoices:write", "invoices:read"]
82
+ self._user_agent_suffix = user_agent or ""
83
+ self._transport: Transport = transport or _stdlib_transport
84
+
85
+ self._token_cache: Optional[Tuple[str, float]] = None # (token, expires_at_epoch)
86
+ self._token_lock = threading.Lock()
87
+
88
+ self.invoices = InvoicesAPI(self)
89
+ self.webhooks = WebhooksAPI(self)
90
+ self.lookups = LookupsAPI(self)
91
+
92
+ # ----- public helpers -------------------------------------------------
93
+
94
+ @staticmethod
95
+ def idempotency_key() -> str:
96
+ """Return a fresh URL-safe Idempotency-Key (32 hex chars + prefix)."""
97
+ return "lb-" + secrets.token_hex(16)
98
+
99
+ def user_agent(self) -> str:
100
+ base = f"linkbridge-python/{SDK_VERSION}"
101
+ return f"{base} {self._user_agent_suffix}".rstrip()
102
+
103
+ # ----- token management ----------------------------------------------
104
+
105
+ def _token(self) -> str:
106
+ if self._static_token:
107
+ return self._static_token
108
+ with self._token_lock:
109
+ if self._token_cache and self._token_cache[1] - time.time() > 60:
110
+ return self._token_cache[0]
111
+ self._refresh_token_locked()
112
+ assert self._token_cache is not None
113
+ return self._token_cache[0]
114
+
115
+ def _refresh_token_locked(self) -> None:
116
+ body = json.dumps(
117
+ {
118
+ "client_id": self._client_id,
119
+ "client_secret": self._client_secret,
120
+ "grant_type": "client_credentials",
121
+ "scope": " ".join(self._scopes),
122
+ }
123
+ ).encode("utf-8")
124
+ status, _headers, raw = self._transport(
125
+ "POST",
126
+ f"{self._base_url}/v1/oauth/token",
127
+ {
128
+ "content-type": "application/json",
129
+ "accept": "application/json",
130
+ "user-agent": self.user_agent(),
131
+ },
132
+ body,
133
+ )
134
+ if status // 100 != 2:
135
+ raise APIError.from_response(status, raw)
136
+ try:
137
+ decoded = json.loads(raw)
138
+ except ValueError as exc:
139
+ raise APIError(
140
+ status=status,
141
+ code="invalid_token_response",
142
+ message="non-JSON token response",
143
+ raw=raw,
144
+ ) from exc
145
+ token = decoded.get("access_token")
146
+ ttl = int(decoded.get("expires_in") or 300)
147
+ if not token:
148
+ raise APIError(
149
+ status=status,
150
+ code="invalid_token_response",
151
+ message="empty access_token",
152
+ raw=raw,
153
+ )
154
+ self._token_cache = (token, time.time() + ttl)
155
+
156
+ # ----- request plumbing ----------------------------------------------
157
+
158
+ def request(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ query: Optional[Mapping[str, Any]] = None,
164
+ body: Any = None,
165
+ extra_headers: Optional[Mapping[str, str]] = None,
166
+ expect_json: bool = True,
167
+ ) -> Any:
168
+ """Authenticated request. Returns the decoded JSON body on
169
+ 2xx (or ``None`` for 204 / empty bodies). Raises :class:`APIError`
170
+ otherwise."""
171
+ url = self._base_url + path
172
+ if query:
173
+ cleaned = {k: str(v) for k, v in query.items() if v is not None and v != ""}
174
+ if cleaned:
175
+ url = f"{url}?{urllib.parse.urlencode(cleaned)}"
176
+
177
+ headers: Dict[str, str] = {
178
+ "authorization": "Bearer " + self._token(),
179
+ "accept": "application/json",
180
+ "user-agent": self.user_agent(),
181
+ }
182
+ raw_body: Optional[bytes] = None
183
+ if body is not None:
184
+ headers["content-type"] = "application/json"
185
+ raw_body = json.dumps(body).encode("utf-8")
186
+ if extra_headers:
187
+ for k, v in extra_headers.items():
188
+ headers[k.lower()] = v
189
+
190
+ status, _resp_headers, payload = self._transport(method, url, headers, raw_body)
191
+ if status // 100 != 2:
192
+ raise APIError.from_response(status, payload)
193
+ if not expect_json or status == 204 or not payload:
194
+ return None
195
+ try:
196
+ return json.loads(payload)
197
+ except ValueError as exc:
198
+ raise APIError(
199
+ status=status,
200
+ code="invalid_json_response",
201
+ message="server returned non-JSON body",
202
+ raw=payload,
203
+ ) from exc
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Resource handles. Each one is a thin facade over LinkbridgeClient.request
208
+ # so callers get IDE-friendly grouping (`client.invoices.submit(...)`).
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ class InvoicesAPI:
213
+ def __init__(self, client: LinkbridgeClient) -> None:
214
+ self._c = client
215
+
216
+ def submit(
217
+ self,
218
+ invoice: Mapping[str, Any],
219
+ *,
220
+ idempotency_key: Optional[str] = None,
221
+ mode: Optional[str] = None,
222
+ ) -> Dict[str, Any]:
223
+ """POST /v1/invoices. Returns the InvoiceAccepted envelope."""
224
+ headers = {
225
+ "idempotency-key": idempotency_key or LinkbridgeClient.idempotency_key(),
226
+ }
227
+ return self._c.request(
228
+ "POST",
229
+ "/v1/invoices",
230
+ query={"mode": mode} if mode else None,
231
+ body=invoice,
232
+ extra_headers=headers,
233
+ )
234
+
235
+ def get(self, irn: str) -> Dict[str, Any]:
236
+ """GET /v1/invoices/{irn}."""
237
+ return self._c.request("GET", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}")
238
+
239
+ def list(
240
+ self,
241
+ *,
242
+ cursor: Optional[str] = None,
243
+ limit: Optional[int] = None,
244
+ status: Optional[str] = None,
245
+ ) -> Dict[str, Any]:
246
+ """GET /v1/invoices. Returns ``{"data": [...], "next_cursor": ...}``."""
247
+ return self._c.request(
248
+ "GET",
249
+ "/v1/invoices",
250
+ query={"cursor": cursor, "limit": limit, "status": status},
251
+ )
252
+
253
+ def transmit(self, irn: str) -> Dict[str, Any]:
254
+ """POST /v1/invoices/{irn}/transmit — re-queue for transmission."""
255
+ return self._c.request(
256
+ "POST", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/transmit"
257
+ )
258
+
259
+ def update_status(
260
+ self,
261
+ irn: str,
262
+ *,
263
+ payment_status: str,
264
+ reference: Optional[str] = None,
265
+ ) -> Dict[str, Any]:
266
+ """POST /v1/invoices/{irn}/status — record a payment-state change."""
267
+ body: Dict[str, Any] = {"payment_status": payment_status}
268
+ if reference is not None:
269
+ body["reference"] = reference
270
+ return self._c.request(
271
+ "POST",
272
+ f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/status",
273
+ body=body,
274
+ )
275
+
276
+
277
+ class WebhooksAPI:
278
+ def __init__(self, client: LinkbridgeClient) -> None:
279
+ self._c = client
280
+
281
+ def create(
282
+ self,
283
+ *,
284
+ url: str,
285
+ events: Iterable[str],
286
+ description: Optional[str] = None,
287
+ ) -> Dict[str, Any]:
288
+ """POST /v1/webhooks. The plaintext ``secret`` is returned on
289
+ the response and never again — store it securely."""
290
+ body: Dict[str, Any] = {"url": url, "events": list(events)}
291
+ if description is not None:
292
+ body["description"] = description
293
+ return self._c.request("POST", "/v1/webhooks", body=body)
294
+
295
+ def list(self) -> Dict[str, Any]:
296
+ """GET /v1/webhooks."""
297
+ return self._c.request("GET", "/v1/webhooks")
298
+
299
+ def delete(self, webhook_id: str) -> None:
300
+ """DELETE /v1/webhooks/{id}."""
301
+ self._c.request(
302
+ "DELETE",
303
+ f"/v1/webhooks/{urllib.parse.quote(webhook_id, safe='')}",
304
+ expect_json=False,
305
+ )
306
+
307
+
308
+ class LookupsAPI:
309
+ def __init__(self, client: LinkbridgeClient) -> None:
310
+ self._c = client
311
+
312
+ def tax_codes(self) -> Dict[str, Any]:
313
+ """GET /v1/lookups/tax-codes."""
314
+ return self._c.request("GET", "/v1/lookups/tax-codes")
315
+
316
+ def hsn_codes(self, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
317
+ """GET /v1/lookups/hsn-codes (paginated)."""
318
+ return self._c.request(
319
+ "GET",
320
+ "/v1/lookups/hsn-codes",
321
+ query={"limit": limit, "cursor": cursor},
322
+ )
@@ -0,0 +1,66 @@
1
+ """Error types for the Linkbridge Python SDK.
2
+
3
+ The API always returns a `{"error": {"code", "message", "trace_id"}}`
4
+ envelope on failure. We surface those fields verbatim on `APIError` so
5
+ callers can switch on the canonical code without parsing the message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Mapping, Optional
12
+
13
+
14
+ class APIError(Exception):
15
+ """Raised for any non-2xx HTTP response from the Linkbridge API."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ status: int,
21
+ code: str,
22
+ message: str,
23
+ trace_id: Optional[str] = None,
24
+ details: Optional[Any] = None,
25
+ raw: Optional[bytes] = None,
26
+ ) -> None:
27
+ super().__init__(f"linkbridge: {status} {code}: {message}")
28
+ self.status = status
29
+ self.code = code
30
+ self.message = message
31
+ self.trace_id = trace_id
32
+ self.details = details
33
+ self.raw = raw
34
+
35
+ @classmethod
36
+ def from_response(cls, status: int, body: bytes) -> "APIError":
37
+ """Best-effort decoder. Falls back to opaque codes when the
38
+ response is not the canonical error envelope (e.g. a 502 from a
39
+ load balancer that has no idea what an APIError looks like)."""
40
+ code = "http_error"
41
+ message = body.decode("utf-8", errors="replace")[:500] or f"http {status}"
42
+ trace_id: Optional[str] = None
43
+ details: Any = None
44
+ try:
45
+ decoded = json.loads(body)
46
+ except (ValueError, UnicodeDecodeError):
47
+ decoded = None
48
+ if isinstance(decoded, Mapping):
49
+ err = decoded.get("error")
50
+ if isinstance(err, Mapping):
51
+ code = str(err.get("code") or code)
52
+ message = str(err.get("message") or message)
53
+ trace_id = err.get("trace_id")
54
+ details = err.get("details")
55
+ return cls(
56
+ status=status,
57
+ code=code,
58
+ message=message,
59
+ trace_id=trace_id,
60
+ details=details,
61
+ raw=body,
62
+ )
63
+
64
+
65
+ class SignatureError(Exception):
66
+ """Raised by ``verify_webhook`` when a delivery cannot be trusted."""
@@ -0,0 +1,97 @@
1
+ """HMAC-SHA256 verification for Linkbridge webhook deliveries.
2
+
3
+ Spec contract (see openapi.yaml §security and ADR-0006):
4
+
5
+ X-Linkbridge-Signature: t=<unix>,v1=<hex>
6
+ v1 = hex(HMAC-SHA256(secret, f"{t}.{body}"))
7
+
8
+ The receiver MUST reject deliveries whose timestamp falls outside a
9
+ 5-minute window to bound replay-attack horizons. We mirror the same
10
+ constant-time comparison logic the API server uses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hmac
16
+ import time
17
+ from hashlib import sha256
18
+ from typing import Optional
19
+
20
+ from .errors import SignatureError
21
+
22
+ SIGNATURE_HEADER = "X-Linkbridge-Signature"
23
+ MAX_WEBHOOK_SKEW_SECONDS = 5 * 60
24
+
25
+
26
+ def verify_webhook(
27
+ *,
28
+ secret: bytes,
29
+ body: bytes,
30
+ header: str,
31
+ now: Optional[float] = None,
32
+ ) -> None:
33
+ """Verify a webhook signature. Raises ``SignatureError`` on any
34
+ failure mode (missing/malformed header, replay-window violation,
35
+ signature mismatch). Returns ``None`` on success.
36
+
37
+ Parameters
38
+ ----------
39
+ secret:
40
+ The raw webhook secret bytes returned by ``POST /v1/webhooks``.
41
+ Do NOT pass the secret as ``str`` — Python would otherwise
42
+ encode it implicitly with the platform's default encoding.
43
+ body:
44
+ The exact request body bytes as read off the wire. JSON
45
+ re-serialisation will alter byte ordering and break verification.
46
+ header:
47
+ The raw value of the ``X-Linkbridge-Signature`` header.
48
+ now:
49
+ Override the current time (epoch seconds). Production code
50
+ should leave this as ``None`` so we use ``time.time()``.
51
+ """
52
+ if not header:
53
+ raise SignatureError("missing X-Linkbridge-Signature")
54
+ if not isinstance(secret, (bytes, bytearray)):
55
+ raise TypeError("secret must be bytes; pass secret.encode() if it's a str")
56
+ if not isinstance(body, (bytes, bytearray)):
57
+ raise TypeError("body must be bytes; do not pre-decode the request body")
58
+
59
+ t, sig = _parse_header(header)
60
+ current = time.time() if now is None else now
61
+ if abs(current - t) > MAX_WEBHOOK_SKEW_SECONDS:
62
+ raise SignatureError("signature timestamp outside replay window")
63
+
64
+ expected = hmac.new(secret, f"{t}.".encode("ascii"), sha256)
65
+ expected.update(body)
66
+ expected_hex = expected.hexdigest()
67
+ # hmac.compare_digest is constant-time; explicit lower() avoids
68
+ # accidentally rejecting a server that uppercased the hex.
69
+ if not hmac.compare_digest(expected_hex, sig.lower()):
70
+ raise SignatureError("signature mismatch")
71
+
72
+
73
+ def _parse_header(header: str) -> tuple[int, str]:
74
+ """Parse ``t=<unix>,v1=<hex>`` into ``(timestamp_int, hex_str)``.
75
+
76
+ Raises ``SignatureError`` for any structural problem.
77
+ """
78
+ t: Optional[int] = None
79
+ sig: Optional[str] = None
80
+ for part in header.split(","):
81
+ kv = part.strip().split("=", 1)
82
+ if len(kv) != 2:
83
+ raise SignatureError("malformed signature header")
84
+ key, value = kv[0], kv[1]
85
+ if key == "t":
86
+ try:
87
+ t = int(value)
88
+ except ValueError as exc:
89
+ raise SignatureError("malformed signature timestamp") from exc
90
+ if t <= 0:
91
+ raise SignatureError("malformed signature timestamp")
92
+ elif key == "v1":
93
+ sig = value
94
+ # Unknown keys are tolerated for forward-compat (e.g. v2=).
95
+ if t is None or not sig:
96
+ raise SignatureError("malformed signature header")
97
+ return t, sig
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkbridge
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Linkbridge e-invoicing API
5
+ Author: Linkbridge
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://docs.linkbridge.ng
8
+ Project-URL: Documentation, https://docs.linkbridge.ng
9
+ Project-URL: Source, https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python
10
+ Project-URL: Issues, https://github.com/Linkbridge-Systems/linkbridge-sdks/issues
11
+ Keywords: linkbridge,e-invoicing,nrs,fbr,fiscalisation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Requires-Dist: pytest-cov>=4; extra == "test"
23
+
24
+ # linkbridge (Python SDK)
25
+
26
+ Official Python client for the [Linkbridge](https://linkbridge.ng)
27
+ e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
28
+ [Node](../sdk-node) SDKs against the same OpenAPI contract
29
+ (`tools/openapi/openapi.yaml`).
30
+
31
+ * **Zero runtime dependencies** — uses only the Python standard library
32
+ so the package drops cleanly into Lambda layers, vendored POS
33
+ firmware, and air-gapped merchant ERPs.
34
+ * **Sync API** with explicit OAuth2 client-credentials handling and
35
+ automatic token refresh.
36
+ * **Webhook signature verification** with constant-time comparison and
37
+ the same 5-minute clock-skew window enforced server-side.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install linkbridge
43
+ ```
44
+
45
+ Requires Python 3.9 or newer.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from linkbridge import LinkbridgeClient
51
+
52
+ client = LinkbridgeClient(
53
+ base_url="https://api.linkbridge.ng",
54
+ client_id="your-client-id",
55
+ client_secret="your-client-secret",
56
+ scopes=["invoices:write", "invoices:read"],
57
+ )
58
+
59
+ accepted = client.invoices.submit({
60
+ "irn": "INV001-SVC01-20260601",
61
+ "invoice_kind": "Standard",
62
+ # …rest of the canonical NRS payload — see
63
+ # packages/schema/invoice.schema.json
64
+ })
65
+ print(accepted["irn"], accepted["status"])
66
+
67
+ # Read back, paginate, retry, mutate payment status:
68
+ record = client.invoices.get(accepted["irn"])
69
+ page = client.invoices.list(limit=20, status="failed")
70
+ requeued = client.invoices.transmit(accepted["irn"])
71
+ paid = client.invoices.update_status(accepted["irn"],
72
+ payment_status="PAID", reference="RCPT-001")
73
+ ```
74
+
75
+ ## Webhook verification
76
+
77
+ ```python
78
+ from linkbridge import verify_webhook, SignatureError
79
+
80
+ @app.post("/hooks/linkbridge")
81
+ def hook(request):
82
+ try:
83
+ verify_webhook(
84
+ secret=os.environ["WEBHOOK_SECRET"].encode(),
85
+ body=request.body, # raw bytes off the wire
86
+ header=request.headers["X-Linkbridge-Signature"],
87
+ )
88
+ except SignatureError:
89
+ return 401
90
+ # …handle the event
91
+ ```
92
+
93
+ ## Errors
94
+
95
+ All non-2xx responses raise `linkbridge.APIError`, which exposes the
96
+ HTTP status, the canonical `error.code`, the human `error.message`, and
97
+ the `trace_id` so that operators can correlate against server logs.
98
+
99
+ ## Versioning
100
+
101
+ The package follows the same `0.MINOR.PATCH` cadence as the API surface
102
+ during the beta. Breaking changes will be confined to MINOR bumps until
103
+ the API freezes at `1.0.0`.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ linkbridge/__init__.py
4
+ linkbridge/client.py
5
+ linkbridge/errors.py
6
+ linkbridge/webhook.py
7
+ linkbridge.egg-info/PKG-INFO
8
+ linkbridge.egg-info/SOURCES.txt
9
+ linkbridge.egg-info/dependency_links.txt
10
+ linkbridge.egg-info/requires.txt
11
+ linkbridge.egg-info/top_level.txt
12
+ tests/test_client.py
13
+ tests/test_webhook.py
@@ -0,0 +1,4 @@
1
+
2
+ [test]
3
+ pytest>=7
4
+ pytest-cov>=4
@@ -0,0 +1 @@
1
+ linkbridge
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "linkbridge"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Linkbridge e-invoicing API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Linkbridge" }]
13
+ keywords = ["linkbridge", "e-invoicing", "nrs", "fbr", "fiscalisation"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Office/Business :: Financial",
21
+ ]
22
+ # Zero runtime dependencies — we deliberately use stdlib (urllib, hmac,
23
+ # json, secrets) so the package installs cleanly into restricted
24
+ # environments (Lambda layers, vendored merchant POS firmware, etc.).
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=7", "pytest-cov>=4"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://docs.linkbridge.ng"
32
+ Documentation = "https://docs.linkbridge.ng"
33
+ Source = "https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python"
34
+ Issues = "https://github.com/Linkbridge-Systems/linkbridge-sdks/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ include = ["linkbridge*"]
38
+ exclude = ["tests*"]
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,269 @@
1
+ """Unit tests for the Linkbridge Python SDK client.
2
+
3
+ We rely on the Transport injection point to drive every code path
4
+ hermetically — no real HTTP, no Postgres. The contract is small enough
5
+ (four arguments in, three values out) that tests stay readable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+ import time
13
+ from typing import Any, List, Mapping, Optional, Tuple
14
+
15
+ import pytest
16
+
17
+ from linkbridge import APIError, LinkbridgeClient
18
+
19
+
20
+ # --- helpers ---------------------------------------------------------------
21
+
22
+
23
+ class FakeTransport:
24
+ """Records every call and returns canned responses."""
25
+
26
+ def __init__(self, responses: List[Tuple[int, Mapping[str, str], bytes]]) -> None:
27
+ self._responses = list(responses)
28
+ self.calls: List[Tuple[str, str, Mapping[str, str], Optional[bytes]]] = []
29
+ self.lock = threading.Lock()
30
+
31
+ def __call__(
32
+ self,
33
+ method: str,
34
+ url: str,
35
+ headers: Mapping[str, str],
36
+ body: Optional[bytes],
37
+ ) -> Tuple[int, Mapping[str, str], bytes]:
38
+ with self.lock:
39
+ self.calls.append((method, url, dict(headers), body))
40
+ if not self._responses:
41
+ raise AssertionError(f"FakeTransport: unexpected call {method} {url}")
42
+ return self._responses.pop(0)
43
+
44
+
45
+ def _ok(body: Any, *, status: int = 200) -> Tuple[int, Mapping[str, str], bytes]:
46
+ return status, {"content-type": "application/json"}, json.dumps(body).encode()
47
+
48
+
49
+ def _err(status: int, code: str, message: str) -> Tuple[int, Mapping[str, str], bytes]:
50
+ body = {"error": {"code": code, "message": message, "trace_id": "trace-xyz"}}
51
+ return status, {"content-type": "application/json"}, json.dumps(body).encode()
52
+
53
+
54
+ def _new_client(transport: FakeTransport, **overrides: Any) -> LinkbridgeClient:
55
+ base: dict[str, Any] = {
56
+ "base_url": "https://api.test",
57
+ "client_id": "cid",
58
+ "client_secret": "csecret",
59
+ "transport": transport,
60
+ }
61
+ base.update(overrides)
62
+ return LinkbridgeClient(**base)
63
+
64
+
65
+ # --- construction ----------------------------------------------------------
66
+
67
+
68
+ def test_constructor_requires_credentials():
69
+ with pytest.raises(ValueError):
70
+ LinkbridgeClient(base_url="https://x")
71
+
72
+
73
+ def test_constructor_requires_base_url():
74
+ with pytest.raises(ValueError):
75
+ LinkbridgeClient(base_url="", static_token="t")
76
+
77
+
78
+ def test_static_token_skips_oauth_dance():
79
+ transport = FakeTransport([_ok({"data": [], "next_cursor": None})])
80
+ client = LinkbridgeClient(
81
+ base_url="https://api.test", static_token="static-bearer", transport=transport
82
+ )
83
+ client.invoices.list()
84
+ method, url, headers, _ = transport.calls[0]
85
+ assert method == "GET"
86
+ assert url == "https://api.test/v1/invoices"
87
+ assert headers["authorization"] == "Bearer static-bearer"
88
+
89
+
90
+ # --- token caching ---------------------------------------------------------
91
+
92
+
93
+ def test_token_is_fetched_once_and_cached():
94
+ transport = FakeTransport(
95
+ [
96
+ _ok({"access_token": "tok-1", "expires_in": 3600, "token_type": "Bearer"}),
97
+ _ok({"data": [], "next_cursor": None}),
98
+ _ok({"data": [], "next_cursor": None}),
99
+ ]
100
+ )
101
+ client = _new_client(transport)
102
+ client.invoices.list()
103
+ client.invoices.list()
104
+ # Only one /v1/oauth/token call despite two list() calls.
105
+ token_calls = [c for c in transport.calls if c[1].endswith("/v1/oauth/token")]
106
+ assert len(token_calls) == 1
107
+
108
+
109
+ def test_token_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch):
110
+ transport = FakeTransport(
111
+ [
112
+ _ok({"access_token": "tok-1", "expires_in": 30, "token_type": "Bearer"}),
113
+ _ok({"data": [], "next_cursor": None}),
114
+ _ok({"access_token": "tok-2", "expires_in": 3600, "token_type": "Bearer"}),
115
+ _ok({"data": [], "next_cursor": None}),
116
+ ]
117
+ )
118
+ client = _new_client(transport)
119
+ client.invoices.list() # first call → fetches tok-1
120
+ # Advance time well past tok-1's window. expires_in=30 means cache
121
+ # window is now-60; we cross instantly.
122
+ base = time.time()
123
+ monkeypatch.setattr("linkbridge.client.time.time", lambda: base + 120)
124
+ client.invoices.list() # should refresh
125
+ token_calls = [c for c in transport.calls if c[1].endswith("/v1/oauth/token")]
126
+ assert len(token_calls) == 2
127
+
128
+
129
+ # --- error envelopes -------------------------------------------------------
130
+
131
+
132
+ def test_api_error_decodes_canonical_envelope():
133
+ transport = FakeTransport(
134
+ [
135
+ _ok({"access_token": "t", "expires_in": 3600}),
136
+ _err(404, "invoice_not_found", "no invoice with that IRN"),
137
+ ]
138
+ )
139
+ client = _new_client(transport)
140
+ with pytest.raises(APIError) as excinfo:
141
+ client.invoices.get("INV-MISSING")
142
+ assert excinfo.value.status == 404
143
+ assert excinfo.value.code == "invoice_not_found"
144
+ assert excinfo.value.trace_id == "trace-xyz"
145
+
146
+
147
+ def test_api_error_handles_non_envelope_body():
148
+ transport = FakeTransport(
149
+ [
150
+ _ok({"access_token": "t", "expires_in": 3600}),
151
+ (502, {}, b"<html>Bad Gateway</html>"),
152
+ ]
153
+ )
154
+ client = _new_client(transport)
155
+ with pytest.raises(APIError) as excinfo:
156
+ client.invoices.get("INV-X")
157
+ assert excinfo.value.status == 502
158
+ # Falls back to opaque code rather than crashing on the HTML body.
159
+ assert excinfo.value.code == "http_error"
160
+
161
+
162
+ # --- resource calls --------------------------------------------------------
163
+
164
+
165
+ def test_invoices_submit_attaches_idempotency_key_and_body():
166
+ transport = FakeTransport(
167
+ [
168
+ _ok({"access_token": "t", "expires_in": 3600}),
169
+ _ok(
170
+ {"irn": "INV001", "status": "pending", "tracking_url": "/v1/invoices/INV001"},
171
+ status=202,
172
+ ),
173
+ ]
174
+ )
175
+ client = _new_client(transport)
176
+ out = client.invoices.submit({"irn": "INV001"}, idempotency_key="my-key", mode="async")
177
+ assert out["irn"] == "INV001"
178
+
179
+ method, url, headers, body = transport.calls[1]
180
+ assert method == "POST"
181
+ assert url == "https://api.test/v1/invoices?mode=async"
182
+ assert headers["idempotency-key"] == "my-key"
183
+ assert headers["content-type"] == "application/json"
184
+ assert json.loads(body) == {"irn": "INV001"}
185
+
186
+
187
+ def test_invoices_submit_generates_idempotency_key_when_omitted():
188
+ transport = FakeTransport(
189
+ [
190
+ _ok({"access_token": "t", "expires_in": 3600}),
191
+ _ok({"irn": "INV", "status": "pending", "tracking_url": "/v1/invoices/INV"}, status=202),
192
+ ]
193
+ )
194
+ client = _new_client(transport)
195
+ client.invoices.submit({"irn": "INV"})
196
+ _, _, headers, _ = transport.calls[1]
197
+ assert headers["idempotency-key"].startswith("lb-")
198
+ assert len(headers["idempotency-key"]) == len("lb-") + 32
199
+
200
+
201
+ def test_invoices_transmit_targets_correct_path():
202
+ transport = FakeTransport(
203
+ [
204
+ _ok({"access_token": "t", "expires_in": 3600}),
205
+ _ok({"irn": "INV-X", "status": "failed", "tracking_url": "/v1/invoices/INV-X"}, status=202),
206
+ ]
207
+ )
208
+ client = _new_client(transport)
209
+ client.invoices.transmit("INV-X")
210
+ _, url, _, body = transport.calls[1]
211
+ assert url == "https://api.test/v1/invoices/INV-X/transmit"
212
+ assert body is None
213
+
214
+
215
+ def test_invoices_update_status_serialises_body():
216
+ transport = FakeTransport(
217
+ [
218
+ _ok({"access_token": "t", "expires_in": 3600}),
219
+ _ok({"irn": "INV-Y", "status": "transmitted"}),
220
+ ]
221
+ )
222
+ client = _new_client(transport)
223
+ client.invoices.update_status("INV-Y", payment_status="PAID", reference="RCPT-1")
224
+ _, url, _, body = transport.calls[1]
225
+ assert url == "https://api.test/v1/invoices/INV-Y/status"
226
+ assert json.loads(body) == {"payment_status": "PAID", "reference": "RCPT-1"}
227
+
228
+
229
+ def test_invoices_list_drops_empty_query_params():
230
+ transport = FakeTransport(
231
+ [
232
+ _ok({"access_token": "t", "expires_in": 3600}),
233
+ _ok({"data": [], "next_cursor": None}),
234
+ ]
235
+ )
236
+ client = _new_client(transport)
237
+ client.invoices.list(limit=20)
238
+ _, url, _, _ = transport.calls[1]
239
+ # cursor=None and status=None must not appear in the query string.
240
+ assert url == "https://api.test/v1/invoices?limit=20"
241
+
242
+
243
+ def test_webhooks_create_and_delete():
244
+ transport = FakeTransport(
245
+ [
246
+ _ok({"access_token": "t", "expires_in": 3600}),
247
+ _ok({"id": "wh-1", "url": "https://hook", "events": ["invoice.accepted"]}),
248
+ (204, {}, b""),
249
+ ]
250
+ )
251
+ client = _new_client(transport)
252
+ created = client.webhooks.create(url="https://hook", events=["invoice.accepted"])
253
+ assert created["id"] == "wh-1"
254
+ assert client.webhooks.delete("wh-1") is None
255
+ assert transport.calls[-1][0] == "DELETE"
256
+
257
+
258
+ def test_user_agent_includes_sdk_version_and_suffix():
259
+ transport = FakeTransport(
260
+ [
261
+ _ok({"access_token": "t", "expires_in": 3600}),
262
+ _ok({"data": [], "next_cursor": None}),
263
+ ]
264
+ )
265
+ client = _new_client(transport, user_agent="myapp/1.0")
266
+ client.invoices.list()
267
+ _, _, headers, _ = transport.calls[1]
268
+ assert headers["user-agent"].startswith("linkbridge-python/")
269
+ assert headers["user-agent"].endswith("myapp/1.0")
@@ -0,0 +1,98 @@
1
+ """Tests for the webhook signature verifier.
2
+
3
+ The vector below is reproduced byte-for-byte against the Go SDK's
4
+ ``VerifyWebhook`` (see ``packages/sdk-go/webhook_test.go``) so the two
5
+ implementations stay in lockstep.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hmac
11
+ import time
12
+ from hashlib import sha256
13
+
14
+ import pytest
15
+
16
+ from linkbridge import (
17
+ MAX_WEBHOOK_SKEW_SECONDS,
18
+ SignatureError,
19
+ verify_webhook,
20
+ )
21
+
22
+
23
+ SECRET = b"shhh-it-is-a-secret"
24
+ BODY = b'{"event":"invoice.accepted","data":{"irn":"INV-1"}}'
25
+ NOW = 1_700_000_000 # fixed reference timestamp
26
+
27
+
28
+ def _sign(secret: bytes, body: bytes, t: int) -> str:
29
+ mac = hmac.new(secret, f"{t}.".encode("ascii"), sha256)
30
+ mac.update(body)
31
+ return f"t={t},v1={mac.hexdigest()}"
32
+
33
+
34
+ def test_accepts_well_formed_signature():
35
+ header = _sign(SECRET, BODY, NOW)
36
+ verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
37
+
38
+
39
+ def test_rejects_missing_header():
40
+ with pytest.raises(SignatureError, match="missing"):
41
+ verify_webhook(secret=SECRET, body=BODY, header="", now=NOW)
42
+
43
+
44
+ def test_rejects_malformed_header():
45
+ with pytest.raises(SignatureError, match="malformed"):
46
+ verify_webhook(secret=SECRET, body=BODY, header="garbage", now=NOW)
47
+
48
+
49
+ def test_rejects_negative_timestamp():
50
+ with pytest.raises(SignatureError, match="malformed"):
51
+ verify_webhook(secret=SECRET, body=BODY, header="t=-1,v1=abc", now=NOW)
52
+
53
+
54
+ def test_rejects_replay_outside_window():
55
+ header = _sign(SECRET, BODY, NOW)
56
+ skewed = NOW + MAX_WEBHOOK_SKEW_SECONDS + 1
57
+ with pytest.raises(SignatureError, match="replay"):
58
+ verify_webhook(secret=SECRET, body=BODY, header=header, now=skewed)
59
+
60
+
61
+ def test_rejects_signature_mismatch():
62
+ header = _sign(b"different-secret", BODY, NOW)
63
+ with pytest.raises(SignatureError, match="mismatch"):
64
+ verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
65
+
66
+
67
+ def test_tolerates_unknown_keys_for_forward_compat():
68
+ # The verifier must ignore unknown k=v pairs (e.g. v2= once we add
69
+ # an algorithm bump) so that future signers stay backward-compatible
70
+ # with deployed receivers.
71
+ header = _sign(SECRET, BODY, NOW) + ",v2=ignored"
72
+ verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
73
+
74
+
75
+ def test_uppercased_hex_is_accepted():
76
+ header = _sign(SECRET, BODY, NOW)
77
+ parts = dict(p.split("=", 1) for p in header.split(","))
78
+ upper_header = f"t={parts['t']},v1={parts['v1'].upper()}"
79
+ verify_webhook(secret=SECRET, body=BODY, header=upper_header, now=NOW)
80
+
81
+
82
+ def test_secret_must_be_bytes():
83
+ header = _sign(SECRET, BODY, NOW)
84
+ with pytest.raises(TypeError):
85
+ verify_webhook(secret="str-secret", body=BODY, header=header, now=NOW) # type: ignore[arg-type]
86
+
87
+
88
+ def test_body_must_be_bytes():
89
+ header = _sign(SECRET, BODY, NOW)
90
+ with pytest.raises(TypeError):
91
+ verify_webhook(secret=SECRET, body="str-body", header=header, now=NOW) # type: ignore[arg-type]
92
+
93
+
94
+ def test_default_now_uses_wall_clock(monkeypatch):
95
+ """When ``now`` is omitted, the verifier must call ``time.time()``."""
96
+ monkeypatch.setattr(time, "time", lambda: float(NOW))
97
+ header = _sign(SECRET, BODY, NOW)
98
+ verify_webhook(secret=SECRET, body=BODY, header=header)