Skip to main content

OrionRelay

OrionRelay

CI/CD NuGet

Outbound webhook delivery for .NET. You hand it a payload and an endpoint; it signs the request, sends it, and retries transient failures with backoff until it lands or the attempt budget runs out.

Part of the Orion family. Usable entirely on its own.

Why​

Delivering a webhook reliably is more than one HttpClient.PostAsync. You need request signing so receivers can trust the payload, retries that distinguish a transient 503 from a permanent 400, backoff with jitter so a fleet of senders does not stampede a recovering receiver, and telemetry so you can see delivery health. OrionRelay packages those decisions so you do not re-derive them per project.

Features​

  • HMAC-SHA256 request signing. Every attempt carries an Orion-Signature header of the form t=<unix-seconds>,v1=<hex-hmac>, with the send timestamp bound into the MAC so a receiver can reject replays.
  • Transient-aware retries. Transport faults, per-attempt timeouts, and HTTP 408/429/5xx are retried; any other 4xx fails fast.
  • Equal-jitter exponential backoff. Backoff doubles per attempt, clamps to a ceiling, and randomises half the delay so concurrent senders do not retry in lockstep.
  • Per-attempt telemetry. A System.Diagnostics.Metrics meter exposes delivery/attempt counters and an attempts-per-delivery histogram, ready for OpenTelemetry.
  • Fault-safe delivery observer. An optional hook sees every attempt and every exhausted delivery for dead-lettering or alerting; faults it raises never break delivery.
  • Pluggable dead-letter sink. Deliveries that exhaust their attempt budget are routed to an IDeadLetterSink exactly once, carrying their terminal failure context, so you can persist, alert on, or replay them. The default is a no-op; a bounded InMemoryDeadLetterSink with oldest-first eviction is available as an opt-in, and faults the sink raises never break delivery.
  • One-call DI registration. AddOrionRelay wires a dedicated HttpClient, the signer, the diagnostics, and the dispatcher, validating your options eagerly.
  • Multi-targeted. net8.0, net9.0, and net10.0, with nullable enabled and warnings as errors.

Install​

dotnet add package OrionRelay

The NuGet package id is OrionRelay; the root namespace is Moongazing.OrionRelay.

Quick start​

Register OrionRelay with a shared signing secret and optional delivery tuning:

using Moongazing.OrionRelay;

services.AddOrionRelay(signingSecret: "whsec_your_shared_secret", o =>
{
o.MaxAttempts = 5;
o.BaseDelay = TimeSpan.FromSeconds(2);
o.MaxDelay = TimeSpan.FromMinutes(1);
});

Inject IWebhookDispatcher and send a signed payload:

using Moongazing.OrionRelay.Delivery;

public sealed class OrderEvents(IWebhookDispatcher dispatcher)
{
public async Task NotifyAsync(Uri subscriber, byte[] payload, CancellationToken ct)
{
var result = await dispatcher.DispatchAsync(new WebhookMessage
{
Endpoint = subscriber,
Body = payload,
EventId = Guid.NewGuid().ToString("N"),
EventType = "order.created",
}, ct);

if (!result.Succeeded)
{
// Persist for later redelivery; result.Attempts / result.StatusCode tell you why.
}
}
}

DispatchAsync returns when delivery succeeds (a 2xx response) or the attempt budget is exhausted. A cancelled token aborts the whole delivery, including backoff waits, and throws OperationCanceledException rather than returning a failure result.

Usage​

Sending a webhook​

WebhookMessage describes a single delivery:

PropertyTypeNotes
EndpointUriRequired. The absolute endpoint to POST to.
BodyReadOnlyMemory<byte>Required. Transmitted verbatim and covered by the signature.
ContentTypestringDefaults to application/json. Sets the request Content-Type.
EventIdstring?Optional. Sent as the Orion-Event-Id header for receiver-side deduplication.
EventTypestring?Optional. Sent as the Orion-Event-Type header and used for the event_type telemetry tag.

WebhookDeliveryResult reports the outcome:

MemberTypeNotes
SucceededboolTrue when a 2xx response arrived within the attempt budget.
AttemptsintAttempts made, including the first send.
StatusCodeint?Last HTTP status observed, or null if every attempt failed at the transport level.
FinalExceptionException?The final transport fault, when delivery ended on one rather than an HTTP error.

Signature verification on the receiver​

When a signing secret is configured, every request carries an Orion-Signature header of the form t=<unix-seconds>,v1=<hex-hmac>. The HMAC-SHA256 is taken over <unix-seconds>.<body>, so the timestamp is bound into the MAC. A receiver verifies by recomputing the MAC over the exact raw body it received and rejecting requests whose timestamp falls outside a freshness window, which stops replays.

