Webhooks: signature verify and typed handlers
Paylera.AspNetCore ships an inbound webhook receiver that handles the
fiddly parts: raw-body capture before any middleware mutates it,
HMAC-SHA-256 verification, replay-window enforcement, and dispatch to
typed or class-based handlers. You write the business logic.
Mount the endpoint
app.MapPayleraWebhooks("/webhooks/paylera", opts =>{ opts.SigningSecret = builder.Configuration["Paylera:WebhookSecret"]; opts.ToleranceSeconds = 300;});The route is a single POST at the prefix you pass. Don’t put auth
middleware in front of it — webhook delivery is authenticated by HMAC
signature, not by a bearer token.
Signature format
Paylera sets a Paylera-Signature header that looks like
t=1714000000,v1=base64hmac. The receiver computes
HMAC-SHA-256(secret, "${t}.${raw_body_bytes}") and compares against
v1 in constant time. The timestamp must be within ToleranceSeconds
(default 300) of the receiver’s clock — otherwise the call is rejected
as a replay.
Behaviour matrix:
| Outcome | Status |
|---|---|
| Bad or missing or replay-window-violating signature | 401 |
| No handler registered for the event type | 200 (Paylera does not retry) |
| Handler returned successfully | 200 |
| Handler threw | 500 (Paylera retries per its delivery policy) |
Inline handlers
app.MapPayleraWebhooks("/webhooks/paylera", opts =>{ opts.SigningSecret = builder.Configuration["Paylera:WebhookSecret"];
opts.On<InvoicePaidEvent>("invoice.paid", async (ev, ctx) => { await fulfilment.MarkPaidAsync(ev.Id, ctx.RequestAborted); });
opts.On<SubscriptionCanceledEvent>("subscription.canceled", async (ev, ctx) => { await dunning.OnCancelAsync(ev.Data, ctx.RequestAborted); });});The first generic parameter is the event envelope you want bound to;
PayleraWebhookEvent is the fallthrough for events without a typed
record.
Class-based handlers
For testable, DI-friendly handlers, annotate methods with
[PayleraWebhookHandler] and register the class:
public sealed class PayleraWebhookHandlers{ private readonly IFulfilmentService _fulfilment; private readonly ILogger<PayleraWebhookHandlers> _logger;
public PayleraWebhookHandlers(IFulfilmentService f, ILogger<PayleraWebhookHandlers> l) { _fulfilment = f; _logger = l; }
[PayleraWebhookHandler("invoice.paid")] public Task OnInvoicePaid(InvoicePaidEvent ev, CancellationToken ct) => _fulfilment.MarkPaidAsync(ev.Id, ct);
[PayleraWebhookHandler("subscription.created")] [PayleraWebhookHandler("subscription.canceled")] public Task OnSubscriptionLifecycle(PayleraWebhookEvent ev) { _logger.LogInformation("subscription event {Type} for {Id}", ev.Type, ev.Id); return Task.CompletedTask; }}
builder.Services.AddPayleraWebhookHandlers<PayleraWebhookHandlers>();app.MapPayleraWebhooks("/webhooks/paylera", opts =>{ opts.SigningSecret = builder.Configuration["Paylera:WebhookSecret"];});The class is resolved from DI per request, so it can depend on scoped services. A single method can be annotated multiple times to bind to several event types.
Typed event envelopes
The package ships records for the high-traffic event families:
InvoicePaidEventInvoiceFailedEventSubscriptionCreatedEventSubscriptionCanceledEventPaymentSucceededEventPaymentFailedEventCustomerCreatedEventCustomerExternalRefDiscoveredEvent— back-link from the RevenueAttribution worker; matchedrevenue.*event id is exposed on the record.RevenueCapturedEventRevenueRefundedEventRevenueSubscriptionStartedEventRevenueSubscriptionRenewedEventRevenueSubscriptionCanceledEventRevenueSubscriptionGracePeriodEnteredEventRevenueSubscriptionRecoveredEventRevenueSubscriptionExpiredEventRevenueOneTimePurchaseEventRevenueUnknownCurrencySeenEvent
The 10 revenue.* records cover the multi-source revenue capture
family (Stripe / Toss / Apple App Store Server Notifications V2 /
Google Play RTDN) — see
Multi-source revenue capture
for the family overview and the money-bearing vs. lifecycle split.
Each carries the base Id, Type, Created_at, and a JsonElement Data
with the full aggregate payload. The long-tail event types fall through
to PayleraWebhookEvent (the base) — bind to it and inspect Type
yourself, then deserialise Data into the NSwag-generated DTOs from
Paylera.Sdk if you need typed fields.
opts.On<PayleraWebhookEvent>("plan.activated", async (ev, ctx) =>{ var plan = ev.Data.Deserialize<Plan>(); // ...});Secret rotation
When you rotate the signing secret, add the previous secret to
AcceptedSecrets for the 24-hour overlap window so deliveries signed
under either secret still verify:
opts.SigningSecret = "whsec_NEW_KEY";opts.AcceptedSecrets = new[] { "whsec_OLD_KEY" };After 24 hours, Paylera will only sign with the new secret and the previous one can be removed from the array.
Raw-body integrity
The receiver hashes the bytes the framework handed it — no JSON
re-parse, no whitespace normalisation. It reads HttpRequest.Body
before any other middleware can consume the stream, which is why the
receiver must be mapped on the outer application object rather than
buried under a JSON-body-parser middleware.
If you have a downstream middleware that also needs the raw body
(uncommon), the receiver re-emits the bytes onto a MemoryStream-backed
HttpRequest.Body after verification.
OpenTelemetry
Each delivery opens an Activity on Paylera.Webhook named
Paylera.Webhook.<EventType> (e.g. Paylera.Webhook.invoice.paid).
The span covers signature verification + handler dispatch. Attributes:
paylera.event_idpaylera.event_typepaylera.tenant_id
No-op when no OTel SDK is registered. See opentelemetry for the full attribute list.