Skip to main content
Version: v1.5.0

Webhook Channel

The Hermodr.Publisher.Webhook package delivers CloudEvent instances over HTTP to a configured endpoint URL, with optional HMAC request signing, exponential-backoff retries, and pluggable serialisers.

Installation

dotnet add package Hermodr.Publisher.Webhook

Registration

Inline configuration

using Hermodr;

builder.Services
.AddEventPublisher()
.AddWebhooks(options =>
{
options.EndpointUrl = "https://partner.example.com/events";
options.SigningSecret = "s3cr3t";
options.SignatureAlgorithm = WebhookSignatureAlgorithm.HmacSha256;
options.MaxRetryCount = 3;
});

From appsettings.json

builder.Services
.AddEventPublisher()
.AddWebhooks("Events:Webhook");
// appsettings.json
{
"Events": {
"Webhook": {
"EndpointUrl": "https://partner.example.com/events",
"SigningSecret": "s3cr3t",
"SignatureAlgorithm": "HmacSha256",
"MaxRetryCount": 3,
"RetryDelay": "00:00:01",
"RetryBackoffMultiplier": 2.0,
"RequestTimeout": "00:00:30"
}
}
}

Options reference

WebhookPublishOptions

Delivery settings (nullable — null in a per-call override inherits the channel default):

PropertyTypeEffective defaultDescription
EndpointUrlstring?(required)URL of the webhook endpoint
SigningSecretstring?nullShared secret for HMAC signing; no signature header is sent when omitted
SignatureAlgorithmWebhookSignatureAlgorithm?HmacSha256HMAC algorithm used to sign the body
MessageFormatstring?"json"Serialisation format. Use EventMessageFormat constants (for built-ins: "json", "xml", "cloudevents+json", "cloudevents+xml", "cloudevents+binary") or a custom serializer format key
MaxRetryCountint?3Maximum delivery attempts; 0 disables retries
RetryDelayTimeSpan?1 sInitial delay between retries
RetryBackoffMultiplierdouble?2.0Multiplier for exponential backoff
RequestTimeoutTimeSpan?30 sTimeout per individual HTTP request
AdditionalHeadersIDictionary<string, string>{}Extra HTTP headers merged into every request; per-call entries win on key collision
DiscoveryWebhookDiscoveryOptions?nullCloudEvents WebHooks abuse-protection handshake (lazy OPTIONS pre-flight + WebHook-Request-Origin on every POST); see WebHook discovery below

Channel-structural settings (always taken from the channel-level defaults; ignored in per-call overrides):

PropertyTypeDefaultDescription
SignatureHeaderNamestringX-Webhook-SignatureHTTP header carrying the computed signature
SignatureAlgorithmHeaderNamestring?X-Webhook-Signature-AlgorithmHeader advertising the algorithm used; set to null to suppress
DeliveryIdHeaderNamestringX-Webhook-DeliveryHeader carrying a unique delivery identifier
EventTypeHeaderNamestringX-Webhook-EventHeader carrying the event type
TimestampHeaderNamestringX-Webhook-TimestampUnix-epoch timestamp header (used in signature payload to prevent replay attacks)
RetryableStatusCodesISet<int>429, 500, 502, 503, 504HTTP status codes that trigger a retry
HttpClientNamestring?nullNamed HttpClient resolved from IHttpClientFactory; defaults to the internal channel name

TimestampHeaderName carries the Unix timestamp used in signature computation. The channel uses CloudEvent.time when present; otherwise it falls back to IEventSystemTime.UtcNow. This keeps signatures deterministic in tests when you replace the clock with UseSystemTime<TClock>().

Typed channel

Use AddWebhooks<TEvent>() to register a channel that receives only events whose data class is TEvent. At construction time the typed channel (WebhookPublishChannel<TEvent>) merges the general WebhookPublishOptions with the type-specific WebhookPublishOptions<TEvent>: non-null typed values win; null values fall back to the base defaults. AdditionalHeaders are merged at the dictionary level — typed entries win on key collision. Channel-structural properties (SignatureHeaderName, DeliveryIdHeaderName, RetryableStatusCodes, …) are always taken from the base options and cannot be overridden per event type.

builder.Services
.AddEventPublisher()
// General catch-all webhook
.AddWebhooks(opts =>
{
opts.EndpointUrl = "https://partner.example.com/events";
opts.SigningSecret = "shared-secret";
opts.MaxRetryCount = 3;
opts.SignatureAlgorithm = WebhookSignatureAlgorithm.HmacSha256;
})
// OrderPlaced delivers to a dedicated endpoint with its own secret
.AddWebhooks<OrderPlaced>(opts =>
{
opts.EndpointUrl = "https://orders.example.com/hooks";
opts.SigningSecret = "order-secret";
// MaxRetryCount and SignatureAlgorithm inherited from base
});

