Webority.Webhooks.Inbound.AspNetCore 0.12.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Webority.Webhooks.Inbound.AspNetCore --version 0.12.0
                    
NuGet\Install-Package Webority.Webhooks.Inbound.AspNetCore -Version 0.12.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="Webority.Webhooks.Inbound.AspNetCore" Version="0.12.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Webority.Webhooks.Inbound.AspNetCore" Version="0.12.0" />
                    
Directory.Packages.props
<PackageReference Include="Webority.Webhooks.Inbound.AspNetCore" />
                    
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 Webority.Webhooks.Inbound.AspNetCore --version 0.12.0
                    
#r "nuget: Webority.Webhooks.Inbound.AspNetCore, 0.12.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 Webority.Webhooks.Inbound.AspNetCore@0.12.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=Webority.Webhooks.Inbound.AspNetCore&version=0.12.0
                    
Install as a Cake Addin
#tool nuget:?package=Webority.Webhooks.Inbound.AspNetCore&version=0.12.0
                    
Install as a Cake Tool

Webority.Webhooks

Outbound signed webhooks for Webority products: a product's customers register HTTPS endpoints, the product publishes events in the same transaction as the state change they describe, and a dispatcher delivers them signed, with a fixed retry schedule, dead-lettering and replay. Messages follow Standard Webhooks v1 (standardwebhooks.com), so a receiver verifies them with any off-the-shelf Standard Webhooks library.

Customers who sign their own API calls to a product are verified by Webority.Security (AddWeboritySignedRequests, RFC 9421), not here.

Receiving webhooks from a provider is in Webority.Webhooks.Inbound: the package stores, processes and reprocesses them. Verifying the provider's signature and mapping its payload stay in the domain package that understands the provider.

Package What it holds
Webority.Webhooks Entities and EF mappings, table scripts, IWebhookPublisher, IWebhookDispatcher, IWebhookEndpointService, IWebhookRetention, the optional IWebhookEndpointNotifier, WeborityWebhooksDiagnostics (metrics), WebhookSignature, the destination guard and the delivery client.
Webority.Webhooks.Testing StaticWebhookHostResolver, RecordingWebhookHandler and UseWebhookTestTransport(). For tests only.
Webority.Webhooks.Inbound The InboundWebhookDelivery entity and its mapping, IInboundWebhookIntake (store, processing run, reprocess), the receiver and processor interfaces, and the registration with AddWeborityInboundWebhooks.
Webority.Webhooks.Inbound.AspNetCore MapInboundWebhook, the POST endpoint of one registered route: it runs that route's receiver and stores the body, anonymous unless the route names an authorization policy.
Webority.Webhooks.Inbound.Admin The Webhooks admin area: deliveries by source and status, and reprocessing one.

Usage pages: docs/usage.md.

Using it in a product

1. Tables and model

The package owns no DbContext. Map the four entities in the product's own context, so an event commits with the change it describes:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyWeborityWebhooks();
}

Run Schema/Migrations/0001_Baseline.sql (shipped in the nupkg under content/schema/migrations/) in the product's own journaled migration, or copy it into the product's Scripts/. It creates the four tables in foreign-key order and is a no-op on a database that already has them, so it is safe on a database made from an earlier copy. The declarative table shapes are in Schema/Tables/ (content/schema/tables/) for reading and for the drift test; do not run them. SigningSecret is a plain column by decision; the database is what is secured.

Upgrading a database made from an earlier copy. Copy these guarded batches into a journaled migration of the product's own:

  • From WebhookEndpoint.sql: the ALTER batches for PreviousSigningSecret and PreviousSigningSecretExpiresDateTimeUtc (secret rotation), and the one for ConsecutiveDeadLetters (INT NOT NULL, default 0; auto-disable).
  • WebhookDeliveryAttempt.sql in full: a new table, so its CREATE TABLE batch and its index batch.
  • From WebhookDelivery.sql: the batch that rebuilds IX_WebhookDelivery_Due with INCLUDE (ClaimedUntilDateTimeUtc) (DROP_EXISTING). The rebuild blocks writes to the table while it runs, so schedule it for a quiet moment on a large table.

