diff --git a/README.md b/README.md index 4e751f6..0fa6e4b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The .NET SDK for the SumUp [API](https://developer.sumup.com). dotnet add package SumUp --prerelease ``` -See `examples/Basic`, `examples/CardReaderCheckout`, and `examples/OAuth2` for runnable projects. +See `examples/Basic`, `examples/CardReaderCheckout`, `examples/OAuth2`, and `examples/Webhooks` for runnable projects. ## Supported .NET Versions @@ -104,11 +104,32 @@ var readerCheckout = await client.Readers.CreateCheckoutAsync( Console.WriteLine($"Reader checkout created: {readerCheckout.Data?.Data?.ClientTransactionId}"); ``` +### Handling Webhooks + +```csharp +using SumUp; + +using var client = new SumUpClient(); +var webhookHandler = client.CreateWebhookHandler(secret: "whsec_..."); + +var eventNotification = webhookHandler.VerifyAndParse( + signatureHeader: request.Headers[WebhookConstants.SignatureHeader].ToString(), + timestampHeader: request.Headers[WebhookConstants.TimestampHeader].ToString(), + body: rawRequestBody); + +if (eventNotification is CheckoutCreatedEvent checkoutCreated) +{ + var checkout = await checkoutCreated.FetchObjectAsync(); + Console.WriteLine($"Received checkout {checkout?.Id}"); +} +``` + ## Examples - `examples/Basic` – lists recent checkouts to sanity check your API token. - `examples/CardReaderCheckout` – mirrors the `../sumup-rs/examples/card_reader_checkout.rs` sample by listing the merchant’s paired readers and creating a €10 checkout on the first available device. - `examples/OAuth2` – starts a local OAuth2 Authorization Code flow with PKCE, exchanges the callback code for an access token, and fetches merchant information using the returned `merchant_code`. +- `examples/Webhooks` – runs a minimal ASP.NET Core endpoint that verifies `X-SumUp-Webhook-*` headers, parses typed webhook events, and fetches the referenced object through the SDK. To run the card reader example: @@ -129,4 +150,12 @@ export REDIRECT_URI="http://localhost:8080/callback" dotnet run --project examples/OAuth2 ``` +To run the webhook example: + +```sh +export SUMUP_ACCESS_TOKEN="your_api_key" +export SUMUP_WEBHOOK_SECRET="whsec_..." +dotnet run --project examples/Webhooks +``` + [docs-badge]: https://img.shields.io/badge/SumUp-documentation-white.svg?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgY29sb3I9IndoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPHBhdGggZD0iTTIyLjI5IDBIMS43Qy43NyAwIDAgLjc3IDAgMS43MVYyMi4zYzAgLjkzLjc3IDEuNyAxLjcxIDEuN0gyMi4zYy45NCAwIDEuNzEtLjc3IDEuNzEtMS43MVYxLjdDMjQgLjc3IDIzLjIzIDAgMjIuMjkgMFptLTcuMjIgMTguMDdhNS42MiA1LjYyIDAgMCAxLTcuNjguMjQuMzYuMzYgMCAwIDEtLjAxLS40OWw3LjQ0LTcuNDRhLjM1LjM1IDAgMCAxIC40OSAwIDUuNiA1LjYgMCAwIDEtLjI0IDcuNjlabTEuNTUtMTEuOS03LjQ0IDcuNDVhLjM1LjM1IDAgMCAxLS41IDAgNS42MSA1LjYxIDAgMCAxIDcuOS03Ljk2bC4wMy4wM2MuMTMuMTMuMTQuMzUuMDEuNDlaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+ diff --git a/SumUp.sln b/SumUp.sln index 2318d33..bbffc6c 100644 --- a/SumUp.sln +++ b/SumUp.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Basic", "examples\Basic\Bas EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CardReaderCheckout", "examples\CardReaderCheckout\CardReaderCheckout.csproj", "{5DD2877F-E30C-42F1-9192-4E80DB983FA8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Webhooks", "examples\Webhooks\Webhooks.csproj", "{50E0145D-2C1B-4C9D-B408-DB2C160F1E71}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -40,11 +42,16 @@ Global {5DD2877F-E30C-42F1-9192-4E80DB983FA8}.Debug|Any CPU.Build.0 = Debug|Any CPU {5DD2877F-E30C-42F1-9192-4E80DB983FA8}.Release|Any CPU.ActiveCfg = Release|Any CPU {5DD2877F-E30C-42F1-9192-4E80DB983FA8}.Release|Any CPU.Build.0 = Release|Any CPU + {50E0145D-2C1B-4C9D-B408-DB2C160F1E71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50E0145D-2C1B-4C9D-B408-DB2C160F1E71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50E0145D-2C1B-4C9D-B408-DB2C160F1E71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50E0145D-2C1B-4C9D-B408-DB2C160F1E71}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8E9FAE0B-D427-4A36-B173-8F5EAB93B47F} = {00434BF0-6DBC-4D10-A4E1-CCCE5AAFFCC0} {C3EB3DA3-79E1-483E-A838-CF8A563BF0CB} = {00434BF0-6DBC-4D10-A4E1-CCCE5AAFFCC0} {38032149-4A67-4856-8854-00F400CBA388} = {27A079ED-6B3F-4A5D-BC98-647A379F5864} {5DD2877F-E30C-42F1-9192-4E80DB983FA8} = {27A079ED-6B3F-4A5D-BC98-647A379F5864} + {50E0145D-2C1B-4C9D-B408-DB2C160F1E71} = {27A079ED-6B3F-4A5D-BC98-647A379F5864} EndGlobalSection EndGlobal diff --git a/examples/Webhooks/Program.cs b/examples/Webhooks/Program.cs new file mode 100644 index 0000000..2a27562 --- /dev/null +++ b/examples/Webhooks/Program.cs @@ -0,0 +1,62 @@ +using System.Text; +using SumUp; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(_ => new SumUpClient()); + +var app = builder.Build(); + +app.MapPost("/webhooks/sumup", async (HttpRequest request, SumUpClient client) => +{ + using var reader = new StreamReader(request.Body, Encoding.UTF8); + var body = await reader.ReadToEndAsync(); + + try + { + var webhookHandler = client.CreateWebhookHandler(); + var eventNotification = webhookHandler.VerifyAndParse( + request.Headers[WebhookConstants.SignatureHeader].ToString(), + request.Headers[WebhookConstants.TimestampHeader].ToString(), + body); + + switch (eventNotification) + { + case CheckoutCreatedEvent checkoutCreated: + { + var checkout = await checkoutCreated.FetchObjectAsync(); + Console.WriteLine($"Received checkout.created for checkout {checkout?.Id}"); + break; + } + case CheckoutProcessedEvent checkoutProcessed: + { + var checkout = await checkoutProcessed.FetchObjectAsync(); + Console.WriteLine($"Received checkout.processed for checkout {checkout?.Id}"); + break; + } + case MemberCreatedEvent memberCreated: + { + var member = await memberCreated.FetchObjectAsync(); + Console.WriteLine($"Received member.created for member {member?.Id}"); + break; + } + default: + Console.WriteLine($"Received webhook {eventNotification.Type} for object {eventNotification.Object.Id}"); + break; + } + + return Results.Ok(); + } + catch (WebhookException exception) + { + Console.Error.WriteLine($"Webhook verification failed: {exception.Message}"); + return Results.BadRequest(new { error = exception.Message }); + } +}); + +app.MapGet("/", () => Results.Text("POST SumUp webhooks to /webhooks/sumup")); + +Console.WriteLine("Listening on http://localhost:5000"); +Console.WriteLine($"Set {WebhookConstants.SecretEnvironmentVariable} and SUMUP_ACCESS_TOKEN before sending live webhooks."); + +await app.RunAsync("http://localhost:5000"); diff --git a/examples/Webhooks/Webhooks.csproj b/examples/Webhooks/Webhooks.csproj new file mode 100644 index 0000000..2e51e55 --- /dev/null +++ b/examples/Webhooks/Webhooks.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/SumUp.Tests/WebhooksTests.cs b/src/SumUp.Tests/WebhooksTests.cs new file mode 100644 index 0000000..35602b4 --- /dev/null +++ b/src/SumUp.Tests/WebhooksTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace SumUp.Tests; + +public class WebhooksTests +{ + private static readonly DateTimeOffset FixedNow = new(2026, 4, 12, 10, 0, 0, TimeSpan.Zero); + + [Fact] + public void Verify_AcceptsValidSignature() + { + const string body = """{"id":"evt_123","type":"checkout.created"}"""; + var signature = SignPayload("whsec_test", FixedNow.ToUnixTimeSeconds(), body); + + var handler = new WebhookHandler("whsec_test"); + + handler.Verify(signature, FixedNow.ToUnixTimeSeconds().ToString(), body, FixedNow); + } + + [Fact] + public void Verify_RejectsExpiredTimestamp() + { + const string body = """{"id":"evt_123","type":"checkout.created"}"""; + var timestamp = FixedNow - WebhookConstants.DefaultTolerance - TimeSpan.FromSeconds(1); + var signature = SignPayload("whsec_test", timestamp.ToUnixTimeSeconds(), body); + + var handler = new WebhookHandler("whsec_test"); + + Assert.Throws(() => + handler.Verify(signature, timestamp.ToUnixTimeSeconds().ToString(), body, FixedNow)); + } + + [Fact] + public void Verify_RejectsInvalidSignature() + { + var handler = new WebhookHandler("whsec_test"); + + Assert.Throws(() => + handler.Verify("v1=deadbeef", FixedNow.ToUnixTimeSeconds().ToString(), "{}", FixedNow)); + } + + [Fact] + public void Verify_RejectsInvalidTimestamp() + { + var handler = new WebhookHandler("whsec_test"); + + Assert.Throws(() => + handler.Verify("v1=deadbeef", "nope", "{}", FixedNow)); + } + + [Fact] + public void Verify_RequiresConfiguredSecret() + { + var handler = new WebhookHandler(); + + Assert.Throws(() => + handler.Verify("v1=deadbeef", FixedNow.ToUnixTimeSeconds().ToString(), "{}", FixedNow)); + } + + [Fact] + public void Parse_ReturnsTypedKnownEvent() + { + var eventNotification = new WebhookHandler("whsec_test").Parse(CheckoutCreatedPayload); + + var checkoutCreated = Assert.IsType(eventNotification); + Assert.Equal("checkout.created", checkoutCreated.Type); + Assert.Equal("checkout", checkoutCreated.Object.Type); + } + + [Fact] + public void Parse_ReturnsGenericEventForUnknownType() + { + const string body = """ + { + "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" + } + } + """; + + var eventNotification = new WebhookHandler("whsec_test").Parse(body); + + Assert.IsType(eventNotification); + Assert.Equal("something.else", eventNotification.Type); + } + + [Fact] + public void Parse_RejectsMalformedJson() + { + var handler = new WebhookHandler("whsec_test"); + + Assert.Throws(() => handler.Parse("{")); + } + + [Fact] + public void Parse_RejectsObjectTypeMismatch() + { + const string body = """ + { + "id": "evt_123", + "type": "checkout.created", + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "mem_123", + "type": "member", + "url": "https://api.sumup.com/v0.1/members/mem_123" + } + } + """; + + var handler = new WebhookHandler("whsec_test"); + + Assert.Throws(() => handler.Parse(body)); + } + + [Fact] + public void ClientCanCreateBoundWebhookHandler() + { + using var client = new SumUpClient(new SumUpClientOptions + { + AccessToken = "test-token" + }); + + var handler = client.CreateWebhookHandler("whsec_test"); + + Assert.Equal("whsec_test", handler.Secret); + Assert.Equal(WebhookConstants.DefaultTolerance, handler.Tolerance); + } + + [Fact] + public async Task VerifyAndParse_BindsClientAndFetchesObject() + { + using var accessTokenScope = new EnvironmentVariableScope("SUMUP_ACCESS_TOKEN", null); + var transport = new RecordingHttpMessageHandler(_ => + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "id": "chk_123", + "amount": 10.0, + "checkout_reference": "ref_123", + "currency": "EUR", + "date": "2026-04-11T10:00:00Z", + "description": "Test payment", + "merchant_code": "MC123", + "status": "PENDING" + } + """, + Encoding.UTF8, + "application/json") + }; + }); + + using var httpClient = new HttpClient(transport) + { + BaseAddress = new Uri("https://mocked.sumup.test/") + }; + + using var client = new SumUpClient(new SumUpClientOptions + { + HttpClient = httpClient, + AccessToken = "test-token" + }); + + var signature = SignPayload("whsec_test", FixedNow.ToUnixTimeSeconds(), CheckoutCreatedPayload); + var eventNotification = client.CreateWebhookHandler("whsec_test") + .VerifyAndParse(signature, FixedNow.ToUnixTimeSeconds().ToString(), CheckoutCreatedPayload, FixedNow); + + var checkoutCreated = Assert.IsType(eventNotification); + var checkout = await checkoutCreated.FetchObjectAsync(); + + Assert.NotNull(checkout); + Assert.Equal("chk_123", checkout!.Id); + Assert.Equal(Currency.Eur, checkout.Currency); + Assert.Equal("https://api.sumup.com/v0.1/checkouts/chk_123", transport.LastRequest?.RequestUri?.AbsoluteUri); + Assert.Equal("Bearer test-token", transport.LastRequest?.Headers.Authorization?.ToString()); + } + + private const string CheckoutCreatedPayload = """ + { + "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" + } + } + """; + + private static string SignPayload(string secret, long timestamp, string body) + { + var payload = Encoding.UTF8.GetBytes($"{WebhookConstants.SignatureVersion}:{timestamp}:{body}"); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var digest = hmac.ComputeHash(payload); + return $"{WebhookConstants.SignatureVersion}={Convert.ToHexString(digest).ToLowerInvariant()}"; + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _previousValue; + + internal EnvironmentVariableScope(string name, string? value) + { + _name = name; + _previousValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previousValue); + } + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + internal RecordingHttpMessageHandler(Func handler) + { + _handler = handler; + } + + internal HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_handler(request)); + } + } +} diff --git a/src/SumUp/Http/ApiClient.cs b/src/SumUp/Http/ApiClient.cs index 4982c5d..b8c9efb 100644 --- a/src/SumUp/Http/ApiClient.cs +++ b/src/SumUp/Http/ApiClient.cs @@ -96,6 +96,88 @@ internal HttpContent CreateContent(object body, string? contentType) } } + internal TModel? GetAbsolute( + string absoluteUrl, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + where TModel : class + { + var request = new HttpRequestMessage(HttpMethod.Get, absoluteUrl); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/problem+json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd(_options.UserAgent); + RuntimeHeaders.Apply(request.Headers); + + var effectiveCancellationToken = CreateCancellationToken(cancellationToken, requestOptions, out var timeoutScope); + try + { + ApplyAuthorizationHeaderAsync(request, effectiveCancellationToken, requestOptions).GetAwaiter().GetResult(); + + using var response = _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + effectiveCancellationToken).GetAwaiter().GetResult(); + + if (!response.IsSuccessStatusCode) + { + var responseBody = response.Content is null + ? null + : ReadContentAsStringAsync(response.Content, effectiveCancellationToken).GetAwaiter().GetResult(); + var fallbackError = TryDeserialize(responseBody); + throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri); + } + + using var stream = ReadContentAsStreamAsync(response.Content!, effectiveCancellationToken).GetAwaiter().GetResult(); + return JsonSerializer.Deserialize(stream, _serializerOptions); + } + finally + { + timeoutScope?.Dispose(); + } + } + + internal async Task GetAbsoluteAsync( + string absoluteUrl, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + where TModel : class + { + var request = new HttpRequestMessage(HttpMethod.Get, absoluteUrl); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/problem+json")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.UserAgent.ParseAdd(_options.UserAgent); + RuntimeHeaders.Apply(request.Headers); + + var effectiveCancellationToken = CreateCancellationToken(cancellationToken, requestOptions, out var timeoutScope); + try + { + await ApplyAuthorizationHeaderAsync(request, effectiveCancellationToken, requestOptions).ConfigureAwait(false); + + using var response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + effectiveCancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var responseBody = response.Content is null + ? null + : await ReadContentAsStringAsync(response.Content, effectiveCancellationToken).ConfigureAwait(false); + var fallbackError = TryDeserialize(responseBody); + throw new ApiException(response.StatusCode, fallbackError, responseBody, response.RequestMessage?.RequestUri); + } + + using var stream = await ReadContentAsStreamAsync(response.Content!, effectiveCancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, _serializerOptions, effectiveCancellationToken).ConfigureAwait(false); + } + finally + { + timeoutScope?.Dispose(); + } + } + internal static Task ReadContentAsStringAsync(HttpContent content, CancellationToken cancellationToken) { #if NETSTANDARD2_0 diff --git a/src/SumUp/SumUpClient.cs b/src/SumUp/SumUpClient.cs index 3942498..bb6533d 100644 --- a/src/SumUp/SumUpClient.cs +++ b/src/SumUp/SumUpClient.cs @@ -1,5 +1,7 @@ using System; using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using SumUp.Http; namespace SumUp; @@ -35,6 +37,34 @@ public SumUpClient(SumUpClientOptions? options = null) partial void InitializeGeneratedClients(ApiClient apiClient); + /// + /// Creates a webhook handler bound to this client for typed event parsing and object fetches. + /// + /// The webhook signing secret. Falls back to when omitted. + /// The allowed clock skew when validating webhook timestamps. + public WebhookHandler CreateWebhookHandler(string? secret = null, TimeSpan? tolerance = null) + { + return new WebhookHandler(secret, tolerance, this); + } + + internal TModel? FetchWebhookObject( + string absoluteUrl, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + where TModel : class + { + return _apiClient.GetAbsolute(absoluteUrl, requestOptions, cancellationToken); + } + + internal Task FetchWebhookObjectAsync( + string absoluteUrl, + RequestOptions? requestOptions = null, + CancellationToken cancellationToken = default) + where TModel : class + { + return _apiClient.GetAbsoluteAsync(absoluteUrl, requestOptions, cancellationToken); + } + public void Dispose() { if (_disposed) diff --git a/src/SumUp/Webhooks.cs b/src/SumUp/Webhooks.cs new file mode 100644 index 0000000..0140632 --- /dev/null +++ b/src/SumUp/Webhooks.cs @@ -0,0 +1,474 @@ +using System; +using System.Globalization; +using System.Runtime.Serialization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace SumUp; + +/// +/// Shared constants for SumUp webhook verification. +/// +public static class WebhookConstants +{ + /// + /// The header containing the signed payload digest. + /// + public const string SignatureHeader = "X-SumUp-Webhook-Signature"; + + /// + /// The header containing the Unix timestamp used for signing. + /// + public const string TimestampHeader = "X-SumUp-Webhook-Timestamp"; + + /// + /// The current webhook signing version. + /// + public const string SignatureVersion = "v1"; + + /// + /// The environment variable used when no webhook secret is passed explicitly. + /// + public const string SecretEnvironmentVariable = "SUMUP_WEBHOOK_SECRET"; + + /// + /// The default tolerance applied when validating webhook timestamps. + /// + public static readonly TimeSpan DefaultTolerance = TimeSpan.FromMinutes(5); +} + +/// +/// Base exception for webhook verification and parsing failures. +/// +public class WebhookException : Exception +{ + public WebhookException(string message) + : base(message) + { + } + + public WebhookException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Raised when webhook verification is attempted without a configured secret. +/// +public sealed class WebhookSecretMissingException : WebhookException +{ + public WebhookSecretMissingException() + : base($"Webhook secret is not configured. Pass a secret explicitly or set {WebhookConstants.SecretEnvironmentVariable}.") + { + } +} + +/// +/// Raised when the webhook timestamp header is missing or malformed. +/// +public class WebhookTimestampException : WebhookException +{ + public WebhookTimestampException(string message) + : base(message) + { + } + + public WebhookTimestampException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Raised when the webhook signature header is missing or invalid. +/// +public class WebhookSignatureException : WebhookException +{ + public WebhookSignatureException(string message) + : base(message) + { + } +} + +/// +/// Raised when the webhook timestamp falls outside the configured tolerance. +/// +public sealed class WebhookSignatureExpiredException : WebhookTimestampException +{ + public WebhookSignatureExpiredException() + : base("Webhook timestamp is outside the allowed tolerance.") + { + } +} + +/// +/// Raised when the webhook body cannot be parsed into a valid event notification. +/// +public sealed class WebhookPayloadException : WebhookException +{ + public WebhookPayloadException(string message) + : base(message) + { + } + + public WebhookPayloadException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Known SumUp webhook event types. +/// +[JsonConverter(typeof(EnumMemberJsonConverterFactory))] +public enum WebhookEventType +{ + [EnumMember(Value = "checkout.created")] + CheckoutCreated, + [EnumMember(Value = "checkout.processed")] + CheckoutProcessed, + [EnumMember(Value = "checkout.failed")] + CheckoutFailed, + [EnumMember(Value = "checkout.terminated")] + CheckoutTerminated, + [EnumMember(Value = "member.created")] + MemberCreated, + [EnumMember(Value = "member.removed")] + MemberRemoved, +} + +/// +/// Reference to the SumUp resource associated with a webhook event. +/// +public sealed class WebhookObjectReference +{ + /// + /// Gets the resource identifier. + /// + [JsonPropertyName("id")] + public string Id { get; init; } = default!; + + /// + /// Gets the resource type. + /// + [JsonPropertyName("type")] + public string Type { get; init; } = default!; + + /// + /// Gets the absolute URL for the referenced resource. + /// + [JsonPropertyName("url")] + public string Url { get; init; } = default!; +} + +/// +/// Generic SumUp webhook event envelope. +/// +public class WebhookEvent +{ + private SumUpClient? _client; + + /// + /// Gets the webhook event identifier. + /// + [JsonPropertyName("id")] + public string Id { get; init; } = default!; + + /// + /// Gets the raw webhook event type. + /// + [JsonPropertyName("type")] + public string Type { get; init; } = default!; + + /// + /// Gets the timestamp at which the webhook event was created. + /// + [JsonPropertyName("created_at")] + public DateTimeOffset CreatedAt { get; init; } + + /// + /// Gets the referenced SumUp object. + /// + [JsonPropertyName("object")] + public WebhookObjectReference Object { get; init; } = default!; + + internal void BindClient(SumUpClient? client) + { + _client = client; + } + + internal SumUpClient RequireClient() + { + return _client ?? throw new InvalidOperationException("This webhook event is not bound to a SumUpClient instance."); + } +} + +/// +/// Base class for webhook events whose referenced object can be fetched from the API. +/// +/// The API model returned by the referenced object URL. +public abstract class WebhookEvent : WebhookEvent where TModel : class +{ + /// + /// Fetches the resource referenced by this webhook event. + /// + /// Optional per-request overrides. + /// Token used to cancel the request. + public TModel? FetchObject(RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return RequireClient().FetchWebhookObject(Object.Url, requestOptions, cancellationToken); + } + + /// + /// Fetches the resource referenced by this webhook event asynchronously. + /// + /// Optional per-request overrides. + /// Token used to cancel the request. + public Task FetchObjectAsync(RequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return RequireClient().FetchWebhookObjectAsync(Object.Url, requestOptions, cancellationToken); + } +} + +/// +/// Event emitted when a checkout is created. +/// +public sealed class CheckoutCreatedEvent : WebhookEvent +{ +} + +/// +/// Event emitted when a checkout is processed. +/// +public sealed class CheckoutProcessedEvent : WebhookEvent +{ +} + +/// +/// Event emitted when a checkout processing attempt fails. +/// +public sealed class CheckoutFailedEvent : WebhookEvent +{ +} + +/// +/// Event emitted when a checkout is terminated. +/// +public sealed class CheckoutTerminatedEvent : WebhookEvent +{ +} + +/// +/// Event emitted when a member is created. +/// +public sealed class MemberCreatedEvent : WebhookEvent +{ +} + +/// +/// Event emitted when a member is removed. +/// +public sealed class MemberRemovedEvent : WebhookEvent +{ +} + +/// +/// Verifies and parses incoming SumUp webhook notifications. +/// +public sealed class WebhookHandler +{ + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + private readonly byte[]? _secretBytes; + private readonly SumUpClient? _client; + + /// + /// Initializes a new webhook handler. + /// + /// The webhook signing secret. Falls back to when omitted. + /// The allowed clock skew when validating the timestamp header. + /// Optional client bound to parsed events for fetch operations. + public WebhookHandler(string? secret = null, TimeSpan? tolerance = null, SumUpClient? client = null) + { + Secret = secret ?? Environment.GetEnvironmentVariable(WebhookConstants.SecretEnvironmentVariable); + Tolerance = tolerance ?? WebhookConstants.DefaultTolerance; + _secretBytes = Secret is null ? null : Encoding.UTF8.GetBytes(Secret); + _client = client; + } + + /// + /// Gets the configured webhook signing secret, if any. + /// + public string? Secret { get; } + + /// + /// Gets the allowed clock skew applied during timestamp verification. + /// + public TimeSpan Tolerance { get; } + + /// + /// Verifies the webhook signature and timestamp headers for a payload. + /// + /// The value of . + /// The value of . + /// The raw request body. + /// Optional current time override used for testing. + public void Verify(string? signatureHeader, string? timestampHeader, string body, DateTimeOffset? now = null) + { + Verify(signatureHeader, timestampHeader, Encoding.UTF8.GetBytes(body), now); + } + + /// + /// Verifies the webhook signature and timestamp headers for a payload. + /// + /// The value of . + /// The value of . + /// The raw request body. + /// Optional current time override used for testing. + public void Verify(string? signatureHeader, string? timestampHeader, ReadOnlySpan body, DateTimeOffset? now = null) + { + if (_secretBytes is null) + { + throw new WebhookSecretMissingException(); + } + + if (string.IsNullOrWhiteSpace(signatureHeader)) + { + throw new WebhookSignatureException("Missing webhook signature header."); + } + + if (string.IsNullOrWhiteSpace(timestampHeader)) + { + throw new WebhookTimestampException("Missing webhook timestamp header."); + } + + if (!long.TryParse(timestampHeader, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixTimestamp)) + { + throw new WebhookTimestampException("Webhook timestamp header is invalid."); + } + + var timestamp = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp); + var effectiveNow = now ?? DateTimeOffset.UtcNow; + if ((effectiveNow - timestamp).Duration() > Tolerance) + { + throw new WebhookSignatureExpiredException(); + } + + var separatorIndex = signatureHeader.IndexOf('='); + if (separatorIndex <= 0 || separatorIndex == signatureHeader.Length - 1) + { + throw new WebhookSignatureException("Webhook signature header has an invalid format."); + } + + var version = signatureHeader[..separatorIndex]; + var providedDigest = signatureHeader[(separatorIndex + 1)..]; + if (!string.Equals(version, WebhookConstants.SignatureVersion, StringComparison.Ordinal)) + { + throw new WebhookSignatureException("Unsupported webhook signature version."); + } + + var expectedDigest = SignPayload(_secretBytes, unixTimestamp, body); + var providedDigestBytes = Encoding.ASCII.GetBytes(providedDigest); + var expectedDigestBytes = Encoding.ASCII.GetBytes(expectedDigest); + if (!CryptographicOperations.FixedTimeEquals(providedDigestBytes, expectedDigestBytes)) + { + throw new WebhookSignatureException("Webhook signature is invalid."); + } + } + + /// + /// Parses a webhook payload into the most specific known event model. + /// + /// The raw webhook payload. + public WebhookEvent Parse(string body) + { + try + { + using var document = JsonDocument.Parse(body); + var eventType = document.RootElement.TryGetProperty("type", out var typeProperty) && typeProperty.ValueKind == JsonValueKind.String + ? typeProperty.GetString() + : null; + + var notification = eventType switch + { + "checkout.created" => DeserializeAndValidate(body, "checkout"), + "checkout.processed" => DeserializeAndValidate(body, "checkout"), + "checkout.failed" => DeserializeAndValidate(body, "checkout"), + "checkout.terminated" => DeserializeAndValidate(body, "checkout"), + "member.created" => DeserializeAndValidate(body, "member"), + "member.removed" => DeserializeAndValidate(body, "member"), + _ => JsonSerializer.Deserialize(body, SerializerOptions) + }; + + if (notification is null) + { + throw new WebhookPayloadException("Webhook payload could not be deserialized."); + } + + if (notification.Object is null) + { + throw new WebhookPayloadException("Webhook payload is missing the referenced object."); + } + + notification.BindClient(_client); + return notification; + } + catch (JsonException exception) + { + throw new WebhookPayloadException("Webhook payload is not valid JSON.", exception); + } + } + + /// + /// Verifies the webhook signature and then parses the payload. + /// + /// The value of . + /// The value of . + /// The raw request body. + /// Optional current time override used for testing. + public WebhookEvent VerifyAndParse(string? signatureHeader, string? timestampHeader, string body, DateTimeOffset? now = null) + { + Verify(signatureHeader, timestampHeader, body, now); + return Parse(body); + } + + private static string SignPayload(byte[] secret, long unixTimestamp, ReadOnlySpan body) + { + var prefix = Encoding.UTF8.GetBytes($"{WebhookConstants.SignatureVersion}:{unixTimestamp}:"); + var payload = new byte[prefix.Length + body.Length]; + prefix.CopyTo(payload, 0); + body.CopyTo(payload.AsSpan(prefix.Length)); + + using var hmac = new HMACSHA256(secret); + var digest = hmac.ComputeHash(payload); + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + private static TEvent DeserializeAndValidate(string body, string expectedObjectType) + where TEvent : WebhookEvent + { + var notification = JsonSerializer.Deserialize(body, SerializerOptions) + ?? throw new WebhookPayloadException("Webhook payload could not be deserialized."); + + if (notification.Object is null) + { + throw new WebhookPayloadException("Webhook payload is missing the referenced object."); + } + + if (!string.Equals(notification.Object.Type, expectedObjectType, StringComparison.OrdinalIgnoreCase)) + { + throw new WebhookPayloadException( + $"Webhook payload object type '{notification.Object.Type}' does not match event '{notification.Type}'."); + } + + return notification; + } +}