Webhooks — Push Events to Other Systems When Things Happen
Store webhook subscriptions, send events reliably with retry and exponential backoff, sign payloads with HMAC-SHA256, verify incoming webhooks, and handle idempotency.
What a Webhook Is
A webhook is an HTTP POST your system sends to a subscriber's URL when an event occurs. Instead of the subscriber polling your API every 30 seconds ("did anything happen?"), you push the event the moment it fires.
Key properties:
- Subscriber registers a URL and the event types they care about
- You sign the payload so they can verify it came from you
- You retry on failure — their server might be down
- They acknowledge with a 2xx response immediately and process async
Storing Subscriptions
// Entities/WebhookSubscription.cs
public class WebhookSubscription
{
public Guid Id { get; set; }
public string TargetUrl { get; set; } = "";
public string[] Events { get; set; } = []; // e.g. ["order.created", "order.shipped"]
public string Secret { get; set; } = ""; // HMAC signing secret
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; }
public int FailureCount { get; set; } // disable after too many failures
}// EF Core config
modelBuilder.Entity<WebhookSubscription>()
.Property(e => e.Events)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries));// Endpoints to register/delete subscriptions
[HttpPost("subscriptions")]
public async Task<IActionResult> Register([FromBody] RegisterWebhookDto dto)
{
var sub = new WebhookSubscription
{
Id = Guid.NewGuid(),
TargetUrl = dto.Url,
Events = dto.Events,
Secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)),
CreatedAt = DateTime.UtcNow
};
_db.WebhookSubscriptions.Add(sub);
await _db.SaveChangesAsync();
return Ok(new { sub.Id, sub.Secret }); // return secret once; subscriber must store it
}Signing Payloads with HMAC-SHA256
public static class WebhookSigner
{
public static string Sign(string payload, string secret)
{
var keyBytes = Convert.FromBase64String(secret);
var msgBytes = Encoding.UTF8.GetBytes(payload);
var hash = HMACSHA256.HashData(keyBytes, msgBytes);
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string payload, string secret, string signature)
{
var expected = Sign(payload, secret);
// Constant-time comparison prevents timing attacks
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature));
}
}Sending Webhooks Reliably With Retry
Don't fire and forget inline — use a background service that reads from a queue and retries with exponential backoff.
// Services/WebhookDispatcher.cs
public class WebhookDispatcher
{
private readonly IHttpClientFactory _http;
private readonly ILogger<WebhookDispatcher> _log;
public WebhookDispatcher(IHttpClientFactory http, ILogger<WebhookDispatcher> log)
{
_http = http;
_log = log;
}
public async Task<bool> SendAsync(
WebhookSubscription sub, string eventType, object payload,
CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new
{
id = Guid.NewGuid(), // idempotency key
@event = eventType,
occurredAt = DateTime.UtcNow,
data = payload
});
var sig = WebhookSigner.Sign(body, sub.Secret);
using var request = new HttpRequestMessage(HttpMethod.Post, sub.TargetUrl)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Webhook-Signature", sig);
request.Headers.Add("X-Webhook-Event", eventType);
const int MaxAttempts = 5;
for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
var client = _http.CreateClient("webhooks");
client.Timeout = TimeSpan.FromSeconds(10);
var response = await client.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
{
_log.LogInformation("Webhook {Event} delivered to {Url}", eventType, sub.TargetUrl);
return true;
}
_log.LogWarning("Webhook attempt {Attempt} got {Status}", attempt, (int)response.StatusCode);
}
catch (Exception ex)
{
_log.LogWarning(ex, "Webhook attempt {Attempt} threw", attempt);
}
if (attempt < MaxAttempts)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2, 4, 8, 16 seconds
await Task.Delay(delay, ct);
}
}
return false; // all attempts exhausted
}
}Background Delivery Worker
// Services/WebhookDeliveryWorker.cs
public class WebhookDeliveryWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly Channel<WebhookJob> _queue;
public WebhookDeliveryWorker(IServiceScopeFactory scopes)
{
_scopes = scopes;
_queue = Channel.CreateBounded<WebhookJob>(new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
});
}
public ValueTask EnqueueAsync(WebhookJob job) => _queue.Writer.WriteAsync(job);
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var job in _queue.Reader.ReadAllAsync(ct))
{
await using var scope = _scopes.CreateAsyncScope();
var dispatcher = scope.ServiceProvider.GetRequiredService<WebhookDispatcher>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var subs = await db.WebhookSubscriptions
.Where(s => s.IsActive && s.Events.Contains(job.EventType))
.ToListAsync(ct);
foreach (var sub in subs)
{
var ok = await dispatcher.SendAsync(sub, job.EventType, job.Payload, ct);
if (!ok)
{
sub.FailureCount++;
if (sub.FailureCount >= 10)
sub.IsActive = false; // auto-disable dead endpoints
await db.SaveChangesAsync(ct);
}
}
}
}
}
public record WebhookJob(string EventType, object Payload);// Program.cs
builder.Services.AddSingleton<WebhookDeliveryWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<WebhookDeliveryWorker>());
builder.Services.AddScoped<WebhookDispatcher>();Triggering From Domain Events
// In your order service, after saving
await _webhookWorker.EnqueueAsync(new WebhookJob("order.created", new
{
orderId = order.Id,
customerId = order.CustomerId,
total = order.Total
}));Receiving Webhooks From Third Parties
When Stripe, GitHub, etc. POST to your endpoint, verify their signature before processing anything.
[HttpPost("webhooks/stripe")]
public async Task<IActionResult> StripeWebhook()
{
// Read raw body — do NOT use [FromBody], model binding alters the stream
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault() ?? "";
if (!WebhookSigner.Verify(body, _config["Stripe:WebhookSecret"]!, signature))
return BadRequest("Invalid signature.");
// Respond 200 immediately — then process async
_ = Task.Run(() => ProcessStripeEventAsync(body));
return Ok();
}Never do heavy work synchronously in a webhook receiver. The caller will time out and retry, causing duplicate processing.
Idempotency With Event IDs
// Track processed event IDs to ignore retried deliveries
public class ProcessedWebhookEvent
{
public string EventId { get; set; } = ""; // from "id" field in payload
public DateTime ProcessedAt { get; set; }
}
private async Task ProcessStripeEventAsync(string body)
{
var evt = JsonDocument.Parse(body).RootElement;
var eventId = evt.GetProperty("id").GetString()!;
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (await db.ProcessedWebhookEvents.AnyAsync(e => e.EventId == eventId))
return; // already handled
db.ProcessedWebhookEvents.Add(new() { EventId = eventId, ProcessedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
// Now safe to process
}Webhook Delivery Dashboard
Expose a simple endpoint so subscribers can see delivery history:
[HttpGet("subscriptions/{id}/deliveries")]
public async Task<IActionResult> Deliveries(Guid id, [FromQuery] int page = 1)
{
var deliveries = await _db.WebhookDeliveries
.Where(d => d.SubscriptionId == id)
.OrderByDescending(d => d.AttemptedAt)
.Skip((page - 1) * 20).Take(20)
.Select(d => new { d.EventType, d.Status, d.ResponseCode, d.AttemptedAt })
.ToListAsync();
return Ok(deliveries);
}Persist a WebhookDelivery row (success/failure, HTTP status, timestamp) inside WebhookDispatcher.SendAsync for every attempt.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.