LowCodeHub.Webhooks 0.2.0

dotnet add package LowCodeHub.Webhooks --version 0.2.0
                    
NuGet\Install-Package LowCodeHub.Webhooks -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LowCodeHub.Webhooks" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.Webhooks" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.Webhooks" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LowCodeHub.Webhooks --version 0.2.0
                    
#r "nuget: LowCodeHub.Webhooks, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LowCodeHub.Webhooks@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LowCodeHub.Webhooks&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.Webhooks&version=0.2.0
                    
Install as a Cake Tool

LowCodeHub.Webhooks

Reliable outgoing webhooks for ASP.NET Core: database-stored subscriptions, outbox-style persisted deliveries processed by a background worker, durable retries with backoff, HMAC signatures, SSRF protection, paginated "pull" sources, OpenTelemetry and health checks. SQL Server or PostgreSQL.

NuGet License: MIT

dotnet add package LowCodeHub.Webhooks

Quick start

// Program.cs — one call registers the dispatcher, worker, cleanup, health checks and the SQL Server store
builder.Services.AddWebhooksSqlServer(builder.Configuration);       // or AddWebhooksPostgreSql
{ "Webhooks": { "SqlServer": { "ConnectionString": "Server=...;Database=...;" } } }

Create the tables once (see Database schema), then:

// Register a subscription (e.g. from your admin API)
await subscriptions.AddAsync(new WebhookSubscription
{
    EventType = "order.created",
    Name = "Acme ERP",
    TenantId = "acme",                       // optional
    WebhookUrl = "https://erp.acme.example/hooks/orders",
    Secret = "whsec_long_random_value"       // optional: enables HMAC signing
});

// Dispatch an event from anywhere (IWebhookDispatcher is scoped)
await dispatcher.DispatchAsync("order.created", new { order.Id, order.Total }, tenantId: "acme");

subscriptions is IWebhookSubscriptionRepository (AddAsync, UpdateAsync, DeleteAsync, GetByIdAsync, ListAsync, GetActiveByEventTypeAsync); WebhookSubscription is a record, so updates are await subscriptions.UpdateAsync(s with { IsActive = false }).

UpdateAsync and DeleteAsync return Outcome<Success> (from LowCodeHub.Outcomes): Success, or a 404 problem with errorCode Webhooks.SubscriptionNotFound (WebhookErrorCodes.SubscriptionNotFound) when the id does not exist. Handle it with a switch, or render it straight from a Minimal API:

var message = await subscriptions.DeleteAsync(id) switch
{
    Success          => "deleted",
    ProblemDetails p => $"failed: {p.GetErrorCode()}",
};

app.MapDelete("/subscriptions/{id:guid}", (Guid id, IWebhookSubscriptionRepository subscriptions) =>
    subscriptions.DeleteAsync(id).ToHttpResult());   // 204, or the 404 problem

Requires net11.0 (.NET 11 SDK).

Verify on the receiving side

app.MapPost("/hooks/orders", async (HttpRequest request) =>
{
    var body = await new StreamReader(request.Body).ReadToEndAsync();
    var valid = WebhookSignature.Verify(
        secret: "whsec_long_random_value",
        body: body,
        deliveryIdHeader: request.Headers[WebhookSignature.IdHeader],
        timestampHeader: request.Headers[WebhookSignature.TimestampHeader],
        signatureHeader: request.Headers[WebhookSignature.SignatureHeader]);   // constant-time, rejects > 5 min skew

    if (!valid) return TypedResults.Unauthorized();
    // De-duplicate on X-Webhook-Id: delivery is at-least-once.
    return TypedResults.Ok();
});
Header Value
X-Webhook-Id Delivery id, stable across retries — sent on every request
X-Webhook-Timestamp Unix seconds when signed (only with a secret)
X-Webhook-Signature sha256= + lowercase hex HMAC-SHA256 of "{id}.{timestamp}.{body}" (empty body for GET/DELETE/...)

How it works

  1. DispatchAsync loads active subscriptions for the event type and tenant (tenantId: null matches only untenanted subscriptions), snapshots each one (URL, method, headers, timeout, proxy, secret) into a Pending delivery row — all rows in one transaction on the store's own connection (it does not enlist in your business transaction; dispatch after commit).
  2. The worker claims batches (UPDLOCK, READPAST / FOR UPDATE SKIP LOCKED) with a lease, renews the lease every LeaseDuration / 3 while sending, and abandons a delivery if the lease was taken over.
  3. 2xx → Delivered. Anything else → Pending again with exponential backoff and jitter (InitialRetryDelay × 2ⁿ, capped at MaxRetryDelay) until MaxRetries, then Failed.
  4. Redirects are never followed; responses are read up to MaxResponseBodyBytes.
  5. A cleanup worker deletes Delivered/Failed rows older than CleanupRetentionPeriod.

In-process retries (HttpRetryCount) apply only to safe methods (GET/HEAD/OPTIONS…), so a POST is never sent twice within one attempt; POST/PUT/PATCH/DELETE rely on the durable retries. Set HttpRetryUnsafeMethods to opt in. Proxied deliveries bypass the in-process pipeline.

