LowCodeHub.Webhooks
0.0.13
See the version list below for details.
dotnet add package LowCodeHub.Webhooks --version 0.0.13
NuGet\Install-Package LowCodeHub.Webhooks -Version 0.0.13
<PackageReference Include="LowCodeHub.Webhooks" Version="0.0.13" />
<PackageVersion Include="LowCodeHub.Webhooks" Version="0.0.13" />
<PackageReference Include="LowCodeHub.Webhooks" />
paket add LowCodeHub.Webhooks --version 0.0.13
#r "nuget: LowCodeHub.Webhooks, 0.0.13"
#:package LowCodeHub.Webhooks@0.0.13
#addin nuget:?package=LowCodeHub.Webhooks&version=0.0.13
#tool nuget:?package=LowCodeHub.Webhooks&version=0.0.13
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.
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 }).
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 Results.Unauthorized();
// De-duplicate on X-Webhook-Id: delivery is at-least-once.
return Results.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
DispatchAsyncloads active subscriptions for the event type and tenant (tenantId: nullmatches only untenanted subscriptions), snapshots each one (URL, method, headers, timeout, proxy, secret) into aPendingdelivery row — all rows in one transaction on the store's own connection (it does not enlist in your business transaction; dispatch after commit).- The worker claims batches (
UPDLOCK, READPAST/FOR UPDATE SKIP LOCKED) with a lease, renews the lease everyLeaseDuration / 3while sending, and abandons a delivery if the lease was taken over. - 2xx →
Delivered. Anything else →Pendingagain with exponential backoff and jitter (InitialRetryDelay× 2ⁿ, capped atMaxRetryDelay) untilMaxRetries, thenFailed. - Redirects are never followed; responses are read up to
MaxResponseBodyBytes. - A cleanup worker deletes
Delivered/Failedrows older thanCleanupRetentionPeriod.
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-Idis sent on every request.WebhookSignatureis public (Compute(secret, id, timestamp, payload),Verify(...)). - Delivery table has a new
PaginationCheckpointcolumn andSecretcolumns are2000wide — recreate tables from the updated scripts. IWebhookSubscriptionRepositorygainedGetByIdAsync/ListAsync/AddAsync/UpdateAsync/DeleteAsync;IWebhookDeliveryRepository.RenewLeaseAsync,LeaseDurationand newSavePaginationCheckpointAsyncare required members.WebhookSubscriptionandWebhookDeliveryAttemptare 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 bindWebhooksoptions and register the core services; store options are validated at startup.
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.AspNetCore.DataProtection.Abstractions (>= 10.0.13)
- Microsoft.Data.SqlClient (>= 7.1.1)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.13)
- Microsoft.Extensions.Http (>= 10.0.13)
- Microsoft.Extensions.Http.Resilience (>= 10.11.0)
- Npgsql (>= 10.0.3)
- System.Security.Cryptography.Pkcs (>= 10.0.13)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.