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.

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.
| Step | Sume rule | .NET |
|---|---|---|
| Raw body | Verify the raw bytes before any JSON parse | A Stream body parameter, read with ReadAtLeastAsync |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature | req.Headers["x-sume-webhook-signature"] |
| MAC | HMAC-SHA256 over <timestamp>.<raw_body> | HMACSHA256.HashData(key, source) |
| Signature | sume-v1=<hex>; during a rotation, one entry per live secret, comma-separated | Split on commas; Convert.FromHexString throws FormatException on non-hex input or an odd length |
| Compare | Constant time; accept any matching entry | CryptographicOperations.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
- Run webhooks
- Webhooks
- Verifying webhooks
- ASP.NET Core: Parameter binding in Minimal API apps (read 2026-09-27)
- ASP.NET Core: Create responses in Minimal API applications (read 2026-09-27)
- HMACSHA256.HashData Method (read 2026-09-27)
- CryptographicOperations.FixedTimeEquals Method (read 2026-09-27)
- Convert.FromHexString Method (read 2026-09-27)
Related posts
More in Integrations
- Dify custom tool from OpenAPI: import the Sume API schema
Make a Dify custom tool from Sume's OpenAPI schema: trim it to three video operations, import it as a Swagger API tool, and keep the key secret.
- Discord bot AI video generation: defer, then edit the reply
Defer the Discord interaction within 3 seconds, submit POST /v1/videos with a callback_url, then edit the reply when Sume's job webhook arrives.
- Durable Functions wait for external event: AI video webhook
A Durable Functions orchestrator waits for an external event that your webhook function raises, racing a durable timer for runs that never POST.
- Express raw body for webhook signatures and the 100kb limit
Mount express.raw with type application/json and a limit above 1 MiB on the Sume webhook route, then pass the raw Buffer to verifyWebhook.
Written by Sume