Delivery handlers (optional)

React to delivery results with typed handlers — register them with AddWebhooks(configuration, typeof(Program).Assembly) (assembly scan) or AddWebhookHandler<OrderCreatedHandler>(). Handlers are scoped and run after the result is persisted; a failing handler is logged and never breaks delivery.

public sealed class OrderCreatedHandler(IOrderService orders) : WebhookDeliveryHandler<ErpAck>
{
    public override string EventType => "order.created";

    public override Task OnSuccessAsync(WebhookDeliveryAttempt delivery, ErpAck? message, CancellationToken ct = default)
        => orders.MarkSyncedAsync(message?.OrderId, ct);

    public override Task OnFailureAsync(WebhookFailureContext failure, CancellationToken ct = default)
        => failure.PermanentlyFailed ? orders.FlagAsync(failure.Delivery.Id, failure.Error, ct) : Task.CompletedTask;
}

Paginated sources

A WebhookPageDeliveryHandler<TMessage> turns a delivery into a paged pull: each fetched page is handed to OnPageSuccessAsync as soon as it arrives, then the resume position is saved on the delivery row. If page n fails, the retry resumes at page n — earlier pages are not delivered again (at-least-once still applies if the process dies between handling a page and saving the checkpoint).

public sealed class OrderImportHandler(IOrderImport imports) : WebhookPageDeliveryHandler<OrderPage>
{
    public override string EventType => "orders.import";

    public override WebhookPaginationOptions Pagination => new()
    {
        Mode = WebhookPaginationMode.Offset,
        PageSize = 250,
        MaxPages = 200,
        QueryParameters = new() { PageNumber = "from", PageSize = "size" },
        ItemsPath = "results"
    };

    public override Task OnPageSuccessAsync(WebhookPageSuccessContext<OrderPage> page, CancellationToken ct = default)
        => imports.UpsertAsync(page.Message?.Results ?? [], ct);
}
Mode Requires
PageNumber QueryParameters.PageNumber + PageSize, and ItemsPath, HasMorePath or TotalPagesPath
Offset same as PageNumber (the page-number parameter carries the offset)
Cursor QueryParameters.Cursor + PageSize, NextCursorPath
NextUrl NextUrlPath; the first request uses WebhookUrl

Paths are dot-separated JSON paths (data.items, $ for a root array). MaxPages caps one delivery; reaching it fails the delivery. Exactly one paginated handler may match an event type.

Next URLs must stay on the subscription's origin (scheme, host, port) because subscription headers and the signature are sent with every page. A cross-origin next link fails the delivery unless AllowCrossOriginNextUrl = true.

Security

SSRF. Every outbound URL (delivery, each next page, proxy) must be absolute http/https and is resolved and rejected if any address is loopback, private (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), link-local/metadata (169.254/16), benchmarking (198.18/15), documentation/test nets, multicast/reserved, IPv6 unique-local/link-local/site-local, or a translated form of those (IPv4-mapped, IPv4-compatible ::a.b.c.d, NAT64 64:ff9b::/96, 6to4 2002::/16, Teredo). The connected IP is re-checked at socket connect time (Ssrf:ValidateOnConnect) to defeat DNS rebinding.

{ "Webhooks": { "Ssrf": { "AllowedHosts": [ "billing.internal" ], "DeniedHosts": [ "metadata.internal" ] } } }

With a per-subscription ProxyUrl, the target is validated by DNS before the request, but the proxy performs its own resolution — the connect-time check applies to the proxy, not the target. Only use proxies you trust to enforce egress rules. Proxy clients are cached (bounded, 64) and disposed only after in-flight requests finish.

Secrets at rest. By default secrets are stored as given. To encrypt them with ASP.NET Core Data Protection:

builder.Services.AddDataProtection().PersistKeysToDbContext<KeysContext>();   // shared, persisted key ring is required
builder.Services.AddWebhookSecretDataProtection();

Repositories protect the secret on AddAsync/UpdateAsync; delivery rows hold the protected value and it is unprotected only at signing time (a secret that cannot be unprotected fails the delivery rather than sending it unsigned). If you insert subscription rows yourself, store IWebhookSecretProtector.Protect(secret). Implement IWebhookSecretProtector for a KMS/Key Vault.

Configuration

Webhooks section (validated at startup):

Option Default Description
Enabled true Run the delivery worker
BatchSize / MaxParallelism 50 / 4 Rows claimed per poll / concurrent sends
PollInterval 2s Delay between polls when the last batch was not full
InitialRetryDelay / MaxRetryDelay 5s / 5min Durable backoff
RetryJitterFactor 0.2 ± jitter on durable retry delays
DefaultMaxRetries / DefaultTimeoutSeconds 5 / 30 Used when the subscription value is ≤ 0
HttpRetryCount / HttpRetryBaseDelay 3 / 1s In-process retries (safe methods only)
HttpRetryUnsafeMethods false Also retry POST/PUT/PATCH/DELETE in-process
HttpTotalTimeout 60s Total time per request including in-process retries
MaxResponseBodyBytes / MaxRequestBodyBytes 1 MB / 5 MB Response read cap / payload cap (checked at dispatch)
DrainOnShutdown / DrainTimeout true / 30s Wait for the in-flight batch on shutdown
HealthStaleAfter 2min Worker heartbeat staleness
CleanupRetentionPeriod 30 days null disables cleanup
CleanupInterval / CleanupBatchSize / CleanupBatchDelay / CleanupMaxBatchesPerCycle 1h / 1000 / 250ms / 100 Cleanup pacing
Ssrf:Enabled / AllowedHosts / DeniedHosts / ValidateOnConnect true / [] / [] / true SSRF guard