From configuration:

builder.Services
.AddEventPublisher()
.AddWebhooks("Events:Webhook")
.AddWebhooks<OrderPlacedData>("Events:Webhook:Orders");
{
"Events": {
"Webhook": {
"EndpointUrl": "https://partner.example.com/events",
"SigningSecret": "shared-secret",
"MaxRetryCount": 3,
"Orders": {
"EndpointUrl": "https://orders.example.com/hooks",
"SigningSecret": "order-secret"
}
}
}
}

See Typed Channels for the full merge semantics and further examples.

Signature algorithms

ValueAlgorithmNote
HmacSha256HMAC-SHA256Recommended default
HmacSha384HMAC-SHA384
HmacSha512HMAC-SHA512
HmacSha1HMAC-SHA1Deprecated; included for legacy compatibility

The signature is computed over <timestamp>.<body> and sent in the configured signature header.

Message formats

Use EventMessageFormat constants when selecting a built-in format.

MessageFormat valueContent-TypeDescription
"json" (EventMessageFormat.Json)application/jsonPlain JSON payload (default)
"xml" (EventMessageFormat.Xml)application/xmlPlain XML payload
"cloudevents+json" (EventMessageFormat.CloudEventsJson)application/cloudevents+jsonFull CloudEvents JSON envelope
"cloudevents+xml" (EventMessageFormat.CloudEventsXml)application/cloudevents+xmlFull CloudEvents XML envelope
"cloudevents+binary" (EventMessageFormat.CloudEventsBinary)event's datacontenttype (fallback application/json)CloudEvents binary HTTP content modece-* attribute headers + data-only body; single-event only (batch throws NotSupportedException)

In binary content mode the channel maps every CloudEvent context attribute (including extensions) to a ce-* HTTP header, sets the request Content-Type to the event's datacontenttype, and sends the raw data as the body. This lets receivers compliant with the CloudEvents HTTP binding (Azure Event Grid, Knative Eventing, etc.) route on attributes without deserialising the body, and allows data to be any content type (e.g. application/protobuf).

builder.Services
.AddEventPublisher()
.AddWebhooks(options =>
{
options.EndpointUrl = "https://partner.example.com/events";
options.MessageFormat = EventMessageFormat.CloudEventsBinary;
});

Batch + binary: the CloudEvents HTTP binding defines no binary batch mode; PublishBatchAsync throws NotSupportedException when the effective format is cloudevents+binary. Use cloudevents+json for batched delivery.

WebHook discovery (abuse protection)

When the delivery target supports and requires CloudEvents HTTP WebHooks abuse protection, the channel can perform an OPTIONS validation handshake before the first delivery and attach the WebHook-Request-Origin header to every subsequent POST. Enable it by setting WebhookPublishOptions.Discovery to a WebhookDiscoveryOptions instance.

WebhookDiscoveryOptions

PropertyTypeDefaultDescription
RequestOriginstring(required)DNS name identifying the sending system, sent as WebHook-Request-Origin on the OPTIONS request and on every delivery POST
RequestCallbackstring?nullOptional HTTPS URL advertised via WebHook-Request-Callback; the receiver may call it asynchronously to grant permission. The channel does not host this endpoint.
RequestRateint?nullDesired request rate (requests per minute), sent via WebHook-Request-Rate; the receiver may grant a different rate
RequestTimeoutTimeSpan?nullTimeout for the OPTIONS request; falls back to RequestTimeout (default 30 s) when null
RequireSuccessfulHandshakeboolfalsefalse (best-effort): a refused or faulted handshake is logged and the delivery proceeds. true (strict): throws WebhookHandshakeException and suppresses the POST.

How the handshake works

  1. Before the first delivery to an endpoint, the channel issues an OPTIONS request carrying WebHook-Request-Origin (and, when set, WebHook-Request-Callback / WebHook-Request-Rate).
  2. The target grants permission by returning WebHook-Allowed-Origin (the origin name, or * for all origins) and optionally WebHook-Allowed-Rate (an integer, or * for unlimited). The granted permission is cached per endpoint and reused for subsequent deliveries; concurrent first deliveries share a single round-trip.
  3. After a successful handshake, WebHook-Request-Origin is attached to every delivery POST, matching the value used in the handshake (spec §2.1).
  4. The granted WebHook-Allowed-Rate is tagged on the publish Activity (webhook.allowed_origin, webhook.allowed_rate) and cached as a WebhookDiscoveryPermission, but not auto-enforced — the host can read it to apply its own throttling policy.

