The webhook feature provides outbound event delivery from the application to external systems. In the current starter implementation, it supports:
- webhook subscription management in the admin UI
- event-based subscription matching
- asynchronous delivery through the background worker
- HMAC-SHA256 request signing
- delivery attempt logging
- retry metadata and delivery status tracking
This is an outbound webhook system. The starter does not currently include inbound webhook receiver endpoints.
Webhook support is included so new projects can notify other systems when important business events occur, without forcing those integrations to poll the application database or API.
Typical use cases include:
- syncing users or organizations into another platform
- notifying internal systems about lifecycle events
- triggering downstream workflows
- posting updates into third-party automation tools
The webhook feature is split into four main parts:
| Area | Responsibility | Main files |
|---|---|---|
| Subscription management | Stores and manages webhook endpoints | src/Server/Admin/Services/Webhooks/WebhookAdminService.cs |
| Event dispatching | Finds matching subscriptions and creates delivery jobs | src/Server/Admin/Services/Webhooks/WebhookDispatcher.cs |
| Background delivery | Sends HTTP requests and updates delivery status | src/Server/Admin/Services/Background/Tasks/Handlers/WebhookDeliveryTaskHandler.cs |
| Admin visibility | Lets admins manage subscriptions and inspect delivery logs | src/Server/Admin/WebService/UI/Pages/Admin/WebhooksPage.razor |
The flow is:
- Application code calls
IWebhookDispatcher.DispatchAsync(eventType, payload). - The dispatcher finds all active subscriptions for that event type.
- A
WebhookDeliveryrecord is created for each subscription. - A background task is queued for each delivery.
- The background worker runs
WebhookDeliveryTaskHandler. - The handler signs and POSTs the payload to the subscription URL.
- The delivery record is updated with status, response details, and retry metadata.
WebhookSubscription stores registered endpoints.
public class WebhookSubscription : DatabaseObject
{
public string EventType { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public Guid? OrganizationId { get; set; }
public Guid CreatedById { get; set; }
public UserProfile CreatedBy { get; set; } = default!;
public Organization? Organization { get; set; }
}| Field | Type | Meaning |
|---|---|---|
EventType |
string |
Logical event key such as User.Created. |
Url |
string |
Destination endpoint for delivery. |
Secret |
string |
Per-subscription secret used to sign requests. |
OrganizationId |
Guid? |
Optional organization link in the data model. |
CreatedById |
Guid |
Internal user profile id of the creator. |
WebhookDelivery stores each delivery attempt record.
public class WebhookDelivery : DatabaseObject
{
public Guid SubscriptionId { get; set; }
public string EventType { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public int? ResponseStatusCode { get; set; }
public string? ResponseBody { get; set; }
public int AttemptCount { get; set; }
public DateTime? NextRetryAt { get; set; }
public WebhookDeliveryStatus Status { get; set; }
public WebhookSubscription Subscription { get; set; } = default!;
}| Field | Type | Meaning |
|---|---|---|
SubscriptionId |
Guid |
Internal FK to the subscription. |
EventType |
string |
Event that triggered the delivery. |
Payload |
string |
JSON payload sent to the endpoint. |
ResponseStatusCode |
int? |
HTTP response code from the remote endpoint. |
ResponseBody |
string? |
Response content or failure message. |
AttemptCount |
int |
Number of delivery attempts made so far. |
NextRetryAt |
DateTime? |
Next planned retry time, when applicable. |
Status |
WebhookDeliveryStatus |
Pending, Delivered, or Failed. |
The database configuration lives in ApplicationDbContext.
CreatedByIdpoints toUserProfilewithDeleteBehavior.RestrictOrganizationIdpoints toOrganizationwithDeleteBehavior.Cascade- index on
EventType - index on
(EventType, IsActive)
Payloadis stored asjsonbSubscriptionIdpoints toWebhookSubscriptionwithDeleteBehavior.Cascade- indexes on:
SubscriptionIdStatusEventTypeNextRetryAt
- deleting a subscription also deletes its delivery log
- event-type lookup is optimized
- pending and retry-oriented monitoring is supported by indexes
This is the admin/service DTO for subscriptions.
Validation rules:
| Field | Rules |
|---|---|
EventType |
required, min length 2, max length 256 |
Url |
required, valid URL, max length 2048 |
The DTO also exposes:
IsActiveCreatedByNameOrganizationNameCreateDateUpdateDate
This is the admin/service DTO for delivery log entries. It includes:
- subscription public id
- event type
- resolved destination URL
- JSON payload
- HTTP status
- response body
- attempt count
- next retry time
- delivery status
- timestamps
The starter defines a small fixed set of event names in WebhookEventTypes.
| Constant | Value |
|---|---|
UserCreated |
User.Created |
UserUpdated |
User.Updated |
UserDeleted |
User.Deleted |
OrganizationCreated |
Organization.Created |
OrganizationUpdated |
Organization.Updated |
These are exposed to the admin UI through WebhookEventTypes.All.
Subscription CRUD is handled by IWebhookAdminService and WebhookAdminService.
- list subscriptions
- search by event type or URL
- sort by event type, URL, or create date
- create subscriptions
- update subscriptions
- delete subscriptions
- view delivery log
- view delivery log filtered by subscription
When a subscription is created:
- the submitted DTO is mapped into a new
WebhookSubscription - a new secret is generated with 32 random bytes and Base64 encoding
CreatedByIdis set fromAuthorizationContext.CurrentProfile.Id
The generated secret is stored in the database and used for request signing.
Updates modify:
EventTypeUrlIsActive
The secret is not rotated during update.
Deleting a subscription removes the subscription and, because of FK cascade delete, also removes all associated delivery rows.
The subscription management page is:
/admin/webhooks
The page supports:
- search
- paging
- sorting
- grid settings persistence
- create/edit modal
- delete confirmation
- link to delivery log
The create/edit modal currently exposes:
EventTypeUrlIsActive
The page is protected by:
Admin.Webhooks.View
The create, edit, and delete buttons are additionally wrapped in AuthorizeView with:
Admin.Webhooks.CreateAdmin.Webhooks.EditAdmin.Webhooks.Delete
The delivery log pages are:
/admin/webhook-deliveries
/admin/webhook-deliveries/{SubscriptionId}
The page supports:
- global delivery list
- per-subscription filtered list
- paging
- sorting
- grid settings persistence
- row click to open delivery details
The detail modal shows:
- event type
- URL
- status
- HTTP status
- attempts
- created timestamp
- request payload
- response body
Runtime dispatching is handled by IWebhookDispatcher.
public interface IWebhookDispatcher
{
Task DispatchAsync(string eventType, object eventPayload);
}When DispatchAsync is called:
- active subscriptions for the event type are loaded
- if none exist, the method logs and returns
- the payload object is JSON-serialized using its runtime type
- one
WebhookDeliveryrow is created per matching subscription - one background task is queued per delivery
Each background job payload is a WebhookDeliveryRequest containing:
- delivery public id
- subscription public id
- event type
- serialized payload
Projects using this starter are expected to call IWebhookDispatcher from application services when domain-relevant events occur.
Example:
await _webhookDispatcher.DispatchAsync(
WebhookEventTypes.UserCreated,
new
{
userId = user.PublicId,
email = user.Email
});The current starter provides the dispatcher and background delivery pipeline, but it does not yet wire event dispatch into existing admin CRUD flows automatically.
Actual HTTP delivery is performed by WebhookDeliveryTaskHandler.
The handler sends an HTTP POST to the subscription URL with:
Content-Type: application/json- request body = serialized JSON payload
X-Webhook-Eventheader = event typeX-Webhook-Deliveryheader = delivery public idX-Webhook-Signatureheader =sha256={hex digest}
The signature is:
- HMAC-SHA256
- key = subscription secret as UTF-8 bytes
- message = raw payload JSON as UTF-8 bytes
- output = lowercase hex string
This allows receivers to verify authenticity if they know the shared secret.
If the remote endpoint returns a success status code:
ResponseStatusCodeis setResponseBodyis captured- response body is truncated to 4096 characters if needed
- delivery status becomes
Delivered NextRetryAtis cleared
If the remote endpoint returns a non-success response or throws HttpRequestException or TaskCanceledException:
AttemptCountis incrementedResponseStatusCodeorResponseBodyis captured when available- retry metadata may be set
- the handler throws after update when the delivery should be retried
Retry behavior currently spans two different layers:
WebhookDeliveryTaskHandler- background task infrastructure
Inside the delivery handler:
- max delivery attempts is defined as
5 - backoff schedule is
30s,120s,480s,1920s NextRetryAtis set on the delivery record- delivery stays
Pendinguntil max attempts is reached
Inside BackgroundWorker.SubmitAsync, webhook background tasks are created with:
MaxRetries = 3
Inside BackgroundTaskWorker:
- task retries use worker-level exponential backoff
- the worker re-queues the task when the handler throws
- once retry count reaches the task max, the background task is marked
Failed
These two retry systems are not aligned.
Practical result:
- the delivery handler plans for up to 5 attempts
- the background task only retries 3 times
- on the final worker retry failure, the delivery record can remain
Pendingwith a futureNextRetryAt - no additional retry may actually happen, because the background task has already failed
This is a real implementation gap in the current starter.
CoreWebhookSubscriptionQuery supports:
ByPublicIdByEventTypeActiveOnlySearch- paging
- sort by:
eventtypeurlcreatedate
CoreWebhookDeliveryQuery supports:
ByPublicIdBySubscriptionIdByStatusByEventType- paging
- sort by:
eventtypestatuscreatedate
Subscription mapping exposes:
PublicIdas DTOId- creator display name
- organization display name
Delivery mapping exposes:
Subscription.PublicIdas DTOSubscriptionIdSubscription.Urlas DTOUrl- stored payload and response details
Webhook permissions are defined in PermissionDefinitions.
| Permission | Purpose |
|---|---|
Admin.Webhooks.View |
View subscriptions and deliveries |
Admin.Webhooks.Create |
Create subscriptions |
Admin.Webhooks.Edit |
Edit subscriptions |
Admin.Webhooks.Delete |
Delete subscriptions |
- the subscriptions page requires
Admin.Webhooks.View - the deliveries page requires
Admin.Webhooks.View - subscription create/edit/delete buttons are hidden in the UI based on the fine-grained permission policies
- the service methods themselves do not currently show explicit per-action permission checks in
WebhookAdminService
This means UI enforcement is stronger here than in feature flags, but service-layer enforcement still depends on broader application authorization patterns.
The starter includes WebhookAdminServiceMock.
It provides sample:
- subscriptions
- delivered records
- failed records
- pending retry records
This is useful for UI development and demo data, but it only simulates admin CRUD and delivery-log viewing. It does not simulate actual dispatching, signing, or HTTP delivery.
The starter only implements outbound webhook delivery. There are no provider-specific inbound endpoints, signature validators, or receiver controllers for third-party callbacks.
A secret is generated on create and stored in the database, but:
- it is not returned in
WebhookSubscriptionItem - it is not shown in the admin UI
- there is no rotate-secret or reveal-secret workflow
Practical result:
- the receiver cannot easily configure signature verification unless the secret is retrieved another way
WebhookSubscription includes OrganizationId, but the current DTO and UI do not expose it, and WebhookSubscription does not implement IOrganizationScoped.
Practical result:
- organization linkage exists in the schema
- the built-in UI does not let admins assign it
- automatic organization query filtering does not apply to this entity
The dispatcher is available, but the starter does not automatically emit events from existing feature flows. Each project still needs to decide where to call IWebhookDispatcher.
Because delivery retries and background task retries use different limits and schedulers, a delivery may show:
Pending- a future
NextRetryAt
even when the underlying background task has already exhausted its retries and failed.
The delivery log is view-only. There is no built-in button to:
- retry a failed delivery
- replay a delivered webhook
- resend with a regenerated payload
This follows the current FK cascade behavior, but it may not be what every project wants for auditability.
If you want to make this webhook starter more production-ready, the highest-value follow-ups are:
- Add a secure secret reveal and rotation workflow.
- Align delivery retry logic with background task retry logic.
- Add manual replay/retry actions in the admin UI.
- Decide whether delivery logs should be preserved after subscription deletion.
- Add organization assignment and enforce organization scoping consistently.
- Wire
IWebhookDispatcherinto the business events your project actually emits. - Add tests for signature generation, retry behavior, and failure transitions.
| Concern | File |
|---|---|
| Admin service | src/Server/Admin/Services/Webhooks/WebhookAdminService.cs |
| Dispatcher | src/Server/Admin/Services/Webhooks/WebhookDispatcher.cs |
| Dispatcher interface | src/Server/Admin/Services/Webhooks/IWebhookDispatcher.cs |
| Background delivery handler | src/Server/Admin/Services/Background/Tasks/Handlers/WebhookDeliveryTaskHandler.cs |
| Delivery request payload | src/Server/Admin/Services/Background/Requests/WebhookDeliveryRequest.cs |
| Background task types | src/Server/Admin/Services/Background/Tasks/BackgroundTaskTypes.cs |
| Subscription entity | src/Server/Database/Core/Models/Webhooks/WebhookSubscription.cs |
| Delivery entity | src/Server/Database/Core/Models/Webhooks/WebhookDelivery.cs |
| Subscription DTO | src/Shared/Model/Webhooks/WebhookSubscriptionItem.cs |
| Delivery DTO | src/Shared/Model/Webhooks/WebhookDeliveryItem.cs |
| Event constants | src/Shared/Model/Webhooks/WebhookEventTypes.cs |
| Delivery status enum | src/Shared/Model/Webhooks/WebhookDeliveryStatus.cs |
| EF configuration | src/Server/Database/Core/ApplicationDbContext.cs |
| Subscription query | src/Server/Database/Core/Data/Queries/BasicsImplementation/CoreWebhookSubscriptionQuery.cs |
| Delivery query | src/Server/Database/Core/Data/Queries/BasicsImplementation/CoreWebhookDeliveryQuery.cs |
| Subscription mapping | src/Server/Database/Core/Data/Decorators/WebhookSubscriptionDecorators.cs |
| Delivery mapping | src/Server/Database/Core/Data/Decorators/WebhookDeliveryDecorators.cs |
| Admin subscriptions page | src/Server/Admin/WebService/UI/Pages/Admin/WebhooksPage.razor |
| Admin deliveries page | src/Server/Admin/WebService/UI/Pages/Admin/WebhookDeliveriesPage.razor |
| Mock service | mocks/Server/Admin/ServicesMocks/Webhooks/WebhookAdminServiceMock.cs |