.NET & C# Development · Lesson 54 of 92

Build a Webhook System — Event-Driven API Communication

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

C#
// 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
}
C#
// EF Core config
modelBuilder.Entity<WebhookSubscription>()
    .Property(e => e.Events)
    .HasConversion(
        v => string.Join(',', v),
        v => v.Split(',', StringSplitOptions.RemoveEmptyEntries));
C#
// 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

C#
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.

C#
// 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

C#
// 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);
C#
// Program.cs
builder.Services.AddSingleton<WebhookDeliveryWorker>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<WebhookDeliveryWorker>());
builder.Services.AddScoped<WebhookDispatcher>();

Triggering From Domain Events

C#
// 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.

C#
[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

C#
// 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:

C#
[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.