Best-effort vs. strict

  • RequireSuccessfulHandshake = false (default) — a refused handshake (no WebHook-Allowed-Origin) or a faulted request is logged and the delivery proceeds. Use this when the target may not implement the handshake.
  • RequireSuccessfulHandshake = true — a refused or faulted handshake throws WebhookHandshakeException (carrying the EndpointUrl and, when applicable, the StatusCode) and the delivery POST is not sent.

Retry-After on 429

When a delivery returns 429 Too Many Requests with a Retry-After header (delta-seconds or HTTP-date), the channel uses it as the delay before the next retry attempt, overriding the configured exponential backoff for that attempt (spec §2.2). An absent or unparseable Retry-After falls back to the normal backoff.

Configuration

builder.Services
.AddEventPublisher()
.AddWebhooks(options =>
{
options.EndpointUrl = "https://partner.example.com/events";
options.SigningSecret = "s3cr3t";
options.Discovery = new WebhookDiscoveryOptions
{
RequestOrigin = "eventemitter.example.com",
RequestRate = 120,
RequireSuccessfulHandshake = true
};
});
{
"Events": {
"Webhook": {
"EndpointUrl": "https://partner.example.com/events",
"SigningSecret": "s3cr3t",
"Discovery": {
"RequestOrigin": "eventemitter.example.com",
"RequestRate": 120,
"RequireSuccessfulHandshake": true
}
}
}
}

The publisher does not host the WebHook-Request-Callback endpoint: it only advertises the callback URL so the receiver (or an administrator) can grant permission out-of-band. Hosting a callback receiver belongs to the subscription-management layer.

Per-delivery options

Pass a WebhookPublishOptions instance as the second argument to PublishAsync. Only the properties you set (non-null) override the channel default — all others fall back to the values configured at registration time. AdditionalHeaders are merged: per-call entries win on key collision.

using Hermodr;

// Resolve the concrete channel directly from DI.
var webhookChannel = serviceProvider.GetRequiredService<WebhookPublishChannel>();

// Override endpoint and secret for this delivery only;
// everything else (MaxRetryCount, SignatureAlgorithm, …) is inherited.
await webhookChannel.PublishAsync(@event, new WebhookPublishOptions
{
EndpointUrl = "https://dynamic-endpoint.example.com/hook",
SigningSecret = "per-tenant-secret",
SignatureAlgorithm = WebhookSignatureAlgorithm.HmacSha512
});

You can also supply overrides through the non-generic IEventPublishChannel interface — casting the options to EventPublishOptions works because WebhookPublishOptions inherits from it:

IEventPublishChannel channel = serviceProvider.GetRequiredService<WebhookPublishChannel>();
await channel.PublishAsync(@event, new WebhookPublishOptions { EndpointUrl = "https://..." });

Batch delivery

The channel implements IBatchEventPublishChannel for dispatching multiple events in a single HTTP call:

var batchChannel = serviceProvider.GetRequiredService<IBatchEventPublishChannel>();

await batchChannel.PublishBatchAsync(events, new WebhookPublishOptions
{
EndpointUrl = "https://partner.example.com/events/batch"
});

Custom serialiser

Register a custom IEventSerializer by adding it to the service collection as a singleton enumerable entry for IEventSerializer. The channel picks it up by matching its Format key.

public class ProtobufEventSerializer : IEventSerializer
{
public string Format => "protobuf";
public string ContentType => "application/x-protobuf";
public string BatchContentType => "application/x-protobuf";

public byte[] Serialize(CloudEvent @event)
{
// ... protobuf serialisation
throw new NotImplementedException();
}

public byte[] SerializeBatch(IReadOnlyList<CloudEvent> events)
{
// ... batch serialisation
throw new NotImplementedException();
}
}
builder.Services
.AddEventPublisher()
.AddWebhooks(options => options.MessageFormat = "protobuf");

// Register the custom serializer so the channel discovers it via DI
builder.Services.AddSingleton<IEventSerializer, ProtobufEventSerializer>();

Custom signature provider

Implement IWebhookSignatureProvider and register it as a singleton:

public class Ed25519SignatureProvider : IWebhookSignatureProvider
{
public static readonly Ed25519SignatureProvider Default = new();
public WebhookSignatureAlgorithm Algorithm => (WebhookSignatureAlgorithm)100; // custom value
public string AlgorithmName => "ed25519";

public string ComputeSignature(byte[] payload, long timestamp, string secret)
{
// ... Ed25519 signing
throw new NotImplementedException();
}
}
builder.Services
.AddEventPublisher()
.AddWebhooks(options => { /* ... */ });

// Register the custom provider so the channel discovers it via DI
builder.Services.AddSingleton<IWebhookSignatureProvider, Ed25519SignatureProvider>();