From 4c5a0252bb4640afab564d8826d0bdac1e7f81e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 12 Apr 2026 11:20:50 +0200 Subject: [PATCH 1/2] feat(sdk): webhooks Initial draft of a webhook capabilities. The goal of this PR is to test the interfacing between the webhooks service that we are working on and the SDKs. --- README.md | 23 +++ codegen/templates/client.py.tmpl | 33 +++ examples/webhooks.py | 63 ++++++ sumup/__init__.py | 11 +- sumup/_client.py | 34 ++++ sumup/webhooks.py | 335 +++++++++++++++++++++++++++++++ tests/test_webhooks.py | 259 ++++++++++++++++++++++++ 7 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 examples/webhooks.py create mode 100644 sumup/webhooks.py create mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index a690d595..23ab1322 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,29 @@ reader_checkout = client.readers.create_checkout( print(f"Reader checkout created: {reader_checkout}") ``` +### Verifying Webhooks + +```python +from sumup import Sumup, WebhookHandler +from sumup.webhooks import WebhookSignatureError + +client = Sumup(api_key="sup_sk_MvxmLOl0...") +webhooks = WebhookHandler(secret="whsec_...", client=client) + +def handle_webhook(headers: dict[str, str], body: bytes) -> None: + try: + event = webhooks.parse_and_verify(headers, body) + except WebhookSignatureError: + # Reject the request with 400/401 in your web framework. + raise + + if event.type == "checkout.created": + checkout = event.fetch_object() + print(f"Checkout {checkout.id} is now {checkout.status}") +``` + +For a minimal end-to-end example using Python's built-in HTTP server, see [examples/webhooks.py](./examples/webhooks.py). + ## Version support policy `sumup-py` maintains compatibility with Python versions that are no pass their End of life support, see [Status of Python versions](https://devguide.python.org/versions/). diff --git a/codegen/templates/client.py.tmpl b/codegen/templates/client.py.tmpl index 9e5f8861..898c165c 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -1,9 +1,12 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os import httpx import typing from ._service import Resource, AsyncResource, runtime_headers +if typing.TYPE_CHECKING: + from .webhooks import WebhookHandler {{- range .Resources }} from .{{ .Package }} import {{ .Name }}Resource, Async{{ .Name }}Resource {{- end }} @@ -40,6 +43,21 @@ class Sumup(Resource): }, )) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + {{- range .Resources }} @property def {{ .Package }}(self) -> {{ .Name }}Resource: @@ -69,6 +87,21 @@ class AsyncSumup(AsyncResource): }, )) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + {{- range .Resources }} @property def {{ .Package }}(self) -> Async{{ .Name }}Resource: diff --git a/examples/webhooks.py b/examples/webhooks.py new file mode 100644 index 00000000..ed76933d --- /dev/null +++ b/examples/webhooks.py @@ -0,0 +1,63 @@ +"""Minimal HTTP server example for receiving and verifying SumUp webhooks.""" + +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pydantic + +from sumup import Sumup +from sumup.webhooks import ( + CheckoutCreatedEvent, + WebhookSignatureError, + WebhookSignatureExpiredError, + WebhookTimestampError, +) + + +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) +webhooks = client.webhook_handler( + secret=os.environ["SUMUP_WEBHOOK_SECRET"], +) + + +class WebhookRequestHandler(BaseHTTPRequestHandler): + """Handle incoming webhook POST requests.""" + + def do_POST(self) -> None: + if self.path != "/webhooks": + self.send_error(404) + return + + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) + + try: + event = webhooks.parse_and_verify(dict(self.headers.items()), body) + except (WebhookSignatureError, WebhookSignatureExpiredError, WebhookTimestampError): + self.send_error(400, "Invalid webhook signature") + return + except pydantic.ValidationError: + self.send_error(400, "Invalid webhook payload") + return + + print( + "Webhook received:", + { + "id": event.id, + "type": event.type, + "object_id": event.object.id, + }, + ) + + if isinstance(event, CheckoutCreatedEvent): + checkout = event.fetch_object() + print(f"Checkout status: {checkout.status}") + + self.send_response(204) + self.end_headers() + + +if __name__ == "__main__": + server = HTTPServer(("127.0.0.1", 8080), WebhookRequestHandler) + print("Listening on http://127.0.0.1:8080/webhooks") + server.serve_forever() diff --git a/sumup/__init__.py b/sumup/__init__.py index 2c7b40f7..05bbbc84 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,5 +1,14 @@ from sumup._client import Sumup, AsyncSumup from sumup._service import Resource, AsyncResource from sumup._exceptions import APIError +from sumup.webhooks import WebhookHandler -__all__ = ["APIError", "AsyncResource", "AsyncSumup", "MerchantAccount", "Resource", "Sumup"] +__all__ = [ + "APIError", + "AsyncResource", + "AsyncSumup", + "MerchantAccount", + "Resource", + "Sumup", + "WebhookHandler", +] diff --git a/sumup/_client.py b/sumup/_client.py index 078f6ce2..4a44e028 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -1,9 +1,13 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os import httpx import typing from ._service import Resource, AsyncResource, runtime_headers + +if typing.TYPE_CHECKING: + from .webhooks import WebhookHandler from .checkouts import CheckoutsResource, AsyncCheckoutsResource from .customers import CustomersResource, AsyncCustomersResource from .members import MembersResource, AsyncMembersResource @@ -50,6 +54,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + @property def checkouts(self) -> CheckoutsResource: """Access the Checkouts API endpoints.""" @@ -149,6 +168,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + @property def checkouts(self) -> AsyncCheckoutsResource: """Access the Checkouts API endpoints.""" diff --git a/sumup/webhooks.py b/sumup/webhooks.py new file mode 100644 index 00000000..684cc304 --- /dev/null +++ b/sumup/webhooks.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import os +from enum import Enum +from typing import Any, ClassVar, Generic, Mapping, Type, TypeVar, Union, cast + +import httpx +import pydantic + +from ._exceptions import APIError, SumupError +from .types import Checkout, Member + +WEBHOOK_SIGNATURE_HEADER = "X-SumUp-Webhook-Signature" +WEBHOOK_TIMESTAMP_HEADER = "X-SumUp-Webhook-Timestamp" +WEBHOOK_SIGNATURE_VERSION = "v1" +DEFAULT_WEBHOOK_TOLERANCE = dt.timedelta(minutes=5) +WEBHOOK_SECRET_ENV_VAR = "SUMUP_WEBHOOK_SECRET" + +_UTC = dt.timezone.utc +_ClientT = TypeVar("_ClientT", httpx.Client, httpx.AsyncClient) +_BodyT = Union[bytes, bytearray, memoryview, str] +_ResponseT = TypeVar("_ResponseT", bound=pydantic.BaseModel) + + +class WebhookError(SumupError): + """Base class for webhook parsing and verification failures.""" + + +class WebhookSecretMissingError(WebhookError): + """Raised when webhook verification is attempted without a configured secret.""" + + +class WebhookTimestampError(WebhookError): + """Raised when the webhook timestamp header is missing or malformed.""" + + +class WebhookSignatureError(WebhookError): + """Raised when the webhook signature is missing or invalid.""" + + +class WebhookSignatureExpiredError(WebhookSignatureError): + """Raised when the webhook timestamp is outside the allowed tolerance window.""" + + +class WebhookEventType(str, Enum): + """Known SumUp webhook event type strings.""" + + CHECKOUT_CREATED = "checkout.created" + CHECKOUT_PROCESSED = "checkout.processed" + CHECKOUT_FAILED = "checkout.failed" + CHECKOUT_TERMINATED = "checkout.terminated" + MEMBER_CREATED = "member.created" + MEMBER_REMOVED = "member.removed" + + +class WebhookObject(pydantic.BaseModel): + """Reference to the SumUp resource associated with a webhook event.""" + + id: str + type: str + url: str + + +class WebhookEvent(pydantic.BaseModel): + """Generic SumUp webhook event envelope.""" + + id: str + type: str + created_at: dt.datetime + object: WebhookObject + + _client: httpx.Client | httpx.AsyncClient | None = pydantic.PrivateAttr(default=None) + + def bind_client(self, client: object | None) -> WebhookEvent: + """Attach a SumUp or HTTPX client used by fetchable event helpers.""" + self._client = _unwrap_client(client) + return self + + +class _FetchableEvent(WebhookEvent, Generic[_ResponseT]): + _response_model: ClassVar[Type[pydantic.BaseModel]] + + def _require_sync_client(self) -> httpx.Client: + if self._client is None: + raise RuntimeError("webhook event is not bound to a SumUp client") + if not isinstance(self._client, httpx.Client): + raise RuntimeError( + "webhook event is bound to an async client; use fetch_object_async()" + ) + return self._client + + def _require_async_client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("webhook event is not bound to a SumUp client") + if not isinstance(self._client, httpx.AsyncClient): + raise RuntimeError("webhook event is bound to a sync client; use fetch_object()") + return self._client + + def _parse_response(self, response: httpx.Response) -> _ResponseT: + if response.status_code != 200: + raise APIError("Unexpected response", status=response.status_code, body=response.text) + return cast(_ResponseT, self._response_model.model_validate(response.json())) + + def fetch_object(self) -> _ResponseT: + """Fetch the resource referenced by this event using a bound sync client.""" + response = self._require_sync_client().get(self.object.url) + return self._parse_response(response) + + async def fetch_object_async(self) -> _ResponseT: + """Fetch the resource referenced by this event using a bound async client.""" + response = await self._require_async_client().get(self.object.url) + return self._parse_response(response) + + +class CheckoutCreatedEvent(_FetchableEvent[Checkout]): + """Event emitted when a checkout is created.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_CREATED + + +class CheckoutProcessedEvent(_FetchableEvent[Checkout]): + """Event emitted when a checkout is processed.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_PROCESSED + + +class CheckoutFailedEvent(_FetchableEvent[Checkout]): + """Event emitted when a checkout processing attempt fails.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_FAILED + + +class CheckoutTerminatedEvent(_FetchableEvent[Checkout]): + """Event emitted when a checkout is terminated.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_TERMINATED + + +class MemberCreatedEvent(_FetchableEvent[Member]): + """Event emitted when a merchant member is created.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Member + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.MEMBER_CREATED + + +class MemberRemovedEvent(_FetchableEvent[Member]): + """Event emitted when a merchant member is removed.""" + + _response_model: ClassVar[Type[pydantic.BaseModel]] = Member + type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.MEMBER_REMOVED + + +KnownWebhookEvent = Union[ + CheckoutCreatedEvent, + CheckoutProcessedEvent, + CheckoutFailedEvent, + CheckoutTerminatedEvent, + MemberCreatedEvent, + MemberRemovedEvent, +] +WebhookNotification = Union[KnownWebhookEvent, WebhookEvent] + + +class WebhookHandler: + """Verify and parse incoming SumUp webhook requests.""" + + def __init__( + self, + *, + secret: str | None = None, + tolerance: dt.timedelta = DEFAULT_WEBHOOK_TOLERANCE, + client: object | None = None, + ) -> None: + self.secret = secret or os.getenv(WEBHOOK_SECRET_ENV_VAR) + self.tolerance = tolerance + self._client = _unwrap_client(client) + + def verify( + self, + headers: Mapping[str, str], + body: _BodyT, + *, + now: dt.datetime | None = None, + ) -> None: + """Verify the webhook signature and timestamp headers for a payload.""" + if not self.secret: + raise WebhookSecretMissingError( + f"webhook secret is not configured; pass secret=... or set {WEBHOOK_SECRET_ENV_VAR}" + ) + + signature = _get_header(headers, WEBHOOK_SIGNATURE_HEADER) + if not signature: + raise WebhookSignatureError("missing webhook signature header") + + timestamp_text = _get_header(headers, WEBHOOK_TIMESTAMP_HEADER) + if not timestamp_text: + raise WebhookTimestampError("missing webhook timestamp header") + + try: + timestamp = dt.datetime.fromtimestamp(int(timestamp_text), tz=_UTC) + except (TypeError, ValueError) as exc: + raise WebhookTimestampError("invalid webhook timestamp") from exc + + if abs(_coerce_now(now) - timestamp) > self.tolerance: + raise WebhookSignatureExpiredError("webhook timestamp outside allowed tolerance") + + version, separator, digest = signature.partition("=") + if separator != "=" or not version or not digest: + raise WebhookSignatureError("invalid webhook signature format") + if version != WEBHOOK_SIGNATURE_VERSION: + raise WebhookSignatureError("unsupported webhook signature version") + + expected = hmac.new( + self.secret.encode("utf-8"), + _signed_content(timestamp, body), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected, digest): + raise WebhookSignatureError("invalid webhook signature") + + def parse(self, body: _BodyT) -> WebhookNotification: + """Parse a webhook payload into the most specific known event model.""" + payload = _load_json(body) + event_type = payload.get("type") + if isinstance(event_type, str): + model = _EVENT_TYPES.get(event_type, WebhookEvent) + else: + model = WebhookEvent + event = model.model_validate(payload) + return event.bind_client(self._client) + + def parse_and_verify( + self, + headers: Mapping[str, str], + body: _BodyT, + *, + now: dt.datetime | None = None, + ) -> WebhookNotification: + """Verify a webhook request and then parse it into an event model.""" + self.verify(headers, body, now=now) + return self.parse(body) + + +_EVENT_TYPES: dict[str, type[WebhookEvent]] = { + WebhookEventType.CHECKOUT_CREATED.value: CheckoutCreatedEvent, + WebhookEventType.CHECKOUT_PROCESSED.value: CheckoutProcessedEvent, + WebhookEventType.CHECKOUT_FAILED.value: CheckoutFailedEvent, + WebhookEventType.CHECKOUT_TERMINATED.value: CheckoutTerminatedEvent, + WebhookEventType.MEMBER_CREATED.value: MemberCreatedEvent, + WebhookEventType.MEMBER_REMOVED.value: MemberRemovedEvent, +} + + +def _unwrap_client(client: object | None) -> httpx.Client | httpx.AsyncClient | None: + if client is None: + return None + if isinstance(client, (httpx.Client, httpx.AsyncClient)): + return client + + inner_client = getattr(client, "_client", None) + if isinstance(inner_client, (httpx.Client, httpx.AsyncClient)): + return inner_client + + raise TypeError("client must be a Sumup client, httpx.Client, or httpx.AsyncClient") + + +def _coerce_now(now: dt.datetime | None) -> dt.datetime: + if now is None: + return dt.datetime.now(tz=_UTC) + if now.tzinfo is None: + return now.replace(tzinfo=_UTC) + return now.astimezone(_UTC) + + +def _coerce_body_bytes(body: _BodyT) -> bytes: + if isinstance(body, bytes): + return body + if isinstance(body, str): + return body.encode("utf-8") + return bytes(body) + + +def _load_json(body: _BodyT) -> dict[str, Any]: + return pydantic.TypeAdapter(dict[str, Any]).validate_json(_coerce_body_bytes(body)) + + +def _get_header(headers: Mapping[str, str], name: str) -> str | None: + value = headers.get(name) + if value is not None: + return value + + target = name.lower() + for key, header_value in headers.items(): + if key.lower() == target: + return header_value + return None + + +def _signed_content(timestamp: dt.datetime, body: _BodyT) -> bytes: + return f"{WEBHOOK_SIGNATURE_VERSION}:{int(timestamp.timestamp())}:".encode( + "utf-8" + ) + _coerce_body_bytes(body) + + +__all__ = [ + "DEFAULT_WEBHOOK_TOLERANCE", + "WEBHOOK_SECRET_ENV_VAR", + "WEBHOOK_SIGNATURE_HEADER", + "WEBHOOK_SIGNATURE_VERSION", + "WEBHOOK_TIMESTAMP_HEADER", + "CheckoutCreatedEvent", + "CheckoutFailedEvent", + "CheckoutProcessedEvent", + "CheckoutTerminatedEvent", + "KnownWebhookEvent", + "MemberCreatedEvent", + "MemberRemovedEvent", + "WebhookError", + "WebhookEvent", + "WebhookEventType", + "WebhookHandler", + "WebhookNotification", + "WebhookObject", + "WebhookSecretMissingError", + "WebhookSignatureError", + "WebhookSignatureExpiredError", + "WebhookTimestampError", +] diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 00000000..5525f934 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,259 @@ +import datetime as dt +import hashlib +import hmac +import json +import asyncio + +from typing import Mapping, Union + +import httpx +import pytest +import pydantic + +from sumup import AsyncSumup, Sumup +from sumup.types import Checkout +from sumup.webhooks import ( + DEFAULT_WEBHOOK_TOLERANCE, + WEBHOOK_SIGNATURE_HEADER, + WEBHOOK_SIGNATURE_VERSION, + WEBHOOK_TIMESTAMP_HEADER, + CheckoutCreatedEvent, + WebhookEvent, + WebhookHandler, + WebhookSignatureError, + WebhookSignatureExpiredError, + WebhookTimestampError, +) + + +def test_verify_accepts_valid_signature() -> None: + body = b'{"id":"evt_123","type":"checkout.created"}' + now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) + headers = _sign_headers("wh_sec_test", now, body) + + handler = WebhookHandler(secret="wh_sec_test") + + handler.verify(headers, body, now=now) + + +def test_verify_rejects_expired_timestamp() -> None: + body = b'{"id":"evt_123","type":"checkout.created"}' + now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) + timestamp = now - DEFAULT_WEBHOOK_TOLERANCE - dt.timedelta(seconds=1) + headers = _sign_headers("wh_sec_test", timestamp, body) + + handler = WebhookHandler(secret="wh_sec_test") + + with pytest.raises(WebhookSignatureExpiredError): + handler.verify(headers, body, now=now) + + +def test_verify_rejects_invalid_signature() -> None: + body = b'{"id":"evt_123","type":"checkout.created"}' + now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) + headers = { + WEBHOOK_TIMESTAMP_HEADER: str(int(now.timestamp())), + WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef", + } + + handler = WebhookHandler(secret="wh_sec_test") + + with pytest.raises(WebhookSignatureError): + handler.verify(headers, body, now=now) + + +def test_verify_rejects_missing_timestamp() -> None: + handler = WebhookHandler(secret="wh_sec_test") + + with pytest.raises(WebhookTimestampError): + handler.verify({WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef"}, b"{}", now=_utc_now()) + + +def test_parse_returns_typed_known_event() -> None: + body = json.dumps( + { + "id": "evt_123", + "type": "checkout.created", + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "chk_123", + "type": "checkout", + "url": "https://api.sumup.com/v0.1/checkouts/chk_123", + }, + } + ) + + event = WebhookHandler(secret="wh_sec_test").parse(body) + + assert isinstance(event, CheckoutCreatedEvent) + assert event.type.value == "checkout.created" + + +def test_parse_returns_generic_event_for_unknown_types() -> None: + body = json.dumps( + { + "id": "evt_123", + "type": "something.else", + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "obj_123", + "type": "other", + "url": "https://api.sumup.com/v0.1/other/obj_123", + }, + } + ) + + event = WebhookHandler(secret="wh_sec_test").parse(body) + + assert type(event) is WebhookEvent + assert event.type == "something.else" + + +def test_sumup_client_can_create_bound_webhook_handler() -> None: + client = Sumup(api_key="test") + + handler = client.webhook_handler(secret="wh_sec_test") + + assert handler.secret == "wh_sec_test" + assert handler._client is client._client + + client._client.close() + + +def test_async_sumup_client_can_create_bound_webhook_handler() -> None: + client = AsyncSumup(api_key="test") + + handler = client.webhook_handler(secret="wh_sec_test") + + assert handler.secret == "wh_sec_test" + assert handler._client is client._client + + asyncio.run(client._client.aclose()) + + +def test_parse_rejects_invalid_json_payload() -> None: + with pytest.raises(pydantic.ValidationError): + WebhookHandler(secret="wh_sec_test").parse_and_verify( + _sign_headers("wh_sec_test", _utc_now(), b"{"), + b"{", + now=_utc_now(), + ) + + +def test_parse_and_verify_binds_client_and_fetches_object(sdk_factory) -> None: + checkout_payload = { + "id": "chk_123", + "amount": 10.0, + "checkout_reference": "ref_123", + "currency": "EUR", + "date": "2026-04-11T10:00:00Z", + "description": "Test payment", + "idempotency_key": "idem_123", + "merchant_code": "MC123", + "status": "PENDING", + } + + sdk = sdk_factory( + lambda request: ( + _json_response(checkout_payload) + if str(request.url) == "https://api.sumup.com/v0.1/checkouts/chk_123" + else _json_response({"error": "not found"}, status_code=404) + ) + ) + + body = json.dumps( + { + "id": "evt_123", + "type": "checkout.created", + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "chk_123", + "type": "checkout", + "url": "https://api.sumup.com/v0.1/checkouts/chk_123", + }, + } + ) + now = _utc_now() + headers = _sign_headers("wh_sec_test", now, body.encode("utf-8")) + handler = WebhookHandler(secret="wh_sec_test", client=sdk) + + event = handler.parse_and_verify(headers, body, now=now) + assert isinstance(event, CheckoutCreatedEvent) + checkout = event.fetch_object() + + assert isinstance(checkout, Checkout) + assert checkout.id == "chk_123" + + +def test_parse_and_verify_binds_async_client_and_fetches_object_async() -> None: + checkout_payload = { + "id": "chk_123", + "amount": 10.0, + "checkout_reference": "ref_123", + "currency": "EUR", + "date": "2026-04-11T10:00:00Z", + "description": "Test payment", + "idempotency_key": "idem_123", + "merchant_code": "MC123", + "status": "PENDING", + } + + async def transport_handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == "https://api.sumup.com/v0.1/checkouts/chk_123": + return _json_response(checkout_payload) + return _json_response({"error": "not found"}, status_code=404) + + sdk = AsyncSumup(api_key="test", base_url="https://api.sumup.test") + original_client = sdk._client + sdk._client = httpx.AsyncClient( + base_url=original_client.base_url, + timeout=original_client.timeout, + headers=original_client.headers, + transport=httpx.MockTransport(transport_handler), + ) + asyncio.run(original_client.aclose()) + + body = json.dumps( + { + "id": "evt_123", + "type": "checkout.created", + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "chk_123", + "type": "checkout", + "url": "https://api.sumup.com/v0.1/checkouts/chk_123", + }, + } + ) + now = _utc_now() + headers = _sign_headers("wh_sec_test", now, body.encode("utf-8")) + webhook_handler = WebhookHandler(secret="wh_sec_test", client=sdk) + + try: + event = webhook_handler.parse_and_verify(headers, body, now=now) + assert isinstance(event, CheckoutCreatedEvent) + checkout = asyncio.run(event.fetch_object_async()) + + assert isinstance(checkout, Checkout) + assert checkout.id == "chk_123" + finally: + asyncio.run(sdk._client.aclose()) + + +def _sign_headers(secret: str, timestamp: dt.datetime, body: bytes) -> dict[str, str]: + payload = f"{WEBHOOK_SIGNATURE_VERSION}:{int(timestamp.timestamp())}:".encode("utf-8") + body + digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + return { + WEBHOOK_TIMESTAMP_HEADER: str(int(timestamp.timestamp())), + WEBHOOK_SIGNATURE_HEADER: f"{WEBHOOK_SIGNATURE_VERSION}={digest}", + } + + +def _json_response(body: Mapping[str, Union[object, str, int, float]], status_code: int = 200): + import httpx + + return httpx.Response(status_code, json=body) + + +def _utc_now() -> dt.datetime: + return dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) From 349ece4d7de6e34934e2146a7520cf5fa3d1f353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 2 Aug 2026 20:32:02 +0200 Subject: [PATCH 2/2] feat(sdk): add typed event handlers Generate event models from OpenAPI webhooks and route verified notifications through sync and async callbacks. --- README.md | 39 +- codegen/pkg/builder/builder.go | 9 + codegen/pkg/builder/events.go | 107 +++++ codegen/pkg/builder/events_test.go | 70 +++ .../builder/intermediate_representation.go | 9 + codegen/templates/client.py.tmpl | 34 +- codegen/templates/events.py.tmpl | 93 ++++ examples/events.py | 71 +++ examples/webhooks.py | 63 --- openapi.json | 228 ++++++++- sumup/__init__.py | 9 +- sumup/_client.py | 34 +- sumup/_events.py | 432 ++++++++++++++++++ sumup/events.py | 123 +++++ sumup/webhooks.py | 335 -------------- tests/test_events.py | 289 ++++++++++++ tests/test_webhooks.py | 259 ----------- 17 files changed, 1498 insertions(+), 706 deletions(-) create mode 100644 codegen/pkg/builder/events.go create mode 100644 codegen/pkg/builder/events_test.go create mode 100644 codegen/templates/events.py.tmpl create mode 100644 examples/events.py delete mode 100644 examples/webhooks.py create mode 100644 sumup/_events.py create mode 100755 sumup/events.py delete mode 100644 sumup/webhooks.py create mode 100644 tests/test_events.py delete mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index 23ab1322..1f550bad 100644 --- a/README.md +++ b/README.md @@ -110,28 +110,43 @@ reader_checkout = client.readers.create_checkout( print(f"Reader checkout created: {reader_checkout}") ``` -### Verifying Webhooks +### Handling Events ```python -from sumup import Sumup, WebhookHandler -from sumup.webhooks import WebhookSignatureError +from sumup import Sumup +from sumup.events import ( + EventNotification, + EventSignatureError, + EventTimestampError, + ReaderCreatedEvent, +) client = Sumup(api_key="sup_sk_MvxmLOl0...") -webhooks = WebhookHandler(secret="whsec_...", client=client) -def handle_webhook(headers: dict[str, str], body: bytes) -> None: +def handle_unhandled_event(event: EventNotification, _client: Sumup) -> None: + print(f"Received unhandled event type: {event.type}") + +def handle_reader_created(event: ReaderCreatedEvent, _client: Sumup) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id}") + +events = client.events_handler("event_secret", handle_unhandled_event) +events.on(ReaderCreatedEvent, handle_reader_created) + +def handle_request(body: bytes, signature: str, timestamp: str) -> None: try: - event = webhooks.parse_and_verify(headers, body) - except WebhookSignatureError: + events.handle(body, signature, timestamp) + except (EventSignatureError, EventTimestampError): # Reject the request with 400/401 in your web framework. raise - - if event.type == "checkout.created": - checkout = event.fetch_object() - print(f"Checkout {checkout.id} is now {checkout.status}") ``` -For a minimal end-to-end example using Python's built-in HTTP server, see [examples/webhooks.py](./examples/webhooks.py). +Pass the exact raw request body together with the +`X-SumUp-Webhook-Signature` and `X-SumUp-Webhook-Timestamp` header values. +Events without a registered callback are sent to the required fallback callback. + +For a minimal end-to-end example using Python's built-in HTTP server, see +[examples/events.py](./examples/events.py). ## Version support policy diff --git a/codegen/pkg/builder/builder.go b/codegen/pkg/builder/builder.go index 011ef2e4..8e1901dc 100644 --- a/codegen/pkg/builder/builder.go +++ b/codegen/pkg/builder/builder.go @@ -32,6 +32,8 @@ type Builder struct { pathsByTag map[string]*v3.Paths + events []EventDefinition + templates *template.Template start time.Time @@ -79,6 +81,9 @@ func (b *Builder) Load(spec *v3.Document) error { b.collectPaths() b.collectSchemas() + if err := b.collectEvents(); err != nil { + return err + } return nil } @@ -94,6 +99,10 @@ func (b *Builder) Build() error { return err } + if err := b.writeEventsFile(path.Join(b.cfg.Out, "events.py")); err != nil { + return err + } + for tagName, paths := range b.pathsByTag { if err := b.generateResource(tagName, paths); err != nil { return err diff --git a/codegen/pkg/builder/events.go b/codegen/pkg/builder/events.go new file mode 100644 index 00000000..75376ad3 --- /dev/null +++ b/codegen/pkg/builder/events.go @@ -0,0 +1,107 @@ +package builder + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/iancoleman/strcase" + + "github.com/sumup/sumup-py/codegen/pkg/extension" +) + +type eventObjectExtension struct { + Reference string `yaml:"$ref"` +} + +func (b *Builder) collectEvents() error { + if b.spec == nil || b.spec.Webhooks == nil { + return nil + } + + events := make([]EventDefinition, 0, b.spec.Webhooks.Len()) + for eventType, pathItem := range b.spec.Webhooks.FromOldest() { + if pathItem == nil || pathItem.Post == nil { + continue + } + + operation := pathItem.Post + name := strings.TrimSuffix(operation.OperationId, "Webhook") + if name == "" { + return fmt.Errorf("webhook %q is missing an operationId", eventType) + } + + if operation.Extensions == nil { + return fmt.Errorf("webhook %q is missing x-object", eventType) + } + object, ok := extension.Get[eventObjectExtension](operation.Extensions, "x-object") + if !ok || object.Reference == "" { + return fmt.Errorf("webhook %q is missing x-object", eventType) + } + + const schemaPrefix = "#/components/schemas/" + objectSchema, ok := strings.CutPrefix(object.Reference, schemaPrefix) + if !ok || objectSchema == "" { + return fmt.Errorf( + "webhook %q has unsupported x-object reference %q", + eventType, + object.Reference, + ) + } + + description := strings.TrimSpace(operation.Description) + if description == "" { + description = strings.TrimSpace(operation.Summary) + } + + events = append(events, EventDefinition{ + ClassName: strcase.ToCamel(name) + "Event", + EventType: eventType, + ObjectType: strcase.ToCamel(objectSchema), + Description: description, + }) + } + + slices.SortFunc(events, func(a, b EventDefinition) int { + return strings.Compare(a.ClassName, b.ClassName) + }) + b.events = events + return nil +} + +type eventsTemplateData struct { + Events []EventDefinition + ObjectTypes []string +} + +func (b *Builder) writeEventsFile(filename string) error { + objectTypes := make([]string, 0, len(b.events)) + for _, event := range b.events { + if !slices.Contains(objectTypes, event.ObjectType) { + objectTypes = append(objectTypes, event.ObjectType) + } + } + slices.Sort(objectTypes) + + buf := bytes.NewBuffer(nil) + if err := b.templates.ExecuteTemplate(buf, "events.py.tmpl", eventsTemplateData{ + Events: b.events, + ObjectTypes: objectTypes, + }); err != nil { + return fmt.Errorf("generate events: %w", err) + } + + file, err := openGeneratedFile(filename) + if err != nil { + return err + } + defer func() { + _ = file.Close() + }() + + if _, err := file.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write events: %w", err) + } + return nil +} diff --git a/codegen/pkg/builder/events_test.go b/codegen/pkg/builder/events_test.go new file mode 100644 index 00000000..fb9911e6 --- /dev/null +++ b/codegen/pkg/builder/events_test.go @@ -0,0 +1,70 @@ +package builder + +import ( + "reflect" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestCollectEvents(t *testing.T) { + document, err := libopenapi.NewDocument([]byte(`{ + "openapi": "3.1.0", + "info": {"title": "Events", "version": "1.0.0"}, + "paths": {}, + "components": { + "schemas": { + "Member": {"type": "object"}, + "Reader": {"type": "object"} + } + }, + "webhooks": { + "readers.created": { + "post": { + "operationId": "ReaderCreatedWebhook", + "description": "Sent when a reader is paired.", + "responses": {"2XX": {"description": "Acknowledged"}}, + "x-object": {"$ref": "#/components/schemas/Reader"} + } + }, + "members.updated": { + "post": { + "operationId": "MemberUpdatedWebhook", + "description": "Sent when a member changes.", + "responses": {"2XX": {"description": "Acknowledged"}}, + "x-object": {"$ref": "#/components/schemas/Member"} + } + } + } +}`)) + if err != nil { + t.Fatalf("load document: %v", err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatalf("build model: %v", err) + } + + builder := New(Config{}) + if err := builder.Load(&model.Model); err != nil { + t.Fatalf("load builder: %v", err) + } + + want := []EventDefinition{ + { + ClassName: "MemberUpdatedEvent", + EventType: "members.updated", + ObjectType: "Member", + Description: "Sent when a member changes.", + }, + { + ClassName: "ReaderCreatedEvent", + EventType: "readers.created", + ObjectType: "Reader", + Description: "Sent when a reader is paired.", + }, + } + if !reflect.DeepEqual(builder.events, want) { + t.Fatalf("events mismatch:\n got: %#v\nwant: %#v", builder.events, want) + } +} diff --git a/codegen/pkg/builder/intermediate_representation.go b/codegen/pkg/builder/intermediate_representation.go index c81da55d..3c6f92ef 100644 --- a/codegen/pkg/builder/intermediate_representation.go +++ b/codegen/pkg/builder/intermediate_representation.go @@ -26,6 +26,15 @@ type OneOfDeclaration struct { Options []string } +// EventDefinition describes a typed event notification generated from an +// OpenAPI webhook operation. +type EventDefinition struct { + ClassName string + EventType string + ObjectType string + Description string +} + // Property holds the information for Property of a type. type Property struct { // Name of the field diff --git a/codegen/templates/client.py.tmpl b/codegen/templates/client.py.tmpl index 898c165c..f5c2ec8b 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -6,7 +6,7 @@ import typing from ._service import Resource, AsyncResource, runtime_headers if typing.TYPE_CHECKING: - from .webhooks import WebhookHandler + from .events import AsyncEventCallback, AsyncEventsHandler, EventCallback, EventsHandler {{- range .Resources }} from .{{ .Package }} import {{ .Name }}Resource, Async{{ .Name }}Resource {{- end }} @@ -43,19 +43,21 @@ class Sumup(Resource): }, )) - def webhook_handler( + def events_handler( self, + secret: str, + fallback: "EventCallback", *, - secret: typing.Optional[str] = None, tolerance: typing.Optional[dt.timedelta] = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + ) -> "EventsHandler": + """Create a verified event handler bound to this client.""" + from .events import DEFAULT_TOLERANCE, EventsHandler - return WebhookHandler( + return EventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + fallback=fallback, client=self, + tolerance=DEFAULT_TOLERANCE if tolerance is None else tolerance, ) {{- range .Resources }} @@ -87,19 +89,21 @@ class AsyncSumup(AsyncResource): }, )) - def webhook_handler( + def events_handler( self, + secret: str, + fallback: "AsyncEventCallback", *, - secret: typing.Optional[str] = None, tolerance: typing.Optional[dt.timedelta] = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + ) -> "AsyncEventsHandler": + """Create a verified async event handler bound to this client.""" + from .events import AsyncEventsHandler, DEFAULT_TOLERANCE - return WebhookHandler( + return AsyncEventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + fallback=fallback, client=self, + tolerance=DEFAULT_TOLERANCE if tolerance is None else tolerance, ) {{- range .Resources }} diff --git a/codegen/templates/events.py.tmpl b/codegen/templates/events.py.tmpl new file mode 100644 index 00000000..898f0fc1 --- /dev/null +++ b/codegen/templates/events.py.tmpl @@ -0,0 +1,93 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import typing + +import pydantic + +from ._events import ( + AsyncEventCallback, + AsyncEventsHandler, + DEFAULT_TOLERANCE, + EventCallback, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + EventsHandler, + FetchableEvent, + SIGNATURE_HEADER, + SIGNATURE_VERSION, + TIMESTAMP_HEADER, + UnknownEvent, + dangerously_parse_unverified_event_notification, + parse_event_notification, + verify_signature, +) +{{- if .ObjectTypes }} +from .types import ( +{{- range .ObjectTypes }} + {{ . }}, +{{- end }} +) +{{- end }} + + +{{ range .Events -}} +class {{ .ClassName }}(FetchableEvent[{{ .ObjectType }}]): + """{{ .Description }}""" + + EVENT_TYPE: typing.ClassVar[str] = {{ printf "%q" .EventType }} + type: typing.Literal[{{ printf "%q" .EventType }}] = {{ printf "%q" .EventType }} + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = {{ .ObjectType }} + + +{{ end -}} +{{ if .Events -}} +KnownEventNotification = typing.Union[ +{{- range .Events }} + {{ .ClassName }}, +{{- end }} +] +{{ else -}} +KnownEventNotification = EventNotification +{{ end }} + +_EVENT_MODELS: typing.Dict[str, typing.Type[EventNotification]] = { +{{- range .Events }} + {{ printf "%q" .EventType }}: {{ .ClassName }}, +{{- end }} +} + + +__all__ = [ + "AsyncEventCallback", + "AsyncEventsHandler", + "DEFAULT_TOLERANCE", + "EventCallback", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "FetchableEvent", + "KnownEventNotification", + "SIGNATURE_HEADER", + "SIGNATURE_VERSION", + "TIMESTAMP_HEADER", + "UnknownEvent", + "dangerously_parse_unverified_event_notification", + "parse_event_notification", + "verify_signature", +{{- range .Events }} + "{{ .ClassName }}", +{{- end }} +] diff --git a/examples/events.py b/examples/events.py new file mode 100644 index 00000000..565f2806 --- /dev/null +++ b/examples/events.py @@ -0,0 +1,71 @@ +"""Minimal HTTP server for verified, typed SumUp event notifications.""" + +import os +from http.server import BaseHTTPRequestHandler, HTTPServer + +import pydantic + +from sumup import Sumup +from sumup.events import ( + SIGNATURE_HEADER, + TIMESTAMP_HEADER, + EventNotification, + EventSignatureError, + EventTimestampError, + ReaderCreatedEvent, +) + + +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) + + +def handle_unhandled_event(event: EventNotification, _client: Sumup) -> None: + print(f"Received unhandled event type: {event.type}") + + +def handle_reader_created(event: ReaderCreatedEvent, _client: Sumup) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id} ({reader.name})") + + +events = client.events_handler( + os.environ["SUMUP_EVENT_SECRET"], + handle_unhandled_event, +) +events.on(ReaderCreatedEvent, handle_reader_created) + + +class EventRequestHandler(BaseHTTPRequestHandler): + """Handle incoming event notification requests.""" + + def do_POST(self) -> None: + if self.path != "/events": + self.send_error(404) + return + + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) + signature = self.headers.get(SIGNATURE_HEADER, "") + timestamp = self.headers.get(TIMESTAMP_HEADER, "") + + try: + events.handle(body, signature, timestamp) + except (EventSignatureError, EventTimestampError): + self.send_error(400, "Invalid event signature") + return + except pydantic.ValidationError: + self.send_error(400, "Invalid event payload") + return + except Exception as error: + print(f"Event callback failed: {error}") + self.send_error(500, "Event callback failed") + return + + self.send_response(204) + self.end_headers() + + +if __name__ == "__main__": + server = HTTPServer(("127.0.0.1", 8080), EventRequestHandler) + print("Listening on http://127.0.0.1:8080/events") + server.serve_forever() diff --git a/examples/webhooks.py b/examples/webhooks.py deleted file mode 100644 index ed76933d..00000000 --- a/examples/webhooks.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Minimal HTTP server example for receiving and verifying SumUp webhooks.""" - -import os -from http.server import BaseHTTPRequestHandler, HTTPServer - -import pydantic - -from sumup import Sumup -from sumup.webhooks import ( - CheckoutCreatedEvent, - WebhookSignatureError, - WebhookSignatureExpiredError, - WebhookTimestampError, -) - - -client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) -webhooks = client.webhook_handler( - secret=os.environ["SUMUP_WEBHOOK_SECRET"], -) - - -class WebhookRequestHandler(BaseHTTPRequestHandler): - """Handle incoming webhook POST requests.""" - - def do_POST(self) -> None: - if self.path != "/webhooks": - self.send_error(404) - return - - content_length = int(self.headers.get("Content-Length", "0")) - body = self.rfile.read(content_length) - - try: - event = webhooks.parse_and_verify(dict(self.headers.items()), body) - except (WebhookSignatureError, WebhookSignatureExpiredError, WebhookTimestampError): - self.send_error(400, "Invalid webhook signature") - return - except pydantic.ValidationError: - self.send_error(400, "Invalid webhook payload") - return - - print( - "Webhook received:", - { - "id": event.id, - "type": event.type, - "object_id": event.object.id, - }, - ) - - if isinstance(event, CheckoutCreatedEvent): - checkout = event.fetch_object() - print(f"Checkout status: {checkout.status}") - - self.send_response(204) - self.end_headers() - - -if __name__ == "__main__": - server = HTTPServer(("127.0.0.1", 8080), WebhookRequestHandler) - print("Listening on http://127.0.0.1:8080/webhooks") - server.serve_forever() diff --git a/openapi.json b/openapi.json index a29a6dfe..17f83617 100755 --- a/openapi.json +++ b/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.1", + "openapi": "3.1.0", "info": { "title": "SumUp REST API", "version": "1.0.0", @@ -11508,5 +11508,227 @@ } ] } - ] -} \ No newline at end of file + ], + "webhooks": { + "readers.created": { + "post": { + "operationId": "ReaderCreatedWebhook", + "summary": "Reader created", + "description": "Sent when a reader is paired to a merchant account and becomes available through the Readers API.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + }, + "examples": { + "created": { + "summary": "A reader created webhook event.", + "value": { + "id": "evt_reader_123", + "type": "readers.created", + "created_at": "2023-05-09T14:52:58.714Z", + "object": { + "id": "rdr_3MSAFM23CK82VSTT4BN6RWSQ65", + "type": "reader", + "url": "https://api.sumup.com/v0.1/merchants/MC0DE/readers/rdr_3MSAFM23CK82VSTT4BN6RWSQ65" + } + } + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx response to acknowledge successful delivery." + } + }, + "tags": [ + "Readers" + ], + "x-object": { + "$ref": "#/components/schemas/Reader" + }, + "x-object-type": "reader" + } + }, + "readers.deleted": { + "post": { + "operationId": "ReaderDeletedWebhook", + "summary": "Reader deleted", + "description": "Sent when a reader is unpaired from a merchant account and is no longer available through the Readers API.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + }, + "examples": { + "deleted": { + "summary": "A reader deleted webhook event.", + "value": { + "id": "evt_reader_456", + "type": "readers.deleted", + "created_at": "2023-05-10T09:21:33.004Z", + "object": { + "id": "rdr_3MSAFM23CK82VSTT4BN6RWSQ65", + "type": "reader", + "url": "https://api.sumup.com/v0.1/merchants/MC0DE/readers/rdr_3MSAFM23CK82VSTT4BN6RWSQ65" + } + } + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx response to acknowledge successful delivery." + } + }, + "tags": [ + "Readers" + ], + "x-object": { + "$ref": "#/components/schemas/Reader" + }, + "x-object-type": "reader" + } + }, + "members.created": { + "post": { + "operationId": "MemberCreatedWebhook", + "summary": "Member created", + "description": "Sent when a member is created, invited, or accepts an invitation for a merchant account.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + }, + "examples": { + "created": { + "summary": "A member created webhook event.", + "value": { + "id": "evt_membership_123", + "type": "members.created", + "created_at": "2026-05-14T08:30:00Z", + "object": { + "id": "mem_123", + "type": "member", + "url": "https://api.sumup.com/v0.1/merchants/MC0DE/members/mem_123" + } + } + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx response to acknowledge successful delivery." + } + }, + "tags": [ + "Members" + ], + "x-object": { + "$ref": "#/components/schemas/Member" + }, + "x-object-type": "member" + } + }, + "members.updated": { + "post": { + "operationId": "MemberUpdatedWebhook", + "summary": "Member updated", + "description": "Sent when a member is updated, disabled, rejected, or expires for a merchant account.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + }, + "examples": { + "updated": { + "summary": "A member updated webhook event.", + "value": { + "id": "evt_membership_456", + "type": "members.updated", + "created_at": "2026-05-14T09:15:00Z", + "object": { + "id": "mem_123", + "type": "member", + "url": "https://api.sumup.com/v0.1/merchants/MC0DE/members/mem_123" + } + } + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx response to acknowledge successful delivery." + } + }, + "tags": [ + "Members" + ], + "x-object": { + "$ref": "#/components/schemas/Member" + }, + "x-object-type": "member" + } + }, + "members.deleted": { + "post": { + "operationId": "MemberDeletedWebhook", + "summary": "Member deleted", + "description": "Sent when a member is deleted from a merchant account.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + }, + "examples": { + "deleted": { + "summary": "A member deleted webhook event.", + "value": { + "id": "evt_membership_789", + "type": "members.deleted", + "created_at": "2026-05-14T10:45:00Z", + "object": { + "id": "mem_123", + "type": "member", + "url": "https://api.sumup.com/v0.1/merchants/MC0DE/members/mem_123" + } + } + } + } + } + } + }, + "responses": { + "2XX": { + "description": "Return any 2xx response to acknowledge successful delivery." + } + }, + "tags": [ + "Members" + ], + "x-object": { + "$ref": "#/components/schemas/Member" + }, + "x-object-type": "member" + } + } + } +} diff --git a/sumup/__init__.py b/sumup/__init__.py index 05bbbc84..0bed5655 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,14 +1,15 @@ -from sumup._client import Sumup, AsyncSumup -from sumup._service import Resource, AsyncResource +from sumup._client import AsyncSumup, Sumup from sumup._exceptions import APIError -from sumup.webhooks import WebhookHandler +from sumup._service import AsyncResource, Resource +from sumup.events import AsyncEventsHandler, EventsHandler __all__ = [ "APIError", + "AsyncEventsHandler", "AsyncResource", "AsyncSumup", + "EventsHandler", "MerchantAccount", "Resource", "Sumup", - "WebhookHandler", ] diff --git a/sumup/_client.py b/sumup/_client.py index 4a44e028..d48af5d7 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -7,7 +7,7 @@ from ._service import Resource, AsyncResource, runtime_headers if typing.TYPE_CHECKING: - from .webhooks import WebhookHandler + from .events import AsyncEventCallback, AsyncEventsHandler, EventCallback, EventsHandler from .checkouts import CheckoutsResource, AsyncCheckoutsResource from .customers import CustomersResource, AsyncCustomersResource from .members import MembersResource, AsyncMembersResource @@ -54,19 +54,21 @@ def __init__( ) ) - def webhook_handler( + def events_handler( self, + secret: str, + fallback: "EventCallback", *, - secret: typing.Optional[str] = None, tolerance: typing.Optional[dt.timedelta] = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + ) -> "EventsHandler": + """Create a verified event handler bound to this client.""" + from .events import DEFAULT_TOLERANCE, EventsHandler - return WebhookHandler( + return EventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + fallback=fallback, client=self, + tolerance=DEFAULT_TOLERANCE if tolerance is None else tolerance, ) @property @@ -168,19 +170,21 @@ def __init__( ) ) - def webhook_handler( + def events_handler( self, + secret: str, + fallback: "AsyncEventCallback", *, - secret: typing.Optional[str] = None, tolerance: typing.Optional[dt.timedelta] = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + ) -> "AsyncEventsHandler": + """Create a verified async event handler bound to this client.""" + from .events import AsyncEventsHandler, DEFAULT_TOLERANCE - return WebhookHandler( + return AsyncEventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + fallback=fallback, client=self, + tolerance=DEFAULT_TOLERANCE if tolerance is None else tolerance, ) @property diff --git a/sumup/_events.py b/sumup/_events.py new file mode 100644 index 00000000..8374b37f --- /dev/null +++ b/sumup/_events.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import threading +import typing + +import httpx +import pydantic + +from ._exceptions import APIError, SumupError + +if typing.TYPE_CHECKING: + from ._client import AsyncSumup, Sumup + + +SIGNATURE_HEADER = "X-SumUp-Webhook-Signature" +TIMESTAMP_HEADER = "X-SumUp-Webhook-Timestamp" +SIGNATURE_VERSION = "v1" +DEFAULT_TOLERANCE = dt.timedelta(minutes=5) + +_UTC = dt.timezone.utc +_Body = typing.Union[bytes, bytearray, memoryview] +_ObjectT = typing.TypeVar("_ObjectT", bound=pydantic.BaseModel) + + +class EventError(SumupError): + """Base class for event verification and handling failures.""" + + +class EventTimestampError(EventError): + """Raised when the event timestamp header is missing or malformed.""" + + +class EventSignatureError(EventError): + """Raised when the event signature is missing or invalid.""" + + +class EventSignatureExpiredError(EventSignatureError): + """Raised when the event timestamp is outside the allowed tolerance.""" + + +class EventHandlerRegistrationError(EventError): + """Raised when an event callback cannot be registered.""" + + +class EventObjectUrlError(EventError): + """Raised when an event references an object outside the API host.""" + + +class EventObject(pydantic.BaseModel): + """Reference to the SumUp resource associated with an event notification.""" + + id: str + type: str + url: str + + +class EventNotification(pydantic.BaseModel): + """Event notification envelope received from SumUp.""" + + id: str + type: str + created_at: dt.datetime + object: EventObject + + _client: typing.Optional[typing.Union[httpx.Client, httpx.AsyncClient]] = pydantic.PrivateAttr( + default=None + ) + + def _bind_client(self, client: object) -> EventNotification: + self._client = _unwrap_client(client) + return self + + +class UnknownEvent(EventNotification): + """Event notification whose type is not generated by this SDK version.""" + + +class FetchableEvent(EventNotification, typing.Generic[_ObjectT]): + """Generated event notification with a fetchable API resource.""" + + EVENT_TYPE: typing.ClassVar[str] + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] + + def _require_sync_client(self) -> httpx.Client: + if self._client is None: + raise RuntimeError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.Client): + raise RuntimeError( + "event notification is bound to an async client; use fetch_object_async()" + ) + return self._client + + def _require_async_client(self) -> httpx.AsyncClient: + if self._client is None: + raise RuntimeError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.AsyncClient): + raise RuntimeError("event notification is bound to a sync client; use fetch_object()") + return self._client + + def _parse_response(self, response: httpx.Response) -> _ObjectT: + if response.status_code != 200: + raise APIError("Unexpected response", status=response.status_code, body=response.text) + return typing.cast(_ObjectT, self._response_model.model_validate(response.json())) + + def fetch_object(self) -> _ObjectT: + """Fetch the latest state of the resource referenced by this event.""" + client = self._require_sync_client() + response = client.get(_validated_object_url(client, self.object.url)) + return self._parse_response(response) + + async def fetch_object_async(self) -> _ObjectT: + """Asynchronously fetch the resource referenced by this event.""" + client = self._require_async_client() + response = await client.get(_validated_object_url(client, self.object.url)) + return self._parse_response(response) + + +EventCallback = typing.Callable[[EventNotification, "Sumup"], None] +AsyncEventCallback = typing.Callable[[EventNotification, "AsyncSumup"], typing.Awaitable[None]] +_EventT = typing.TypeVar("_EventT", bound=EventNotification) +_ErasedCallback = typing.Callable[[EventNotification, object], object] + + +class _BaseEventsHandler: + def __init__( + self, + *, + secret: str, + fallback: _ErasedCallback, + client: object, + tolerance: dt.timedelta, + ) -> None: + self._secret = secret + self._fallback = fallback + self._client = client + self.tolerance = tolerance + self._callbacks: typing.Dict[str, _ErasedCallback] = {} + self._handling_started = False + self._registration_lock = threading.Lock() + + @property + def registered_event_types(self) -> typing.Tuple[str, ...]: + """Return event types with dedicated callbacks.""" + with self._registration_lock: + return tuple(sorted(self._callbacks)) + + def _register( + self, + event_model: typing.Type[EventNotification], + callback: _ErasedCallback, + ) -> None: + event_type = _event_type_for_model(event_model) + if event_type is None: + raise EventHandlerRegistrationError( + "event callbacks can only be registered for generated event classes" + ) + + with self._registration_lock: + if self._handling_started: + raise EventHandlerRegistrationError( + "cannot register event callbacks after handling has started" + ) + if event_type in self._callbacks: + raise EventHandlerRegistrationError( + f"callback already registered for event type: {event_type}" + ) + self._callbacks[event_type] = callback + + def _prepare_event( + self, + body: _Body, + signature_header: str, + timestamp_header: str, + *, + now: typing.Optional[dt.datetime], + ) -> typing.Tuple[EventNotification, _ErasedCallback]: + with self._registration_lock: + self._handling_started = True + + event = parse_event_notification( + self._secret, + body, + signature_header, + timestamp_header, + client=self._client, + tolerance=self.tolerance, + now=now, + ) + callback = self._callbacks.get(event.type, self._fallback) + return event, callback + + +class EventsHandler(_BaseEventsHandler): + """Verify and route event notifications to typed synchronous callbacks.""" + + def __init__( + self, + *, + secret: str, + fallback: EventCallback, + client: Sumup, + tolerance: dt.timedelta = DEFAULT_TOLERANCE, + ) -> None: + super().__init__( + secret=secret, + fallback=typing.cast(_ErasedCallback, fallback), + client=client, + tolerance=tolerance, + ) + + @property + def client(self) -> Sumup: + """Return the SumUp client supplied to this handler.""" + return typing.cast("Sumup", self._client) + + def on( + self, + event_model: typing.Type[_EventT], + callback: typing.Callable[[_EventT, Sumup], None], + ) -> EventsHandler: + """Register a callback for one generated event class.""" + self._register(event_model, typing.cast(_ErasedCallback, callback)) + return self + + def handle( + self, + body: _Body, + signature_header: str, + timestamp_header: str, + *, + now: typing.Optional[dt.datetime] = None, + ) -> None: + """Verify, parse, and route one raw event notification.""" + event, callback = self._prepare_event( + body, + signature_header, + timestamp_header, + now=now, + ) + callback(event, self._client) + + +class AsyncEventsHandler(_BaseEventsHandler): + """Verify and route event notifications to typed asynchronous callbacks.""" + + def __init__( + self, + *, + secret: str, + fallback: AsyncEventCallback, + client: AsyncSumup, + tolerance: dt.timedelta = DEFAULT_TOLERANCE, + ) -> None: + super().__init__( + secret=secret, + fallback=typing.cast(_ErasedCallback, fallback), + client=client, + tolerance=tolerance, + ) + + @property + def client(self) -> AsyncSumup: + """Return the async SumUp client supplied to this handler.""" + return typing.cast("AsyncSumup", self._client) + + def on( + self, + event_model: typing.Type[_EventT], + callback: typing.Callable[[_EventT, AsyncSumup], typing.Awaitable[None]], + ) -> AsyncEventsHandler: + """Register an async callback for one generated event class.""" + self._register(event_model, typing.cast(_ErasedCallback, callback)) + return self + + async def handle( + self, + body: _Body, + signature_header: str, + timestamp_header: str, + *, + now: typing.Optional[dt.datetime] = None, + ) -> None: + """Verify, parse, and route one raw event notification.""" + event, callback = self._prepare_event( + body, + signature_header, + timestamp_header, + now=now, + ) + await typing.cast(typing.Awaitable[None], callback(event, self._client)) + + +def verify_signature( + secret: str, + body: _Body, + signature_header: str, + timestamp_header: str, + *, + tolerance: dt.timedelta = DEFAULT_TOLERANCE, + now: typing.Optional[dt.datetime] = None, +) -> None: + """Verify an event signature and timestamp against the exact raw body.""" + if not signature_header.strip(): + raise EventSignatureError("missing event signature header") + if not timestamp_header.strip(): + raise EventTimestampError("missing event timestamp header") + + try: + timestamp = dt.datetime.fromtimestamp(int(timestamp_header), tz=_UTC) + except (TypeError, ValueError) as error: + raise EventTimestampError("invalid event timestamp") from error + + if abs(_coerce_now(now) - timestamp) > tolerance: + raise EventSignatureExpiredError("event timestamp outside allowed tolerance") + + version, separator, digest = signature_header.partition("=") + if separator != "=" or not version or not digest: + raise EventSignatureError("invalid event signature format") + if version != SIGNATURE_VERSION: + raise EventSignatureError("unsupported event signature version") + + expected = hmac.new( + secret.encode("utf-8"), + _signed_content(timestamp, body), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(expected, digest): + raise EventSignatureError("invalid event signature") + + +def parse_event_notification( + secret: str, + body: _Body, + signature_header: str, + timestamp_header: str, + *, + client: object = None, + tolerance: dt.timedelta = DEFAULT_TOLERANCE, + now: typing.Optional[dt.datetime] = None, +) -> EventNotification: + """Verify and parse an event notification into its generated event class.""" + verify_signature( + secret, + body, + signature_header, + timestamp_header, + tolerance=tolerance, + now=now, + ) + return dangerously_parse_unverified_event_notification(body, client=client) + + +def dangerously_parse_unverified_event_notification( + body: _Body, + *, + client: object = None, +) -> EventNotification: + """Parse an event without verification for trusted fixtures and tests only.""" + payload = pydantic.TypeAdapter(typing.Dict[str, typing.Any]).validate_json( + _coerce_body_bytes(body) + ) + event_type = payload.get("type") + models = _event_models() + model = models.get(event_type, UnknownEvent) if isinstance(event_type, str) else UnknownEvent + event = model.model_validate(payload) + return event._bind_client(client) + + +def _event_models() -> typing.Mapping[str, typing.Type[EventNotification]]: + from .events import _EVENT_MODELS + + return _EVENT_MODELS + + +def _event_type_for_model(event_model: typing.Type[EventNotification]) -> typing.Optional[str]: + for event_type, generated_model in _event_models().items(): + if generated_model is event_model: + return event_type + return None + + +def _unwrap_client( + client: object, +) -> typing.Optional[typing.Union[httpx.Client, httpx.AsyncClient]]: + if client is None: + return None + if isinstance(client, (httpx.Client, httpx.AsyncClient)): + return client + + inner_client = getattr(client, "_client", None) + if isinstance(inner_client, (httpx.Client, httpx.AsyncClient)): + return inner_client + + raise TypeError("client must be a SumUp client, httpx.Client, or httpx.AsyncClient") + + +def _validated_object_url( + client: typing.Union[httpx.Client, httpx.AsyncClient], + value: str, +) -> httpx.URL: + try: + object_url = httpx.URL(value) + except (httpx.InvalidURL, ValueError) as error: + raise EventObjectUrlError(f"invalid event object URL: {value}") from error + + if object_url.host != client.base_url.host: + raise EventObjectUrlError(f"invalid event object URL: {value}") + return object_url + + +def _coerce_now(now: typing.Optional[dt.datetime]) -> dt.datetime: + if now is None: + return dt.datetime.now(tz=_UTC) + if now.tzinfo is None: + return now.replace(tzinfo=_UTC) + return now.astimezone(_UTC) + + +def _coerce_body_bytes(body: _Body) -> bytes: + if isinstance(body, bytes): + return body + if isinstance(body, (bytearray, memoryview)): + return bytes(body) + raise TypeError("event body must contain the exact raw bytes received over HTTP") + + +def _signed_content(timestamp: dt.datetime, body: _Body) -> bytes: + prefix = f"{SIGNATURE_VERSION}:{int(timestamp.timestamp())}:".encode("utf-8") + return prefix + _coerce_body_bytes(body) diff --git a/sumup/events.py b/sumup/events.py new file mode 100755 index 00000000..a1d4861f --- /dev/null +++ b/sumup/events.py @@ -0,0 +1,123 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import typing + +import pydantic + +from ._events import ( + AsyncEventCallback, + AsyncEventsHandler, + DEFAULT_TOLERANCE, + EventCallback, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + EventsHandler, + FetchableEvent, + SIGNATURE_HEADER, + SIGNATURE_VERSION, + TIMESTAMP_HEADER, + UnknownEvent, + dangerously_parse_unverified_event_notification, + parse_event_notification, + verify_signature, +) +from .types import ( + Member, + Reader, +) + + +class MemberCreatedEvent(FetchableEvent[Member]): + """Sent when a member is created, invited, or accepts an invitation for a merchant account.""" + + EVENT_TYPE: typing.ClassVar[str] = "members.created" + type: typing.Literal["members.created"] = "members.created" + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = Member + + +class MemberDeletedEvent(FetchableEvent[Member]): + """Sent when a member is deleted from a merchant account.""" + + EVENT_TYPE: typing.ClassVar[str] = "members.deleted" + type: typing.Literal["members.deleted"] = "members.deleted" + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = Member + + +class MemberUpdatedEvent(FetchableEvent[Member]): + """Sent when a member is updated, disabled, rejected, or expires for a merchant account.""" + + EVENT_TYPE: typing.ClassVar[str] = "members.updated" + type: typing.Literal["members.updated"] = "members.updated" + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = Member + + +class ReaderCreatedEvent(FetchableEvent[Reader]): + """Sent when a reader is paired to a merchant account and becomes available through the Readers API.""" + + EVENT_TYPE: typing.ClassVar[str] = "readers.created" + type: typing.Literal["readers.created"] = "readers.created" + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = Reader + + +class ReaderDeletedEvent(FetchableEvent[Reader]): + """Sent when a reader is unpaired from a merchant account and is no longer available through the Readers API.""" + + EVENT_TYPE: typing.ClassVar[str] = "readers.deleted" + type: typing.Literal["readers.deleted"] = "readers.deleted" + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = Reader + + +KnownEventNotification = typing.Union[ + MemberCreatedEvent, + MemberDeletedEvent, + MemberUpdatedEvent, + ReaderCreatedEvent, + ReaderDeletedEvent, +] + + +_EVENT_MODELS: typing.Dict[str, typing.Type[EventNotification]] = { + "members.created": MemberCreatedEvent, + "members.deleted": MemberDeletedEvent, + "members.updated": MemberUpdatedEvent, + "readers.created": ReaderCreatedEvent, + "readers.deleted": ReaderDeletedEvent, +} + + +__all__ = [ + "AsyncEventCallback", + "AsyncEventsHandler", + "DEFAULT_TOLERANCE", + "EventCallback", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "FetchableEvent", + "KnownEventNotification", + "SIGNATURE_HEADER", + "SIGNATURE_VERSION", + "TIMESTAMP_HEADER", + "UnknownEvent", + "dangerously_parse_unverified_event_notification", + "parse_event_notification", + "verify_signature", + "MemberCreatedEvent", + "MemberDeletedEvent", + "MemberUpdatedEvent", + "ReaderCreatedEvent", + "ReaderDeletedEvent", +] diff --git a/sumup/webhooks.py b/sumup/webhooks.py deleted file mode 100644 index 684cc304..00000000 --- a/sumup/webhooks.py +++ /dev/null @@ -1,335 +0,0 @@ -from __future__ import annotations - -import datetime as dt -import hashlib -import hmac -import os -from enum import Enum -from typing import Any, ClassVar, Generic, Mapping, Type, TypeVar, Union, cast - -import httpx -import pydantic - -from ._exceptions import APIError, SumupError -from .types import Checkout, Member - -WEBHOOK_SIGNATURE_HEADER = "X-SumUp-Webhook-Signature" -WEBHOOK_TIMESTAMP_HEADER = "X-SumUp-Webhook-Timestamp" -WEBHOOK_SIGNATURE_VERSION = "v1" -DEFAULT_WEBHOOK_TOLERANCE = dt.timedelta(minutes=5) -WEBHOOK_SECRET_ENV_VAR = "SUMUP_WEBHOOK_SECRET" - -_UTC = dt.timezone.utc -_ClientT = TypeVar("_ClientT", httpx.Client, httpx.AsyncClient) -_BodyT = Union[bytes, bytearray, memoryview, str] -_ResponseT = TypeVar("_ResponseT", bound=pydantic.BaseModel) - - -class WebhookError(SumupError): - """Base class for webhook parsing and verification failures.""" - - -class WebhookSecretMissingError(WebhookError): - """Raised when webhook verification is attempted without a configured secret.""" - - -class WebhookTimestampError(WebhookError): - """Raised when the webhook timestamp header is missing or malformed.""" - - -class WebhookSignatureError(WebhookError): - """Raised when the webhook signature is missing or invalid.""" - - -class WebhookSignatureExpiredError(WebhookSignatureError): - """Raised when the webhook timestamp is outside the allowed tolerance window.""" - - -class WebhookEventType(str, Enum): - """Known SumUp webhook event type strings.""" - - CHECKOUT_CREATED = "checkout.created" - CHECKOUT_PROCESSED = "checkout.processed" - CHECKOUT_FAILED = "checkout.failed" - CHECKOUT_TERMINATED = "checkout.terminated" - MEMBER_CREATED = "member.created" - MEMBER_REMOVED = "member.removed" - - -class WebhookObject(pydantic.BaseModel): - """Reference to the SumUp resource associated with a webhook event.""" - - id: str - type: str - url: str - - -class WebhookEvent(pydantic.BaseModel): - """Generic SumUp webhook event envelope.""" - - id: str - type: str - created_at: dt.datetime - object: WebhookObject - - _client: httpx.Client | httpx.AsyncClient | None = pydantic.PrivateAttr(default=None) - - def bind_client(self, client: object | None) -> WebhookEvent: - """Attach a SumUp or HTTPX client used by fetchable event helpers.""" - self._client = _unwrap_client(client) - return self - - -class _FetchableEvent(WebhookEvent, Generic[_ResponseT]): - _response_model: ClassVar[Type[pydantic.BaseModel]] - - def _require_sync_client(self) -> httpx.Client: - if self._client is None: - raise RuntimeError("webhook event is not bound to a SumUp client") - if not isinstance(self._client, httpx.Client): - raise RuntimeError( - "webhook event is bound to an async client; use fetch_object_async()" - ) - return self._client - - def _require_async_client(self) -> httpx.AsyncClient: - if self._client is None: - raise RuntimeError("webhook event is not bound to a SumUp client") - if not isinstance(self._client, httpx.AsyncClient): - raise RuntimeError("webhook event is bound to a sync client; use fetch_object()") - return self._client - - def _parse_response(self, response: httpx.Response) -> _ResponseT: - if response.status_code != 200: - raise APIError("Unexpected response", status=response.status_code, body=response.text) - return cast(_ResponseT, self._response_model.model_validate(response.json())) - - def fetch_object(self) -> _ResponseT: - """Fetch the resource referenced by this event using a bound sync client.""" - response = self._require_sync_client().get(self.object.url) - return self._parse_response(response) - - async def fetch_object_async(self) -> _ResponseT: - """Fetch the resource referenced by this event using a bound async client.""" - response = await self._require_async_client().get(self.object.url) - return self._parse_response(response) - - -class CheckoutCreatedEvent(_FetchableEvent[Checkout]): - """Event emitted when a checkout is created.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_CREATED - - -class CheckoutProcessedEvent(_FetchableEvent[Checkout]): - """Event emitted when a checkout is processed.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_PROCESSED - - -class CheckoutFailedEvent(_FetchableEvent[Checkout]): - """Event emitted when a checkout processing attempt fails.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_FAILED - - -class CheckoutTerminatedEvent(_FetchableEvent[Checkout]): - """Event emitted when a checkout is terminated.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Checkout - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.CHECKOUT_TERMINATED - - -class MemberCreatedEvent(_FetchableEvent[Member]): - """Event emitted when a merchant member is created.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Member - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.MEMBER_CREATED - - -class MemberRemovedEvent(_FetchableEvent[Member]): - """Event emitted when a merchant member is removed.""" - - _response_model: ClassVar[Type[pydantic.BaseModel]] = Member - type: pydantic.SerializeAsAny[WebhookEventType] = WebhookEventType.MEMBER_REMOVED - - -KnownWebhookEvent = Union[ - CheckoutCreatedEvent, - CheckoutProcessedEvent, - CheckoutFailedEvent, - CheckoutTerminatedEvent, - MemberCreatedEvent, - MemberRemovedEvent, -] -WebhookNotification = Union[KnownWebhookEvent, WebhookEvent] - - -class WebhookHandler: - """Verify and parse incoming SumUp webhook requests.""" - - def __init__( - self, - *, - secret: str | None = None, - tolerance: dt.timedelta = DEFAULT_WEBHOOK_TOLERANCE, - client: object | None = None, - ) -> None: - self.secret = secret or os.getenv(WEBHOOK_SECRET_ENV_VAR) - self.tolerance = tolerance - self._client = _unwrap_client(client) - - def verify( - self, - headers: Mapping[str, str], - body: _BodyT, - *, - now: dt.datetime | None = None, - ) -> None: - """Verify the webhook signature and timestamp headers for a payload.""" - if not self.secret: - raise WebhookSecretMissingError( - f"webhook secret is not configured; pass secret=... or set {WEBHOOK_SECRET_ENV_VAR}" - ) - - signature = _get_header(headers, WEBHOOK_SIGNATURE_HEADER) - if not signature: - raise WebhookSignatureError("missing webhook signature header") - - timestamp_text = _get_header(headers, WEBHOOK_TIMESTAMP_HEADER) - if not timestamp_text: - raise WebhookTimestampError("missing webhook timestamp header") - - try: - timestamp = dt.datetime.fromtimestamp(int(timestamp_text), tz=_UTC) - except (TypeError, ValueError) as exc: - raise WebhookTimestampError("invalid webhook timestamp") from exc - - if abs(_coerce_now(now) - timestamp) > self.tolerance: - raise WebhookSignatureExpiredError("webhook timestamp outside allowed tolerance") - - version, separator, digest = signature.partition("=") - if separator != "=" or not version or not digest: - raise WebhookSignatureError("invalid webhook signature format") - if version != WEBHOOK_SIGNATURE_VERSION: - raise WebhookSignatureError("unsupported webhook signature version") - - expected = hmac.new( - self.secret.encode("utf-8"), - _signed_content(timestamp, body), - hashlib.sha256, - ).hexdigest() - if not hmac.compare_digest(expected, digest): - raise WebhookSignatureError("invalid webhook signature") - - def parse(self, body: _BodyT) -> WebhookNotification: - """Parse a webhook payload into the most specific known event model.""" - payload = _load_json(body) - event_type = payload.get("type") - if isinstance(event_type, str): - model = _EVENT_TYPES.get(event_type, WebhookEvent) - else: - model = WebhookEvent - event = model.model_validate(payload) - return event.bind_client(self._client) - - def parse_and_verify( - self, - headers: Mapping[str, str], - body: _BodyT, - *, - now: dt.datetime | None = None, - ) -> WebhookNotification: - """Verify a webhook request and then parse it into an event model.""" - self.verify(headers, body, now=now) - return self.parse(body) - - -_EVENT_TYPES: dict[str, type[WebhookEvent]] = { - WebhookEventType.CHECKOUT_CREATED.value: CheckoutCreatedEvent, - WebhookEventType.CHECKOUT_PROCESSED.value: CheckoutProcessedEvent, - WebhookEventType.CHECKOUT_FAILED.value: CheckoutFailedEvent, - WebhookEventType.CHECKOUT_TERMINATED.value: CheckoutTerminatedEvent, - WebhookEventType.MEMBER_CREATED.value: MemberCreatedEvent, - WebhookEventType.MEMBER_REMOVED.value: MemberRemovedEvent, -} - - -def _unwrap_client(client: object | None) -> httpx.Client | httpx.AsyncClient | None: - if client is None: - return None - if isinstance(client, (httpx.Client, httpx.AsyncClient)): - return client - - inner_client = getattr(client, "_client", None) - if isinstance(inner_client, (httpx.Client, httpx.AsyncClient)): - return inner_client - - raise TypeError("client must be a Sumup client, httpx.Client, or httpx.AsyncClient") - - -def _coerce_now(now: dt.datetime | None) -> dt.datetime: - if now is None: - return dt.datetime.now(tz=_UTC) - if now.tzinfo is None: - return now.replace(tzinfo=_UTC) - return now.astimezone(_UTC) - - -def _coerce_body_bytes(body: _BodyT) -> bytes: - if isinstance(body, bytes): - return body - if isinstance(body, str): - return body.encode("utf-8") - return bytes(body) - - -def _load_json(body: _BodyT) -> dict[str, Any]: - return pydantic.TypeAdapter(dict[str, Any]).validate_json(_coerce_body_bytes(body)) - - -def _get_header(headers: Mapping[str, str], name: str) -> str | None: - value = headers.get(name) - if value is not None: - return value - - target = name.lower() - for key, header_value in headers.items(): - if key.lower() == target: - return header_value - return None - - -def _signed_content(timestamp: dt.datetime, body: _BodyT) -> bytes: - return f"{WEBHOOK_SIGNATURE_VERSION}:{int(timestamp.timestamp())}:".encode( - "utf-8" - ) + _coerce_body_bytes(body) - - -__all__ = [ - "DEFAULT_WEBHOOK_TOLERANCE", - "WEBHOOK_SECRET_ENV_VAR", - "WEBHOOK_SIGNATURE_HEADER", - "WEBHOOK_SIGNATURE_VERSION", - "WEBHOOK_TIMESTAMP_HEADER", - "CheckoutCreatedEvent", - "CheckoutFailedEvent", - "CheckoutProcessedEvent", - "CheckoutTerminatedEvent", - "KnownWebhookEvent", - "MemberCreatedEvent", - "MemberRemovedEvent", - "WebhookError", - "WebhookEvent", - "WebhookEventType", - "WebhookHandler", - "WebhookNotification", - "WebhookObject", - "WebhookSecretMissingError", - "WebhookSignatureError", - "WebhookSignatureExpiredError", - "WebhookTimestampError", -] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 00000000..13ebdb02 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,289 @@ +import asyncio +import datetime as dt +import hashlib +import hmac +import json +from collections.abc import Callable +from typing import cast + +import httpx +import pydantic +import pytest + +from sumup import AsyncSumup, Sumup +from sumup.events import ( + DEFAULT_TOLERANCE, + SIGNATURE_VERSION, + EventHandlerRegistrationError, + EventNotification, + EventObjectUrlError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + MemberUpdatedEvent, + ReaderCreatedEvent, + UnknownEvent, + dangerously_parse_unverified_event_notification, + parse_event_notification, + verify_signature, +) +from sumup.types import Reader + +_NOW = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) +_SECRET = "event_secret_test" + + +def test_parse_event_notification_verifies_and_returns_generated_event() -> None: + body = _event_body("readers.created") + signature, timestamp = _signature(_SECRET, _NOW, body) + + event = parse_event_notification( + _SECRET, + body, + signature, + timestamp, + now=_NOW, + ) + + assert isinstance(event, ReaderCreatedEvent) + assert event.type == "readers.created" + + +def test_dangerous_parse_returns_generated_and_unknown_events() -> None: + known = dangerously_parse_unverified_event_notification(_event_body("members.updated")) + unknown = dangerously_parse_unverified_event_notification(_event_body("merchant.updated")) + + assert isinstance(known, MemberUpdatedEvent) + assert isinstance(unknown, UnknownEvent) + assert unknown.type == "merchant.updated" + + +def test_verify_signature_rejects_invalid_and_expired_signatures() -> None: + body = _event_body("readers.created") + _, timestamp = _signature(_SECRET, _NOW, body) + + with pytest.raises(EventSignatureError): + verify_signature(_SECRET, body, "v1=deadbeef", timestamp, now=_NOW) + + expired_at = _NOW - DEFAULT_TOLERANCE - dt.timedelta(seconds=1) + signature, timestamp = _signature(_SECRET, expired_at, body) + with pytest.raises(EventSignatureExpiredError): + verify_signature(_SECRET, body, signature, timestamp, now=_NOW) + + +def test_verify_signature_rejects_missing_or_invalid_timestamp() -> None: + body = _event_body("readers.created") + + with pytest.raises(EventTimestampError): + verify_signature(_SECRET, body, "v1=deadbeef", "", now=_NOW) + with pytest.raises(EventTimestampError): + verify_signature(_SECRET, body, "v1=deadbeef", "not-a-timestamp", now=_NOW) + + +def test_events_handler_routes_registered_and_unhandled_events() -> None: + client = Sumup(api_key="test") + handled: list[tuple[str, Sumup]] = [] + unhandled: list[tuple[str, Sumup]] = [] + + def fallback(event: EventNotification, callback_client: Sumup) -> None: + unhandled.append((event.type, callback_client)) + + def handle_reader(event: ReaderCreatedEvent, callback_client: Sumup) -> None: + handled.append((event.type, callback_client)) + + handler = client.events_handler(_SECRET, fallback) + handler.on(ReaderCreatedEvent, handle_reader) + + try: + _handle(handler.handle, _event_body("readers.created")) + _handle(handler.handle, _event_body("members.updated")) + _handle(handler.handle, _event_body("merchant.updated")) + + assert handled == [("readers.created", client)] + assert unhandled == [("members.updated", client), ("merchant.updated", client)] + assert handler.registered_event_types == ("readers.created",) + assert handler.client is client + finally: + client._client.close() + + +def test_events_handler_verifies_before_dispatching() -> None: + client = Sumup(api_key="test") + calls: list[str] = [] + + def fallback(event: EventNotification, _: Sumup) -> None: + calls.append(event.type) + + handler = client.events_handler(_SECRET, fallback) + body = _event_body("readers.created") + _, timestamp = _signature(_SECRET, _NOW, body) + + try: + with pytest.raises(EventSignatureError): + handler.handle(body, "v1=deadbeef", timestamp, now=_NOW) + assert calls == [] + finally: + client._client.close() + + +def test_events_handler_rejects_unsupported_duplicate_and_late_registration() -> None: + client = Sumup(api_key="test") + handler = client.events_handler(_SECRET, lambda _event, _client: None) + + class CustomEvent(EventNotification): + pass + + try: + with pytest.raises(EventHandlerRegistrationError, match="generated event classes"): + handler.on(CustomEvent, lambda _event, _client: None) + + handler.on(ReaderCreatedEvent, lambda _event, _client: None) + with pytest.raises(EventHandlerRegistrationError, match="already registered"): + handler.on(ReaderCreatedEvent, lambda _event, _client: None) + + _handle(handler.handle, _event_body("readers.created")) + with pytest.raises(EventHandlerRegistrationError, match="handling has started"): + handler.on(MemberUpdatedEvent, lambda _event, _client: None) + finally: + client._client.close() + + +def test_registered_event_can_fetch_its_object(sdk_factory) -> None: + reader_payload = _reader_payload() + object_url = "https://api.sumup.test/v0.1/merchants/MC0DE/readers/rdr_123" + sdk = sdk_factory( + lambda request: ( + _json_response(reader_payload) + if str(request.url) == object_url + else _json_response({"error": "not found"}, status_code=404) + ) + ) + fetched: list[Reader] = [] + + def handle_reader(event: ReaderCreatedEvent, _client: Sumup) -> None: + fetched.append(event.fetch_object()) + + handler = sdk.events_handler(_SECRET, lambda _event, _client: None) + handler.on(ReaderCreatedEvent, handle_reader) + + _handle(handler.handle, _event_body("readers.created", object_url=object_url)) + + assert len(fetched) == 1 + assert fetched[0].id == "rdr_123" + + +def test_fetch_object_rejects_urls_outside_the_client_host(sdk_factory) -> None: + sdk = sdk_factory(lambda _request: _json_response(_reader_payload())) + event = dangerously_parse_unverified_event_notification( + _event_body("readers.created", object_url="https://example.com/readers/rdr_123"), + client=sdk, + ) + + assert isinstance(event, ReaderCreatedEvent) + with pytest.raises(EventObjectUrlError): + event.fetch_object() + + +def test_async_events_handler_routes_and_fetches_generated_event() -> None: + async def run() -> None: + object_url = "https://api.sumup.test/v0.1/merchants/MC0DE/readers/rdr_123" + + async def transport(request: httpx.Request) -> httpx.Response: + if str(request.url) == object_url: + return _json_response(_reader_payload()) + return _json_response({"error": "not found"}, status_code=404) + + client = AsyncSumup(api_key="test", base_url="https://api.sumup.test") + original_client = client._client + client._client = httpx.AsyncClient( + base_url=original_client.base_url, + timeout=original_client.timeout, + headers=original_client.headers, + transport=httpx.MockTransport(transport), + ) + await original_client.aclose() + fetched: list[Reader] = [] + + async def fallback(_event: EventNotification, _client: AsyncSumup) -> None: + raise AssertionError("fallback should not be called") + + async def handle_reader(event: ReaderCreatedEvent, callback_client: AsyncSumup) -> None: + assert callback_client is client + fetched.append(await event.fetch_object_async()) + + handler = client.events_handler(_SECRET, fallback) + handler.on(ReaderCreatedEvent, handle_reader) + body = _event_body("readers.created", object_url=object_url) + signature, timestamp = _signature(_SECRET, _NOW, body) + + try: + await handler.handle(body, signature, timestamp, now=_NOW) + finally: + await client._client.aclose() + + assert len(fetched) == 1 + assert fetched[0].id == "rdr_123" + + asyncio.run(run()) + + +def test_event_parsing_requires_raw_bytes() -> None: + with pytest.raises(TypeError, match="exact raw bytes"): + dangerously_parse_unverified_event_notification(cast(bytes, "{}")) + + +def test_valid_signature_does_not_hide_invalid_json() -> None: + body = b"{" + signature, timestamp = _signature(_SECRET, _NOW, body) + + with pytest.raises(pydantic.ValidationError): + parse_event_notification(_SECRET, body, signature, timestamp, now=_NOW) + + +def _handle(callback: Callable[..., None], body: bytes) -> None: + signature, timestamp = _signature(_SECRET, _NOW, body) + callback(body, signature, timestamp, now=_NOW) + + +def _event_body( + event_type: str, + *, + object_url: str = "https://api.sumup.com/v0.1/objects/obj_123", +) -> bytes: + return json.dumps( + { + "id": "evt_123", + "type": event_type, + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "obj_123", + "type": "reader" if event_type.startswith("readers.") else "member", + "url": object_url, + }, + } + ).encode() + + +def _signature(secret: str, timestamp: dt.datetime, body: bytes) -> tuple[str, str]: + timestamp_text = str(int(timestamp.timestamp())) + payload = f"{SIGNATURE_VERSION}:{timestamp_text}:".encode() + body + digest = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return f"{SIGNATURE_VERSION}={digest}", timestamp_text + + +def _reader_payload() -> dict[str, object]: + return { + "created_at": "2026-04-11T10:00:00Z", + "device": {"identifier": "device_123", "model": "solo"}, + "id": "rdr_123", + "name": "Front counter", + "status": "paired", + "updated_at": "2026-04-11T10:00:00Z", + } + + +def _json_response( + body: dict[str, object], + status_code: int = 200, +) -> httpx.Response: + return httpx.Response(status_code, json=body) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py deleted file mode 100644 index 5525f934..00000000 --- a/tests/test_webhooks.py +++ /dev/null @@ -1,259 +0,0 @@ -import datetime as dt -import hashlib -import hmac -import json -import asyncio - -from typing import Mapping, Union - -import httpx -import pytest -import pydantic - -from sumup import AsyncSumup, Sumup -from sumup.types import Checkout -from sumup.webhooks import ( - DEFAULT_WEBHOOK_TOLERANCE, - WEBHOOK_SIGNATURE_HEADER, - WEBHOOK_SIGNATURE_VERSION, - WEBHOOK_TIMESTAMP_HEADER, - CheckoutCreatedEvent, - WebhookEvent, - WebhookHandler, - WebhookSignatureError, - WebhookSignatureExpiredError, - WebhookTimestampError, -) - - -def test_verify_accepts_valid_signature() -> None: - body = b'{"id":"evt_123","type":"checkout.created"}' - now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) - headers = _sign_headers("wh_sec_test", now, body) - - handler = WebhookHandler(secret="wh_sec_test") - - handler.verify(headers, body, now=now) - - -def test_verify_rejects_expired_timestamp() -> None: - body = b'{"id":"evt_123","type":"checkout.created"}' - now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) - timestamp = now - DEFAULT_WEBHOOK_TOLERANCE - dt.timedelta(seconds=1) - headers = _sign_headers("wh_sec_test", timestamp, body) - - handler = WebhookHandler(secret="wh_sec_test") - - with pytest.raises(WebhookSignatureExpiredError): - handler.verify(headers, body, now=now) - - -def test_verify_rejects_invalid_signature() -> None: - body = b'{"id":"evt_123","type":"checkout.created"}' - now = dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc) - headers = { - WEBHOOK_TIMESTAMP_HEADER: str(int(now.timestamp())), - WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef", - } - - handler = WebhookHandler(secret="wh_sec_test") - - with pytest.raises(WebhookSignatureError): - handler.verify(headers, body, now=now) - - -def test_verify_rejects_missing_timestamp() -> None: - handler = WebhookHandler(secret="wh_sec_test") - - with pytest.raises(WebhookTimestampError): - handler.verify({WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef"}, b"{}", now=_utc_now()) - - -def test_parse_returns_typed_known_event() -> None: - body = json.dumps( - { - "id": "evt_123", - "type": "checkout.created", - "created_at": "2026-04-11T10:00:00Z", - "object": { - "id": "chk_123", - "type": "checkout", - "url": "https://api.sumup.com/v0.1/checkouts/chk_123", - }, - } - ) - - event = WebhookHandler(secret="wh_sec_test").parse(body) - - assert isinstance(event, CheckoutCreatedEvent) - assert event.type.value == "checkout.created" - - -def test_parse_returns_generic_event_for_unknown_types() -> None: - body = json.dumps( - { - "id": "evt_123", - "type": "something.else", - "created_at": "2026-04-11T10:00:00Z", - "object": { - "id": "obj_123", - "type": "other", - "url": "https://api.sumup.com/v0.1/other/obj_123", - }, - } - ) - - event = WebhookHandler(secret="wh_sec_test").parse(body) - - assert type(event) is WebhookEvent - assert event.type == "something.else" - - -def test_sumup_client_can_create_bound_webhook_handler() -> None: - client = Sumup(api_key="test") - - handler = client.webhook_handler(secret="wh_sec_test") - - assert handler.secret == "wh_sec_test" - assert handler._client is client._client - - client._client.close() - - -def test_async_sumup_client_can_create_bound_webhook_handler() -> None: - client = AsyncSumup(api_key="test") - - handler = client.webhook_handler(secret="wh_sec_test") - - assert handler.secret == "wh_sec_test" - assert handler._client is client._client - - asyncio.run(client._client.aclose()) - - -def test_parse_rejects_invalid_json_payload() -> None: - with pytest.raises(pydantic.ValidationError): - WebhookHandler(secret="wh_sec_test").parse_and_verify( - _sign_headers("wh_sec_test", _utc_now(), b"{"), - b"{", - now=_utc_now(), - ) - - -def test_parse_and_verify_binds_client_and_fetches_object(sdk_factory) -> None: - checkout_payload = { - "id": "chk_123", - "amount": 10.0, - "checkout_reference": "ref_123", - "currency": "EUR", - "date": "2026-04-11T10:00:00Z", - "description": "Test payment", - "idempotency_key": "idem_123", - "merchant_code": "MC123", - "status": "PENDING", - } - - sdk = sdk_factory( - lambda request: ( - _json_response(checkout_payload) - if str(request.url) == "https://api.sumup.com/v0.1/checkouts/chk_123" - else _json_response({"error": "not found"}, status_code=404) - ) - ) - - body = json.dumps( - { - "id": "evt_123", - "type": "checkout.created", - "created_at": "2026-04-11T10:00:00Z", - "object": { - "id": "chk_123", - "type": "checkout", - "url": "https://api.sumup.com/v0.1/checkouts/chk_123", - }, - } - ) - now = _utc_now() - headers = _sign_headers("wh_sec_test", now, body.encode("utf-8")) - handler = WebhookHandler(secret="wh_sec_test", client=sdk) - - event = handler.parse_and_verify(headers, body, now=now) - assert isinstance(event, CheckoutCreatedEvent) - checkout = event.fetch_object() - - assert isinstance(checkout, Checkout) - assert checkout.id == "chk_123" - - -def test_parse_and_verify_binds_async_client_and_fetches_object_async() -> None: - checkout_payload = { - "id": "chk_123", - "amount": 10.0, - "checkout_reference": "ref_123", - "currency": "EUR", - "date": "2026-04-11T10:00:00Z", - "description": "Test payment", - "idempotency_key": "idem_123", - "merchant_code": "MC123", - "status": "PENDING", - } - - async def transport_handler(request: httpx.Request) -> httpx.Response: - if str(request.url) == "https://api.sumup.com/v0.1/checkouts/chk_123": - return _json_response(checkout_payload) - return _json_response({"error": "not found"}, status_code=404) - - sdk = AsyncSumup(api_key="test", base_url="https://api.sumup.test") - original_client = sdk._client - sdk._client = httpx.AsyncClient( - base_url=original_client.base_url, - timeout=original_client.timeout, - headers=original_client.headers, - transport=httpx.MockTransport(transport_handler), - ) - asyncio.run(original_client.aclose()) - - body = json.dumps( - { - "id": "evt_123", - "type": "checkout.created", - "created_at": "2026-04-11T10:00:00Z", - "object": { - "id": "chk_123", - "type": "checkout", - "url": "https://api.sumup.com/v0.1/checkouts/chk_123", - }, - } - ) - now = _utc_now() - headers = _sign_headers("wh_sec_test", now, body.encode("utf-8")) - webhook_handler = WebhookHandler(secret="wh_sec_test", client=sdk) - - try: - event = webhook_handler.parse_and_verify(headers, body, now=now) - assert isinstance(event, CheckoutCreatedEvent) - checkout = asyncio.run(event.fetch_object_async()) - - assert isinstance(checkout, Checkout) - assert checkout.id == "chk_123" - finally: - asyncio.run(sdk._client.aclose()) - - -def _sign_headers(secret: str, timestamp: dt.datetime, body: bytes) -> dict[str, str]: - payload = f"{WEBHOOK_SIGNATURE_VERSION}:{int(timestamp.timestamp())}:".encode("utf-8") + body - digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() - return { - WEBHOOK_TIMESTAMP_HEADER: str(int(timestamp.timestamp())), - WEBHOOK_SIGNATURE_HEADER: f"{WEBHOOK_SIGNATURE_VERSION}={digest}", - } - - -def _json_response(body: Mapping[str, Union[object, str, int, float]], status_code: int = 200): - import httpx - - return httpx.Response(status_code, json=body) - - -def _utc_now() -> dt.datetime: - return dt.datetime(2026, 4, 12, 10, 0, tzinfo=dt.timezone.utc)