Implement Webhooks
Granit.Webhooks delivers outbound HTTP POST notifications to external systems when business events occur. Each webhook is signed with HMAC-SHA256, delivered with at-least-once guarantees via the Wolverine transactional outbox, and recorded in an immutable audit trail.
Prerequisites
Section titled “Prerequisites”- A working Granit application with
Granit.Wolverineconfigured - PostgreSQL database (for durable subscription and delivery stores)
1. Install the packages
Section titled “1. Install the packages”dotnet add package Granit.Webhooksdotnet add package Granit.Webhooks.EntityFrameworkCore2. Register the module
Section titled “2. Register the module”using Granit.Core.Modularity;using Granit.Webhooks.EntityFrameworkCore;
[DependsOn(typeof(GranitWebhooksEntityFrameworkCoreModule))]public sealed class MyAppModule : GranitModule { }using Granit.Core.Modularity;using Granit.Webhooks;
[DependsOn(typeof(GranitWebhooksModule))]public sealed class MyAppModule : GranitModule { }3. Configure options
Section titled “3. Configure options”{ "Webhooks": { "HttpTimeoutSeconds": 10, "MaxParallelDeliveries": 20, "StorePayload": false }}| Option | Default | Description |
|---|---|---|
HttpTimeoutSeconds | 10 | Timeout for HTTP requests to subscribers (5—120) |
MaxParallelDeliveries | 20 | Parallelism of the webhook-delivery queue (1—100) |
StorePayload | false | Store the full JSON body in delivery attempts |
4. Publish webhook events
Section titled “4. Publish webhook events”Inject IWebhookPublisher and call PublishAsync with an event type and payload:
public sealed class OrderService(IWebhookPublisher webhooks){ public async Task CreateOrderAsync( CreateOrderRequest request, CancellationToken cancellationToken) { // ... business logic ...
await webhooks.PublishAsync("order.created", new { orderId = order.Id, amount = order.TotalAmount, }, cancellationToken); }}The publisher serializes the payload, wraps it in a WebhookTrigger, and inserts it
into the Wolverine outbox. The fan-out handler then resolves all active subscriptions
matching the event type and dispatches one SendWebhookCommand per subscriber.
5. Register webhook endpoints
Section titled “5. Register webhook endpoints”Map the built-in configuration and redelivery endpoints:
app.MapGranitWebhooksConfig(); // GET /webhooks/configapp.MapGranitWebhooksRedelivery(); // POST /webhooks/deliveries/{id}/retryWebhook envelope format
Section titled “Webhook envelope format”Each subscriber receives an HTTP POST with this JSON body:
{ "eventId": "01951234-abcd-7000-8000-000000000001", "eventType": "order.created", "tenantId": "9f3b1234-0000-0000-0000-000000000001", "timestamp": "2025-06-01T14:32:00Z", "apiVersion": "2025-01-01", "data": { "orderId": "...", "amount": 42.50 }}Three custom headers are added to every request:
| Header | Value |
|---|---|
x-granit-signature | t=<unix>,v1=<hmac-sha256-hex> |
x-granit-event-id | UUID of the event |
x-granit-event-type | Event type (e.g. order.created) |
Signature verification (subscriber side)
Section titled “Signature verification (subscriber side)”Subscribers should verify the HMAC-SHA256 signature to authenticate incoming webhooks:
toSign = "<unix_timestamp>.<json_body>"secret = shared secret from the subscription (plaintext on receiver side)hmac = HMAC-SHA256(secret, toSign) -- hex lowercaseexpected = "t=<unix>,v1=<hmac>"The timestamp is included in the signed string to protect against replay attacks. Subscribers should reject requests older than 5 minutes.
Error handling and retry policy
Section titled “Error handling and retry policy”The delivery handler classifies HTTP response codes into three categories:
| Category | Codes | Behavior |
|---|---|---|
| Success | 2xx | Records attempt, continues |
| Non-retriable (no suspend) | 400, 405, 422 | Records failure, no retry |
| Non-retriable (suspend) | 401, 403, 404, 410 | Records failure, suspends subscription |
| Retriable | 429, 5xx, timeout | Records failure, retries via outbox |
Suspended subscriptions require manual reactivation. This prevents wasting resources on endpoints that have been decommissioned or revoked access.
Failed deliveries are retried with exponential backoff:
| Attempt | Delay | Elapsed |
|---|---|---|
| 1 | 30 seconds | 30 s |
| 2 | 2 minutes | ~2 min 30 s |
| 3 | 10 minutes | ~12 min 30 s |
| 4 | 30 minutes | ~42 min 30 s |
| 5 | 2 hours | ~2 h 43 min |
| 6 | 12 hours | ~14 h 43 min |
After 6 attempts, the message moves to the Wolverine Dead Letter Queue.
Redelivery
Section titled “Redelivery”When StorePayload = true, failed deliveries can be replayed:
POST /webhooks/deliveries/{deliveryId}/retry| Condition | HTTP code | Reason |
|---|---|---|
| Attempt not found | 404 | Unknown deliveryId |
| Attempt succeeded | 400 | No replay on a 2xx delivery |
| Subscription deactivated | 409 | Permanently disabled |
| Subscription suspended | 202 | Allowed — useful for testing reactivation |
Secret protection
Section titled “Secret protection”In production, webhook signing secrets must be encrypted at rest. Replace the
default NoOpWebhookSecretProtector with a Vault-backed implementation:
builder.Services.Replace( ServiceDescriptor.Scoped<IWebhookSecretProtector, VaultWebhookSecretProtector>());Multi-tenancy
Section titled “Multi-tenancy”Subscriptions support both global and tenant-scoped delivery:
TenantId = null— global subscription, receives events from all tenantsTenantId = <guid>— tenant-specific, receives only that tenant’s events
The fan-out handler automatically resolves the tenant from ICurrentTenant or from
the WebhookTrigger.TenantId property.
Next steps
Section titled “Next steps”- Set up notifications for user-facing multi-channel notifications
- Add background jobs for recurring scheduled tasks
- Granit.Webhooks reference for the full store interfaces and database schema