C# · Code recipe
Verify a webhook signature in C#
Prove a delivery came from URLpipe before you act on it, in .NET 8+ with HttpClient. Every program on this page runs as it stands — each one was run against a stub of the API before it was published.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a URLpipe webhook in C#, compute HMAC-SHA256 over the X-URLpipe-Timestamp value, a dot and the raw request body, keyed with your whsec_ secret. Prefix it with v1= and compare it with CryptographicOperations.FixedTimeEquals against each comma-separated value of X-URLpipe-Signature; reject timestamps more than five minutes off.
Free plan, no credit card. 1,000 credits a month.
Your report_to URL accepts a POST from anyone who learns it. Turn on webhook signing for the project and every delivery carries X-URLpipe-Timestamp and X-URLpipe-Signature, so the receiver can prove the body came from URLpipe, unchanged, in the last five minutes.
The C# receiver below does the whole check: it reads the raw body (Request.Body copied into a MemoryStream), recomputes the HMAC, compares it in constant time with CryptographicOperations.FixedTimeEquals, and rejects stale timestamps. To send it a signed test delivery, use the shell script on the cURL page.
Setup
Before you start
An empty ASP.NET Core project is the whole setup; System.Security.Cryptography is in the base library. Turn signing on under Settings → Webhook Signing and copy the secret (it starts with whsec_).
dotnet new web -o UrlpipeWebhook && cd UrlpipeWebhook
export URLPIPE_WEBHOOK_SECRET="whsec_your_signing_secret"
# replace Program.cs with the receiver below, then:
dotnet run --urls http://localhost:8000
Receiver
A receiver that verifies every delivery
Verify is the part to copy into your app. Copy Request.Body into a MemoryStream and verify those bytes; model binding would parse and re-encode them. CryptographicOperations.FixedTimeEquals is the constant-time compare.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
const int ToleranceSeconds = 5 * 60;
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("URLPIPE_WEBHOOK_SECRET")
?? throw new InvalidOperationException("Set URLPIPE_WEBHOOK_SECRET"));
var app = WebApplication.Create(args);
app.MapPost("/webhooks/urlpipe", async (HttpRequest request) =>
{
// The raw bytes, exactly as sent.
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
var timestamp = request.Headers["X-URLpipe-Timestamp"].ToString();
var signature = request.Headers["X-URLpipe-Signature"].ToString();
if (!Verify(body, timestamp, signature)) return Results.Unauthorized();
using var delivery = JsonDocument.Parse(body);
app.Logger.LogInformation("Verified delivery for {Token}", delivery.RootElement.GetProperty("token").GetString());
return Results.Ok();
});
app.Run();
// True when the delivery was signed with the secret in the last five minutes.
bool Verify(byte[] body, string timestamp, string signatureHeader)
{
if (!long.TryParse(timestamp, out var seconds) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > ToleranceSeconds)
{
return false;
}
var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(body).ToArray();
var expected = Encoding.UTF8.GetBytes("v1=" + Convert.ToHexString(HMACSHA256.HashData(secret, signed)).ToLowerInvariant());
// One signature normally, two during a secret rotation: accept any match.
return signatureHeader.Split(',').Any(candidate =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate.Trim()), expected));
}
Run it: dotnet run --urls http://localhost:8000
Details
What to know about signed deliveries
- Signing is off until you turn it on under Settings → Webhook Signing; the secret starts with
whsec_. Enabling it is safe at any time — the body does not change — so enable it first and deploy the check after. - Rotating the secret opens a 24-hour window in which every delivery carries two signatures, the new one first. That is why the header is a list and any match is accepted.
- Each attempt is signed with a fresh timestamp, so a retry passes the five-minute check like the first delivery did.
- A delivery your endpoint rejects is retried with backoff, up to six attempts over roughly twenty minutes, and can be resent by hand from the dashboard afterwards.
- Make the handler idempotent on
token: the same result can arrive more than once.
Other languages
Verify a webhook signature in another language
More C#: every C# recipe · how signing works, in the docs
FAQ
Frequently asked questions
Why verify against the raw body?
Why can the signature header hold more than one value?
What should a receiver answer when the check fails?
Why a five-minute tolerance?
Get a key and run it.
Free plan, no card. Paste your key into URLPIPE_API_KEY and every program on this page runs as it is.