Webhooks
Two directions: receiving a webhook from a third party (verify it's genuinely from them before trusting the body) and sending one to a customer's endpoint (sign it yourself, and retry when their server is down).
Receiving: verify the signature before touching the body
Anyone can POST to a public webhook URL. Providers (Stripe, GitHub, etc.) sign the raw request body with a shared secret and send the signature in a header — recompute it yourself and compare before you deserialize or act on anything.
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly IConfiguration _config;
private readonly IWebhookProcessor _processor;
public WebhookController(IConfiguration config, IWebhookProcessor processor)
{
_config = config;
_processor = processor;
}
[HttpPost("provider")]
public async Task<IActionResult> ReceiveAsync()
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var signature = Request.Headers["X-Webhook-Signature"].ToString();
var secret = _config["Webhooks:ProviderSecret"]!;
if (!IsValidSignature(rawBody, signature, secret))
return Unauthorized();
var payload = JsonSerializer.Deserialize<WebhookPayload>(rawBody)
?? throw new ArgumentException("Invalid payload.");
await _processor.HandleAsync(payload);
return Ok(); // 200 as fast as possible — see "acknowledge fast" below
}
private static bool IsValidSignature(string body, string signatureHeader, string secret)
{
var expected = Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)));
// constant-time comparison — string == would leak timing information
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected.ToLowerInvariant()),
Encoding.UTF8.GetBytes(signatureHeader.ToLowerInvariant()));
}
}
Acknowledge fast, process slow
Most providers retry a webhook if they don't get a 2xx response within a few seconds — and will keep retrying, creating duplicates, if your handler is slow. Acknowledge immediately and hand the real work to a background queue (see Background Jobs) instead of doing it inline in the request.
[HttpPost("provider")]
public async Task<IActionResult> ReceiveAsync([FromBody] WebhookPayload payload)
{
if (!IsValidSignature(/* ... */)) return Unauthorized();
// idempotency: providers redeliver, so upsert on the provider's event ID
var alreadyProcessed = await _eventLog.ExistsAsync(payload.EventId);
if (alreadyProcessed) return Ok();
await _eventLog.RecordAsync(payload.EventId);
_queue.Enqueue(payload); // picked up by a BackgroundService
return Ok();
}
Sending: sign your own outgoing webhooks
If your app calls out to a customer's endpoint, give them the same guarantee providers give you — a signature they can verify — plus retries with backoff, since their endpoint being briefly down shouldn't lose the event.
public class WebhookSender
{
private readonly HttpClient _http;
public WebhookSender(HttpClient http) => _http = http;
public async Task SendAsync(string url, string secret, object payload)
{
var body = JsonSerializer.Serialize(payload);
var signature = Convert.ToHexString(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(body)));
var delays = new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) };
for (int attempt = 0; attempt <= delays.Length; attempt++)
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Webhook-Signature", signature.ToLowerInvariant());
var response = await _http.SendAsync(request);
if (response.IsSuccessStatusCode) return;
if (attempt < delays.Length)
await Task.Delay(delays[attempt]);
}
// all retries exhausted — log it, don't throw and crash the caller's flow
}
}
For anything beyond a handful of webhook subscribers, reach for Background Jobs (or a real queue like Azure Service Bus/RabbitMQ) to drive the retry loop instead of blocking the original request thread on Task.Delay — the pattern above is the right shape, but shouldn't run inline in whatever triggered the event.