Intro

An ASP.NET Core webhook receiver is an untrusted public endpoint. It must authenticate the exact bytes the provider signed, reject replays when the provider supplies a timestamp, deduplicate events, and durably accept work before returning 2xx. Parsing and reserializing JSON before HMAC verification changes bytes and can invalidate the signature.

Raw-byte verification

using System.Security.Cryptography;
using System.Text;
 
app.MapPost("/webhooks/provider", async (
    HttpRequest request,
    IWebhookInbox inbox,
    IConfiguration configuration,
    CancellationToken ct) =>
{
    await using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer, ct);
    var body = buffer.ToArray();
 
    if (!request.Headers.TryGetValue("X-Signature", out var supplied) ||
        !Convert.TryFromHexString(supplied.ToString(), new byte[32], out var written) ||
        written != 32)
    {
        return Results.Unauthorized();
    }
 
    var secret = Encoding.UTF8.GetBytes(configuration["Webhooks:Secret"]!);
    var expected = HMACSHA256.HashData(secret, body);
    var actual = Convert.FromHexString(supplied.ToString());
 
    if (!CryptographicOperations.FixedTimeEquals(expected, actual))
    {
        return Results.Unauthorized();
    }
 
    var accepted = await inbox.StoreIfNewAsync(body, request.Headers, ct);
    return accepted ? Results.Accepted() : Results.Ok();
});

Adapt the signed payload construction to the provider contract: many providers sign timestamp + "." + rawBody, not the body alone. Validate timestamp skew before acceptance and store the provider event ID under a unique constraint. StoreIfNewAsync must commit the inbox record or durable queue message before the response is sent; background work can happen later.

References