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 1,100 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 signing secret, encrypted at rest — see below),IsActive. - Signing secret encrypted at rest — the secret is the HMAC key, so it must stay recoverable (a one-way hash would make signing impossible). It is encrypted with ASP.NET Data Protection (
IWebhookSecretProtector) on create and decrypted only at sign time, so a database breach never exposes plaintext secrets. - Permission-gated endpoints —
Webhooks.View/Create/Delete/Test(previously authentication-only). - 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 permission-gated 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/ ~1,100 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 One-shot delivery (test endpoint)│ │ ├── WebhookSecretProtector.cs DataProtection encrypt/decrypt│ │ └── 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:
// src/Modules/Webhooks/Modules.Webhooks/Services/WebhookFanoutHandler.cs (simplified)
public sealed class WebhookFanoutHandler<TEvent> : IIntegrationEventHandler<TEvent> where TEvent : IIntegrationEvent{ // ctor injects WebhookDbContext, IWebhookDispatcher, IEventSerializer, // IMultiTenantContextAccessor<AppTenantInfo>, ILogger
public async Task HandleAsync(TEvent @event, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(@event.TenantId)) return; // skip platform-wide events
// Install the tenant context for the subscription read, restore the previous one after. var prev = _tenantContextAccessor.MultiTenantContext; try { var info = new AppTenantInfo(@event.TenantId, @event.TenantId); ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = new MultiTenantContext<AppTenantInfo>(info);
var eventType = typeof(TEvent).Name; var subscriptions = await _db.Subscriptions .AsNoTracking() .Where(s => s.IsActive) .ToListAsync(ct).ConfigureAwait(false);
var matching = subscriptions.Where(s => s.MatchesEvent(eventType)).ToList(); if (matching.Count == 0) return;
var payload = _serializer.Serialize(@event); foreach (var subscription in matching) await _dispatcher .EnqueueAsync(@event.TenantId, subscription.Id, eventType, payload, ct) .ConfigureAwait(false); // one bad enqueue is caught + logged, fan-out continues } finally { ((IMultiTenantContextSetter)_tenantContextAccessor).MultiTenantContext = prev; } }}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
// src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs (simplified)
public sealed class WebhookDispatchJob{ // ctor injects IServiceScopeFactory, IHttpClientFactory, IWebhookSecretProtector, ILogger
// 4 retries after the initial attempt → up to 5 total. Backoff: 30s, 2m, 10m, 1h. [AutomaticRetry(Attempts = 4, DelaysInSeconds = new[] { 30, 120, 600, 3600 }, OnAttemptsExceeded = AttemptsExceededAction.Fail)] public async Task DispatchAsync( Guid subscriptionId, string tenantId, string eventType, string payloadJson, PerformContext? context, CancellationToken ct) { // Fresh scope: set the tenant context FIRST, then resolve WebhookDbContext so its // Finbuckle filter sees a real TenantInfo (same pattern as SqlAuditSink). using var scope = _scopeFactory.CreateScope(); /* resolve tenant from store, set IMultiTenantContextSetter, load subscription */
var attemptNumber = (context?.GetJobParameter<int?>("RetryCount") ?? 0) + 1; var delivery = WebhookDelivery.Create(subscriptionId, eventType, payloadJson, attemptNumber);
using var content = new StringContent(payloadJson, Encoding.UTF8, new MediaTypeHeaderValue("application/json")); var signingSecret = _secretProtector.Unprotect(subscription.SecretHash); // decrypt at sign time if (!string.IsNullOrEmpty(signingSecret)) content.Headers.Add("X-Webhook-Signature", WebhookPayloadSigner.Sign(payloadJson, signingSecret)); content.Headers.Add("X-Webhook-Event", eventType); content.Headers.Add("X-Webhook-Delivery-Id", delivery.Id.ToString());
var response = await _httpClientFactory.CreateClient("Webhooks") .PostAsync(new Uri(subscription.Url), content, ct).ConfigureAwait(false); delivery.RecordResult((int)response.StatusCode, response.IsSuccessStatusCode, null); /* save delivery row */
if (response.IsSuccessStatusCode) return; if (IsTransient((int)response.StatusCode)) throw new WebhookDeliveryFailedException(/* … */); // throw → Hangfire retries // Permanent client error (4xx other than 408/429) — log it, give up, no exception. }
private static bool IsTransient(int statusCode) => statusCode >= 500 || statusCode == 408 || statusCode == 429;}IsTransient decides between 5xx / 408 / 429 (throw → retry) and other 4xx (give up quietly). Network errors, DNS failures, and timeouts count as transient too. Each attempt — successful or failed — appends a WebhookDelivery row to the log with its own attempt number.
Public API
| Type | Purpose |
|---|---|
CreateWebhookSubscriptionCommand(url, events[], secret?) | Subscribe (the secret is encrypted before storage) |
DeleteWebhookSubscriptionCommand(id) | Delete the subscription |
TestWebhookSubscriptionCommand(id) | Send a synchronous webhook.test delivery (signed if a secret is set) |
GetWebhookSubscriptionsQuery(paging) | List subscriptions for caller’s tenant |
GetWebhookDeliveriesQuery(subscriptionId, paging) | Delivery log for one subscription |
Permissions
Every endpoint is gated by a Webhooks.* permission (defined in Modules.Webhooks.Contracts/Authorization/WebhooksPermissions.cs and registered at module startup). View is a basic permission; the rest are assigned per role.
| Permission | Grants |
|---|---|
Webhooks.View | List subscriptions and read the delivery log |
Webhooks.Create | Create a subscription |
Webhooks.Delete | Delete a subscription |
Webhooks.Test | Send a one-off test delivery |
Endpoints
| Verb | Route | What it does | Permission |
|---|---|---|---|
| POST | /api/v1/webhooks/subscriptions | Create subscription | Webhooks.Create |
| DELETE | /api/v1/webhooks/subscriptions/{id} | Delete subscription | Webhooks.Delete |
| GET | /api/v1/webhooks/subscriptions | List subscriptions | Webhooks.View |
| GET | /api/v1/webhooks/subscriptions/{id}/deliveries | Delivery log (newest first) | Webhooks.View |
| POST | /api/v1/webhooks/subscriptions/{id}/test | Test delivery | Webhooks.Test |
Headers on every delivery
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event type name (e.g. ProductRepricedIntegrationEvent) |
X-Webhook-Delivery-Id | The WebhookDelivery row id — fresh per attempt, so receivers can dedupe |
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 ships unit tests in src/Tests/Webhooks.Tests/ (domain, validators, the fan-out handler, payload signer, and the secret protector — encryption round-trips and never stores plaintext) and end-to-end coverage in src/Tests/Integration.Tests/Tests/Webhooks/ (subscription CRUD, signed delivery, dispatch outcomes/retry, pagination, and cross-tenant isolation) using a controllable in-process HTTP receiver.
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.