Skip to content
fullstackhero

Reference

Webhooks module

Tenant-scoped webhook subscriptions, open-generic event fan-out, HMAC-signed delivery with secrets encrypted at rest, permission-gated endpoints, 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 1,100 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 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 endpointsWebhooks.View / Create / Delete / Test (previously authentication-only).
  • 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 permission-gated 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/ ~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, 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 (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

TypePurpose
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.

PermissionGrants
Webhooks.ViewList subscriptions and read the delivery log
Webhooks.CreateCreate a subscription
Webhooks.DeleteDelete a subscription
Webhooks.TestSend a one-off test delivery

Endpoints

VerbRouteWhat it doesPermission
POST/api/v1/webhooks/subscriptionsCreate subscriptionWebhooks.Create
DELETE/api/v1/webhooks/subscriptions/{id}Delete subscriptionWebhooks.Delete
GET/api/v1/webhooks/subscriptionsList subscriptionsWebhooks.View
GET/api/v1/webhooks/subscriptions/{id}/deliveriesDelivery log (newest first)Webhooks.View
POST/api/v1/webhooks/subscriptions/{id}/testTest deliveryWebhooks.Test

Headers on every delivery

HeaderValue
Content-Typeapplication/json
X-Webhook-EventThe event type name (e.g. ProductRepricedIntegrationEvent)
X-Webhook-Delivery-IdThe WebhookDelivery row id — fresh per attempt, so receivers can dedupe
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 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.