CookbookVerify in C#

Verify webhooks in C#

⚠️
Preview — this feature is being built; the design below is the spec.

Check the syntic-signature header before trusting a webhook delivery. The header carries a timestamp t and one or more v1 signatures (several during secret rotation). The signature is the hex HMAC-SHA256 of t + "." + raw body, keyed with your endpoint’s signing secret. Reject the delivery if no v1 matches in constant time, or if t is more than 300 seconds away from your clock. Always hash the raw body bytes, before any JSON parsing.

using System.Security.Cryptography;
using System.Text;
 
public static class SynticWebhooks
{
    public static bool Verify(string header, byte[] rawBody, string secret, DateTimeOffset now)
    {
        string? t = null;
        var sigs = new List<string>();
        foreach (var part in header.Split(','))
        {
            var kv = part.Trim().Split('=', 2);
            if (kv.Length != 2) continue;
            if (kv[0] == "t") t = kv[1];
            else if (kv[0] == "v1") sigs.Add(kv[1]);
        }
        if (t is null || !long.TryParse(t, out var ts) || Math.Abs(now.ToUnixTimeSeconds() - ts) > 300) return false;
 
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var signed = Encoding.UTF8.GetBytes(t + ".").Concat(rawBody).ToArray();
        var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant());
        return sigs.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(s), expected));
    }
}
 
// ASP.NET Core minimal API
app.MapPost("/syntic", async (HttpRequest req) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var ok = SynticWebhooks.Verify(req.Headers["syntic-signature"].ToString(), ms.ToArray(),
        Environment.GetEnvironmentVariable("SYNTIC_WEBHOOK_SECRET")!, DateTimeOffset.UtcNow);
    if (!ok) return Results.BadRequest();
    Queue.Enqueue(ms.ToArray()); // process later
    return Results.Ok();
});

Convert.ToHexString returns upper case; lower-case it before comparing, since Syntic sends lower-case hex.

Test it

Send a test event to your endpoint, or recompute a signature by hand as shown on Webhooks.

Was this page helpful?