Skip to content

Recipe Webhook Receiver

Mark Lauter edited this page Jun 13, 2026 · 3 revisions

Webhook Receiver

A minimal API endpoint that receives a webhook (Stripe, GitHub, Slack, Shopify, your own internal one), validates the signature, parses the payload, dispatches to a workflow, and acks. The work is real — signature verification has to happen, payloads have to be parsed and validated, downstream work has to be routed — and you want each step composable, testable, and visible in one place.

This is a focused variant of Recipe-Aspnet-Host-Integration. That recipe sets up the host-mode pattern: register the handler as a singleton via RequestHandler.Create<TReq, TRes>(sp), inject it into your endpoint, and each InvokeAsync call gets its own DI scope from the host. This page assumes you've seen it and concentrates on webhook-specific concerns: signature verification, raw-body capture, idempotency, and ack semantics.

When this recipe applies

You have an inbound webhook endpoint that:

  • Verifies an HMAC signature against a shared secret.
  • Parses the body into a typed payload (often a discriminated union).
  • Dispatches to one of several handlers based on payload type.
  • Returns 200 quickly so the provider stops retrying.

Each of those is a separate concern; each becomes one middleware. Plumber threads them together with a shared context, a per-request DI scope, and a typed request/response.

Project setup

dotnet new web -n Webhooks.Api
cd Webhooks.Api
dotnet add package MSL.Plumber.Pipeline

The request type

The pipeline needs three things on the request:

  • The raw body bytes — signature verification runs over the raw bytes, not the parsed JSON.
  • The headers — the signature lives in one of them.
  • A slot for the parsed payload — filled in by middleware.

A record makes that explicit:

public sealed record WebhookRequest(
    string Provider,
    ReadOnlyMemory<byte> Body,
    IReadOnlyDictionary<string, string> Headers);

The response type carries the HTTP status code and an optional body. The endpoint translates that into an IResult:

public sealed record WebhookResponse(int StatusCode, object? Body = null)
{
    public static readonly WebhookResponse Ok = new(200);
    public static readonly WebhookResponse Unauthorized = new(401, new { error = "invalid signature" });
    public static readonly WebhookResponse BadRequest = new(400, new { error = "malformed payload" });
}

The parsed payload doesn't live on the request type. It's added to context.Data by the parsing middleware so the dispatcher can read it without leaking parsing concerns into upstream code.

The pipeline

Three middleware, in order. Signature first (a 401 short-circuits the rest), then parsing (a 400 short-circuits the dispatcher), then dispatch.

internal static class WebhookPipeline
{
    public static RequestHandler<WebhookRequest, WebhookResponse> Configure(
        RequestHandler<WebhookRequest, WebhookResponse> handler) =>
        handler
            .Use<SignatureMiddleware>()
            .Use<ParsePayloadMiddleware>()
            .Use<DispatchMiddleware>();
}

internal static class WebhookDataKeys
{
    public const string Payload = "webhook.payload";
}

SignatureMiddleware

Reads the secret from configuration (per provider), computes the HMAC, compares it constant-time to the header value, and short-circuits with WebhookResponse.Unauthorized on mismatch.

internal sealed class SignatureMiddleware(
    RequestMiddleware<WebhookRequest, WebhookResponse> next,
    ILogger<SignatureMiddleware> logger,
    IOptionsMonitor<WebhookSecrets> secrets)
{
    public Task InvokeAsync(RequestContext<WebhookRequest, WebhookResponse> context)
    {
        var provider = context.Request.Provider;
        var secret = secrets.CurrentValue.GetSecret(provider);
        if (secret is null)
        {
            logger.LogWarning("no secret configured for provider {Provider}", provider);
            context.Response = WebhookResponse.Unauthorized;
            return Task.CompletedTask;
        }

        if (!context.Request.Headers.TryGetValue("X-Signature", out var sent))
        {
            context.Response = WebhookResponse.Unauthorized;
            return Task.CompletedTask;
        }

        var expected = ComputeHmac(secret, context.Request.Body.Span);
        if (!CryptographicOperations.FixedTimeEquals(
                Convert.FromHexString(sent),
                expected))
        {
            logger.LogWarning("signature mismatch for {Provider}", provider);
            context.Response = WebhookResponse.Unauthorized;
            return Task.CompletedTask;
        }

        return next(context);
    }

    private static byte[] ComputeHmac(string secret, ReadOnlySpan<byte> body)
    {
        var key = Encoding.UTF8.GetBytes(secret);
        return HMACSHA256.HashData(key, body);
    }
}

