diff --git a/README.md b/README.md index a690d59..23ab132 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,29 @@ reader_checkout = client.readers.create_checkout( print(f"Reader checkout created: {reader_checkout}") ``` +### Verifying Webhooks + +```python +from sumup import Sumup, WebhookHandler +from sumup.webhooks import WebhookSignatureError + +client = Sumup(api_key="sup_sk_MvxmLOl0...") +webhooks = WebhookHandler(secret="whsec_...", client=client) + +def handle_webhook(headers: dict[str, str], body: bytes) -> None: + try: + event = webhooks.parse_and_verify(headers, body) + except WebhookSignatureError: + # Reject the request with 400/401 in your web framework. + raise + + if event.type == "checkout.created": + checkout = event.fetch_object() + print(f"Checkout {checkout.id} is now {checkout.status}") +``` + +For a minimal end-to-end example using Python's built-in HTTP server, see [examples/webhooks.py](./examples/webhooks.py). + ## Version support policy `sumup-py` maintains compatibility with Python versions that are no pass their End of life support, see [Status of Python versions](https://devguide.python.org/versions/). diff --git a/codegen/templates/client.py.tmpl b/codegen/templates/client.py.tmpl index 9e5f886..898c165 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -1,9 +1,12 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os import httpx import typing from ._service import Resource, AsyncResource, runtime_headers +if typing.TYPE_CHECKING: + from .webhooks import WebhookHandler {{- range .Resources }} from .{{ .Package }} import {{ .Name }}Resource, Async{{ .Name }}Resource {{- end }} @@ -40,6 +43,21 @@ class Sumup(Resource): }, )) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + {{- range .Resources }} @property def {{ .Package }}(self) -> {{ .Name }}Resource: @@ -69,6 +87,21 @@ class AsyncSumup(AsyncResource): }, )) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + {{- range .Resources }} @property def {{ .Package }}(self) -> Async{{ .Name }}Resource: diff --git a/examples/webhooks.py b/examples/webhooks.py new file mode 100644 index 0000000..ed76933 --- /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 2c7b40f..05bbbc8 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,5 +1,14 @@ from sumup._client import Sumup, AsyncSumup from sumup._service import Resource, AsyncResource from sumup._exceptions import APIError +from sumup.webhooks import WebhookHandler -__all__ = ["APIError", "AsyncResource", "AsyncSumup", "MerchantAccount", "Resource", "Sumup"] +__all__ = [ + "APIError", + "AsyncResource", + "AsyncSumup", + "MerchantAccount", + "Resource", + "Sumup", + "WebhookHandler", +] diff --git a/sumup/_client.py b/sumup/_client.py index 078f6ce..4a44e02 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -1,9 +1,13 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. +import datetime as dt import os import httpx import typing from ._service import Resource, AsyncResource, runtime_headers + +if typing.TYPE_CHECKING: + from .webhooks import WebhookHandler from .checkouts import CheckoutsResource, AsyncCheckoutsResource from .customers import CustomersResource, AsyncCustomersResource from .members import MembersResource, AsyncMembersResource @@ -50,6 +54,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + @property def checkouts(self) -> CheckoutsResource: """Access the Checkouts API endpoints.""" @@ -149,6 +168,21 @@ def __init__( ) ) + def webhook_handler( + self, + *, + secret: typing.Optional[str] = None, + tolerance: typing.Optional[dt.timedelta] = None, + ) -> "WebhookHandler": + """Create a webhook handler bound to this client.""" + from .webhooks import DEFAULT_WEBHOOK_TOLERANCE, WebhookHandler + + return WebhookHandler( + secret=secret, + tolerance=tolerance or DEFAULT_WEBHOOK_TOLERANCE, + client=self, + ) + @property def checkouts(self) -> AsyncCheckoutsResource: """Access the Checkouts API endpoints.""" diff --git a/sumup/webhooks.py b/sumup/webhooks.py new file mode 100644 index 0000000..684cc30 --- /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 0000000..5525f93 --- /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)