Webority.Webhooks.Testing 0.8.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Webority.Webhooks.Testing --version 0.8.0
                    
NuGet\Install-Package Webority.Webhooks.Testing -Version 0.8.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.Testing" Version="0.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Webority.Webhooks.Testing" Version="0.8.0" />
                    
Directory.Packages.props
<PackageReference Include="Webority.Webhooks.Testing" />
                    
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.Testing --version 0.8.0
                    
#r "nuget: Webority.Webhooks.Testing, 0.8.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.Testing@0.8.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.Testing&version=0.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Webority.Webhooks.Testing&version=0.8.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 (a payment gateway, a mail service) is not in this package; that stays with 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.

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.

Release notes

The version of record is VersionPrefix in Directory.Build.props. Add each change to Next release as it lands; cutting a release moves its rows into a new version entry below, newest first, and empties the section.

Next release

0.8.0

  • Schema ships as tables and a baseline migration. The tables now ship as Schema/Tables/ (the declarative shapes) plus the numbered, run-once Schema/Migrations/0001_Baseline.sql. The nupkg paths changed from content/schema/*.sql to content/schema/tables/ and content/schema/migrations/. Products copy the baseline instead of the four files. No database change.

0.7.0

  • ⚠️ Breaking: messages follow Standard Webhooks v1. Deliveries carry webhook-id, webhook-timestamp and webhook-signature (v1,<base64> entries over <id>.<timestamp>.<body>), and the body is {"type","timestamp","data"}. New endpoint secrets are whsec_ plus base64; an endpoint holding an older secret must rotate it (RotateSecretAsync) before it can be signed for. The Webhooks:SignatureHeaderName setting is gone. Integrators verify with any Standard Webhooks library.
  • ⚠️ Breaking: no subject column. PublishAsync(ownerKey, isLive, eventType, payload, ct) drops subjectPublicId; put the public id inside the payload, which is required and must be a JSON object. WebhookEvent.SubjectPublicId is gone and PayloadJson is required. In a database made from an earlier copy of WebhookEvent.sql, a journaled migration of the product's own drops SubjectPublicId and makes PayloadJson NOT NULL.
  • ⚠️ Breaking: inbound verification removed. IWebhookRequestVerifier, IWebhookNonceStore, WebhookSignedRequest, WebhookVerificationResult, AddWeborityWebhookRequestVerification() and the testing InMemoryWebhookNonceStore are deleted. Verify customers' signed API calls with Webority.Security (AddWeboritySignedRequests, RFC 9421).

0.5.0

  • Diagnostics console section. AddWeborityWebhooks<TContext> registers an "Outbound webhooks" section for Webority.Diagnostics (dispatch settings and active versus switched-off endpoints) and two log metrics: deliveries dead-lettered and signed requests refused. The package now depends on Webority.Diagnostics.Abstractions.
  • Refused signed requests are logged. WebhookRequestVerifier logs one Warning per refusal (event 7120, the reason only) and takes an ILogger<WebhookRequestVerifier>; a host that constructs it by hand passes a logger.

0.4.0

  • Signed-call verification is bounded. VerifyAny hashes the request once however many v1= entries it is given, and a header with more than 8 entries (WebhookSignature.MaxSignatureEntries) is refused as malformed. Before, an unsigned request could make the verifier hash its body once per entry.
  • A dead endpoint no longer holds up the run. After an attempt gets no HTTP answer, the dispatcher leaves that endpoint's other deliveries for the next run instead of waiting out the timeout on each.
  • A run drains more than one batch. The dispatcher claims again while batches come back full and time is left before its lease.
  • One destination check per endpoint per run, with a 5-second limit on the host lookup. The per-connection address check is unchanged.
  • Released claims on failure. A run whose save fails releases the deliveries it claimed but did not finish, instead of leaving them locked until the lease lapses.
  • The due index includes the lease column. A database made from an earlier copy needs the new converge batch from WebhookDelivery.sql in a journaled migration of the product's own (see "Upgrading a database made from an earlier copy"); it rebuilds IX_WebhookDelivery_Due with INCLUDE (ClaimedUntilDateTimeUtc).

0.3.0

  • Attempt history. A new append-only table, WebhookDeliveryAttempt, records every attempt (number, HTTP status, duration, a 512-character response snippet, the error). Run its script, Schema/WebhookDeliveryAttempt.sql, and the ConsecutiveDeadLetters batch from Schema/WebhookEndpoint.sql before deploying the code, or the dispatcher and every endpoint query fail.
  • Metrics. WeborityWebhooksDiagnostics publishes webhooks.delivered, webhooks.failed, webhooks.dead_lettered and webhooks.attempt.duration; subscribe with .AddMeter(WeborityWebhooksDiagnostics.MeterName).
  • Auto-disable. Webhooks:AutoDisableAfterDeadLetters (default 0, off) disables an endpoint after that many consecutive dead letters; register an IWebhookEndpointNotifier to tell the owner.
  • Retention purge. IWebhookRetention.PurgeAsync(cutoff) deletes settled deliveries, their attempts and unreferenced events. Host the job yourself.

0.2.0

  • Secret rotation overlap. After RotateSecretAsync the old secret keeps signing beside the new one for Webhooks:SecretRotationOverlapHours (default 24; 0 is a hard cutover). The signature header can carry two v1= entries, so a receiver that reads exactly one must be updated to try each.
  • Parallel dispatch. Webhooks:DispatchConcurrency (default 8) sends to that many endpoints at once, each still one delivery at a time and in order.
  • Two WebhookEndpoint columns: PreviousSigningSecret and PreviousSigningSecretExpiresDateTimeUtc. Run the ALTER batches from Schema/WebhookEndpoint.sql before deploying code that maps them.
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.4 0 11/4/2026
0.12.3 0 11/4/2026
0.12.2 0 11/4/2026
0.12.1 6 11/3/2026
0.12.0 25 11/3/2026
0.11.5 45 11/3/2026
0.11.4 45 11/3/2026
0.11.3 41 11/3/2026
0.11.2 50 11/2/2026
0.11.1 52 11/2/2026
0.11.0 64 11/2/2026
0.10.0 64 11/2/2026
0.9.3 53 11/2/2026
0.9.2 57 11/2/2026
0.9.1 55 11/2/2026
0.8.2 193 10/28/2026
0.8.1 59 10/28/2026
0.8.0 87 10/25/2026
0.7.0 92 10/25/2026
0.6.1 94 10/17/2026
Loading failed