IOptionsMonitor<WebhookSecrets> is a singleton; constructor injection is fine. FixedTimeEquals keeps the comparison constant-time so a timing attack can't recover bytes of the signature.

The header name varies by provider (Stripe-Signature, X-Hub-Signature-256, X-Slack-Signature). For multi-provider receivers, switch on context.Request.Provider and pull the right header name; the verification logic is otherwise the same.

ParsePayloadMiddleware

Parses the JSON body into a typed payload and stashes it in context.Data. A failed parse short-circuits with WebhookResponse.BadRequest.

internal sealed class ParsePayloadMiddleware(
    RequestMiddleware<WebhookRequest, WebhookResponse> next,
    ILogger<ParsePayloadMiddleware> logger)
{
    public Task InvokeAsync(RequestContext<WebhookRequest, WebhookResponse> context)
    {
        try
        {
            var payload = JsonSerializer.Deserialize<WebhookPayload>(
                context.Request.Body.Span,
                JsonOptions.Default);

            if (payload is null)
            {
                context.Response = WebhookResponse.BadRequest;
                return Task.CompletedTask;
            }

            context.Data[WebhookDataKeys.Payload] = payload;
            return next(context);
        }
        catch (JsonException ex)
        {
            logger.LogWarning(ex, "malformed webhook body");
            context.Response = WebhookResponse.BadRequest;
            return Task.CompletedTask;
        }
    }
}

public abstract record WebhookPayload(string Type);
public sealed record OrderCreatedPayload(string OrderId, decimal Total) : WebhookPayload("order.created");
public sealed record OrderRefundedPayload(string OrderId, decimal Amount) : WebhookPayload("order.refunded");

JsonSerializer.Deserialize operating on ReadOnlySpan<byte> skips a UTF-8-to-string conversion. For high-throughput webhook endpoints that's a real win.

DispatchMiddleware

Reads the payload from context.Data, routes to the right handler, sets WebhookResponse.Ok on success.

internal sealed class DispatchMiddleware(
    RequestMiddleware<WebhookRequest, WebhookResponse> next)
{
    public async Task InvokeAsync(
        RequestContext<WebhookRequest, WebhookResponse> context,
        IOrderService orders)
    {
        if (!context.TryGetValue<WebhookPayload>(WebhookDataKeys.Payload, out var payload))
        {
            context.Response = WebhookResponse.BadRequest;
            return;
        }

        switch (payload)
        {
            case OrderCreatedPayload created:
                await orders.HandleCreatedAsync(created, context.CancellationToken);
                break;
            case OrderRefundedPayload refunded:
                await orders.HandleRefundedAsync(refunded, context.CancellationToken);
                break;
            default:
                // unknown payload — ack so the provider stops retrying.
                break;
        }

        context.Response = WebhookResponse.Ok;
        await next(context);
    }
}

IOrderService is scoped, so it goes on InvokeAsync (method injection) rather than in the constructor. A new instance is resolved from the per-request scope on every webhook call.

The endpoint

The endpoint reads the raw body, builds the request record, invokes the handler, and converts the response into an IResult:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<WebhookSecrets>(builder.Configuration.GetSection("Webhooks"));
builder.Services.AddScoped<IOrderService, OrderService>();

builder.Services.AddSingleton(sp =>
    WebhookPipeline.Configure(
        RequestHandler.Create<WebhookRequest, WebhookResponse>(sp)));

var app = builder.Build();

app.MapPost("/webhook/{provider}", async (
    string provider,
    HttpRequest http,
    RequestHandler<WebhookRequest, WebhookResponse> handler,
    CancellationToken cancellationToken) =>
{
    using var ms = new MemoryStream();
    await http.Body.CopyToAsync(ms, cancellationToken);

    var headers = http.Headers
        .ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);

    var request = new WebhookRequest(provider, ms.ToArray(), headers);
    var response = await handler.InvokeAsync(request, cancellationToken);

    return response is null
        ? Results.Problem("pipeline returned no response", statusCode: 500)
        : Results.Json(response.Body, statusCode: response.StatusCode);
});

app.Run();

A few details worth pulling out.

The body is read into memory before signature verification. Webhook bodies are small (rarely more than a few KB) and signature verification needs the bytes. For the rare endpoint that receives large payloads, switch to RecyclableMemoryStreamManager or stream straight into a hash function and re-read for parsing.

The provider route token tells the signature and parsing middleware which secret to use and which payload schema to expect. Having one endpoint per provider works equally well — the pipeline shape doesn't change.

Idempotency

