Reliability
With a conforming durable store, accepted work can survive the process and resume under an at-least-once model.
Architecture case study · open-source library
An e-commerce platform must communicate order events even when receivers time out, return HTTP errors, become unavailable, or process the same delivery more than once.
The e-commerce scenario is illustrative. The guarantees described here match the v1.0.0 API and tests; they are not claims of adoption, benchmarks, or production operation.
Context and problem
After recording an order, sending HTTP once couples a business decision to a remote dependency. A timeout does not reveal whether the receiver processed the request; 5xx responses may be temporary; a 4xx normally requires correction rather than infinite retries.
If the process crashes after the receiver accepts the message but before success is persisted, a later attempt may duplicate the event. The solution must make queued work, attempts, temporary ownership, and terminal states observable without turning distributed uncertainty into an exactly-once promise.
Architecture requirements
With a conforming durable store, accepted work can survive the process and resume under an at-least-once model.
Stable IDs make enqueueing idempotent; the receiver still has to deduplicate effects.
Bounded concurrency prevents over-claiming; leases coordinate workers that share a store.
HTTPS is the default; HMAC-SHA256 authenticates the envelope when a signer and secret provider are registered.
Structured logs, ActivitySource, and Meter expose queueing, attempts, outcomes, and duration.
An expired lease enables reclaim; retries persist their due time; terminal states stop being claimable.
Solution architecture
IWebhookDeliveryStore, while effect idempotency belongs to the receiver.IWebhookEnqueueService validates limits and uses a stable ID.IWebhookDeliveryStore keeps the message, state, and schedule.WebhookDispatcher claims only due work within available capacity.IWebhookDeliveryTransport represents one HTTP attempt.The public service validates configured limits before persistence. The ID identifies the event–subscriber pair; enqueueing it again must return the existing record without replacing its payload, destination, or state.
Enqueue implementation ↗IWebhookDeliveryStore isolates storage technology but requires atomic claims, renewal, stale-owner rejection, and terminal transitions. The package ships no durable adapter.
The dispatcher fills only available slots, renews a lease during an attempt, and cancels work after ownership is lost. On shutdown, it stops claiming and waits for the grace period.
Dispatcher source ↗One SendAsync call is one attempt. The HTTP transport classifies 2xx as success, 408, 425, 429, and 5xx as retryable, and other statuses as permanent.
The default policy uses capped exponential backoff and positive jitter. A valid Retry-After may delay beyond the local cap; exhausting the attempt limit dead-letters the delivery.
The HMAC-SHA256 signer covers timestamp, ID, event type, content type, and exact payload bytes. Instrumentation publishes stable trace and metric names without payload, signature, or destination.
HMAC signer ↗Public API
// Production: the application supplies a conforming durable store.
services.AddSingleton<IWebhookDeliveryStore, MyDurableWebhookStore>();
ReliableWebhooksBuilder webhooks = services.AddReliableWebhooks(options =>
{
options.Dispatcher.MaxConcurrency = 8;
options.Dispatcher.LeaseDuration = TimeSpan.FromMinutes(2);
options.Retry = new WebhookRetryPolicyOptions { MaxAttempts = 5 };
});
webhooks.AddHostedDispatcher();
WebhookMessage message = new(
id: $"{orderCreated.EventId}:{subscriber.Id}",
eventType: "order.created",
destination: subscriber.WebhookUri,
payload: JsonSerializer.SerializeToUtf8Bytes(orderCreated),
contentType: "application/json");
await webhookEnqueueService.EnqueueAsync(message, cancellationToken);
AddHostedDispatcher() is opt-in. An application may instead resolve WebhookDispatcher and own its lifecycle directly.
Execution scenarios
The dispatcher acquires a lease, the transport receives 2xx, and the store runs MarkSucceededAsync. Succeeded is terminal and is not normally claimed again.
Guarantee: a success transition by the current owner; duplicates remain possible if the endpoint accepted the request but persisting success failed.
The transport returns a retryable failure. The policy calculates a due time with backoff/jitter, and the store runs ScheduleRetryAsync, releases the lease, and preserves the attempt count.
Guarantee: another attempt after the schedule with a conforming store; a timeout does not prove the receiver did not process the request.
A status such as 400 is permanent by default. The dispatcher calls MarkPermanentlyFailedAsync, and the item is not retried automatically.
Guarantee: a traceable terminal state. Exhausted retryable failures become DeadLettered, also without automatic replay.
With a durable store, pending work remains available, and an abandoned lease can be reclaimed after expiration. The former token cannot persist a late transition.
Limit: with InMemoryWebhookDeliveryStore, a restart loses the queue; there is no durable recovery in that mode.
Decisions and trade-offs
Retrying after an uncertain remote outcome preserves the chance of delivery but accepts duplicates. Exactly-once over HTTP would require transaction coordination the library and receiver do not share.
Temporary ownership supports multiple workers and abandoned-work recovery. The store must provide atomic claims, an authoritative clock, and token compare-and-set semantics.
Spacing attempts reduces pressure on an unavailable receiver; jitter reduces synchronized retry storms. Recovery latency increases, and each policy must match the remote contract.
Keeping the core independent of databases and ORMs makes the library reusable. The explicit cost is an application-owned conforming durable adapter for production.
A stable ID helps deduplication, but the library cannot control the remote effect. The receiver chooses an appropriate authenticated key and persists its own idempotency decision.
Store, transport, classifier, retry policy, signer, and providers can evolve independently. Composition gains flexibility, while custom implementations must preserve documented contracts.
| Library responsibility | Application responsibility |
|---|---|
| Enqueue, claim, lease, attempt, and transition protocol. | Supply durable, atomic persistence appropriate for production. |
| Default HTTP transport, classification, and configurable retries. | Choose policy, limits, quotas, dead-letter operation, and any replay. |
| HMAC-SHA256 signer and secret/header provider hooks. | Manage secrets, rotation, destination authorization, and receiver verification. |
| Logs, traces, and metrics with stable public names. | Collect, export, alert, define SLOs, and protect persisted data. |
| Stable identity and idempotent enqueue in the store contract. | Make the receiver's business effects idempotent. |
Limitations and evolution
The library does not guarantee exactly-once delivery, a distributed transaction with the receiver, receiver-side idempotency, automatic dead-letter replay, or a secret-rotation policy. The receiver must tolerate duplicate messages.
InMemoryWebhookDeliveryStore is process-local and not durable. The package ships no production store: the application must implement a conforming IWebhookDeliveryStore, including atomicity, leasing, coordination time, state persistence, and backend-specific tests.
PublicNetworkWebhookDestinationPolicy reduces SSRF risk for untrusted URLs but does not replace change authorization, allow-lists, egress controls, or connection-time DNS-rebinding protection. HTTPS provides confidentiality; HMAC authenticates content but does not encrypt the payload.
Outcomes and lessons
Instead of hiding failures inside an HTTP loop, the design records when work exists, who may change it, when another attempt becomes eligible, and which states finish the flow. Concurrency and recovery can then be reasoned about through executable evidence.
The central lesson is about boundaries: retries do not create exactly-once, leases do not create durability, and HMAC does not create confidentiality. The architectural outcome depends on the library protocol, persistence adapter, observable operation, and an idempotent receiver working together.
Technical evidence