Store options — Webhooks:SqlServer / Webhooks:PostgreSql: ConnectionString (required), SubscriptionSchema, SubscriptionTable, DeliverySchema, DeliveryTable (defaults dbo/WebhookSubscriptions/WebhookDeliveryAttempts and public/webhook_subscriptions/webhook_delivery_attempts), LeaseDuration (2min).

AddWebhooks(o => ...) / AddWebhooksSqlServer(o => ...) configure in code; both styles can be combined. Calling AddWebhooks in addition to a store method is only needed for assembly scanning.

Database schema

The package never creates tables itself. Embedded scripts create the default table names:

Provider Embedded resource prefix
SQL Server LowCodeHub.Webhooks.Repositories.SqlServer.Scripts.
PostgreSQL LowCodeHub.Webhooks.Repositories.PostgreSql.Scripts.
// LowCodeHub.Migration.SqlServer (PostgreSQL: AddPostgreSqlMigrations + the PostgreSql prefix)
builder.Services.AddSqlServerMigrations(m => m.AddTarget<IWebhooksScriptScanner>(o =>
{
    o.ConnectionString = builder.Configuration.GetConnectionString("Webhooks")!;
    o.Directories = ["LowCodeHub.Webhooks.Repositories.SqlServer.Scripts."];
}));

With custom schema/table names, generate the DDL (index names follow the table names), e.g. in an EF Core migration:

migrationBuilder.Sql(SqlWebhooks.BuildSqlServerSchema(new SqlServerWebhookOptions { DeliveryTable = "OutgoingHooks" }));
migrationBuilder.Sql(SqlWebhooks.BuildPostgreSqlSchema(pgOptions));

The readiness health checks (webhooks-sqlserver / webhooks-postgresql) fail when the configured tables are missing; webhook-delivery-worker (tag liveness) reports a stopped or stale worker.

Observability

Meter and ActivitySource LowCodeHub.Webhooks: webhooks.deliveries.dispatched|succeeded|failed|permanently_failed|retried|cleaned, webhooks.worker.batches_claimed, webhooks.dispatch.no_subscriptions, webhooks.delivery.duration (ms), webhooks.worker.batch_size; activities webhooks.dispatch, webhooks.deliver, webhooks.cleanup. URLs in logs and traces are redacted (no user info, query or fragment).

Custom stores

Implement IWebhookSubscriptionRepository and IWebhookDeliveryRepository and register them (scoped) before AddWebhooks; the built-in registrations use TryAdd. Delivery repositories must: claim atomically with a lease and a fresh lock token, honour the lock token in MarkDelivered/MarkFailed/RenewLease/SavePaginationCheckpoint, and return the saved checkpoint from ClaimPendingAsync.

Breaking changes since 0.0.12

  • Signature now covers the delivery id: HMAC("{id}.{timestamp}.{body}"); X-Webhook-Id is sent on every request. WebhookSignature is public (Compute(secret, id, timestamp, payload), Verify(...)).
  • Delivery table has a new PaginationCheckpoint column and Secret columns are 2000 wide — recreate tables from the updated scripts.
  • IWebhookSubscriptionRepository.UpdateAsync/DeleteAsync now return Outcome<Success> (404 Webhooks.SubscriptionNotFound) instead of bool; the package now targets net11.0 and references LowCodeHub.Outcomes.
  • IWebhookSubscriptionRepository gained GetByIdAsync/ListAsync/AddAsync/UpdateAsync/DeleteAsync; IWebhookDeliveryRepository.RenewLeaseAsync, LeaseDuration and new SavePaginationCheckpointAsync are required members.
  • WebhookSubscription and WebhookDeliveryAttempt are records.
  • Paginated handlers receive pages as they are fetched; cross-origin next URLs are rejected by default.
  • In-process HTTP retries no longer apply to POST/PUT/PATCH/DELETE.
  • Store AddWebhooksSqlServer/PostgreSql(IConfiguration) also bind Webhooks options and register the core services; store options are validated at startup.

License

MIT © Ahmed Abuelnour

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 85 10/22/2026
0.0.13 90 10/22/2026
0.0.12 148 7/9/2026
0.0.11 120 6/23/2026
0.0.10 126 6/21/2026
0.0.8 128 6/1/2026
0.0.7 122 5/18/2026
0.0.6 618 5/13/2026
0.0.5 122 5/12/2026
0.0.4 132 4/23/2026
0.0.3 129 4/20/2026
0.0.2 126 4/16/2026
0.0.1 141 3/26/2026