LowCodeHub.Webhooks
0.0.2
See the version list below for details.
dotnet add package LowCodeHub.Webhooks --version 0.0.2
NuGet\Install-Package LowCodeHub.Webhooks -Version 0.0.2
<PackageReference Include="LowCodeHub.Webhooks" Version="0.0.2" />
<PackageVersion Include="LowCodeHub.Webhooks" Version="0.0.2" />
<PackageReference Include="LowCodeHub.Webhooks" />
paket add LowCodeHub.Webhooks --version 0.0.2
#r "nuget: LowCodeHub.Webhooks, 0.0.2"
#:package LowCodeHub.Webhooks@0.0.2
#addin nuget:?package=LowCodeHub.Webhooks&version=0.0.2
#tool nuget:?package=LowCodeHub.Webhooks&version=0.0.2
LowCodeHub.Webhooks
A reliable webhook delivery system for ASP.NET Core with database-driven subscriptions, outbox-based at-least-once delivery, two-level resilience (Polly + durable retries), and multi-tenant support. Webhooks that survive crashes, restarts, and extended outages — without losing a single event.
Why This Library?
| Feature | LowCodeHub.Webhooks | Fire-and-Forget HTTP | Queue-Based |
|---|---|---|---|
| Delivery guarantee | At-least-once (outbox) | None | At-least-once |
| Survives crashes | Yes — persisted before sending | No | Depends on broker |
| Retry strategy | Two-level — immediate + durable backoff | Manual | Single-level |
| Subscription storage | Database-driven — dynamic CRUD | Hardcoded | Hardcoded |
| Multi-tenant | Built-in | Manual | Manual |
| Infrastructure | Your existing DB | None | Requires message broker |
| Observability | OpenTelemetry metrics + traces | Manual | Varies |
Installation
dotnet add package LowCodeHub.Webhooks
Quick Start
// Program.cs
builder.Services
.AddWebhooks(builder.Configuration)
.AddWebhooksSqlServer(builder.Configuration); // or AddWebhooksPostgreSql
// Dispatch a webhook from anywhere via DI
public class OrderService(IWebhookDispatcher webhookDispatcher)
{
public async Task CreateOrderAsync(Order order, CancellationToken ct)
{
// ... create order logic ...
await webhookDispatcher.DispatchAsync(
eventType: "order.created",
payload: new { order.Id, order.Total, order.CreatedAt },
tenantId: order.TenantId, // optional
cancellationToken: ct);
}
}
That's it. The library finds all active subscriptions matching "order.created", snapshots their config, persists delivery attempts to the outbox, and the background worker picks them up — with automatic retries, exponential backoff, and graceful shutdown.
Table of Contents
- How It Works
- Configuration
- Dispatching Webhooks
- Two-Level Resilience
- Per-Subscription Proxy
- Subscription Management
- Delivery Lifecycle
- Health Checks
- Graceful Shutdown
- Automatic Cleanup
- Observability
- Database Migrations
- Custom Repository Implementations
- Requirements
- License
How It Works
┌──────────────────────────────────────────────────────────────────┐
│ Your Code │
│ webhookDispatcher.DispatchAsync("order.created", payload) │
└───────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ DefaultWebhookDispatcher │
│ 1. Query active subscriptions matching event type + tenant │
│ 2. Snapshot each subscription's config (URL, headers, method) │
│ 3. Persist WebhookDeliveryAttempt records (State = Pending) │
└───────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ WebhookDeliveryWorker (BackgroundService, polls every 2s) │
│ 1. Claim batch of pending deliveries (pessimistic lock) │
│ 2. Send HTTP requests via resilience-enhanced HttpClient │
│ ├── Success (2xx) → MarkDelivered │
│ └── Failure → MarkFailed + exponential backoff retry │
│ └── Max retries exhausted → permanently Failed │
└──────────────────────────────────────────────────────────────────┘
Key design principle: snapshot-at-dispatch. When a webhook is dispatched, the subscription's URL, headers, HTTP method, and timeout are copied into the delivery attempt. Later subscription changes don't affect in-flight deliveries — ensuring consistency.
Configuration
appsettings.json
{
"Webhooks": {
"Enabled": true,
"BatchSize": 50,
"MaxParallelism": 4,
"PollInterval": "00:00:02",
"InitialRetryDelay": "00:00:05",
"MaxRetryDelay": "00:05:00",
"DefaultMaxRetries": 5,
"DefaultTimeoutSeconds": 30,
"DrainOnShutdown": true,
"DrainTimeout": "00:00:30",
"HealthStaleAfter": "00:02:00",
"HttpRetryCount": 3,
"HttpRetryBaseDelay": "00:00:01",
"HttpTotalTimeout": "00:01:00",
"CleanupRetentionPeriod": "30.00:00:00",
"CleanupInterval": "01:00:00",
"CleanupBatchSize": 1000,
"SqlServer": {
"ConnectionString": "Server=...;Database=...;",
"SubscriptionSchema": "dbo",
"SubscriptionTable": "WebhookSubscriptions",
"DeliverySchema": "dbo",
"DeliveryTable": "WebhookDeliveryAttempts",
"LeaseDuration": "00:02:00"
}
}
}
All Options
| Option | Default | Description |
|---|---|---|
Enabled |
true |
Enable/disable the delivery worker |
BatchSize |
50 |
Delivery attempts claimed per poll cycle |
MaxParallelism |
4 |
Concurrent HTTP calls per batch |
PollInterval |
2s |
Time between polling cycles |
InitialRetryDelay |
5s |
Delay before first durable retry |
MaxRetryDelay |
5min |
Maximum delay between durable retries |
DefaultMaxRetries |
5 |
Default max retries for new deliveries |
DefaultTimeoutSeconds |
30 |
Default HTTP timeout for new subscriptions |
DrainOnShutdown |
true |
Wait for in-flight deliveries on shutdown |
DrainTimeout |
30s |
Maximum drain wait time |
HealthStaleAfter |
2min |
Worker heartbeat staleness threshold |
HttpRetryCount |
3 |
Immediate Polly retries per HTTP call |
HttpRetryBaseDelay |
1s |
Base delay for immediate retries |
HttpTotalTimeout |
60s |
Total timeout including all immediate retries |
CleanupRetentionPeriod |
30 days |
How long to keep completed records (null to disable) |
CleanupInterval |
1 hour |
Time between cleanup cycles |
CleanupBatchSize |
1000 |
Max records deleted per cleanup cycle |
Code-Based Configuration
builder.Services.AddWebhooks(options =>
{
options.BatchSize = 100;
options.MaxParallelism = 8;
options.PollInterval = TimeSpan.FromSeconds(1);
options.HttpRetryCount = 5;
});
builder.Services.AddWebhooksSqlServer(options =>
{
options.ConnectionString = "Server=...;Database=...;";
options.SubscriptionTable = "MyWebhookSubscriptions";
});
PostgreSQL Configuration
{
"Webhooks": {
"PostgreSql": {
"ConnectionString": "Host=...;Database=...;",
"SubscriptionSchema": "public",
"SubscriptionTable": "webhook_subscriptions",
"DeliverySchema": "public",
"DeliveryTable": "webhook_delivery_attempts",
"LeaseDuration": "00:02:00"
}
}
}
builder.Services
.AddWebhooks(builder.Configuration)
.AddWebhooksPostgreSql(builder.Configuration);
Dispatching Webhooks
Inject IWebhookDispatcher and call DispatchAsync:
await webhookDispatcher.DispatchAsync(
eventType: "order.created",
payload: new { orderId, total, createdAt },
tenantId: "tenant-123", // optional
cancellationToken: ct);
With Multi-Tenancy
Each subscription has an optional TenantId. When you pass a tenantId to DispatchAsync, only subscriptions matching that tenant are triggered:
// Only subscriptions for tenant "acme" listening to "order.created" will receive this
await webhookDispatcher.DispatchAsync("order.created", payload, tenantId: "acme");
Without Multi-Tenancy
Omit tenantId — matches subscriptions with no tenant assigned:
await webhookDispatcher.DispatchAsync("user.registered", new { userId, email });
Two-Level Resilience
| Level | Mechanism | Scope | Typical Duration |
|---|---|---|---|
| Immediate | Polly v8 (via Microsoft.Extensions.Http.Resilience) |
Per HTTP call | Seconds — retry transient 5xx, network blips |
| Durable | Outbox worker + exponential backoff | Across process restarts | Minutes to hours — extended outages |
Immediate retries handle transient failures (503, network timeouts) within a single HTTP call attempt. Configured via HttpRetryCount, HttpRetryBaseDelay, and HttpTotalTimeout.
Durable retries handle persistent failures. When an HTTP call fails after all immediate retries, the delivery attempt is marked as failed and scheduled for retry with exponential backoff:
Retry 1: +5s (InitialRetryDelay)
Retry 2: +10s (×2)
Retry 3: +20s (×2)
Retry 4: +40s (×2)
Retry 5: +80s (×2, capped at MaxRetryDelay)
After MaxRetries is exhausted, the delivery is permanently marked as Failed.
Per-Subscription Proxy
Proxy settings are per-subscription, not global. Each subscription can route through a different proxy — useful for multi-tenant deployments where tenants have their own network egress requirements.
Set ProxyUrl on the subscription record (supports embedded credentials):
-- SQL Server
UPDATE [dbo].[WebhookSubscriptions]
SET [ProxyUrl] = 'http://proxy.tenant-a.example.com:8080'
WHERE [TenantId] = 'tenant-a';
-- With embedded credentials
UPDATE [dbo].[WebhookSubscriptions]
SET [ProxyUrl] = 'http://user:password@proxy.example.com:8080'
WHERE [Id] = '...';
When ProxyUrl is NULL, the library uses a direct connection through the factory-managed HttpClient (with Polly resilience). When set, a standalone HttpClient with the specified WebProxy is created per delivery attempt.
Note: Proxy-routed deliveries bypass the factory-managed resilience pipeline. The per-delivery timeout from
TimeoutSecondsstill applies.
Subscription Management
Subscriptions are stored in the database. The library reads them — you manage them (insert/update/delete) through your own API or admin UI. Here's the subscription model:
| Field | Type | Default | Description |
|---|---|---|---|
Id |
Guid |
NewGuid() |
Unique identifier |
TenantId |
string? |
null |
Optional tenant for multi-tenant filtering |
EventType |
string |
required | Event type to listen for (e.g. "order.created") |
Name |
string |
required | Human-readable name |
WebhookUrl |
string |
required | Target URL for delivery |
HttpMethod |
string |
"POST" |
HTTP method (POST, PUT, PATCH, etc.) |
ContentType |
string |
"application/json" |
Request body content type |
Headers |
string? |
null |
Custom headers as JSON dictionary |
TimeoutSeconds |
int |
30 |
Per-request timeout |
MaxRetries |
int |
5 |
Maximum durable retry attempts |
ProxyUrl |
string? |
null |
Proxy URL for outbound HTTP calls (supports embedded credentials) |
IsActive |
bool |
true |
Whether this subscription receives events |
Custom Headers
Headers are stored as a JSON dictionary and added to every delivery request:
{
"Authorization": "Bearer eyJ...",
"X-Webhook-Secret": "my-secret-key",
"X-Custom-Header": "custom-value"
}
Delivery Lifecycle
Each delivery attempt progresses through these states:
Pending → Processing → Delivered
└→ Pending (retry scheduled)
└→ Failed (max retries exhausted)
| State | Description |
|---|---|
Pending |
Waiting to be picked up by the delivery worker |
Processing |
Claimed by a worker, currently being sent (locked via lease) |
Delivered |
Successfully delivered (2xx response received) |
Failed |
Permanently failed after exhausting all retries |
Pessimistic Locking
Workers claim deliveries atomically using database-level locking:
- SQL Server:
UPDLOCK, READPAST, ROWLOCK— claimed rows are invisible to other workers - PostgreSQL:
FOR UPDATE SKIP LOCKED— same behavior
A LockedUntilUtc lease prevents stuck deliveries. If a worker crashes mid-delivery, the lease expires (default: 2 minutes) and another worker reclaims the attempt.
Health Checks
Three health checks are registered automatically:
| Check | Tag | What It Monitors |
|---|---|---|
webhook-delivery-worker |
webhooks, liveness |
Worker running + heartbeat freshness |
webhooks-sqlserver |
webhooks, sqlserver, readiness |
SQL Server connectivity |
webhooks-postgresql |
webhooks, postgresql, readiness |
PostgreSQL connectivity |
// Map health check endpoints filtered by tag
app.MapHealthChecks("/health/live", new() { Predicate = hc => hc.Tags.Contains("liveness") });
app.MapHealthChecks("/health/ready", new() { Predicate = hc => hc.Tags.Contains("readiness") });
The worker health check reports unhealthy if:
- The worker is not running
- The worker has no heartbeat yet
- The heartbeat is older than
HealthStaleAfter(default: 2 minutes)
Graceful Shutdown
When the application is stopping:
- The worker stops accepting new batches
- If
DrainOnShutdownistrue(default), it waits for the current in-flight batch to complete - If the drain exceeds
DrainTimeout(default: 30s), in-flight deliveries are force-cancelled - Unfinished deliveries remain in the database as
Processing— their lease expires, and they're reclaimed on the next startup
Automatic Cleanup
The WebhookCleanupWorker runs alongside the delivery worker and periodically deletes old completed records:
| Option | Default | Description |
|---|---|---|
CleanupRetentionPeriod |
30 days |
Records older than this are deleted. Set to null to disable. |
CleanupInterval |
1 hour |
How often the cleanup runs |
CleanupBatchSize |
1000 |
Max records deleted per cycle |
Only Delivered and Failed records are deleted — Pending and Processing records are never touched.
Observability
Built-in OpenTelemetry instrumentation under the LowCodeHub.Webhooks source:
Metrics
| Metric | Type | Description |
|---|---|---|
webhooks.deliveries.dispatched |
Counter | Delivery attempts queued |
webhooks.deliveries.succeeded |
Counter | Successful deliveries (2xx) |
webhooks.deliveries.failed |
Counter | Failed deliveries (will retry or permanent) |
webhooks.deliveries.permanently_failed |
Counter | Deliveries that exhausted all retries |
webhooks.deliveries.retried |
Counter | Deliveries scheduled for retry |
webhooks.deliveries.cleaned |
Counter | Old records removed by cleanup |
webhooks.worker.batches_claimed |
Counter | Batches claimed by the worker |
webhooks.dispatch.no_subscriptions |
Counter | Dispatches with no matching subscriptions |
webhooks.delivery.duration |
Histogram (ms) | HTTP call duration |
webhooks.worker.batch_size |
Histogram | Deliveries per batch |
Traces
| Activity | Kind | Description |
|---|---|---|
webhooks.dispatch |
Producer | Dispatching an event to subscriptions |
webhooks.deliver |
Client | Sending a single HTTP delivery |
webhooks.cleanup |
Internal | Cleanup cycle |
// Wire up OpenTelemetry
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("LowCodeHub.Webhooks"))
.WithMetrics(m => m.AddMeter("LowCodeHub.Webhooks"));
Database Migrations
SQL Server Migration
CREATE TABLE [dbo].[WebhookSubscriptions]
(
[Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY DEFAULT NEWID(),
[TenantId] NVARCHAR(200) NULL,
[EventType] NVARCHAR(200) NOT NULL,
[Name] NVARCHAR(200) NOT NULL,
[WebhookUrl] NVARCHAR(2000) NOT NULL,
[HttpMethod] NVARCHAR(10) NOT NULL DEFAULT 'POST',
[ContentType] NVARCHAR(100) NOT NULL DEFAULT 'application/json',
[Headers] NVARCHAR(MAX) NULL,
[TimeoutSeconds] INT NOT NULL DEFAULT 30,
[MaxRetries] INT NOT NULL DEFAULT 5,
[ProxyUrl] NVARCHAR(2000) NULL,
[IsActive] BIT NOT NULL DEFAULT 1,
[CreatedAtUtc] DATETIMEOFFSET(7) NOT NULL DEFAULT SYSDATETIMEOFFSET(),
[UpdatedAtUtc] DATETIMEOFFSET(7) NULL
);
CREATE INDEX [IX_WebhookSubscriptions_EventType_IsActive]
ON [dbo].[WebhookSubscriptions] ([EventType], [IsActive])
INCLUDE ([TenantId]);
CREATE TABLE [dbo].[WebhookDeliveryAttempts]
(
[Id] UNIQUEIDENTIFIER NOT NULL PRIMARY KEY DEFAULT NEWID(),
[SubscriptionId] UNIQUEIDENTIFIER NOT NULL,
[TenantId] NVARCHAR(200) NULL,
[EventType] NVARCHAR(200) NOT NULL,
[WebhookUrl] NVARCHAR(2000) NOT NULL,
[HttpMethod] NVARCHAR(10) NOT NULL,
[ContentType] NVARCHAR(100) NOT NULL,
[Headers] NVARCHAR(MAX) NULL,
[TimeoutSeconds] INT NOT NULL DEFAULT 30,
[ProxyUrl] NVARCHAR(2000) NULL,
[Payload] NVARCHAR(MAX) NOT NULL,
[State] INT NOT NULL DEFAULT 0,
[RetryCount] INT NOT NULL DEFAULT 0,
[MaxRetries] INT NOT NULL DEFAULT 5,
[NextAttemptAtUtc] DATETIMEOFFSET(7) NULL,
[LastError] NVARCHAR(MAX) NULL,
[ResponseStatusCode] INT NULL,
[CreatedAtUtc] DATETIMEOFFSET(7) NOT NULL DEFAULT SYSDATETIMEOFFSET(),
[DeliveredAtUtc] DATETIMEOFFSET(7) NULL,
[LockedUntilUtc] DATETIMEOFFSET(7) NULL
);
CREATE INDEX [IX_WebhookDeliveryAttempts_State_NextAttempt]
ON [dbo].[WebhookDeliveryAttempts] ([State], [NextAttemptAtUtc])
INCLUDE ([LockedUntilUtc], [CreatedAtUtc]);
PostgreSQL Migration
CREATE TABLE "public"."webhook_subscriptions"
(
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(200) NULL,
event_type VARCHAR(200) NOT NULL,
name VARCHAR(200) NOT NULL,
webhook_url VARCHAR(2000) NOT NULL,
http_method VARCHAR(10) NOT NULL DEFAULT 'POST',
content_type VARCHAR(100) NOT NULL DEFAULT 'application/json',
headers JSONB NULL,
timeout_seconds INT NOT NULL DEFAULT 30,
max_retries INT NOT NULL DEFAULT 5,
proxy_url VARCHAR(2000) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at_utc TIMESTAMPTZ NULL
);
CREATE INDEX ix_webhook_subscriptions_event_type_active
ON "public"."webhook_subscriptions" (event_type, is_active)
INCLUDE (tenant_id);
CREATE TABLE "public"."webhook_delivery_attempts"
(
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL,
tenant_id VARCHAR(200) NULL,
event_type VARCHAR(200) NOT NULL,
webhook_url VARCHAR(2000) NOT NULL,
http_method VARCHAR(10) NOT NULL,
content_type VARCHAR(100) NOT NULL,
headers JSONB NULL,
timeout_seconds INT NOT NULL DEFAULT 30,
proxy_url VARCHAR(2000) NULL,
payload TEXT NOT NULL,
state INT NOT NULL DEFAULT 0,
retry_count INT NOT NULL DEFAULT 0,
max_retries INT NOT NULL DEFAULT 5,
next_attempt_at_utc TIMESTAMPTZ NULL,
last_error TEXT NULL,
response_status_code INT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
delivered_at_utc TIMESTAMPTZ NULL,
locked_until_utc TIMESTAMPTZ NULL
);
CREATE INDEX ix_webhook_delivery_attempts_state_next_attempt
ON "public"."webhook_delivery_attempts" (state, next_attempt_at_utc)
INCLUDE (locked_until_utc, created_at_utc);
Custom Repository Implementations
The library uses two repository interfaces. Implement them to use a different database:
public interface IWebhookSubscriptionRepository
{
Task<IReadOnlyList<WebhookSubscription>> GetActiveByEventTypeAsync(
string eventType, string? tenantId = null, CancellationToken ct = default);
}
public interface IWebhookDeliveryRepository
{
Task AddAsync(WebhookDeliveryAttempt delivery, CancellationToken ct = default);
Task<IReadOnlyList<WebhookDeliveryAttempt>> ClaimPendingAsync(
int batchSize, DateTimeOffset utcNow, CancellationToken ct = default);
Task MarkDeliveredAsync(Guid id, int statusCode, DateTimeOffset deliveredAtUtc, CancellationToken ct = default);
Task MarkFailedAsync(Guid id, string error, int? statusCode, int retryCount,
DateTimeOffset? nextAttemptAtUtc, CancellationToken ct = default);
Task<int> DeleteCompletedBeforeAsync(DateTimeOffset cutoffUtc, int batchSize, CancellationToken ct = default);
}
Register your implementations before calling AddWebhooks:
builder.Services.AddScoped<IWebhookSubscriptionRepository, MyMongoWebhookSubscriptionRepo>();
builder.Services.AddScoped<IWebhookDeliveryRepository, MyMongoWebhookDeliveryRepo>();
builder.Services.AddWebhooks(builder.Configuration);
// No need to call AddWebhooksSqlServer/AddWebhooksPostgreSql
The library uses TryAddScoped, so your registrations take precedence.
Requirements
- .NET 10 or later
- SQL Server (via
Microsoft.Data.SqlClient) or PostgreSQL (viaNpgsql) — or your own repository implementation Microsoft.Extensions.Http.Resilience10.1+ (included as a dependency)
License
MIT © Ahmed Abuelnour
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Data.SqlClient (>= 7.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.6)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.6)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.6)
- Microsoft.Extensions.Http (>= 10.0.6)
- Microsoft.Extensions.Http.Resilience (>= 10.5.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.6)
- Microsoft.Extensions.Options (>= 10.0.6)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.6)
- Npgsql (>= 10.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.