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.

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.
| Piece | Failure it prevents |
|---|---|
| HttpRequest parameter | A bound model re-serializes the body and breaks the HMAC |
| Throw on empty secret | A missing variable in staging accepting any body |
| long.TryParse on the timestamp | A missing header throwing a 500 |
| Math.Abs(...) > 300 | Replays outside the five-minute window |
| Hex lowercased | Sume sends lowercase hex; ToHexString is uppercase |
| FixedTimeEquals after a length check | Timing 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.ToHexStringreturns uppercase, so the sample lowercases it before it builds thesume-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
- Attach a terminal to a run: ant sessions connect vs Sume jobs
The ant CLI can attach to a Managed Agents session. For Sume generation jobs, use sume jobs watch and MCP jobs_wait instead, and never resubmit a paid job.
- Audit logs without file names: what to log for Sume jobs
Claude's Compliance API Activity Feed stopped returning file names. For Sume jobs, log request ids and job ids, never media URLs or transcripts.
- Captions on a non-English avatar clip: language hint and script_text
For Spanish, French or German scripts, set captions language to a hint or auto and pass script_text so the words match your script, not a mis-heard transcript.
- Backgrounded MCP tool lost progress: resume with Sume jobs_wait
A Claude Code fix covers MCP progress dropped when a tool moves to the background. Do not rely on progress for Sume jobs: re-issue jobs_wait in slices.
Written by Sume