WebhookVerifier is the receiver-side counterpart to WebhookSigner. It recomputes the MAC over the same canonical preimage the signer uses, enforces the freshness window in both directions, and compares in constant time. Verify returns a WebhookVerificationResult rather than throwing, so a receiver can branch on the specific reason a request was rejected.

using Moongazing.OrionRelay.Signing;

// Construct once with the shared secret and a freshness window, then reuse.
var verifier = new WebhookVerifier(secret, tolerance: TimeSpan.FromMinutes(5));

// In the request handler, verify against the raw body bytes exactly as received.
var result = verifier.Verify(signatureHeader, rawBody, now: DateTimeOffset.UtcNow);
if (!result.IsValid)
{
return result.Failure switch
{
// Header could not be parsed, or the timestamp is outside the window: reject the request.
WebhookVerificationFailure.Malformed => Results.BadRequest(),
WebhookVerificationFailure.StaleTimestamp => Results.StatusCode(StatusCodes.Status408RequestTimeout),
// The signature did not match: treat as unauthorized.
_ => Results.Unauthorized(),
};
}

// Signature authentic and fresh: process the event.

Verify against the raw bytes exactly as received, before any deserialization reshapes them. The default freshness window is WebhookVerifier.DefaultTolerance (5 minutes) when you do not pass one. The sender side of this contract is IWebhookSigner.Sign(ReadOnlySpan<byte> body, DateTimeOffset timestamp).

Retries and backoff​

An attempt is retried when it produces a transport fault, a per-attempt timeout, or an HTTP 408, 429, or 5xx. Any other 4xx is treated as permanent and fails fast. Backoff is exponential from BaseDelay, doubled per attempt and clamped to MaxDelay, with equal jitter (half the computed delay kept as a floor, the other half randomised) so concurrent senders do not retry in lockstep. The dispatcher enforces a per-attempt timeout (RequestTimeout) independently of the HttpClient, which AddOrionRelay leaves uncapped so the two do not race.

Delivery observer hook​

Implement IWebhookDeliveryObserver and register it in DI before resolving the dispatcher to see every attempt and every exhausted delivery, for dead-lettering, alerting, or audit:

using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionRelay.Delivery;
using Moongazing.OrionRelay.Observers;

public sealed class DeadLetterObserver(IDeadLetterStore store) : IWebhookDeliveryObserver
{
public void OnAttempt(WebhookMessage message, int attempt, int? statusCode, Exception? exception)
{
// Per-attempt visibility: log, count, trace.
}

public void OnExhausted(WebhookMessage message, WebhookDeliveryResult result)
{
// The attempt budget ran out. Park the message for later redelivery.
store.Park(message, result);
}
}

// Register the observer before AddOrionRelay resolves the dispatcher.
services.AddSingleton<IWebhookDeliveryObserver, DeadLetterObserver>();
services.AddOrionRelay(signingSecret: "whsec_your_shared_secret");

The observer is observability only. The dispatcher swallows any exception it raises, so an observer outage never breaks delivery. If you register none, a no-op (NullWebhookDeliveryObserver) is used.

Dead-letter sink​

When a delivery terminates without success, the dispatcher routes it to an IDeadLetterSink exactly once, after the final attempt, so a consumer can persist, alert on, or replay it. A delivery terminates either by exhausting its retry budget or by hitting a fatal, non-retryable response (for example an HTTP 400), so the sink receives any terminal non-success, not only budget-exhausted ones. The sink receives a DeadLetterEntry carrying the original message, the terminal WebhookDeliveryResult, and the instant the delivery was abandoned:

MemberTypeNotes
MessageWebhookMessageThe message that could not be delivered within its attempt budget.
ResultWebhookDeliveryResultThe terminal failure result: attempts made, last status, and final transport fault.
DeadLetteredAtDateTimeOffsetThe instant the delivery was abandoned and routed to the sink.

AddOrionRelay registers the no-op NullDeadLetterSink by default, which retains nothing and so cannot grow the process working set during a prolonged receiver outage. Register your own sink before the dispatcher resolves to capture abandoned deliveries instead. A durable store is the right choice for production; for tests, demos, and single-process apps the library ships a bounded InMemoryDeadLetterSink that retains the most recent entries in arrival order and evicts the oldest once full:

using Microsoft.Extensions.DependencyInjection;
using Moongazing.OrionRelay;
using Moongazing.OrionRelay.Delivery;

// Opt in to the in-memory sink, bounded to the 256 most recent abandoned deliveries.
services.AddSingleton<IDeadLetterSink>(new InMemoryDeadLetterSink(capacity: 256));
services.AddOrionRelay(signingSecret: "whsec_your_shared_secret");

The capacity argument is optional; the parameterless constructor retains up to InMemoryDeadLetterSink.DefaultCapacity (1024) entries. Inspect what has been captured through Count and the oldest-first Entries snapshot:

