Skip to content

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.

  • A working Granit application with Granit.Wolverine configured
  • PostgreSQL database (for durable subscription and delivery stores)
Terminal window
dotnet add package Granit.Webhooks
dotnet add package Granit.Webhooks.EntityFrameworkCore
using Granit.Core.Modularity;
using Granit.Webhooks.EntityFrameworkCore;
[DependsOn(typeof(GranitWebhooksEntityFrameworkCoreModule))]
public sealed class MyAppModule : GranitModule { }
{
"Webhooks": {
"HttpTimeoutSeconds": 10,
"MaxParallelDeliveries": 20,
"StorePayload": false
}
}
OptionDefaultDescription
HttpTimeoutSeconds10Timeout for HTTP requests to subscribers (5—120)
MaxParallelDeliveries20Parallelism of the webhook-delivery queue (1—100)
StorePayloadfalseStore the full JSON body in delivery attempts

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.

Map the built-in configuration and redelivery endpoints:

app.MapGranitWebhooksConfig(); // GET /webhooks/config
app.MapGranitWebhooksRedelivery(); // POST /webhooks/deliveries/{id}/retry

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:

HeaderValue
x-granit-signaturet=<unix>,v1=<hmac-sha256-hex>
x-granit-event-idUUID of the event
x-granit-event-typeEvent type (e.g. order.created)

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 lowercase
expected = "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.

The delivery handler classifies HTTP response codes into three categories:

CategoryCodesBehavior
Success2xxRecords attempt, continues
Non-retriable (no suspend)400, 405, 422Records failure, no retry
Non-retriable (suspend)401, 403, 404, 410Records failure, suspends subscription
Retriable429, 5xx, timeoutRecords 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:

AttemptDelayElapsed
130 seconds30 s
22 minutes~2 min 30 s
310 minutes~12 min 30 s
430 minutes~42 min 30 s
52 hours~2 h 43 min
612 hours~14 h 43 min

After 6 attempts, the message moves to the Wolverine Dead Letter Queue.

When StorePayload = true, failed deliveries can be replayed:

POST /webhooks/deliveries/{deliveryId}/retry
ConditionHTTP codeReason
Attempt not found404Unknown deliveryId
Attempt succeeded400No replay on a 2xx delivery
Subscription deactivated409Permanently disabled
Subscription suspended202Allowed — useful for testing reactivation

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>());

Subscriptions support both global and tenant-scoped delivery:

  • TenantId = null — global subscription, receives events from all tenants
  • TenantId = <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.