Skip to content

Webhooks — Outbound Event Delivery

Polling is expensive: clients repeatedly hit your API asking “did anything change?”, wasting bandwidth, adding latency, and straining rate limits. Webhooks invert this relationship — your application pushes events to subscribers the moment they happen. This is how Stripe, GitHub, and Slack work, and it is what integration partners expect from a modern API.

But outbound webhooks are harder than they look: deliveries fail, endpoints go down, payloads need tamper-proof signatures, and auditors want proof that a notification was (or was not) delivered. Granit.Webhooks handles all of this — HMAC-SHA256 signed payloads, configurable retry policies, durable outbox dispatch via Wolverine, and a full delivery audit trail for ISO 27001 compliance.

  • DirectoryGranit.Webhooks/ Core publisher, HMAC signatures, in-memory default
    • Granit.Webhooks.EntityFrameworkCore Durable subscriptions + delivery audit
    • Granit.Webhooks.Wolverine Durable outbox dispatch via Wolverine
PackageRoleDepends on
Granit.WebhooksIWebhookPublisher, HMAC delivery, domain eventsGranit.Timing, Granit.Encryption
Granit.Webhooks.EntityFrameworkCoreIsolated DbContext for subscriptions + deliveriesGranit.Webhooks, Granit.Persistence
Granit.Webhooks.WolverineDurable outbox dispatch, retry policiesGranit.Webhooks, Granit.Wolverine
graph TD
    W[Granit.Webhooks] --> T[Granit.Timing]
    W --> E[Granit.Encryption]
    WEF[Granit.Webhooks.EntityFrameworkCore] --> W
    WEF --> P[Granit.Persistence]
    WW[Granit.Webhooks.Wolverine] --> W
    WW --> WLV[Granit.Wolverine]

[DependsOn(typeof(GranitWebhooksModule))]
public class AppModule : GranitModule { }

Uses in-memory subscription store and Channel-based in-process dispatch. Suitable for development and integration tests.

Modules declare the webhook event types they support by implementing IWebhookEventTypeDefinitionProvider. Providers are auto-discovered across all loaded module assemblies at startup — no manual DI registration required.

using Granit.Webhooks.Definitions;
internal sealed class OrderWebhookEventTypeProvider : IWebhookEventTypeDefinitionProvider
{
public void Define(IWebhookEventTypeDefinitionContext context)
{
// Convention-based: keys derived from the localization resource type
context.Add<OrderLocalizationResource>("order.created", category: "Orders");
context.Add<OrderLocalizationResource>("order.shipped", category: "Orders");
context.Add<OrderLocalizationResource>("order.cancelled", category: "Orders");
}
}

The definitions are collected into an immutable IWebhookEventTypeRegistry singleton, sorted by name. The registry serves two purposes:

  1. Discovery — the GET /event-types endpoint exposes the full list for admin UIs
  2. Validation — creating a subscription rejects unknown event types at request time

Display names, descriptions, and categories are localized via LocalizableString. The Add<TResource>() method derives localization keys automatically:

FieldKey patternExample
Display nameWebhookEventType:{name}WebhookEventType:order.created
DescriptionWebhookEventType:{name}:DescriptionWebhookEventType:order.created:Description
CategoryWebhookEventTypeCategory:{category}WebhookEventTypeCategory:Orders

Add these keys to your module’s localization JSON files (all 17 cultures):

{
"culture": "en",
"texts": {
"WebhookEventType:order.created": "Order created",
"WebhookEventType:order.created:Description": "Fired when a new order is placed.",
"WebhookEventTypeCategory:Orders": "Orders"
}
}

The GET /event-types endpoint resolves these keys based on the Accept-Language header. If no localizer is available, the raw key is returned as fallback.

For non-localizable labels, use LocalizableString.Fixed("value") with the explicit Add(WebhookEventTypeDefinition) overload:

context.Add(new WebhookEventTypeDefinition(
"system.healthcheck",
DisplayName: LocalizableString.Fixed("Health check"),
Category: LocalizableString.Fixed("System")));

Inject IWebhookPublisher and call PublishAsync with a logical event type and payload:

public class DocumentUploadedHandler(IWebhookPublisher webhookPublisher)
{
public async Task HandleAsync(
DocumentUploaded evt, CancellationToken cancellationToken)
{
await webhookPublisher.PublishAsync(
"document.uploaded",
new { evt.DocumentId, evt.FileName, evt.UploadedBy },
cancellationToken).ConfigureAwait(false);
}
}

The publisher serializes the payload into a WebhookEnvelope, captures the ambient tenant context, and dispatches a WebhookTrigger to the outbox. The fanout handler resolves matching subscriptions and enqueues one SendWebhookCommand per subscriber.

Every HTTP POST to a subscriber endpoint carries a standardized JSON envelope:

{
"eventId": "a1b2c3d4-...",
"eventType": "document.uploaded",
"tenantId": "d4e5f6a7-...",
"timestamp": "2026-03-13T10:30:00Z",
"apiVersion": "1.0",
"data": {
"documentId": "f8e9d0c1-...",
"fileName": "report.pdf",
"uploadedBy": "user-42"
}
}

The eventId is stable across retry attempts, allowing subscribers to deduplicate.

Each delivery is signed with the subscription’s secret using HMAC-SHA256 in Stripe format:

x-granit-signature: t=1710323400,v1=5d3b2a1c...