public sealed class FailedDeliveryReport(IDeadLetterSink sink)
{
public void Print()
{
if (sink is not InMemoryDeadLetterSink inMemory)
{
return;
}

foreach (var entry in inMemory.Entries)
{
Console.WriteLine(
$"{entry.DeadLetteredAt:o} {entry.Message.EventType} " +
$"failed after {entry.Result.Attempts} attempts " +
$"(last status: {entry.Result.StatusCode?.ToString() ?? "none"})");
}
}
}

To persist or replay instead, implement IDeadLetterSink over your own store:

using Moongazing.OrionRelay.Delivery;

public sealed class DurableDeadLetterSink(IDeadLetterStore store) : IDeadLetterSink
{
public async Task WriteAsync(DeadLetterEntry entry, CancellationToken cancellationToken = default)
{
// Persist for later inspection or redelivery. Keep this resilient: the dispatcher swallows
// any fault raised here, so a sink outage cannot turn an already-failed delivery into an
// exception for the caller.
await store.SaveAsync(entry, cancellationToken);
}
}

services.AddSingleton<IDeadLetterSink, DurableDeadLetterSink>();
services.AddOrionRelay(signingSecret: "whsec_your_shared_secret");

The sink and the delivery observer are complementary: IWebhookDeliveryObserver.OnExhausted fires first for in-process observability, then the entry is written to the sink for durable capture.

Configuration​

WebhookDeliveryOptions is configured through the AddOrionRelay callback and validated eagerly at registration (an invalid combination throws ArgumentOutOfRangeException there, not at first send):

OptionTypeDefaultMeaning
MaxAttemptsint4Total attempts including the first send. Must be at least 1.
BaseDelayTimeSpan1sBase backoff delay, doubled each retry. Cannot be negative.
MaxDelayTimeSpan30sCeiling the exponential backoff is clamped to. Cannot be less than BaseDelay.
RequestTimeoutTimeSpan30sPer-attempt HTTP timeout, enforced by the dispatcher.
SignatureHeaderstringOrion-SignatureHeader name carrying the signature value.

Pass the signing secret as the first argument to AddOrionRelay. A null or empty secret registers no signer and sends unsigned, which is not recommended outside trusted networks.

Telemetry​

WebhookDiagnostics exposes a System.Diagnostics.Metrics meter named Moongazing.OrionRelay (also available as the WebhookDiagnostics.MeterName constant). Subscribe to it from OpenTelemetry or any MeterListener:

InstrumentKindTags
orion.relay.deliveriesCounter<long>orion.outcome (succeeded/failed), event_type
orion.relay.attemptsCounter<long>orion.outcome (success/retryable/fatal)
orion.relay.delivery.attemptsHistogram<int>event_type

orion.relay.deliveries counts one per DispatchAsync call; orion.relay.attempts counts each individual HTTP attempt; the histogram records how many attempts each delivery took. With OpenTelemetry:

using Moongazing.OrionRelay.Diagnostics;

builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter(WebhookDiagnostics.MeterName));

The diagnostics instance is registered as a singleton by AddOrionRelay.

Testing​

The dispatcher is built for deterministic testing. An internal constructor accepts seams for the backoff delay, jitter source, and clock, so tests can run the retry loop with zero real waits, a fixed jitter, and a fixed now. The signer is deterministic for a given secret, body, and timestamp. The suite covers signing (envelope shape, determinism, timestamp and secret sensitivity, empty-secret rejection), delivery (first-attempt success, retry-then-success, fail-fast on 4xx, budget exhaustion, transport-fault retry, signing, caller cancellation, observer-fault isolation), and DI registration.

dotnet test

Microbenchmarks for the CPU-bound send-path work (signing, signer construction, telemetry emission) live under benchmarks/ and run with BenchmarkDotNet. See benchmarks.md.

Versioning​

OrionRelay follows Semantic Versioning. The current release is 0.2.0; while on the 0.x line the public surface may still change between minor versions. Notable changes are recorded in CHANGELOG.md.

Design notes​

  • Multi-targets net8.0, net9.0, net10.0.
  • TreatWarningsAsErrors, latest analyzers, nullable enabled.
  • The dispatcher enforces its own per-attempt timeout, so its HttpClient is left uncapped.

Documentation​

More from the Orion family​

OrionRelay is one of a set of standalone .NET libraries by the same author. See OrionGuard and the other Orion packages.

Contributing​

Issues and pull requests welcome. Please read CONTRIBUTING.md and the Code of Conduct before opening one.

License​

This project is licensed under the MIT License.

Author​

Tunahan Ali Ozturk - GitHub

Packages​

PackageVersionDownloads
OrionRelay0.5.01,089
OrionRelay.EntityFrameworkCore0.5.0230