Webority.Webhooks.Inbound.AspNetCore
0.12.0
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
<PackageReference Include="Webority.Webhooks.Inbound.AspNetCore" Version="0.12.0" />
<PackageVersion Include="Webority.Webhooks.Inbound.AspNetCore" Version="0.12.0" />
<PackageReference Include="Webority.Webhooks.Inbound.AspNetCore" />
paket add Webority.Webhooks.Inbound.AspNetCore --version 0.12.0
#r "nuget: Webority.Webhooks.Inbound.AspNetCore, 0.12.0"
#:package Webority.Webhooks.Inbound.AspNetCore@0.12.0
#addin nuget:?package=Webority.Webhooks.Inbound.AspNetCore&version=0.12.0
#tool nuget:?package=Webority.Webhooks.Inbound.AspNetCore&version=0.12.0
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: theALTERbatches forPreviousSigningSecretandPreviousSigningSecretExpiresDateTimeUtc(secret rotation), and the one forConsecutiveDeadLetters(INT NOT NULL, default 0; auto-disable). WebhookDeliveryAttempt.sqlin full: a new table, so itsCREATE TABLEbatch and its index batch.- From
WebhookDelivery.sql: the batch that rebuildsIX_WebhookDelivery_DuewithINCLUDE (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:DispatchBatchSizerows 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 | 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
- Webority.Delivery.AspNetCore (>= 0.30.0)
- Webority.Webhooks.Inbound (>= 0.12.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.