Webhooks — Outbound Event Delivery
Why webhooks?
Section titled “Why webhooks?”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.
Package structure
Section titled “Package structure”DirectoryGranit.Webhooks/ Core publisher, HMAC signatures, in-memory default
- Granit.Webhooks.EntityFrameworkCore Durable subscriptions + delivery audit
- Granit.Webhooks.Wolverine Durable outbox dispatch via Wolverine
| Package | Role | Depends on |
|---|---|---|
Granit.Webhooks | IWebhookPublisher, HMAC delivery, domain events | Granit.Timing, Granit.Encryption |
Granit.Webhooks.EntityFrameworkCore | Isolated DbContext for subscriptions + deliveries | Granit.Webhooks, Granit.Persistence |
Granit.Webhooks.Wolverine | Durable outbox dispatch, retry policies | Granit.Webhooks, Granit.Wolverine |
Dependency graph
Section titled “Dependency graph”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.
[DependsOn( typeof(GranitWebhooksWolverineModule), typeof(GranitWebhooksEntityFrameworkCoreModule))]public class AppModule : GranitModule { }builder.AddGranitWebhooksEntityFrameworkCore( opts => opts.UseNpgsql(connectionString));Wolverine provides durable outbox dispatch with exponential backoff retries. EF Core persists subscriptions and delivery attempts for ISO 27001 audit.
Event type registry
Section titled “Event type registry”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:
- Discovery — the
GET /event-typesendpoint exposes the full list for admin UIs - Validation — creating a subscription rejects unknown event types at request time
Localization convention
Section titled “Localization convention”Display names, descriptions, and categories are localized via LocalizableString.
The Add<TResource>() method derives localization keys automatically:
| Field | Key pattern | Example |
|---|---|---|
| Display name | WebhookEventType:{name} | WebhookEventType:order.created |
| Description | WebhookEventType:{name}:Description | WebhookEventType:order.created:Description |
| Category | WebhookEventTypeCategory:{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")));Publishing a webhook
Section titled “Publishing a webhook”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.
Webhook envelope
Section titled “Webhook envelope”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.
HMAC signature
Section titled “HMAC signature”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:
- Extract the timestamp and signature from the header
- Recompute the HMAC using the shared secret
- Constant-time compare the signatures
- Reject requests older than 5 minutes (replay protection)
Retry and suspension
Section titled “Retry and suspension”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.
Channel-based dispatch retries 3 times with linear backoff. No persistence — failed deliveries are lost on restart.
When consecutive failures exceed the threshold, the subscription transitions:
| Failures | Status | Domain event |
|---|---|---|
| 0 | Active | — |
| Threshold reached | Suspended | WebhookSubscriptionSuspendedEvent |
| Non-retriable (4xx) | Deactivated | WebhookSubscriptionDeactivatedEvent |
Suspension records SuspendedAt and SuspendedBy for ISO 27001 audit compliance.
Delivery audit trail
Section titled “Delivery audit trail”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.
Configuration reference
Section titled “Configuration reference”{ "Webhooks": { "HttpTimeoutSeconds": 10, "MaxParallelDeliveries": 20, "StorePayload": false }}| Property | Default | Description |
|---|---|---|
HttpTimeoutSeconds | 10 | HTTP request timeout (5–120 seconds) |
MaxParallelDeliveries | 20 | Max parallel SendWebhookCommand on the delivery queue (1–100) |
StorePayload | false | Persist full JSON body alongside delivery attempts |
Security
Section titled “Security”Multi-tenancy
Section titled “Multi-tenancy”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.
Secret protection
Section titled “Secret protection”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>();SSRF protection
Section titled “SSRF protection”Target URLs are validated at two levels:
- 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) - At delivery — a
SocketsHttpHandler.ConnectCallbackvalidates 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
Permissions
Section titled “Permissions”Endpoints use two permission levels:
| Permission | Scope |
|---|---|
Webhooks.Subscriptions.Read | View subscriptions, event types, stats |
Webhooks.Subscriptions.Manage | Create, update, delete, lifecycle, secret rotation, test ping |
Audit retention
Section titled “Audit retention”DeleteBeforeAsync enforces a 3-year minimum retention period for delivery records
(ISO 27001). Attempting to delete records newer than 3 years throws
InvalidOperationException.
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Modules | GranitWebhooksModule, GranitWebhooksEntityFrameworkCoreModule, GranitWebhooksWolverineModule | Webhooks |
| Publisher | IWebhookPublisher (PublishAsync<TPayload>) | Granit.Webhooks |
| Event types | IWebhookEventTypeDefinitionProvider, IWebhookEventTypeRegistry, WebhookEventTypeDefinition | Granit.Webhooks |
| Subscriptions | IWebhookSubscriptionReader, IWebhookSubscriptionWriter, WebhookSubscription | Granit.Webhooks |
| Delivery | WebhookDeliveryAttempt, WebhookEnvelope, IWebhookDeliveryReader, IWebhookDeliveryWriter | Granit.Webhooks |
| Events | WebhookSubscriptionSuspendedEvent, WebhookSubscriptionDeactivatedEvent | Granit.Webhooks |
| Options | WebhooksOptions | Granit.Webhooks |
| Extensions | AddGranitWebhooks(), AddGranitWebhooksEntityFrameworkCore() | — |
Notifications
Section titled “Notifications”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.
| Notification | Trigger | Channels | Severity |
|---|---|---|---|
webhooks.delivery_failure_threshold | WebhookDeliveryFailureThresholdExceededEto | Email, InApp | Warning |
webhooks.signing_key_rotation_due | WebhookSigningKeyRotationDueEto (emitted daily by the rotation scanner) | Email, InApp | Warning |
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).
When to use — and when not to
Section titled “When to use — and when not to”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
Common pitfalls
Section titled “Common pitfalls”See also
Section titled “See also”- Implement webhooks guide — step-by-step setup walkthrough
- Signing keys (dual-key rotation) —
WebhookSigningKeyaggregate, overlap rotation, verification fallback - Webhook endpoints — full Minimal API surface (subscriptions, deliveries, stats)
- API & Web module — Exception handling, versioning, idempotency
- Persistence module — EF Core interceptors, query filters
- Wolverine module — Messaging, transactional outbox