ASP.NET minimal API: raw body and FixedTimeEquals for Sume webhooks

Take HttpRequest, not a bound model, in an ASP.NET minimal API. Read the raw body, verify sume-v1 with FixedTimeEquals and refuse an empty signing secret.

5 min readSume
All posts

In an ASP.NET Core minimal API, declare the handler parameter as HttpRequest, not as a record or class. A bound parameter makes the framework deserialize the JSON body, and once the body has been parsed and re-serialized you no longer have the bytes that Sume signed. Read the stream yourself, compute HMAC-SHA256 over <timestamp>.<raw_body>, and compare with CryptographicOperations.FixedTimeEquals.

Sume's webhook docs specify the headers (x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>), a replay tolerance of about five minutes, and a header that carries one sume-v1= entry per live secret during a rotation.

Program.cs

The program throws at startup if SUME_COM_WEBHOOK_SIGNING_SECRET is empty. It rejects a timestamp that does not parse, and it checks every entry in the signature header, so a rotation window does not cause failures.

using System.Security.Cryptography;
using System.Text;

var secret = Environment.GetEnvironmentVariable("SUME_COM_WEBHOOK_SIGNING_SECRET");
if (string.IsNullOrEmpty(secret))
    throw new InvalidOperationException("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");

var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/sume", async (HttpRequest req) =>
{
    using var reader = new StreamReader(req.Body, Encoding.UTF8);
    var raw = await reader.ReadToEndAsync();
    var ts = req.Headers["x-sume-webhook-timestamp"].ToString();
    var header = req.Headers["x-sume-webhook-signature"].ToString();
    if (!long.TryParse(ts, out var t)
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300)
        return Results.Unauthorized();
    var key = Encoding.UTF8.GetBytes(secret);
    var mac = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{t}.{raw}"));
    var want = Encoding.UTF8.GetBytes("sume-v1=" + Convert.ToHexString(mac).ToLowerInvariant());
    var ok = header.Split(',').Any(e =>
    {
        var got = Encoding.UTF8.GetBytes(e.Trim());
        return got.Length == want.Length && CryptographicOperations.FixedTimeEquals(got, want);
    });
    return ok ? Results.NoContent() : Results.Unauthorized();
});
app.Run();

What each piece guards against

Each piece guards against one failure that shows up in production.

Raw-body verification in a minimal API (Sume docs, read 2026-10-04)
PieceFailure it prevents
HttpRequest parameterA bound model re-serializes the body and breaks the HMAC
Throw on empty secretA missing variable in staging accepting any body
long.TryParse on the timestampA missing header throwing a 500
Math.Abs(...) > 300Replays outside the five-minute window
Hex lowercasedSume sends lowercase hex; ToHexString is uppercase
FixedTimeEquals after a length checkTiming leaks, and an exception on unequal lengths

What to add before production

The sample returns 204 without storing anything, to keep it short. In a real route, store job_id before you answer. Parse the verified body with System.Text.Json, and write the id to a table with a unique key, because Sume makes up to 10 attempts and Redeliver can repeat a terminal event.

Use the dashboard Send test while you build. It sends a signed webhook.test event to a URL that you type and never replays a real job, so a green check proves the secret and the raw-body handling without a paid render.

Caveats

  • Hex case matters. Convert.ToHexString returns uppercase, so the sample lowercases it before it builds the sume-v1= string.
  • If a reverse proxy rewrites or compresses bodies, verify against what reaches Kestrel and compare with a direct request.
  • Keep a job status poll available for jobs whose delivery never arrives. A webhook is an optimization, not the only recovery path.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume