Verify a Sume avatar video webhook in C# with FixedTimeEquals

C# and .NET verifier for Sume avatar video webhooks: HMACSHA256 over timestamp.body, FixedTimeEquals, 300 s window, empty secret refused.

5 min readSume
All posts

The short answer

Compute HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{ts}.{rawBody}")), build sume-v1= plus the lowercase hex, and compare it with CryptographicOperations.FixedTimeEquals against each comma-separated header entry. Microsoft's reference (read 2026-10-04) describes it as comparing two byte sequences in time that depends on length, not values.

The C# verifier

In an ASP.NET Core minimal API, read the body with await new StreamReader(ctx.Request.Body).ReadToEndAsync() before model binding, and pass the string in. Do not let a JSON deserializer run first, because Sume signs the raw body.

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

public static class SumeWebhook
{
    public static bool Verify(string secret, string ts, string header, string rawBody)
    {
        if (string.IsNullOrEmpty(secret)) return false;
        if (!long.TryParse(ts, out var t)) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
        var mac = HMACSHA256.HashData(
            Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{ts}.{rawBody}"));
        var want = Encoding.UTF8.GetBytes("sume-v1=" + Convert.ToHexString(mac).ToLowerInvariant());
        var ok = false;
        foreach (var entry in header.Split(','))
        {
            var got = Encoding.UTF8.GetBytes(entry.Trim());
            if (CryptographicOperations.FixedTimeEquals(want, got)) ok = true;
        }
        return ok;
    }
}

What Sume sends

Sume signs the raw JSON body of every terminal job event. An avatar talking video submitted with mode: "webhook" and a public HTTPS webhook_url ends in one of three events, and your endpoint must verify the signature before it trusts the payload. The contract from the webhook docs:

Sume webhook contract for avatar jobs (docs, read 2026-10-04)
ItemValue
Eventsjob.completed, job.failed, job.canceled (terminal only)
Timestamp headerx-sume-webhook-timestamp
Signature headerx-sume-webhook-signature: sume-v1=<hex>, comma-separated during rotation, newest first
Signed string<timestamp>.<raw_body>, HMAC-SHA256, hex
Replay window300 seconds by default
SecretSUME_COM_WEBHOOK_SIGNING_SECRET, from the dashboard Webhooks tab
DeliveryUp to 10 attempts, 30 s apart, 10 s timeout per attempt

Why this C# code is shaped this way

Convert.ToHexString returns uppercase, and Sume's signature is lowercase hex, so the code lowercases it before comparing. FixedTimeEquals returns false early only when lengths differ, which reveals nothing secret because the expected length is public. Each header entry is trimmed before the comparison.

Microsoft Learn, read 2026-10-04
FactDetail
MethodFixedTimeEquals(ReadOnlySpan<byte>, ReadOnlySpan<byte>)
BehaviorCompares in time that depends on length, not on values
Different lengthsShort-circuits and returns false only when lengths differ
Same addressFixed-time behavior holds even when both reference the same address

Operating it

Verify against the raw bytes you received, never a parsed and re-serialized object, because any change in spacing breaks the HMAC. Return a 2xx only after you have stored the event durably, and use job_id as the idempotency key, since a delivery can arrive more than once. If your endpoint was down, POST /v1/jobs/{job_id}/webhook/redeliver (scope jobs:write) re-sends the real terminal event with a fresh timestamp and signature, and POST /v1/webhooks/test-deliveries sends a signed dummy webhook.test payload to try your route first. Keep polling the job status as a fallback, because ten refused attempts end automatic delivery.

The code refuses an empty secret and a stale or non-numeric timestamp, checks every sume-v1= entry in the header so a rotation never rejects a good delivery, and does not stop at the first match. A Standard avatar clip costs $0.184 per second, so a rejected webhook is not a rejected video: the render is already paid for and fetchable from the job result. For the same check in other languages, see the Node and Python versions, and the queue limits by plan if you submit many clips at once.

Sources

Related posts

More in Sume Avatar 1.0

All Sume Avatar 1.0 posts

Written by Sume