Skip to content
Open
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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ $options = new \SumUp\HttpClient\RequestOptions(
$checkout = $sumup->checkouts()->get('checkout-id', $options);
```

## Handling Events

Create a handler with your endpoint's signing secret and register typed callbacks:

```php
use SumUp\Events\EventNotification;
use SumUp\Events\ReaderCreatedEvent;
use SumUp\SumUp;

$sumup = new SumUp();
$events = $sumup->eventsHandler(
getenv('SUMUP_EVENT_SECRET'),
function (EventNotification $event): void {
// Handles unknown event types and known types without a registered callback.
error_log('Received ' . $event->type);
}
)->onReaderCreated(function (ReaderCreatedEvent $event): void {
$reader = $event->fetchObject(); // Returns a SumUp\Types\Reader, or null for an empty response.
error_log('Reader paired: ' . $reader?->id);
});

// In your HTTP route, pass the unchanged request body and complete signature header.
$events->handle(
file_get_contents('php://input'),
$_SERVER['HTTP_X_SUMUP_WEBHOOK_SIGNATURE'] ?? ''
);
```

Register callbacks with methods such as `onCheckoutPaid`, `onMemberUpdated`, and `onRoleDeleted`. A second registration for the same type replaces the previous callback. Notifications expose `id`, `type`, `createdAt` (`DateTimeImmutable`), and an `object` reference. `fetchObject($requestOptions)` uses your API client and returns the resource's current state; deleted resources may return an API error. Object URLs must match the configured `base_uri`, and resource fetches disable redirects. If you use a custom HTTP client, configure a matching `base_uri` and honor `RequestOptions::followRedirects`.

Send a 2xx response after handling succeeds. Return 400 for `SumUp\Exception\EventSignatureException` or `EventPayloadException`, and 500 for `EventCallbackException` so processing can be retried. The callback exception retains the original error in `getPrevious()`. Make processing idempotent using the event ID, and configure request body limits in your server. Verification requires a signing timestamp within five minutes of your server's clock.

For parsing without callbacks, use `$sumup->parseEventNotification($body, $signature, $secret)`. To queue deliveries, verify them first with `SumUp\Events\EventSignature::verify($secret, $body, $signature)`, then use `$sumup->parseEventNotificationWithoutVerification($body)` only on previously verified payloads from trusted storage. Unrecognized types produce `SumUp\Events\UnknownEvent`; their `fetchObject()` result is untyped.

These signed notifications are separate from the checkout and reader `return_url` callbacks. See [examples/events.php](examples/events.php) for a complete HTTP endpoint with error handling.

## Examples

The repository includes runnable examples:
Expand All @@ -150,6 +186,8 @@ The repository includes runnable examples:
Run with: `php examples/custom-http-client.php`
- `examples/guzzle-http-client.php` - use the built-in Guzzle client.
Run with: `php examples/guzzle-http-client.php`
- `examples/events.php` - receive and verify signed events.
Run with: `SUMUP_EVENT_SECRET='your-signing-secret' php -S localhost:8080 examples/events.php`

For checkout-related examples, set:

Expand Down
92 changes: 92 additions & 0 deletions codegen/pkg/generator/events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package generator

import (
"bytes"
"embed"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"text/template"

"github.com/iancoleman/strcase"

"github.com/sumup/sumup-php/codegen/pkg/extension"
)

//go:embed templates/events/*.tmpl
var eventTemplates embed.FS

type eventDefinition struct {
Name string
Type string
ObjectType string
SchemaName string
Method string
}

func (g *Generator) collectEvents() ([]eventDefinition, error) {
var events []eventDefinition
names := make(map[string]bool)
for eventType, item := range g.spec.Webhooks.FromOldest() {
if item == nil || item.Post == nil || item.Post.OperationId == "" {
return nil, fmt.Errorf("webhook %q is missing a POST operationId", eventType)
}
name := strcase.ToCamel(strings.TrimSuffix(item.Post.OperationId, "Webhook"))
if name == "" || names[name] {
return nil, fmt.Errorf("webhook %q has an empty or duplicate event class name", eventType)
}
names[name] = true
object, ok := extension.Get[struct {
Reference string `yaml:"$ref"`
}](item.Post.Extensions, "x-object")
schemaName, local := strings.CutPrefix(object.Reference, "#/components/schemas/")
if !ok || !local || schemaName == "" || g.spec.Components == nil {
return nil, fmt.Errorf("webhook %q requires a local x-object schema reference", eventType)
}
schema := g.spec.Components.Schemas.GetOrZero(schemaName)
if schema == nil || !schemaShouldGenerateClass(schema) {
return nil, fmt.Errorf("webhook %q has missing or unsupported object schema %q", eventType, schemaName)
}
events = append(events, eventDefinition{
Name: name + "Event",
Type: eventType,
ObjectType: strcase.ToCamel(strings.NewReplacer(".", "_", "-", "_").Replace(schemaName)),
SchemaName: schemaName,
Method: "on" + name,
})
}
slices.SortFunc(events, func(a, b eventDefinition) int { return strings.Compare(a.Type, b.Type) })
return events, nil
}

func (g *Generator) writeEvents() error {
templates, err := template.ParseFS(eventTemplates, "templates/events/*.tmpl")
if err != nil {
return fmt.Errorf("parse event templates: %w", err)
}
dir := filepath.Join(g.cfg.Out, "Events")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create events directory: %w", err)
}
write := func(name, templateName string, data any) error {
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, templateName, data); err != nil {
return fmt.Errorf("render event file %q: %w", name, err)
}
if err := os.WriteFile(filepath.Join(dir, name+".php"), buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("write event file %q: %w", name, err)
}
return nil
}
for _, event := range g.events {
if err := write(event.Name, "event.tmpl", event); err != nil {
return err
}
}
if err := write("EventFactory", "factory.tmpl", g.events); err != nil {
return err
}
return write("EventsHandler", "handler.tmpl", g.events)
}
87 changes: 87 additions & 0 deletions codegen/pkg/generator/events_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package generator

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/pb33f/libopenapi"
)

const webhookSpec = `{
"openapi": "3.1.0",
"info": {"title": "Events", "version": "1.0.0"},
"paths": {},
"components": {"schemas": {
"Widget": {"type": "object", "properties": {"id": {"type": "string"}}}
}},
"webhooks": {
"widgets.created": {"post": {
"operationId": "WidgetCreatedWebhook",
"x-object": {"$ref": "#/components/schemas/Widget"},
"responses": {"2XX": {"description": "Accepted"}}
}}
}
}`

func TestGenerateEvents(t *testing.T) {
t.Parallel()
doc, err := libopenapi.NewDocument([]byte(webhookSpec))
if err != nil {
t.Fatal(err)
}
model, err := doc.BuildV3Model()
if err != nil {
t.Fatal(err)
}
out := t.TempDir()
g := New(Config{Out: out})
if err := g.Load(&model.Model); err != nil {
t.Fatal(err)
}
if err := g.Build(); err != nil {
t.Fatal(err)
}
for file, snippets := range map[string][]string{
"Events/WidgetCreatedEvent.php": {"const TYPE = 'widgets.created'", "fetchObject(?RequestOptions $options = null): ?Widget", "Widget::class"},
"Events/EventFactory.php": {"WidgetCreatedEvent::TYPE => new WidgetCreatedEvent", "new UnknownEvent"},
"Events/EventsHandler.php": {"callable(WidgetCreatedEvent): void", "function onWidgetCreated(callable $callback): self"},
"Types/Widget.php": {"class Widget", "public ?string $id"},
} {
data, err := os.ReadFile(filepath.Join(out, file))
if err != nil {
t.Fatal(err)
}
for _, snippet := range snippets {
if !strings.Contains(string(data), snippet) {
t.Errorf("%s missing %q", file, snippet)
}
}
}
}

func TestRejectInvalidWebhooks(t *testing.T) {
t.Parallel()
for name, spec := range map[string]string{
"missing operation": strings.Replace(webhookSpec, `"WidgetCreatedWebhook"`, `""`, 1),
"missing object": strings.Replace(webhookSpec, `"x-object"`, `"x-other"`, 1),
"nonlocal object": strings.Replace(webhookSpec, `#/components/schemas/Widget`, `https://example.com/Widget`, 1),
"unknown object": strings.Replace(webhookSpec, `#/components/schemas/Widget`, `#/components/schemas/Missing`, 1),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
doc, err := libopenapi.NewDocument([]byte(spec))
if err != nil {
t.Fatal(err)
}
model, err := doc.BuildV3Model()
if err == nil {
err = New(Config{}).Load(&model.Model)
}
if err == nil {
t.Fatal("expected invalid webhook definition to fail")
}
})
}
}
14 changes: 14 additions & 0 deletions codegen/pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ type Generator struct {

// requestClassNames tracks schema classes used as OpenAPI request bodies.
requestClassNames map[string]struct{}

events []eventDefinition
}

