Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down
33 changes: 33 additions & 0 deletions codegen/templates/client.py.tmpl
Original file line number Diff line number Diff line change
@@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
63 changes: 63 additions & 0 deletions examples/webhooks.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 10 additions & 1 deletion sumup/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
34 changes: 34 additions & 0 deletions sumup/_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading