Skip to content

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:

OutcomeStatus
Bad or missing or replay-window-violating signature401
No handler registered for the event type200 (Paylera does not retry)
Handler returned successfully200
Handler threw500 (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:

  • InvoicePaidEvent
  • InvoiceFailedEvent
  • SubscriptionCreatedEvent
  • SubscriptionCanceledEvent
  • PaymentSucceededEvent
  • PaymentFailedEvent
  • CustomerCreatedEvent
  • CustomerExternalRefDiscoveredEvent — back-link from the RevenueAttribution worker; matched revenue.* event id is exposed on the record.
  • RevenueCapturedEvent
  • RevenueRefundedEvent
  • RevenueSubscriptionStartedEvent
  • RevenueSubscriptionRenewedEvent
  • RevenueSubscriptionCanceledEvent
  • RevenueSubscriptionGracePeriodEnteredEvent
  • RevenueSubscriptionRecoveredEvent
  • RevenueSubscriptionExpiredEvent
  • RevenueOneTimePurchaseEvent
  • RevenueUnknownCurrencySeenEvent

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_id
  • paylera.event_type
  • paylera.tenant_id

No-op when no OTel SDK is registered. See opentelemetry for the full attribute list.