type enumDefinition struct {
Expand Down Expand Up @@ -90,7 +92,16 @@ func (g *Generator) Load(spec *v3.Document) error {
g.tagLookup[normalizeTagKey(tag.Name)] = tag
}

events, err := g.collectEvents()
if err != nil {
return err
}
g.events = events
usage := g.collectSchemaUsage()
for _, event := range events {
schema := spec.Components.Schemas.GetOrZero(event.SchemaName)
g.collectSchemaUsageFromSchema(schema, nil, usage, make(map[*base.SchemaProxy]struct{}), event.ObjectType)
}
g.schemasByTag, g.schemaNamespaces = g.assignSchemasToTags(usage)
g.operationsByTag = g.collectOperations()
g.collectRequestClassNames()
Expand Down Expand Up @@ -147,6 +158,9 @@ func (g *Generator) Build() error {
if err := g.writeApiVersion(); err != nil {
return err
}
if err := g.writeEvents(); err != nil {
return err
}

return nil
}
Expand Down
26 changes: 26 additions & 0 deletions codegen/pkg/generator/templates/events/event.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

// Code generated by sumup-php/codegen. DO NOT EDIT.
namespace SumUp\Events;

use SumUp\Exception\EventObjectException;
use SumUp\HttpClient\RequestOptions;
use SumUp\Types\{{.ObjectType}};

/** A {{.Type}} notification. Fetch its object to retrieve the latest {{.ObjectType}}. */
final class {{.Name}} extends EventNotification
{
public const TYPE = '{{.Type}}';

/** Fetch the current resource. Deleted resources may return an API error. */
public function fetchObject(?RequestOptions $options = null): ?{{.ObjectType}}
{
$object = $this->fetchResource({{.ObjectType}}::class, $options);
if ($object !== null && !$object instanceof {{.ObjectType}}) {
throw new EventObjectException('Expected a {{.ObjectType}} resource.');
}
return $object;
}
}
23 changes: 23 additions & 0 deletions codegen/pkg/generator/templates/events/factory.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

// Code generated by sumup-php/codegen. DO NOT EDIT.
namespace SumUp\Events;

use SumUp\SumUp;

/** @internal Selects the notification type from the OpenAPI webhook definitions. */
final class EventFactory
{
/** @param array<string, mixed> $payload */
public static function create(SumUp $client, array $payload): EventNotification
{
return match ($payload['type'] ?? null) {
{{- range .}}
{{.Name}}::TYPE => new {{.Name}}($client, $payload),
{{- end}}
default => new UnknownEvent($client, $payload),
};
}
}
27 changes: 27 additions & 0 deletions codegen/pkg/generator/templates/events/handler.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

// Code generated by sumup-php/codegen. DO NOT EDIT.
namespace SumUp\Events;

/** Register typed callbacks before handling requests. A second registration replaces the first. */
final class EventsHandler extends EventsHandlerBase
{
{{range .}}
/**
* Register a callback for {{.Type}}.
*
* @param callable({{.Name}}): void $callback
*/
public function {{.Method}}(callable $callback): self
{
$this->register({{.Name}}::TYPE, static function (EventNotification $event) use ($callback): void {
if ($event instanceof {{.Name}}) {
$callback($event);
}
});
return $this;
}
{{end}}
}
Loading
Loading