Webhook providers retry. Same message ID will arrive multiple times — sometimes seconds apart, sometimes hours. The receiver decides what to do about it.

Tracking message IDs

The simplest reliable approach: track the message ID (most providers include one in a header) and short-circuit duplicates with 200 OK. A middleware between signature and parse:

internal sealed class IdempotencyMiddleware(
    RequestMiddleware<WebhookRequest, WebhookResponse> next)
{
    public async Task InvokeAsync(
        RequestContext<WebhookRequest, WebhookResponse> context,
        IIdempotencyStore store)
    {
        if (!context.Request.Headers.TryGetValue("X-Message-Id", out var id))
        {
            await next(context);
            return;
        }

        if (await store.SeenAsync(id, context.CancellationToken))
        {
            context.Response = WebhookResponse.Ok; // already processed; ack again.
            return;
        }

        await next(context);

        if (context.Response?.StatusCode == 200)
        {
            await store.RecordAsync(id, context.CancellationToken);
        }
    }
}

The store can be Redis, a database table with a unique index, or whatever fits your latency budget. Record the ID after a successful processing so a transient failure on the first attempt lets the retry come through.

Exactly-once boundaries

For exactly-once semantics across processing and storage, the receiver alone is not enough — you need transactional outbox or two-phase commit downstream. The webhook layer's job is to tell the provider "yes I got it" and to keep the same effect from being applied twice.

Ack semantics

Webhook providers retry on non-2xx responses. The status codes the pipeline returns matter:

Status When to use
200 OK Pipeline ran successfully (or processed successfully on a previous attempt).
400 Bad Request The body is malformed. The provider will keep retrying — log loudly.
401 Unauthorized Signature missing or mismatched. Often a misconfigured secret on either end.
500 Internal Server Error Unhandled exception. The provider will retry; you get a window to fix the underlying issue.

The pipeline above maps these explicitly. The endpoint translates whatever the pipeline returns into the corresponding HTTP response.

Catching unhandled exceptions

For unhandled exceptions, the endpoint catches at the Results.Problem line — InvokeAsync returning null, plus an outer try/catch if you want to log the exception before letting ASP.NET Core convert it to 500:

try
{
    var response = await handler.InvokeAsync(request, cancellationToken);
    return response is null
        ? Results.Problem("pipeline returned no response", statusCode: 500)
        : Results.Json(response.Body, statusCode: response.StatusCode);
}
catch (Exception ex)
{
    app.Logger.LogError(ex, "webhook pipeline failed for {Provider}", provider);
    return Results.Problem(statusCode: 500);
}

For structured logging across the pipeline, layer an error boundary middleware above signature verification — a top-of-chain middleware that catches everything below it and logs with the request ID before rethrowing.

Testing

Two test surfaces. The pipeline directly (fast, deterministic) and the endpoint via WebApplicationFactory<Program> (end-to-end, including the body capture and routing).

public sealed class WebhookPipelineTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> factory;

    public WebhookPipelineTests(WebApplicationFactory<Program> factory) =>
        this.factory = factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration(c =>
            c.AddInMemoryCollection([
                new("Webhooks:stripe", "test-secret"),
            ])));

    [Fact]
    public async Task ValidSignatureProducesOkAsync()
    {
        var body = """{"type":"order.created","orderId":"ord-1","total":42.0}"""u8.ToArray();
        var signature = ComputeHexHmac("test-secret", body);

        using var client = factory.CreateClient();
        using var content = new ByteArrayContent(body);
        content.Headers.Add("X-Signature", signature);

        var response = await client.PostAsync("/webhook/stripe", content);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    [Fact]
    public async Task BadSignatureProducesUnauthorizedAsync()
    {
        var body = """{"type":"order.created","orderId":"ord-1","total":42.0}"""u8.ToArray();

        using var client = factory.CreateClient();
        using var content = new ByteArrayContent(body);
        content.Headers.Add("X-Signature", "deadbeef");

        var response = await client.PostAsync("/webhook/stripe", content);

        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
    }

    private static string ComputeHexHmac(string secret, byte[] body) =>
        Convert.ToHexString(
            HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body));
}

A fixed signing secret in test configuration keeps the signature math deterministic. For more on WebApplicationFactory patterns, see Recipe-Aspnet-Host-Integration. For pipeline-only testing patterns, see Testing.

Tested against

  • .NET 10
  • ASP.NET Core 10
  • MSL.Plumber.Pipeline 3.*
  • Microsoft.AspNetCore.Mvc.Testing 10.0.0

See also

Clone this wiki locally