The signed payload is {unix_timestamp}.{body_json}. Subscribers should:

  1. Extract the timestamp and signature from the header
  2. Recompute the HMAC using the shared secret
  3. Constant-time compare the signatures
  4. Reject requests older than 5 minutes (replay protection)

Exponential backoff: 30s, 2m, 10m, 30m, 2h, 12h. After 6 retries (~14h30 total), the message moves to the Dead-Letter Queue and the subscription is suspended.

When consecutive failures exceed the threshold, the subscription transitions:

FailuresStatusDomain event
0Active
Threshold reachedSuspendedWebhookSubscriptionSuspendedEvent
Non-retriable (4xx)DeactivatedWebhookSubscriptionDeactivatedEvent

Suspension records SuspendedAt and SuspendedBy for ISO 27001 audit compliance.

WebhookDeliveryAttempt is an INSERT-only entity (no soft-delete) that records every delivery attempt: HTTP status, duration, payload hash (SHA-256), and optional full payload when StorePayload is enabled.

{
"Webhooks": {
"HttpTimeoutSeconds": 10,
"MaxParallelDeliveries": 20,
"StorePayload": false
}
}
PropertyDefaultDescription
HttpTimeoutSeconds10HTTP request timeout (5–120 seconds)
MaxParallelDeliveries20Max parallel SendWebhookCommand on the delivery queue (1–100)
StorePayloadfalsePersist full JSON body alongside delivery attempts

Both WebhookSubscription and WebhookDeliveryAttempt implement IMultiTenant. ApplyGranitConventions automatically adds a named query filter, ensuring that subscriptions and delivery records are scoped to the current tenant. Global subscriptions (TenantId = null) match all tenants during fan-out.

Signing secrets are stored through IWebhookSecretProtector. The default registration is NoOpWebhookSecretProtector (pass-through, suitable for dev/test).

For production, register an encrypted protector backed by IStringEncryptionService from Granit.Encryption before calling AddGranitWebhooks():

// The EncryptionWebhookSecretProtector is available in Granit.Webhooks
// but not registered by default — register it explicitly for production.
builder.Services.AddSingleton<IWebhookSecretProtector, EncryptionWebhookSecretProtector>();

Target URLs are validated at two levels:

  1. At registration — FluentValidation blocks http://, private IPs (RFC 1918), loopback, link-local, carrier-grade NAT (100.64.0.0/10), and internal TLDs (.local, .internal, .localhost, .onion)
  2. At delivery — a SocketsHttpHandler.ConnectCallback validates the resolved IP address at connection time, preventing DNS rebinding attacks where a hostname resolves to a public IP during validation but to an internal IP during delivery

Endpoints use two permission levels:

PermissionScope
Webhooks.Subscriptions.ReadView subscriptions, event types, stats
Webhooks.Subscriptions.ManageCreate, update, delete, lifecycle, secret rotation, test ping

DeleteBeforeAsync enforces a 3-year minimum retention period for delivery records (ISO 27001). Attempting to delete records newer than 3 years throws InvalidOperationException.

CategoryKey typesPackage
ModulesGranitWebhooksModule, GranitWebhooksEntityFrameworkCoreModule, GranitWebhooksWolverineModuleWebhooks
PublisherIWebhookPublisher (PublishAsync<TPayload>)Granit.Webhooks
Event typesIWebhookEventTypeDefinitionProvider, IWebhookEventTypeRegistry, WebhookEventTypeDefinitionGranit.Webhooks
SubscriptionsIWebhookSubscriptionReader, IWebhookSubscriptionWriter, WebhookSubscriptionGranit.Webhooks
DeliveryWebhookDeliveryAttempt, WebhookEnvelope, IWebhookDeliveryReader, IWebhookDeliveryWriterGranit.Webhooks
EventsWebhookSubscriptionSuspendedEvent, WebhookSubscriptionDeactivatedEventGranit.Webhooks
OptionsWebhooksOptionsGranit.Webhooks
ExtensionsAddGranitWebhooks(), AddGranitWebhooksEntityFrameworkCore()

Granit.Webhooks.Notifications ships notifications for the lifecycle events of this module. Hosts that reference the package get them out-of-the-box; tenants opt in via the notifications admin UI.

NotificationTriggerChannelsSeverity
webhooks.delivery_failure_thresholdWebhookDeliveryFailureThresholdExceededEtoEmail, InAppWarning
webhooks.signing_key_rotation_dueWebhookSigningKeyRotationDueEto (emitted daily by the rotation scanner)Email, InAppWarning

The signing_key_rotation_due notification is the operator-facing arm of the dual-key rotation flow — it gives administrators a configurable lead time (WebhooksOptions.RotationLeadTimeDays, default 14 days) to rotate before a key’s ExpiresAt passes.

Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).

Use Granit.Webhooks when:

  • External systems need real-time notifications of events in your application
  • You need tamper-proof deliveries — HMAC-SHA256 signatures let subscribers verify authenticity
  • Delivery reliability matters — durable outbox ensures no event is lost, retries handle transient failures
  • You need an audit trail of every delivery attempt (ISO 27001, debugging)

Skip it when:

  • Consumers are internal services in the same cluster — use Wolverine integration events (lower latency, no HTTP overhead)
  • You only need one subscriber that you control — a direct HTTP call or message queue is simpler
  • Events are high-frequency (>100/s per subscription) — webhooks add HTTP latency per event; consider a streaming protocol (WebSocket, SSE) instead