The Webhooks module turns every integration event your kit publishes into an outbound HTTP delivery to subscribed tenants. Tenants register a URL + an event list (with * wildcard support), the module fans events using an open-generic IIntegrationEventHandler<>, signs payloads with HMAC-SHA256, and delivers via Hangfire jobs with exponential-backoff retry and a delivery log for audit. Around 580 lines of code in the runtime project.
What ships in v10
- Tenant-scoped subscriptions — each
WebhookSubscriptionrow belongs to a tenant and storesUrl,EventsCsv(comma-separated list with*wildcard support),SecretHash(the HMAC key),IsActive. - Open-generic event handler — one
WebhookFanoutHandler<TEvent>registered for everyIIntegrationEvent; no per-event setup needed. When a new module publishes a new event, existing subscriptions automatically receive it. - Tenant context restoration — the fanout handler explicitly restores the tenant context before querying subscriptions, so Finbuckle’s global query filter sees the right tenant. (This is the single most important gotcha — see below.)
- HMAC-SHA256 payload signing — when
SecretHashis set, every delivery includesX-Webhook-Signature: sha256={hex}so subscribers can verify authenticity. - Hangfire delivery jobs with
[AutomaticRetry]— 5 attempts total, exponential backoff (30 s, 2 min, 10 min, 1 h). - Transient vs permanent error classification — 5xx, 408, 429 retried; other 4xx permanent.
- Delivery log — every attempt (success or failure) appends a
WebhookDeliveryrow with status code, duration, error. - Five endpoints for tenants to manage subscriptions and read the delivery log.
- HTTP resilience via a named
HttpClient“Webhooks” usingMicrosoft.Extensions.Http.Resilience(Polly v8).
Architecture at a glance
src/Modules/Webhooks/├── Modules.Webhooks/ ~580 LoC│ ├── WebhooksModule.cs IModule — order 400│ ├── Domain/│ │ ├── WebhookSubscription.cs Tenant-scoped, IsActive flag│ │ └── WebhookDelivery.cs Per-attempt row, immutable│ ├── Data/WebhookDbContext.cs Schema: webhooks│ ├── Services/│ │ ├── WebhookFanoutHandler<TEvent>.cs Open-generic event bridge│ │ ├── WebhookDispatchJob.cs Hangfire job, HMAC + retry│ │ ├── WebhookDispatcher.cs EnqueueAsync into Hangfire│ │ ├── WebhookDeliveryService.cs Persists Delivery rows│ │ └── WebhookPayloadSigner.cs HMAC-SHA256 hex│ └── Features/v1/ 5 endpoints└── Modules.Webhooks.Contracts/ Commands, queries, DTOsThe module loads at order 400 — after Files (350), before Catalog (600). Its WebhookDbContext is tenant-aware so subscriptions and delivery logs are isolated per tenant.
The open-generic fan-out
The single piece of code that makes the whole module work:
public sealed class WebhookFanoutHandler<TEvent>( IWebhookDbContext db, IWebhookDispatcher dispatcher, IMultiTenantContextSetter tenants, IEventSerializer serializer) : IIntegrationEventHandler<TEvent> where TEvent : IIntegrationEvent{ public async ValueTask HandleAsync(TEvent evt, CancellationToken ct) { if (evt.TenantId is null) return; // skip platform-wide events tenants.SetTenant(evt.TenantId.Value); // restore tenant context
var subs = await db.Subscriptions .Where(s => s.IsActive) .ToListAsync(ct).ConfigureAwait(false);
var eventType = typeof(TEvent).Name; var matching = subs.Where(s => s.MatchesEvent(eventType)); if (!matching.Any()) return;
var payload = serializer.Serialize(evt); foreach (var sub in matching) await dispatcher.EnqueueAsync(sub.Id, eventType, payload, ct).ConfigureAwait(false); }}Registered open-generic in ConfigureServices:
services.AddScoped(typeof(IIntegrationEventHandler<>), typeof(WebhookFanoutHandler<>));The DI container materializes the handler for every event type the eventing system sees. Zero per-event code.
The delivery job
[AutomaticRetry(Attempts = 4, OnAttemptsExceeded = AttemptsExceededAction.Fail, DelaysInSeconds = new[] { 30, 120, 600, 3600 })]public sealed class WebhookDispatchJob(IHttpClientFactory http, IWebhookDeliveryService log, IWebhookPayloadSigner signer){ public async Task RunAsync(Guid subscriptionId, string eventType, string payload, CancellationToken ct) { var sub = await /* fetch */; var client = http.CreateClient("Webhooks"); using var req = new HttpRequestMessage(HttpMethod.Post, sub.Url) { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; req.Headers.Add("X-Webhook-Event", eventType); req.Headers.Add("X-Webhook-Delivery-Id", Guid.NewGuid().ToString()); if (!string.IsNullOrEmpty(sub.SecretHash)) req.Headers.Add("X-Webhook-Signature", "sha256=" + signer.Sign(payload, sub.SecretHash));
var sw = Stopwatch.StartNew(); try { using var resp = await client.SendAsync(req, ct).ConfigureAwait(false); await log.RecordAsync(sub.Id, eventType, payload, (int)resp.StatusCode, success: resp.IsSuccessStatusCode, ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode && IsPermanent(resp.StatusCode)) throw new WebhookPermanentException(resp.StatusCode); // skip retry resp.EnsureSuccessStatusCode(); // throw → Hangfire retries } catch (Exception ex) when (ex is not WebhookPermanentException) { await log.RecordAsync(sub.Id, eventType, payload, 0, success: false, ct, ex.Message).ConfigureAwait(false); throw; } }}IsPermanent decides between 4xx (give up) and 5xx / 408 / 429 (retry). Each attempt — successful or failed — appends a WebhookDelivery row to the log.
Public API
| Type | Purpose |
|---|---|
CreateWebhookSubscriptionCommand(url, events[], secretHash?) | Subscribe |
DeleteWebhookSubscriptionCommand(subscriptionId) | Soft-delete (IsActive = false) |
TestWebhookSubscriptionCommand(subscriptionId, eventType?) | Enqueue one-off test delivery |
GetWebhookSubscriptionsQuery(paging) | List active subscriptions for caller’s tenant |
GetWebhookDeliveriesQuery(subscriptionId?, paging) | Delivery log |
Endpoints
| Verb | Route | What it does |
|---|---|---|
| POST | /api/v1/webhooks | Create subscription |
| DELETE | /api/v1/webhooks/{id} | Deactivate subscription |
| GET | /api/v1/webhooks | List subscriptions |
| GET | /api/v1/webhooks/deliveries | Delivery log (newest first) |
| POST | /api/v1/webhooks/test | Test delivery |
Headers on every delivery
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event type name (e.g. ProductRepricedIntegrationEvent) |
X-Webhook-Delivery-Id | A fresh UUID per attempt for receiver-side idempotency |
X-Webhook-Signature | sha256={hex} (only if SecretHash is set on the subscription) |
Subscribers verify the signature by HMAC-SHA256-hashing the raw request body with the same secret and comparing.
How to extend
Verify signatures on the subscriber side (.NET)
public static bool VerifyWebhook(string body, string signatureHeader, string secret){ if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256=", StringComparison.Ordinal)) return false; var expected = signatureHeader["sha256=".Length..]; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); var actual = Convert.ToHexStringLower(hash); return CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual));}Add per-subscription rate-limiting
The dispatcher queues all matching subscriptions on Hangfire. To throttle high-volume subscribers, wrap IWebhookDispatcher.EnqueueAsync with a per-subscription rate limiter (e.g. System.Threading.RateLimiting) and delay-enqueue when over budget.
Hide private payload fields per subscription
Pre-serialize via a projection by subscription policy. The simplest approach is to accept a payloadProjector Func on the subscription and serialize using it before enqueue. More involved: a per-event projector contract resolved from DI.
Purge old delivery logs
The kit doesn’t auto-purge the WebhookDelivery table. For high-volume tenants, add a Hangfire recurring job that deletes rows older than N days. Pattern: copy AuditRetentionJob from the Auditing module.
Tests
The module has no built-in tests — webhook delivery needs a controllable HTTP endpoint, which is integration-test infrastructure. Tests live in your CI/CD pipeline rather than in the repo.
Related
- Eventing building block —
IIntegrationEvent,IEventBus, and the open-generic handler registration pattern. - Multitenancy module — the tenant context restore the fanout handler depends on.
- Cross-cutting concerns — Hangfire retry policy, HttpClient resilience, idempotency.