C# webhook receiver example: ASP.NET Core and HMAC-SHA256

A C# webhook receiver in ASP.NET Core: bind the raw body as a Stream, HMAC-SHA256 the timestamp and bytes, and check each sume-v1 entry in fixed time.

6 min readSume
All posts

A C# webhook receiver is an ASP.NET Core endpoint that reads the raw request body before anything parses it, checks the sender's signature over those exact bytes, and answers 2xx quickly. In a minimal API, bind the body as a Stream, compute HMACSHA256.HashData(secret, bytes) over what the sender signed, and compare with CryptographicOperations.FixedTimeEquals. For a Sume webhook, the signed bytes are <timestamp>.<raw_body>, and any sume-v1= entry in the signature header may match.

.NET facts come from Microsoft Learn: parameter binding, responses, HMACSHA256.HashData, FixedTimeEquals, and Convert.FromHexString. Sume facts come from Run webhooks, Webhooks, and Verifying webhooks. All were read on 2026-09-27. Sume's SDK, @sume-com/sdk, is TypeScript, so a .NET receiver implements the published scheme itself.

Why read the body as a Stream instead of binding a model?

Because a model parameter consumes the body as JSON, and the bytes are gone. A minimal API parameter such as (Person person) binds from the body as JSON; the request body isn't buffered by default, and once read, it isn't rewindable and can't be read again. A parsed and re-serialized body does not verify, since key order and whitespace are part of what Sume signed. Bind a Stream instead, which is the same object as HttpRequest.Body, read it inside the handler, verify, then deserialize those same bytes.

From Microsoft Learn's parameter binding, HMACSHA256.HashData, FixedTimeEquals, and Convert.FromHexString pages and Sume's Run webhooks and Webhooks, read 2026-09-27.
StepSume rule.NET
Raw bodyVerify the raw bytes before any JSON parseA Stream body parameter, read with ReadAtLeastAsync
Headersx-sume-webhook-timestamp, x-sume-webhook-signaturereq.Headers["x-sume-webhook-signature"]
MACHMAC-SHA256 over <timestamp>.<raw_body>HMACSHA256.HashData(key, source)
Signaturesume-v1=<hex>; during a rotation, one entry per live secret, comma-separatedSplit on commas; Convert.FromHexString throws FormatException on non-hex input or an odd length
CompareConstant time; accept any matching entryCryptographicOperations.FixedTimeEquals: time depends on the length, not the values

How do I compute and check HMAC-SHA256 in C#?

Hash once, then check every entry: for 24 hours after a secret rotation Sume signs with both secrets, newest first, and a check that compares the whole header fails on every delivery in that window. Reject timestamps outside your replay window first; Sume calls five minutes a reasonable default. Microsoft's page lists ArgumentNullException for a null key but no exception for an empty one, so the function refuses an empty secret itself, and an unset variable can never verify a forged delivery.

static bool VerifySume(byte[] body, string? ts, string? header, byte[] secret)
{
    if (secret.Length == 0 || !long.TryParse(ts, out var t)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
    var signedBytes = Encoding.UTF8.GetBytes($"{t}.").Concat(body).ToArray();
    var expected = HMACSHA256.HashData(secret, signedBytes); // over the raw bytes
    var ok = false;
    foreach (var entry in (header ?? "").Split(',')) // two entries during a rotation
    {
        var e = entry.Trim();
        if (!e.StartsWith("sume-v1=", StringComparison.Ordinal)) continue;
        byte[] got;
        try { got = Convert.FromHexString(e["sume-v1=".Length..]); }
        catch (FormatException) { continue; } // not hex: not a match
        if (CryptographicOperations.FixedTimeEquals(expected, got)) ok = true;
    }
    return ok;
}

What does the ASP.NET Core endpoint look like?

It follows Microsoft's own pattern for reading a Stream body with a size cap, set above 1 MiB because Sume's run webhook body limit is 1,048,576 bytes. The stream isn't usable outside the handler, so read it there. Read the signing secret on Sume's Webhooks dashboard tab, or from GET /v1/webhooks/signing-secret with a key carrying account:read, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET.

var app = WebApplication.CreateBuilder(args).Build();
var secret = Encoding.UTF8.GetBytes(
    Environment.GetEnvironmentVariable("SUME_COM_WEBHOOK_SIGNING_SECRET") ?? "");

app.MapPost("/hooks/sume", async (HttpRequest req, Stream body) =>
{
    const int max = 2 * 1024 * 1024; // above Sume's 1 MiB webhook body limit
    if (req.ContentLength is not null && req.ContentLength > max)
        return Results.StatusCode(413);
    var buffer = new byte[(int?)req.ContentLength ?? (max + 1)];
    var read = await body.ReadAtLeastAsync(buffer, buffer.Length, throwOnEndOfStream: false);
    if (read > max) return Results.StatusCode(413);
    var raw = buffer[..read];
    if (!VerifySume(raw, req.Headers["x-sume-webhook-timestamp"],
            req.Headers["x-sume-webhook-signature"], secret))
        return Results.StatusCode(401);
    await RecordOnce(raw); // your store: parse these verified bytes, dedupe, queue
    return Results.NoContent(); // 204 well inside Sume's 10 s
});
app.Run();

Why does every signature fail?

In ASP.NET Core, most often because something read the body first: a model parameter, or ReadFromJsonAsync on the request, and the stream can't be read again. Verify the bytes, then deserialize them. For the other causes, a different secret or a skewed clock, Sume webhook not received? walks through the checks.

What should the receiver do after it verifies?

Record the event durably and answer: any 2xx counts, each attempt gets 10 seconds, and Sume makes up to 10 attempts, so queue slow work instead of doing it before the 204. Dedupe on request_id for run webhooks and on job_id for generation-job webhooks. Signed webhooks for Sume video runs covers retries and Redeliver.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume