Skip to content
fullstackhero

Reference

Webhooks module

Tenant-scoped webhook subscriptions, open-generic event fan-out, HMAC-signed delivery, Hangfire dispatch with exponential backoff, and a delivery audit log.

views 0 Last updated

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 WebhookSubscription row belongs to a tenant and stores Url, EventsCsv (comma-separated list with * wildcard support), SecretHash (the HMAC key), IsActive.
  • Open-generic event handler — one WebhookFanoutHandler<TEvent> registered for every IIntegrationEvent; 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 SecretHash is set, every delivery includes X-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 WebhookDelivery row with status code, duration, error.
  • Five endpoints for tenants to manage subscriptions and read the delivery log.
  • HTTP resilience via a named HttpClient “Webhooks” using Microsoft.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, DTOs

The 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
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

src/Modules/Webhooks/Modules.Webhooks/Services/WebhookDispatchJob.cs
[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

TypePurpose
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

VerbRouteWhat it does
POST/api/v1/webhooksCreate subscription
DELETE/api/v1/webhooks/{id}Deactivate subscription
GET/api/v1/webhooksList subscriptions
GET/api/v1/webhooks/deliveriesDelivery log (newest first)
POST/api/v1/webhooks/testTest delivery

Headers on every delivery

HeaderValue
Content-Typeapplication/json
X-Webhook-EventThe event type name (e.g. ProductRepricedIntegrationEvent)
X-Webhook-Delivery-IdA fresh UUID per attempt for receiver-side idempotency
X-Webhook-Signaturesha256={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.