Run the new WebhookEndpoint column before deploying code that maps it, or every endpoint query fails.

2. Registration

services.AddWeborityWebhooks<ProductDbContext>(new WebhookEventCatalog(new[]
{
    "order.created",
    "order.shipped"
}));

Every setting under Webhooks is optional: DispatchBatchSize (default 100) and DispatchConcurrency (default 8: endpoints sent to at once, each still one delivery at a time and in order), SecretRotationOverlapHours (default 24, see "The signature") and AutoDisableAfterDeadLetters (default 0, see "Auto-disable") are optional. Event types are lowercase dot-separated wire names, pinned as strings; webhook. is reserved for the package's own test ping.

3. Publishing

order.Ship(now);
await publisher.PublishAsync(customerKey, isLive: true, "order.shipped", new { orderPublicId = order.PublicCode, order.TrackingNumber }, ct);
await db.SaveChangesAsync(ct); // the order, the event and its deliveries commit together

PublishAsync never saves. It fans out to the owner's active endpoints whose IsLive matches and whose subscription wants the event. The payload is required and must serialize to a JSON object; put the public id of the thing the event is about inside it, never an internal id. Keep payloads thin, at most 256 KB of UTF-8 JSON: the receiver fetches detail over the product's API.

4. Delivering

Host the dispatcher on a timer, one fresh DI scope per run:

[Function("WebhookDelivery")]
public async Task RunAsync([TimerTrigger("0 */1 * * * *")] TimerInfo timer, CancellationToken cancellationToken)
{
    using var scope = _scopes.CreateScope();
    await scope.ServiceProvider.GetRequiredService<IWebhookDispatcher>().RunAsync(cancellationToken);
}

A Functions (isolated worker) head needs a direct PackageReference to Webority.Webhooks; transitive references drop out of the worker's closure.

  • Retry schedule: 1 minute, 5 minutes, 25 minutes, 2 hours, 10 hours, then dead-lettered (six attempts).
  • Lease: a run claims rows for 5 minutes with one conditional update, so several instances can run at once; a crashed run's rows return when the lease lapses. A run stops attempting before its lease could lapse mid-send.
  • Batches: a run claims Webhooks:DispatchBatchSize rows at a time (default 100) and claims again while a batch comes back full and time is left, so a burst drains in one run rather than one batch per timer tick.
  • An endpoint that gives no answer (timeout, refused connection, unknown host) is not tried again in the same run; its other deliveries go back to the pool untouched and the next run tries again.
  • Delivered means a 2xx. Redirects are not followed. The timeout is 15 seconds.
  • Destination safety: HTTPS only, no credentials in the URL, and the host must resolve only to public addresses (private, loopback, link-local, unique-local, carrier-grade NAT, multicast and reserved ranges are refused, including IPv4 carried in IPv6). Checked at create, update, activate and once per endpoint in each dispatcher run; the delivery client re-checks the address of every connection it opens. A private destination dead-letters; an unresolvable host retries.

5. Managing endpoints

IWebhookEndpointService holds the rules (URL check, known event types, ten active endpoints per owner, no duplicate active URL, a random 32-byte secret shown once) and returns FluentResults with a WebhookError carrying a stable Code and a message safe to show. Read endpoints and the delivery log with plain queries on the product's context.

A delivery is named outside the platform by its event's EventId and its endpoint's PublicId; replay with ReplayAsync(ownerKey, eventId, endpointPublicId).

6. Attempt log, metrics, auto-disable and retention

Attempt log. Every attempt writes one append-only WebhookDeliveryAttempt row in the same save as the delivery's new state: the attempt number (restarts at 1 after a replay), the HTTP status, the send duration in milliseconds, the first 512 characters of the answer body (ResponseSnippet, read for a short time only, so a stalled body costs the log its snippet and never the delivery) and the error. Read it with a plain query on the product's context.

Metrics. Subscribe by name: .AddMeter(WeborityWebhooksDiagnostics.MeterName) in an OpenTelemetry pipeline (the package only uses System.Diagnostics.Metrics and references no OpenTelemetry package). Instruments, each tagged event_type and nothing else, never the owner, endpoint or URL:

Instrument Counts
webhooks.delivered Deliveries the endpoint accepted.
webhooks.failed Attempts that failed, whether they will retry or dead-letter.
webhooks.dead_lettered Deliveries that failed for good.
webhooks.attempt.duration (ms) Time spent sending one attempt.

Auto-disable. WebhookEndpoint.ConsecutiveDeadLetters rises on every dead-lettered delivery and resets when the endpoint accepts one and when the endpoint is activated. Set Webhooks:AutoDisableAfterDeadLetters (0 to 1000; 0, the default, is off): when the run reaches it, the dispatcher calls the endpoint's Disable and saves it. Register an IWebhookEndpointNotifier to tell the owner; it is optional, resolved from the run's scope and skipped when absent:

public sealed class EmailEndpointNotifier : IWebhookEndpointNotifier
{
    public Task EndpointDisabledAsync(WebhookEndpoint endpoint, string reason, CancellationToken cancellationToken = default) =>
        /* look up endpoint.OwnerKey and email them; reason is safe to show */ Task.CompletedTask;
}

It is called after the run's deliveries are recorded. An exception from it is logged and does not fail the run. Counting is not atomic across several dispatcher instances: two runs on one endpoint may each count from the same starting value, so the threshold can be crossed one delivery late.

Retention. The package runs no timer. Host a job of your own and call IWebhookRetention from a fresh scope:

await scope.ServiceProvider.GetRequiredService<IWebhookRetention>()
    .PurgeAsync(DateTimeOffset.UtcNow.AddDays(-90), cancellationToken);

It deletes, in batches of 500, deliveries that are delivered or dead-lettered and were created (and, when delivered, delivered) before the cut-off, their attempts, and then events older than the cut-off that no delivery references. Pending deliveries are never touched. It returns a WebhookPurgeSummary with the three counts.

The signature

Every delivery is a Standard Webhooks v1 message, with three headers:

Header Value
webhook-id The event's EventId, the same on every retry and replay. Receivers dedupe on it.
webhook-timestamp Unix seconds when the message was signed.
webhook-signature One or more v1,<base64 HMAC-SHA256> entries, separated by spaces.

The HMAC key is the endpoint secret's base64 part, decoded: a secret is whsec_ followed by base64 of 32 random bytes. The signed content is <webhook-id>.<webhook-timestamp>.<body>, the body exactly as sent. The URL is not signed.

A header can carry more than one entry, and a receiver accepts the message when any of them verifies. After RotateSecretAsync the old secret keeps signing beside the new one for Webhooks:SecretRotationOverlapHours (default 24; 0 is a hard cutover), new secret first, so an integrator switches secrets without a failed delivery. Standard Webhooks libraries already try each entry. A header carries at most 8 entries (WebhookSignature.MaxSignatureEntries); WebhookSignature.Verify refuses one with more.

A receiver checks the timestamp is within 5 minutes and verifies the HMAC in fixed time (WebhookSignature.Verify(secret, signatureHeader, id, timestamp, body, nowUtc) does both). The body is JSON:

{ "type": "order.shipped", "timestamp": "2026-09-28T10:00:00Z", "data": { } }

type is the event type, timestamp is when the event happened (RFC 3339, UTC), and data is the payload object. Delivery is at-least-once; receivers dedupe on webhook-id.

Build, test, release

Before pushing, run locally (CI does not check code):

dotnet build Webority.Webhooks.slnx -c Release -m:4
dotnet test --solution Webority.Webhooks.slnx -c Release --no-build

The version of record is Directory.Build.props VersionPrefix. A merge to main packs the solution and pushes to nuget.org; version-check.yml fails a release PR that does not raise the version.

Product 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. 
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.12.1 0 11/3/2026
0.12.0 0 11/3/2026
0.11.5 0 11/3/2026
0.11.4 0 11/3/2026
0.11.3 0 11/3/2026
0.11.2 0 11/2/2026
0.11.1 0 11/2/2026
0.11.0 47 11/2/2026
0.10.0 50 11/2/2026
0.9.3 41 11/2/2026
0.9.2 36 11/2/2026
0.9.1 43 11/2/2026