From 0a0b2d03910e24c5b36b95fc8938f5cc45f3c40f 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 | 34 ++++ examples/webhooks.py | 63 ++++++ sumup/__init__.py | 3 +- sumup/_client.py | 35 ++++ sumup/webhooks.py | 335 +++++++++++++++++++++++++++++++ tests/test_webhooks.py | 259 ++++++++++++++++++++++++ 7 files changed, 751 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 fd51e154..c4414e74 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,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 have not passed end-of-life. As of June 8, 2026, that means Python 3.10 through 3.14. 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 830d3b70..2b9f2aea 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -1,8 +1,12 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os +import typing import httpx 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 }} @@ -39,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: @@ -68,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 a4fe0c52..715d0e6a 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,5 +1,6 @@ from sumup._client import AsyncSumup, Sumup from sumup._exceptions import APIError from sumup._service import AsyncResource, Resource +from sumup.webhooks import WebhookHandler -__all__ = ["APIError", "AsyncResource", "AsyncSumup", "Resource", "Sumup"] +__all__ = ["APIError", "AsyncResource", "AsyncSumup", "Resource", "Sumup", "WebhookHandler"] diff --git a/sumup/_client.py b/sumup/_client.py index d33266fc..23c3f3c6 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -1,9 +1,14 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os +import typing import httpx from ._service import AsyncResource, Resource, runtime_headers + +if typing.TYPE_CHECKING: + from .webhooks import WebhookHandler from .checkouts import AsyncCheckoutsResource, CheckoutsResource from .customers import AsyncCustomersResource, CustomersResource from .members import AsyncMembersResource, MembersResource @@ -49,6 +54,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: str | None = None, + tolerance: dt.timedelta | None = 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.""" @@ -141,6 +161,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: str | None = None, + tolerance: dt.timedelta | None = 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 06dd2bde8cce0c70ee69ad55b1ca8fc96dc6133a 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 | 50 +- codegen/pkg/builder/builder.go | 9 + codegen/pkg/builder/events.go | 114 ++++ codegen/pkg/builder/events_test.go | 76 +++ .../builder/intermediate_representation.go | 11 + codegen/templates/client.py.tmpl | 175 +++++- codegen/templates/events.py.tmpl | 173 ++++++ examples/card_reader_checkout.py | 11 + examples/events-fastapi/README.md | 19 + examples/events-fastapi/main.py | 63 +++ examples/events-flask/README.md | 19 + examples/events-flask/main.py | 55 ++ examples/sync.py | 11 + examples/webhooks.py | 63 --- pyproject.toml | 4 + sumup/__init__.py | 12 +- sumup/_client.py | 186 ++++++- sumup/_events.py | 430 +++++++++++++++ sumup/events.py | 391 +++++++++++++ sumup/webhooks.py | 335 ------------ tests/test_events.py | 517 ++++++++++++++++++ tests/test_webhooks.py | 259 --------- 22 files changed, 2259 insertions(+), 724 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-fastapi/README.md create mode 100644 examples/events-fastapi/main.py create mode 100644 examples/events-flask/README.md create mode 100644 examples/events-flask/main.py delete mode 100644 examples/webhooks.py create mode 100644 sumup/_events.py create mode 100644 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 c4414e74..2112462f 100644 --- a/README.md +++ b/README.md @@ -109,28 +109,44 @@ reader_checkout = client.readers.create_checkout( print(f"Reader checkout created: {reader_checkout}") ``` -### Verifying Webhooks +### Handling Events + +Create a handler with your event signing secret and register typed callbacks: ```python -from sumup import Sumup, WebhookHandler -from sumup.webhooks import WebhookSignatureError +import os -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}") +from sumup import Sumup +from sumup.events import EventNotification, ReaderCreatedEvent + +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) + + +def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id}") + + +# In your HTTP route, pass the unchanged body and the complete +# X-SumUp-Webhook-Signature header value: +events.handle(raw_body, signature) ``` -For a minimal end-to-end example using Python's built-in HTTP server, see [examples/webhooks.py](./examples/webhooks.py). +You can also register an existing function with `events.on_reader_created(callback)`. + +Send a 2xx response after handling succeeds. Reject `EventSignatureError` and `EventPayloadError` with 400; return 500 for `EventCallbackError` so processing can be retried. Make callbacks idempotent and configure body limits in your server. + +Use `AsyncSumup` with async callbacks, `await events.handle(...)`, and `await event.fetch_object_async()` for async servers. For parsing without callbacks, use `client.parse_event_notification(body, signature, secret)`. + +See the runnable [Flask](examples/events-flask/) and [FastAPI](examples/events-fastapi/) examples for complete HTTP routes and error handling. ## Version Support Policy diff --git a/codegen/pkg/builder/builder.go b/codegen/pkg/builder/builder.go index 8776a535..bbcc1ece 100644 --- a/codegen/pkg/builder/builder.go +++ b/codegen/pkg/builder/builder.go @@ -34,6 +34,8 @@ type Builder struct { pathsByTag map[string]*v3.Paths + events []EventDefinition + templates *template.Template start time.Time @@ -82,6 +84,9 @@ func (b *Builder) Load(spec *v3.Document) error { b.collectPaths() b.collectSchemas() + if err := b.collectEvents(); err != nil { + return err + } return nil } @@ -101,6 +106,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..5f383375 --- /dev/null +++ b/codegen/pkg/builder/events.go @@ -0,0 +1,114 @@ +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, + ) + } + + objectKind, ok := extension.Get[string](operation.Extensions, "x-object-type") + if !ok || strings.TrimSpace(objectKind) == "" { + return fmt.Errorf("event %q is missing x-object-type", eventType) + } + + description := strings.TrimSpace(operation.Description) + if description == "" { + description = strings.TrimSpace(operation.Summary) + } + + events = append(events, EventDefinition{ + RegistrationMethod: "on_" + strcase.ToSnake(name), + ObjectKind: objectKind, + 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..2f16e315 --- /dev/null +++ b/codegen/pkg/builder/events_test.go @@ -0,0 +1,76 @@ +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"}, + "x-object-type": "reader" + } + }, + "members.updated": { + "post": { + "operationId": "MemberUpdatedWebhook", + "description": "Sent when a member changes.", + "responses": {"2XX": {"description": "Acknowledged"}}, + "x-object": {"$ref": "#/components/schemas/Member"}, + "x-object-type": "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{ + { + RegistrationMethod: "on_member_updated", + ClassName: "MemberUpdatedEvent", + ObjectKind: "member", + EventType: "members.updated", + ObjectType: "Member", + Description: "Sent when a member changes.", + }, + { + RegistrationMethod: "on_reader_created", + ClassName: "ReaderCreatedEvent", + ObjectKind: "reader", + 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 a4ab3ecd..55c34cb9 100644 --- a/codegen/pkg/builder/intermediate_representation.go +++ b/codegen/pkg/builder/intermediate_representation.go @@ -34,6 +34,17 @@ type OneOfDeclaration struct { GenerateInput bool } +// EventDefinition describes a typed event notification generated from an +// OpenAPI webhook operation. +type EventDefinition struct { + RegistrationMethod string + ObjectKind string + 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 2b9f2aea..550ae558 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -1,12 +1,11 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. -import datetime as dt import os import typing import httpx from ._service import Resource, AsyncResource, runtime_headers if typing.TYPE_CHECKING: - from .webhooks import WebhookHandler + from .events import AsyncEventCallback, AsyncEventsHandler, EventBody, EventCallback, EventNotification, EventsHandler {{- range .Resources }} from .{{ .Package }} import {{ .Name }}Resource, Async{{ .Name }}Resource {{- end }} @@ -43,21 +42,85 @@ class Sumup(Resource): }, )) - def webhook_handler( + def events_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: str, + fallback: "EventCallback", + ) -> "EventsHandler": + """Create a synchronous event handler bound to this API client. + + Register typed callbacks with decorators such as @handler.on_reader_created, + then call handler.handle(body, signature) in your HTTP route. Callbacks receive + one event and can use event.fetch_object() to retrieve its current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required synchronous callback for unknown event types and known + types without a registered callback. Return None on success or raise. + + Returns: + An EventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import EventsHandler + + return EventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, - client=self, + fallback=fallback, + client=self._client, ) + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification(self, body: "EventBody") -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + {{- range .Resources }} @property def {{ .Package }}(self) -> {{ .Name }}Resource: @@ -87,21 +150,85 @@ class AsyncSumup(AsyncResource): }, )) - def webhook_handler( + def events_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: str, + fallback: "AsyncEventCallback", + ) -> "AsyncEventsHandler": + """Create an asynchronous event handler bound to this API client. + + Register async callbacks with decorators such as @handler.on_reader_created, + then await handler.handle(body, signature) in your HTTP route. Callbacks can + await event.fetch_object_async() to retrieve the current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required async callback for unknown event types and known types + without a registered callback. It is awaited before handling completes. + + Returns: + An AsyncEventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import AsyncEventsHandler + + return AsyncEventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, - client=self, + fallback=fallback, + client=self._client, ) + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification(self, body: "EventBody") -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + {{- range .Resources }} @property def {{ .Package }}(self) -> Async{{ .Name }}Resource: diff --git a/codegen/templates/events.py.tmpl b/codegen/templates/events.py.tmpl new file mode 100644 index 00000000..f67524ea --- /dev/null +++ b/codegen/templates/events.py.tmpl @@ -0,0 +1,173 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import typing + +import pydantic + +from ._events import ( + AsyncEventCallback, + _AsyncEventsHandler, + _ErasedCallback, + _SyncEventsHandler, + EventBody, + EventCallback, + EventCallbackError, + EventPayloadError, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + FetchableEvent, + SIGNATURE_HEADER, + UnknownEvent, + verify_event_signature, +) +{{- if .ObjectTypes }} +from .types import ( +{{- range .ObjectTypes }} + {{ . }}, +{{- end }} +) +{{- end }} + + +{{ range .Events -}} +class {{ .ClassName }}(FetchableEvent[{{ .ObjectType }}]): + """{{ .Description }} + + The notification type is {{ printf "%q" .EventType }}. Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + {{ .ObjectType }}. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = {{ printf "%q" .EventType }} + type: typing.Literal[{{ printf "%q" .EventType }}] = {{ printf "%q" .EventType }} + _object_type: typing.ClassVar[str] = {{ printf "%q" .ObjectKind }} + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = {{ .ObjectType }} + + +{{ end -}} +{{ if .Events -}} +KnownEventNotification = ({{ range $index, $event := .Events }}{{ if $index }} | {{ end }}{{ $event.ClassName }}{{ end }}) +{{ else -}} +KnownEventNotification = EventNotification +{{ end }} + +_EVENT_MODELS: typing.Dict[str, typing.Type[EventNotification]] = { +{{- range .Events }} + {{ printf "%q" .EventType }}: {{ .ClassName }}, +{{- end }} +} + + +{{ range .Events -}} +_{{ .ClassName }}Callback = typing.TypeVar( + "_{{ .ClassName }}Callback", bound=typing.Callable[[{{ .ClassName }}], None] +) +_Async{{ .ClassName }}Callback = typing.TypeVar( + "_Async{{ .ClassName }}Callback", bound=typing.Callable[[{{ .ClassName }}], typing.Awaitable[None]] +) +{{ end }} + +class EventsHandler(_SyncEventsHandler): + """Verify incoming events and dispatch them to typed synchronous callbacks. + + Create with client.events_handler(secret, fallback). Register callbacks once + at startup using a decorator or a direct method call:: + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(reader.id) + + Registration returns the original function, preserving its type and identity. + Unknown event types and known types without a callback reach the fallback. + Callbacks receive one event and must return None or raise an exception. + + Call handle(body, signature) with the raw request body and complete signature + header. Send a successful HTTP response after handling completes. Deliveries + may be repeated; make side effects idempotent and synchronize shared state if + serving concurrent requests. The caller owns request-body limits. + """ +{{ range .Events }} + def {{ .RegistrationMethod }}(self, callback: _{{ .ClassName }}Callback) -> _{{ .ClassName }}Callback: + """Register a callback for {{ .EventType }} and return it unchanged. + + Use @handler.{{ .RegistrationMethod }} or handler.{{ .RegistrationMethod }}(callback). + The callback receives one {{ .ClassName }} and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register({{ printf "%q" .EventType }}, typing.cast(_ErasedCallback, callback)) + return callback +{{ end }} + +class AsyncEventsHandler(_AsyncEventsHandler): + """Verify incoming events and dispatch them to typed asynchronous callbacks. + + Create with client.events_handler(secret, fallback) on an AsyncSumup client. + Register async callbacks at startup using decorators or direct method calls:: + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(reader.id) + + Registration returns the original function. Unknown and unregistered types + reach the required async fallback. Await handle(body, signature) before + acknowledging delivery; the handler awaits the selected callback. Parsing + alone via parse() is synchronous and performs no network requests. + + Make side effects idempotent for repeat deliveries. Concurrent requests may + run callbacks concurrently; synchronize shared state and configure body limits + in your HTTP server. + """ +{{ range .Events }} + def {{ .RegistrationMethod }}(self, callback: _Async{{ .ClassName }}Callback) -> _Async{{ .ClassName }}Callback: + """Register an async callback for {{ .EventType }} and return it unchanged. + + Use @handler.{{ .RegistrationMethod }} or handler.{{ .RegistrationMethod }}(callback). + The callback receives one {{ .ClassName }} and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register({{ printf "%q" .EventType }}, typing.cast(_ErasedCallback, callback)) + return callback +{{ end }} + + +__all__ = [ + "AsyncEventCallback", + "AsyncEventsHandler", + "EventBody", + "EventCallback", + "EventCallbackError", + "EventPayloadError", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "KnownEventNotification", + "SIGNATURE_HEADER", + "UnknownEvent", + "verify_event_signature", +{{- range .Events }} + "{{ .ClassName }}", +{{- end }} +] diff --git a/examples/card_reader_checkout.py b/examples/card_reader_checkout.py index 877e3aa1..0f511af4 100644 --- a/examples/card_reader_checkout.py +++ b/examples/card_reader_checkout.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# [tool.ty.environment] +# extra-paths = [".."] +# /// +"""Run from the repository root: uv run --with-editable . examples/card_reader_checkout.py + +Set SUMUP_API_KEY and SUMUP_MERCHANT_CODE before running. +""" + import asyncio import os diff --git a/examples/events-fastapi/README.md b/examples/events-fastapi/README.md new file mode 100644 index 00000000..e42942cf --- /dev/null +++ b/examples/events-fastapi/README.md @@ -0,0 +1,19 @@ +# Events with FastAPI + +Verify incoming events and fetch a newly paired reader using async callbacks. + +From the repository root: + +```sh +export SUMUP_API_KEY="your-api-key" +export SUMUP_EVENT_SECRET="your-event-signing-secret" +uv run --with-editable . examples/events-fastapi/main.py +``` + +Forward signed event deliveries to `POST http://localhost:8080/events`. +The signing secret must match the sender; it is separate from your API key. + +The handler receives the raw body and complete signature header. It returns `204` +after processing, `400` for invalid deliveries, and `500` for callback failures. +Make callback side effects idempotent because deliveries can be retried. +Configure request-body limits in your reverse proxy or hosting platform. diff --git a/examples/events-fastapi/main.py b/examples/events-fastapi/main.py new file mode 100644 index 00000000..fa23f1bf --- /dev/null +++ b/examples/events-fastapi/main.py @@ -0,0 +1,63 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["fastapi", "uvicorn"] +# /// +"""Run with: uv run --with-editable . examples/events-fastapi/main.py""" + +import logging +import os +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI, Request, Response + +from sumup import AsyncSumup +from sumup.events import ( + SIGNATURE_HEADER, + EventCallbackError, + EventNotification, + EventPayloadError, + EventSignatureError, + ReaderCreatedEvent, +) + +logger = logging.getLogger(__name__) +client = AsyncSumup(api_key=os.environ["SUMUP_API_KEY"]) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + yield + await client._client.aclose() + + +app = FastAPI(lifespan=lifespan) + + +async def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(f"Reader paired: {reader.id} ({reader.name})") + + +@app.post("/events") +async def receive_event(request: Request) -> Response: + try: + await events.handle(await request.body(), request.headers.get(SIGNATURE_HEADER)) + except (EventSignatureError, EventPayloadError): + return Response("Invalid event", status_code=400) + except EventCallbackError: + logger.exception("Event processing failed") + return Response("Event processing failed", status_code=500) + return Response(status_code=204) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8080) diff --git a/examples/events-flask/README.md b/examples/events-flask/README.md new file mode 100644 index 00000000..04f9ce8b --- /dev/null +++ b/examples/events-flask/README.md @@ -0,0 +1,19 @@ +# Events with Flask + +Verify incoming events and fetch a newly paired reader using synchronous callbacks. + +From the repository root: + +```sh +export SUMUP_API_KEY="your-api-key" +export SUMUP_EVENT_SECRET="your-event-signing-secret" +uv run --with-editable . examples/events-flask/main.py +``` + +Forward signed event deliveries to `POST http://localhost:8080/events`. +The signing secret must match the sender; it is separate from your API key. + +The handler receives the raw body and complete signature header. It returns `204` +after processing, `400` for invalid deliveries, and `500` for callback failures. +Make callback side effects idempotent because deliveries can be retried. +Flask limits request bodies to 1 MiB in this example. diff --git a/examples/events-flask/main.py b/examples/events-flask/main.py new file mode 100644 index 00000000..e854183f --- /dev/null +++ b/examples/events-flask/main.py @@ -0,0 +1,55 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["flask"] +# /// +"""Run with: uv run --with-editable . examples/events-flask/main.py""" + +import os + +from flask import Flask, request + +from sumup import Sumup +from sumup.events import ( + SIGNATURE_HEADER, + EventCallbackError, + EventNotification, + EventPayloadError, + EventSignatureError, + ReaderCreatedEvent, +) + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) + + +def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id} ({reader.name})") + + +@app.post("/events") +def receive_event(): + try: + events.handle(request.get_data(), request.headers.get(SIGNATURE_HEADER)) + except (EventSignatureError, EventPayloadError): + return "Invalid event", 400 + except EventCallbackError: + app.logger.exception("Event processing failed") + return "Event processing failed", 500 + return "", 204 + + +if __name__ == "__main__": + try: + app.run(port=8080) + finally: + client._client.close() diff --git a/examples/sync.py b/examples/sync.py index 156c1a30..5081d889 100644 --- a/examples/sync.py +++ b/examples/sync.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# [tool.ty.environment] +# extra-paths = [".."] +# /// +"""Run from the repository root: uv run --with-editable . examples/sync.py + +Set SUMUP_API_KEY and SUMUP_MERCHANT_CODE before running. +""" + import os from sumup import APIError, Sumup 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/pyproject.toml b/pyproject.toml index 7f06d7d2..fafeba7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,3 +69,7 @@ line-length = 100 [tool.uv.workspace] exclude = ["codegen", "examples"] + +# Framework examples declare isolated dependencies in their script metadata. +[tool.ty.src] +exclude = ["examples/events-flask/", "examples/events-fastapi/"] diff --git a/sumup/__init__.py b/sumup/__init__.py index 715d0e6a..33c79e8b 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,6 +1,14 @@ from sumup._client import AsyncSumup, Sumup from sumup._exceptions import APIError from sumup._service import AsyncResource, Resource -from sumup.webhooks import WebhookHandler +from sumup.events import AsyncEventsHandler, EventsHandler -__all__ = ["APIError", "AsyncResource", "AsyncSumup", "Resource", "Sumup", "WebhookHandler"] +__all__ = [ + "APIError", + "AsyncEventsHandler", + "AsyncResource", + "AsyncSumup", + "EventsHandler", + "Resource", + "Sumup", +] diff --git a/sumup/_client.py b/sumup/_client.py index 23c3f3c6..4062972c 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -1,5 +1,4 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. -import datetime as dt import os import typing @@ -8,7 +7,14 @@ from ._service import AsyncResource, Resource, runtime_headers if typing.TYPE_CHECKING: - from .webhooks import WebhookHandler + from .events import ( + AsyncEventCallback, + AsyncEventsHandler, + EventBody, + EventCallback, + EventNotification, + EventsHandler, + ) from .checkouts import AsyncCheckoutsResource, CheckoutsResource from .customers import AsyncCustomersResource, CustomersResource from .members import AsyncMembersResource, MembersResource @@ -54,21 +60,87 @@ def __init__( ) ) - def webhook_handler( + def events_handler( self, - *, - secret: str | None = None, - tolerance: dt.timedelta | None = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler - - return WebhookHandler( + secret: str, + fallback: "EventCallback", + ) -> "EventsHandler": + """Create a synchronous event handler bound to this API client. + + Register typed callbacks with decorators such as @handler.on_reader_created, + then call handler.handle(body, signature) in your HTTP route. Callbacks receive + one event and can use event.fetch_object() to retrieve its current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required synchronous callback for unknown event types and known + types without a registered callback. Return None on success or raise. + + Returns: + An EventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import EventsHandler + + return EventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, - client=self, + fallback=fallback, + client=self._client, ) + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification( + self, body: "EventBody" + ) -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + @property def checkouts(self) -> CheckoutsResource: """Access the Checkouts API endpoints.""" @@ -161,21 +233,87 @@ def __init__( ) ) - def webhook_handler( + def events_handler( self, - *, - secret: str | None = None, - tolerance: dt.timedelta | None = None, - ) -> "WebhookHandler": - """Create a webhook handler bound to this client.""" - from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler - - return WebhookHandler( + secret: str, + fallback: "AsyncEventCallback", + ) -> "AsyncEventsHandler": + """Create an asynchronous event handler bound to this API client. + + Register async callbacks with decorators such as @handler.on_reader_created, + then await handler.handle(body, signature) in your HTTP route. Callbacks can + await event.fetch_object_async() to retrieve the current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required async callback for unknown event types and known types + without a registered callback. It is awaited before handling completes. + + Returns: + An AsyncEventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import AsyncEventsHandler + + return AsyncEventsHandler( secret=secret, - tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, - client=self, + fallback=fallback, + client=self._client, ) + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification( + self, body: "EventBody" + ) -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + @property def checkouts(self) -> AsyncCheckoutsResource: """Access the Checkouts API endpoints.""" diff --git a/sumup/_events.py b/sumup/_events.py new file mode 100644 index 00000000..65fed9a6 --- /dev/null +++ b/sumup/_events.py @@ -0,0 +1,430 @@ +"""Verification and dispatch for SumUp event notifications.""" + +from __future__ import annotations + +import hashlib +import hmac +import inspect +import json +import re +import time +from collections.abc import Awaitable, Callable +from typing import Annotated, ClassVar, Generic, TypeVar, cast + +import httpx +import pydantic + +from ._exceptions import APIError, SumupError + +SIGNATURE_HEADER = "X-SumUp-Webhook-Signature" +"""Header containing the signing timestamp and signature. Pass its complete value.""" +_TOLERANCE_SECONDS = 300 +EventBody = str | bytes | bytearray | memoryview +"""Unchanged request bytes or a UTF-8 string, as returned by your web framework. + +Read the body before JSON middleware. Parsing and reserializing can change the +signed bytes and cause verification to fail. +""" +_NonemptyString = Annotated[str, pydantic.Field(strict=True, min_length=1)] +_ObjectT = TypeVar("_ObjectT", bound=pydantic.BaseModel) + + +class EventError(SumupError): + """Base class for event configuration, verification, parsing, and callback errors.""" + + +class EventSignatureError(EventError): + """Missing signing secret, invalid signature, or invalid signing timestamp.""" + + +class EventTimestampError(EventSignatureError): + """Missing or malformed signing timestamp in the signature header.""" + + +class EventSignatureExpiredError(EventSignatureError): + """Signing timestamp more than five minutes before or after the receiver's clock.""" + + +class EventPayloadError(EventError): + """Invalid UTF-8, JSON, or notification envelope.""" + + +class EventCallbackError(EventError): + """The selected callback raised an exception or used the wrong sync/async interface. + + The original exception is available as __cause__. Return a 5xx HTTP response + on processing failure to allow the sender to retry the delivery. + """ + + +class EventHandlerRegistrationError(EventError): + """Invalid or duplicate event callback registration.""" + + +class EventObjectUrlError(EventError): + """The resource URL is malformed or differs from the configured API origin. + + The scheme, host, and port must match the API client's base URL. + """ + + +class EventObject(pydantic.BaseModel): + """Reference to the resource associated with an event. + + Attributes: + id: Identifier of the referenced resource. + type: Resource type, such as "member" or "reader". + url: API URL used by the event's fetch_object() or fetch_object_async(). + """ + + id: _NonemptyString + type: _NonemptyString + url: _NonemptyString + + +class EventNotification(pydantic.BaseModel): + """Metadata shared by known events and UnknownEvent. + + Notifications contain a resource reference. Known event classes provide + fetch_object() and fetch_object_async() to retrieve its current state. + + Attributes: + id: Event identifier. Use it to recognize repeated deliveries. + type: Event name, such as "members.updated". + created_at: Time the event was created, as a timezone-aware datetime. + Signature verification checks the header's signing timestamp instead. + object: Reference to the associated API resource. + """ + + id: _NonemptyString + type: _NonemptyString + created_at: pydantic.AwareDatetime + object: EventObject + + _client: httpx.Client | httpx.AsyncClient | None = pydantic.PrivateAttr(default=None) + _object_type: ClassVar[str | None] = None + + @pydantic.field_validator("object") + @classmethod + def _validate_object(cls, value: EventObject) -> EventObject: + if cls._object_type is not None and value.type != cls._object_type: + raise ValueError("event object type does not match its notification type") + return value + + +class UnknownEvent(EventNotification): + """A valid notification whose type is not recognized by this SDK version. + + Handlers send it to the fallback callback. Its id, type, created_at, and object + remain available, but it has no typed resource-fetching method. + """ + + +class FetchableEvent(EventNotification, Generic[_ObjectT]): + """Base for known events with typed resource fetching.""" + + EVENT_TYPE: ClassVar[str] + _response_model: ClassVar[type[pydantic.BaseModel]] + + def _parse_response(self, response: httpx.Response) -> _ObjectT: + if not response.is_success: + try: + body = response.json() + except ValueError: + body = response.text + raise APIError("Unable to fetch event object", status=response.status_code, body=body) + return cast(_ObjectT, self._response_model.model_validate_json(response.content)) + + def fetch_object(self) -> _ObjectT: + """Fetch the latest state of this event's resource. + + Uses the Sumup client that parsed the event, including its authentication and + request settings. The result reflects the resource at fetch time, which may + have changed since the event was created. A deleted resource may return 404. + + The resource URL must match the client's scheme, host, and port. Its path and + query are appended to the configured base URL; URL credentials and fragments + are ignored. Redirects are not followed. + + Returns: + The resource model associated with this event, such as Member or Reader. + + Raises: + EventObjectUrlError: The resource URL is invalid or uses another API origin. + APIError: The API returns a non-2xx response; status and body are available. + EventError: The event was not parsed with an API client. + TypeError: The event belongs to AsyncSumup; use fetch_object_async(). + + HTTPX transport errors and Pydantic response-validation errors propagate. + """ + if self._client is None: + raise EventError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.Client): + raise TypeError("use fetch_object_async() with an AsyncSumup client") + response = self._client.get( + _validated_object_url(self._client, self.object.url), follow_redirects=False + ) + return self._parse_response(response) + + async def fetch_object_async(self) -> _ObjectT: + """Asynchronously fetch the latest state of this event's resource. + + Uses the AsyncSumup client that parsed the event. Await this method inside + async callbacks. Like fetch_object(), it returns current state, checks the + API origin, and does not follow redirects. Deleted resources may return 404. + + Returns: + The resource model associated with this event, such as Member or Reader. + + Raises: + EventObjectUrlError: The resource URL is invalid or uses another API origin. + APIError: The API returns a non-2xx response; status and body are available. + EventError: The event was not parsed with an API client. + TypeError: The event belongs to Sumup; use fetch_object(). + + HTTPX transport errors and Pydantic response-validation errors propagate. + """ + if self._client is None: + raise EventError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.AsyncClient): + raise TypeError("use fetch_object() with a Sumup client") + response = await self._client.get( + _validated_object_url(self._client, self.object.url), follow_redirects=False + ) + return self._parse_response(response) + + +EventCallback = Callable[[EventNotification], None] +"""Synchronous fallback receiving one event. Return None on success or raise on failure.""" +AsyncEventCallback = Callable[[EventNotification], Awaitable[None]] +"""Async fallback receiving one event. Failures are wrapped in EventCallbackError.""" +_ErasedCallback = Callable[[EventNotification], object] + + +class _BaseEventsHandler: + def __init__( + self, *, secret: str, fallback: _ErasedCallback, client: httpx.Client | httpx.AsyncClient + ) -> None: + _assert_secret(secret) + if not callable(fallback): + raise EventHandlerRegistrationError("an event fallback callback is required") + self._secret = secret + self._fallback = fallback + self._client = client + self._callbacks: dict[str, _ErasedCallback] = {} + + def _register(self, event_type: str, callback: _ErasedCallback) -> None: + if not callable(callback): + raise EventHandlerRegistrationError("an event callback is required") + if event_type in self._callbacks: + raise EventHandlerRegistrationError(f"callback already registered for {event_type}") + self._callbacks[event_type] = callback + + def parse(self, body: EventBody, signature: str | None) -> EventNotification: + """Verify and parse an incoming event without running callbacks. + + Performs no network requests. Even on AsyncEventsHandler, this method is + synchronous and does not need to be awaited. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + + Returns: + A typed event bound to this handler's client, or UnknownEvent for a new type. + + Raises: + EventSignatureError: Verification fails, including a signing timestamp more + than five minutes before or after the receiver's clock. + EventPayloadError: The body is not raw input, or its UTF-8, JSON, or event + fields are invalid. + """ + return parse_event_notification(self._secret, body, signature, client=self._client) + + +class _SyncEventsHandler(_BaseEventsHandler): + def handle(self, body: EventBody, signature: str | None) -> None: + """Verify an event and run its registered callback or the fallback. + + Callbacks run only after verification and parsing succeed. This method waits + for the callback to finish; it does not send an HTTP response. Acknowledge with + 2xx after success, reject invalid input with 400, and return 5xx on callback + failure so the sender can retry. Make callback side effects idempotent. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + Your server owns reading the body and enforcing its size limit. + signature: Complete X-SumUp-Webhook-Signature header value. None or an empty + value fails verification. + + Raises: + EventSignatureError: Invalid signature or signing timestamp, including the + fixed five-minute window before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + EventCallbackError: The callback failed; __cause__ holds its exception. + """ + event = self.parse(body, signature) + callback = self._callbacks.get(event.type, self._fallback) + try: + result = callback(event) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + raise TypeError("use AsyncSumup.events_handler() for async callbacks") + except Exception as cause: + raise EventCallbackError("event callback failed") from cause + + +class _AsyncEventsHandler(_BaseEventsHandler): + async def handle(self, body: EventBody, signature: str | None) -> None: + """Verify an event and await its registered callback or the fallback. + + Callbacks run only after verification and parsing succeed. Await completion + before sending a 2xx HTTP response. Reject invalid input with 400; return 5xx + on callback failure so the sender can retry. Make side effects idempotent. + Cancellation propagates to the caller without being wrapped. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + Your server owns reading the body and enforcing its size limit. + signature: Complete X-SumUp-Webhook-Signature header value. None or an empty + value fails verification. + + Raises: + EventSignatureError: Invalid signature or signing timestamp, including the + fixed five-minute window before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + EventCallbackError: The callback failed; __cause__ holds its exception. + """ + event = self.parse(body, signature) + callback = self._callbacks.get(event.type, self._fallback) + try: + await cast(Awaitable[None], callback(event)) + except Exception as cause: + raise EventCallbackError("event callback failed") from cause + + +def verify_event_signature(secret: str, body: EventBody, signature: str | None) -> None: + """Verify a delivery's signature and signing timestamp without parsing JSON. + + Useful before storing a delivery in a trusted queue. Workers can later use + client.parse_event_notification_without_verification() on the stored body. + This function performs no network requests and does not validate event fields. + + Args: + secret: Event signing secret. This is separate from your API key. + body: Unchanged request bytes or a UTF-8 string. Do not parse and reserialize. + signature: Complete X-SumUp-Webhook-Signature header value, including its + timestamp. None is accepted for framework compatibility but fails + verification. + + Raises: + EventSignatureError: The secret is missing, the signature is invalid, or the + signing timestamp is more than five minutes before or after the + receiver's clock. + EventPayloadError: The body is not bytes or a valid UTF-8 string. + """ + _verify_bytes(secret, _body_bytes(body), signature) + + +def _assert_secret(secret: str) -> None: + if not isinstance(secret, str) or not secret: + raise EventSignatureError("event signing secret is required") + + +def _verify_bytes(secret: str, body: bytes, signature: str | None) -> None: + _assert_secret(secret) + if not isinstance(signature, str) or not signature.strip(): + raise EventSignatureError("missing event signature header") + timestamp_field, separator, signature_field = signature.strip().partition(",") + if not timestamp_field.startswith("t="): + raise EventTimestampError("missing signing timestamp") + timestamp_text = timestamp_field[2:] + if not re.fullmatch(r"[0-9]+", timestamp_text): + raise EventTimestampError("invalid signing timestamp") + try: + timestamp = int(timestamp_text) + except ValueError as cause: + raise EventTimestampError("invalid signing timestamp") from cause + if timestamp > 2**53 - 1: + raise EventTimestampError("invalid signing timestamp") + if abs(int(time.time()) - timestamp) > _TOLERANCE_SECONDS: + raise EventSignatureExpiredError("event timestamp outside the five-minute window") + if not separator or not re.fullmatch(r"v1=[0-9a-fA-F]{64}", signature_field): + raise EventSignatureError("invalid event signature header") + # Preserve the timestamp's original spelling and the exact request bytes. + expected = hmac.digest( + secret.encode("utf-8"), + b"v1:" + timestamp_text.encode("ascii") + b":" + body, + hashlib.sha256, + ) + if not hmac.compare_digest(expected, bytes.fromhex(signature_field[3:])): + raise EventSignatureError("invalid event signature") + + +def parse_event_notification( + secret: str, body: EventBody, signature: str | None, *, client: httpx.Client | httpx.AsyncClient +) -> EventNotification: + raw = _body_bytes(body) + _verify_bytes(secret, raw, signature) + return _parse(raw, client) + + +def parse_event_notification_without_verification( + body: EventBody, *, client: httpx.Client | httpx.AsyncClient +) -> EventNotification: + return _parse(_body_bytes(body), client) + + +def _parse(body: bytes, client: httpx.Client | httpx.AsyncClient) -> EventNotification: + from .events import _EVENT_MODELS + + try: + payload = json.loads(body.decode("utf-8"), parse_constant=_reject_json_constant) + if not isinstance(payload, dict): + raise EventPayloadError("expected an event object") + event_type = payload.get("type") + model = ( + _EVENT_MODELS.get(event_type, UnknownEvent) + if isinstance(event_type, str) + else UnknownEvent + ) + event = model.model_validate(payload) + except (ValueError, RecursionError) as cause: + raise EventPayloadError("invalid event JSON, UTF-8, or notification envelope") from cause + event._client = client + return event + + +def _reject_json_constant(value: str) -> None: + raise ValueError(f"invalid JSON constant: {value}") + + +def _body_bytes(body: EventBody) -> bytes: + if isinstance(body, str): + try: + return body.encode("utf-8") + except UnicodeError as cause: + raise EventPayloadError("invalid UTF-8 event body") from cause + if isinstance(body, (bytes, bytearray, memoryview)): + return bytes(body) + raise EventPayloadError("expected raw request bytes or a string, not parsed JSON") + + +def _validated_object_url(client: httpx.Client | httpx.AsyncClient, value: str) -> httpx.URL: + try: + url = httpx.URL(value) + except (httpx.InvalidURL, ValueError) as cause: + raise EventObjectUrlError("invalid event object URL") from cause + base = client.base_url + if url.scheme not in ("https", "http") or (url.scheme, url.host, url.port) != ( + base.scheme, + base.host, + base.port, + ): + raise EventObjectUrlError("event object URL must use the configured API origin") + return base.copy_with( + raw_path=base.raw_path.split(b"?", 1)[0].rstrip(b"/") + b"/" + url.raw_path.lstrip(b"/"), + fragment=None, + ) diff --git a/sumup/events.py b/sumup/events.py new file mode 100644 index 00000000..91a4308c --- /dev/null +++ b/sumup/events.py @@ -0,0 +1,391 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import builtins +import typing + +import pydantic + +from ._events import ( + SIGNATURE_HEADER, + AsyncEventCallback, + EventBody, + EventCallback, + EventCallbackError, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventPayloadError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + FetchableEvent, + UnknownEvent, + _AsyncEventsHandler, + _ErasedCallback, + _SyncEventsHandler, + verify_event_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. + + The notification type is "members.created". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.created" + type: typing.Literal["members.created"] = "members.created" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Member + + +class MemberDeletedEvent(FetchableEvent[Member]): + """Sent when a member is deleted from a merchant account. + + The notification type is "members.deleted". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.deleted" + type: typing.Literal["members.deleted"] = "members.deleted" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Member + + +class MemberUpdatedEvent(FetchableEvent[Member]): + """Sent when a member is updated, disabled, rejected, or expires for a merchant account. + + The notification type is "members.updated". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.updated" + type: typing.Literal["members.updated"] = "members.updated" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.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. + + The notification type is "readers.created". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Reader. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "readers.created" + type: typing.Literal["readers.created"] = "readers.created" + _object_type: typing.ClassVar[str] = "reader" + _response_model: typing.ClassVar[builtins.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. + + The notification type is "readers.deleted". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Reader. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "readers.deleted" + type: typing.Literal["readers.deleted"] = "readers.deleted" + _object_type: typing.ClassVar[str] = "reader" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Reader + + +KnownEventNotification = ( + MemberCreatedEvent + | MemberDeletedEvent + | MemberUpdatedEvent + | ReaderCreatedEvent + | ReaderDeletedEvent +) + + +_EVENT_MODELS: dict[str, type[EventNotification]] = { + "members.created": MemberCreatedEvent, + "members.deleted": MemberDeletedEvent, + "members.updated": MemberUpdatedEvent, + "readers.created": ReaderCreatedEvent, + "readers.deleted": ReaderDeletedEvent, +} + + +_MemberCreatedEventCallback = typing.TypeVar( + "_MemberCreatedEventCallback", bound=typing.Callable[[MemberCreatedEvent], None] +) +_AsyncMemberCreatedEventCallback = typing.TypeVar( + "_AsyncMemberCreatedEventCallback", + bound=typing.Callable[[MemberCreatedEvent], typing.Awaitable[None]], +) +_MemberDeletedEventCallback = typing.TypeVar( + "_MemberDeletedEventCallback", bound=typing.Callable[[MemberDeletedEvent], None] +) +_AsyncMemberDeletedEventCallback = typing.TypeVar( + "_AsyncMemberDeletedEventCallback", + bound=typing.Callable[[MemberDeletedEvent], typing.Awaitable[None]], +) +_MemberUpdatedEventCallback = typing.TypeVar( + "_MemberUpdatedEventCallback", bound=typing.Callable[[MemberUpdatedEvent], None] +) +_AsyncMemberUpdatedEventCallback = typing.TypeVar( + "_AsyncMemberUpdatedEventCallback", + bound=typing.Callable[[MemberUpdatedEvent], typing.Awaitable[None]], +) +_ReaderCreatedEventCallback = typing.TypeVar( + "_ReaderCreatedEventCallback", bound=typing.Callable[[ReaderCreatedEvent], None] +) +_AsyncReaderCreatedEventCallback = typing.TypeVar( + "_AsyncReaderCreatedEventCallback", + bound=typing.Callable[[ReaderCreatedEvent], typing.Awaitable[None]], +) +_ReaderDeletedEventCallback = typing.TypeVar( + "_ReaderDeletedEventCallback", bound=typing.Callable[[ReaderDeletedEvent], None] +) +_AsyncReaderDeletedEventCallback = typing.TypeVar( + "_AsyncReaderDeletedEventCallback", + bound=typing.Callable[[ReaderDeletedEvent], typing.Awaitable[None]], +) + + +class EventsHandler(_SyncEventsHandler): + """Verify incoming events and dispatch them to typed synchronous callbacks. + + Create with client.events_handler(secret, fallback). Register callbacks once + at startup using a decorator or a direct method call:: + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(reader.id) + + Registration returns the original function, preserving its type and identity. + Unknown event types and known types without a callback reach the fallback. + Callbacks receive one event and must return None or raise an exception. + + Call handle(body, signature) with the raw request body and complete signature + header. Send a successful HTTP response after handling completes. Deliveries + may be repeated; make side effects idempotent and synchronize shared state if + serving concurrent requests. The caller owns request-body limits. + """ + + def on_member_created( + self, callback: _MemberCreatedEventCallback + ) -> _MemberCreatedEventCallback: + """Register a callback for members.created and return it unchanged. + + Use @handler.on_member_created or handler.on_member_created(callback). + The callback receives one MemberCreatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_deleted( + self, callback: _MemberDeletedEventCallback + ) -> _MemberDeletedEventCallback: + """Register a callback for members.deleted and return it unchanged. + + Use @handler.on_member_deleted or handler.on_member_deleted(callback). + The callback receives one MemberDeletedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_updated( + self, callback: _MemberUpdatedEventCallback + ) -> _MemberUpdatedEventCallback: + """Register a callback for members.updated and return it unchanged. + + Use @handler.on_member_updated or handler.on_member_updated(callback). + The callback receives one MemberUpdatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.updated", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_created( + self, callback: _ReaderCreatedEventCallback + ) -> _ReaderCreatedEventCallback: + """Register a callback for readers.created and return it unchanged. + + Use @handler.on_reader_created or handler.on_reader_created(callback). + The callback receives one ReaderCreatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_deleted( + self, callback: _ReaderDeletedEventCallback + ) -> _ReaderDeletedEventCallback: + """Register a callback for readers.deleted and return it unchanged. + + Use @handler.on_reader_deleted or handler.on_reader_deleted(callback). + The callback receives one ReaderDeletedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + +class AsyncEventsHandler(_AsyncEventsHandler): + """Verify incoming events and dispatch them to typed asynchronous callbacks. + + Create with client.events_handler(secret, fallback) on an AsyncSumup client. + Register async callbacks at startup using decorators or direct method calls:: + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(reader.id) + + Registration returns the original function. Unknown and unregistered types + reach the required async fallback. Await handle(body, signature) before + acknowledging delivery; the handler awaits the selected callback. Parsing + alone via parse() is synchronous and performs no network requests. + + Make side effects idempotent for repeat deliveries. Concurrent requests may + run callbacks concurrently; synchronize shared state and configure body limits + in your HTTP server. + """ + + def on_member_created( + self, callback: _AsyncMemberCreatedEventCallback + ) -> _AsyncMemberCreatedEventCallback: + """Register an async callback for members.created and return it unchanged. + + Use @handler.on_member_created or handler.on_member_created(callback). + The callback receives one MemberCreatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_deleted( + self, callback: _AsyncMemberDeletedEventCallback + ) -> _AsyncMemberDeletedEventCallback: + """Register an async callback for members.deleted and return it unchanged. + + Use @handler.on_member_deleted or handler.on_member_deleted(callback). + The callback receives one MemberDeletedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_updated( + self, callback: _AsyncMemberUpdatedEventCallback + ) -> _AsyncMemberUpdatedEventCallback: + """Register an async callback for members.updated and return it unchanged. + + Use @handler.on_member_updated or handler.on_member_updated(callback). + The callback receives one MemberUpdatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.updated", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_created( + self, callback: _AsyncReaderCreatedEventCallback + ) -> _AsyncReaderCreatedEventCallback: + """Register an async callback for readers.created and return it unchanged. + + Use @handler.on_reader_created or handler.on_reader_created(callback). + The callback receives one ReaderCreatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_deleted( + self, callback: _AsyncReaderDeletedEventCallback + ) -> _AsyncReaderDeletedEventCallback: + """Register an async callback for readers.deleted and return it unchanged. + + Use @handler.on_reader_deleted or handler.on_reader_deleted(callback). + The callback receives one ReaderDeletedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + +__all__ = [ + "SIGNATURE_HEADER", + "AsyncEventCallback", + "AsyncEventsHandler", + "EventBody", + "EventCallback", + "EventCallbackError", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventPayloadError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "KnownEventNotification", + "MemberCreatedEvent", + "MemberDeletedEvent", + "MemberUpdatedEvent", + "ReaderCreatedEvent", + "ReaderDeletedEvent", + "UnknownEvent", + "verify_event_signature", +] 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..e608e21b --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,517 @@ +import asyncio +import hashlib +import hmac +import json + +import httpx +import pytest + +from sumup import APIError, AsyncSumup +from sumup.events import ( + EventCallbackError, + EventHandlerRegistrationError, + EventNotification, + EventObjectUrlError, + EventPayloadError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + MemberCreatedEvent, + MemberDeletedEvent, + MemberUpdatedEvent, + ReaderCreatedEvent, + ReaderDeletedEvent, + UnknownEvent, + verify_event_signature, +) +from sumup.types import Reader + +_NOW = 1788696000 +_SECRET = "event_secret_test" +_URL = "https://api.sumup.test/v0.1/readers/rdr_123" + + +@pytest.fixture(autouse=True) +def fixed_clock(monkeypatch): + monkeypatch.setattr("sumup._events.time.time", lambda: _NOW + 0.9) + + +@pytest.fixture +def sdk(sdk_factory): + return sdk_factory(lambda _: httpx.Response(200, json=_reader())) + + +def _body(event_type="readers.created", *, url=_URL): + return json.dumps( + { + "id": "evt_123", + "type": event_type, + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "rdr_123", + "type": "reader" if event_type.startswith("readers.") else "member", + "url": url, + }, + } + ).encode() + + +def _signature(body, timestamp=str(_NOW), secret=_SECRET): + digest = hmac.new( + secret.encode(), f"v1:{timestamp}:".encode() + body, hashlib.sha256 + ).hexdigest() + return f"t={timestamp},v1={digest}" + + +def _reader(): + return { + "id": "rdr_123", + "name": "Front counter", + "status": "paired", + "device": {"identifier": "device_123", "model": "solo"}, + "created_at": "2026-04-11T10:00:00Z", + "updated_at": "2026-04-11T10:00:00Z", + } + + +@pytest.mark.parametrize( + "event_type,model", + [ + ("members.created", MemberCreatedEvent), + ("members.updated", MemberUpdatedEvent), + ("members.deleted", MemberDeletedEvent), + ("readers.created", ReaderCreatedEvent), + ("readers.deleted", ReaderDeletedEvent), + ("future.event", UnknownEvent), + ], +) +def test_parse_event_notification(sdk, event_type, model): + body = _body(event_type) + for event in ( + sdk.parse_event_notification(body, _signature(body), _SECRET), + sdk.parse_event_notification_without_verification(body), + ): + assert isinstance(event, model) + assert event.type == event_type + assert event.created_at.isoformat() == "2026-04-11T10:00:00+00:00" + assert "_client" not in event.model_dump() + assert "Bearer" not in repr(event) + + +@pytest.mark.parametrize( + "body", [b'{ "name": "caf\xc3\xa9" }\n', bytearray(b"body"), memoryview(b"body"), "café"] +) +def test_verify_event_signature_preserves_raw_bytes(body): + raw = body.encode() if isinstance(body, str) else bytes(body) + verify_event_signature(_SECRET, body, _signature(raw)) + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, raw + b" ", _signature(raw)) + + +def test_verify_event_signature_normalization(): + body = _body() + signature = _signature(body, "0" + str(_NOW)) + timestamp, digest = signature.split(",v1=") + verify_event_signature(_SECRET, body, " \t" + timestamp + ",v1=" + digest.upper() + "\r\n") + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, body, signature.replace("t=0", "t=")) + + +@pytest.mark.parametrize("offset", [-300, 300, 0]) +def test_verify_event_signature_accepts_five_minute_boundary(offset): + verify_event_signature(_SECRET, b"body", _signature(b"body", str(_NOW + offset))) + + +@pytest.mark.parametrize("offset", [-301, 301]) +def test_verify_event_signature_rejects_expired_and_future(offset): + with pytest.raises(EventSignatureExpiredError): + verify_event_signature(_SECRET, b"body", _signature(b"body", str(_NOW + offset))) + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "v1=" + "a" * 64, + "t=,v1=" + "a" * 64, + "t=-1,v1=" + "a" * 64, + "t=1.0,v1=" + "a" * 64, + "t=١,v1=" + "a" * 64, + "t=+1788696000,v1=" + "a" * 64, + "t=" + "9" * 5000 + ",v1=" + "a" * 64, + "t=9007199254740992,v1=" + "a" * 64, + f"t={_NOW},v2=" + "a" * 64, + f"t={_NOW},v1=abc", + f"t={_NOW},v1=" + "x" * 64, + f"t={_NOW}, v1=" + "a" * 64, + f"t={_NOW},v1=" + "a" * 64 + ",v1=" + "b" * 64, + f"t={_NOW},t={_NOW},v1=" + "a" * 64, + ], +) +def test_verify_event_signature_rejects_malformed_header(header): + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, b"body", header) + + +def test_invalid_timestamp_is_signature_error(): + with pytest.raises(EventTimestampError): + verify_event_signature(_SECRET, b"body", "t=invalid,v1=abc") + + +@pytest.mark.parametrize("secret", ["", None, 123]) +def test_signing_secret_is_required(sdk, secret): + with pytest.raises(EventSignatureError): + verify_event_signature(secret, b"body", _signature(b"body")) + with pytest.raises(EventSignatureError): + sdk.events_handler(secret, lambda _: None) + + +@pytest.mark.parametrize( + "body", [{}, 12, None, [], b"\xff", b"{", b"[]", b"null", b"{}", b'{"x":NaN}'] +) +def test_parse_rejects_invalid_payload(sdk, body): + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(body) + + +@pytest.mark.parametrize( + "field,value", + [ + ("id", ""), + ("type", []), + ("created_at", "2026-04-11"), + ("created_at", "2026-04-11T10:00:00"), + ("created_at", "2026-02-30T10:00:00Z"), + ("object", None), + ("object.id", ""), + ("object.type", "member"), + ("object.url", 123), + ], +) +def test_parse_validates_envelope(sdk, field, value): + payload = json.loads(_body()) + if "." in field: + payload["object"][field.split(".")[1]] = value + else: + payload[field] = value + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(json.dumps(payload)) + + +@pytest.mark.parametrize("created_at", [0, "1970-01-01T00:00:00Z", "1970-01-01 00:00:00+00:00"]) +def test_parse_created_at_uses_datetime_parsing(sdk, created_at): + payload = json.loads(_body()) + payload["created_at"] = created_at + body = json.dumps(payload).encode() + event = sdk.parse_event_notification(body, _signature(body), _SECRET) + assert event.created_at.isoformat() == "1970-01-01T00:00:00+00:00" + + +def test_events_handler_dispatches_and_parse_does_not(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, lambda event: calls.append(("fallback", event.type))) + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + calls.append(("reader", event.type)) + + body = _body() + assert isinstance(handler.parse(body, _signature(body)), ReaderCreatedEvent) + assert calls == [] + for kind in ("readers.created", "members.updated", "future.event"): + raw = _body(kind) + handler.handle(raw, _signature(raw)) + assert calls == [ + ("reader", "readers.created"), + ("fallback", "members.updated"), + ("fallback", "future.event"), + ] + + +def test_events_handler_verifies_before_parsing_or_dispatch(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, calls.append) + with pytest.raises(EventSignatureError): + handler.handle(b"not json", _signature(b"not json", secret="wrong")) + with pytest.raises(EventPayloadError): + handler.handle(b"not json", _signature(b"not json")) + assert calls == [] + + +def test_events_handler_registration_errors(sdk): + with pytest.raises(EventHandlerRegistrationError): + sdk.events_handler(_SECRET, None) + handler = sdk.events_handler(_SECRET, lambda _: None) + with pytest.raises(EventHandlerRegistrationError): + handler.on_reader_created(None) + handler.on_reader_created(lambda _: None) + with pytest.raises(EventHandlerRegistrationError): + handler.on_reader_created(lambda _: None) + + +@pytest.mark.parametrize("registered", [False, True]) +def test_events_handler_chains_callback_errors(sdk, registered): + error = RuntimeError("storage unavailable") + + def fail(_: EventNotification): + raise error + + handler = sdk.events_handler(_SECRET, fail) + if registered: + handler.on_reader_created(fail) + with pytest.raises(EventCallbackError) as raised: + handler.handle(_body(), _signature(_body())) + assert raised.value.__cause__ is error + + +def test_sync_handler_rejects_async_callback(sdk): + async def callback(_: EventNotification): + pass + + handler = sdk.events_handler(_SECRET, callback) + with pytest.raises(EventCallbackError) as raised: + handler.handle(_body(), _signature(_body())) + assert isinstance(raised.value.__cause__, TypeError) + + +@pytest.mark.parametrize( + "url", + [ + "https://evil.test/reader", + "http://api.sumup.test/reader", + "https://api.sumup.test:444/reader", + "/reader", + "//evil.test/reader", + "https://api.sumup.test@evil.test/reader", + "https://[broken", + ], +) +def test_fetch_object_checks_origin_before_request(sdk_factory, url): + calls = [] + sdk = sdk_factory(lambda request: calls.append(request)) + event = sdk.parse_event_notification_without_verification(_body(url=url)) + with pytest.raises(EventObjectUrlError): + event.fetch_object() + assert calls == [] + + +@pytest.mark.parametrize( + "url,path", + [ + ( + "https://user:pass@api.sumup.test/v0.1/readers/rdr_123?expand=true#ignored", + "/prefix/v0.1/readers/rdr_123?expand=true", + ), + ("https://API.SUMUP.TEST:443//readers/rdr_123", "/prefix/readers/rdr_123"), + ], +) +def test_fetch_object_normalizes_url_and_uses_client_options(sdk_factory, url, path): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=_reader()) + + sdk = sdk_factory(respond) + sdk._client.base_url = "https://api.sumup.test/prefix" + sdk._client.headers["X-Custom"] = "value" + event = sdk.parse_event_notification_without_verification(_body(url=url)) + result = event.fetch_object() + assert isinstance(result, Reader) + assert result.name == "Front counter" + assert len(requests) == 1 + request = requests[0] + assert str(request.url) == "https://api.sumup.test" + path + assert request.headers["Authorization"] == "Bearer test" + assert request.headers["X-Custom"] == "value" + + +@pytest.mark.parametrize("status,body", [(404, {"title": "Not Found"}), (500, "unavailable")]) +def test_fetch_object_preserves_api_errors(sdk_factory, status, body): + sdk = sdk_factory( + lambda _: httpx.Response( + status, **({"json": body} if isinstance(body, dict) else {"text": body}) + ) + ) + event = sdk.parse_event_notification_without_verification(_body()) + with pytest.raises(APIError) as raised: + event.fetch_object() + assert raised.value.status == status + assert raised.value.body == body + + +def test_fetch_object_does_not_follow_redirects(sdk_factory): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(302, headers={"Location": "https://evil.test/reader"}) + + sdk = sdk_factory(respond) + sdk._client.follow_redirects = True + event = sdk.parse_event_notification_without_verification(_body()) + with pytest.raises(APIError): + event.fetch_object() + assert len(requests) == 1 + + +def test_async_events_handler_fetch_dispatch_errors_and_cancellation(): + async def run(): + sdk = AsyncSumup(api_key="test", base_url="https://api.sumup.test") + await sdk._client.aclose() + async with httpx.AsyncClient( + base_url="https://api.sumup.test", + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_reader())), + ) as transport: + sdk._client = transport + calls = [] + + async def fallback(event: EventNotification): + await asyncio.sleep(0) + calls.append(event.type) + + handler = sdk.events_handler(_SECRET, fallback) + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent): + reader = await event.fetch_object_async() + calls.append(reader.id) + + assert isinstance(handler.parse(_body(), _signature(_body())), ReaderCreatedEvent) + assert isinstance( + sdk.parse_event_notification(_body(), _signature(_body()), _SECRET), + ReaderCreatedEvent, + ) + for kind in ("readers.created", "members.updated", "future.event"): + raw = _body(kind) + await handler.handle(raw, _signature(raw)) + assert calls == ["rdr_123", "members.updated", "future.event"] + error = RuntimeError("storage unavailable") + + async def fail(_: EventNotification): + raise error + + with pytest.raises(EventCallbackError) as raised: + await sdk.events_handler(_SECRET, fail).handle(_body(), _signature(_body())) + assert raised.value.__cause__ is error + + async def cancelled(_: EventNotification): + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await sdk.events_handler(_SECRET, cancelled).handle(_body(), _signature(_body())) + event = sdk.parse_event_notification_without_verification(_body()) + assert isinstance(event, ReaderCreatedEvent) + with pytest.raises(TypeError, match="fetch_object_async"): + event.fetch_object() + + asyncio.run(run()) + + +def test_body_size_is_owned_by_receiver(): + body = b"x" * (2 * 1024 * 1024) + verify_event_signature(_SECRET, body, _signature(body)) + + +def test_public_surface_has_no_tolerance_or_separate_timestamp(): + import inspect + + from sumup import events + + assert "DEFAULT_TOLERANCE" not in events.__all__ + assert "TIMESTAMP_HEADER" not in events.__all__ + assert "FetchableEvent" not in events.__all__ + assert list(inspect.signature(verify_event_signature).parameters) == [ + "secret", + "body", + "signature", + ] + + +def test_parse_rejects_non_json_constants_in_otherwise_valid_event(sdk): + body = _body()[:-1] + b', "extra": NaN}' + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(body) + + +@pytest.mark.parametrize( + "method,event_type,model", + [ + ("on_member_created", "members.created", MemberCreatedEvent), + ("on_member_deleted", "members.deleted", MemberDeletedEvent), + ("on_member_updated", "members.updated", MemberUpdatedEvent), + ("on_reader_created", "readers.created", ReaderCreatedEvent), + ("on_reader_deleted", "readers.deleted", ReaderDeletedEvent), + ], +) +def test_specific_registration_preserves_callback_and_dispatches(sdk, method, event_type, model): + calls = [] + handler = sdk.events_handler(_SECRET, lambda _: pytest.fail("unexpected fallback")) + + def callback(event: EventNotification) -> None: + calls.append(event) + + register = getattr(handler, method) + assert register(callback) is callback + body = _body(event_type) + handler.handle(body, _signature(body)) + assert len(calls) == 1 + assert isinstance(calls[0], model) + with pytest.raises(EventHandlerRegistrationError): + register(callback) + + +@pytest.mark.parametrize( + "method,event_type,model", + [ + ("on_member_created", "members.created", MemberCreatedEvent), + ("on_member_deleted", "members.deleted", MemberDeletedEvent), + ("on_member_updated", "members.updated", MemberUpdatedEvent), + ("on_reader_created", "readers.created", ReaderCreatedEvent), + ("on_reader_deleted", "readers.deleted", ReaderDeletedEvent), + ], +) +def test_async_specific_registration_preserves_callback_and_dispatches(method, event_type, model): + async def run(): + sdk = AsyncSumup(api_key="test") + try: + calls = [] + + async def fallback(_: EventNotification) -> None: + pytest.fail("unexpected fallback") + + async def callback(event: EventNotification) -> None: + await asyncio.sleep(0) + calls.append(event) + + handler = sdk.events_handler(_SECRET, fallback) + register = getattr(handler, method) + with pytest.raises(EventHandlerRegistrationError): + register(None) + assert register(callback) is callback + body = _body(event_type) + await handler.handle(body, _signature(body)) + assert len(calls) == 1 + assert isinstance(calls[0], model) + with pytest.raises(EventHandlerRegistrationError): + register(callback) + finally: + await sdk._client.aclose() + + asyncio.run(run()) + + +def test_decorated_callback_remains_directly_callable(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, lambda _: None) + + @handler.on_reader_created + def callback(notification: ReaderCreatedEvent) -> None: + calls.append(notification.id) + + event = sdk.parse_event_notification_without_verification(_body()) + assert isinstance(event, ReaderCreatedEvent) + # Keeping the parameter name also checks that the decorator preserves typing. + callback(notification=event) + assert calls == [event.id] 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)