Skip to content

Webhooks Endpoints — The Admin Surface

Every SaaS that ships webhooks eventually grows the same admin screen: a list of subscriptions, a “rotate secret” button that nobody dares click in production, a suspend/resume toggle that’s harder to write than it looks, a delivery log with filters, and a “send test ping” button that copy-pastes the Stripe UX. That’s two to three sprints of work per project — and most teams cut corners on the secret rotation flow exactly where security matters most.

Granit.Webhooks.Endpoints is that admin surface, finished. CRUD, lifecycle transitions, dual-key signing rotation with overlap, test pings, 24-hour delivery stats, and queryable subscription/delivery lists — all behind a permission gate, all SSRF-hardened, all OpenAPI-described. Wire one line, ship a UI on top.

PainWhat this package gives you
Building a webhook admin UI from scratchOne MapGranitWebhooks() call exposes the full CRUD + lifecycle + ops surface
”Rotate secret” that breaks every consumer at onceDual-key rotation with overlap grace period — both signatures accepted during the cutover
Lifecycle bugs (suspend ≠ deactivate, can you reactivate a deactivated sub?)Explicit Active / Suspended / Deactivated state machine, enforced server-side
SSRF in webhook registrationURL validator + DNS-rebinding-proof ConnectCallback at delivery time
”Did this delivery actually go through?” — no auditQueryable WebhookDeliveryAttempt log + 24h stats endpoint
Stripe-style test ping to debug consumer endpointsPOST /subscriptions/{id}/test-ping returns HTTP status + duration
Two permissions (Read vs Manage) without splitting endpoint registrationRead at group level, Manage layered via RequireAuthorization on mutating sub-groups
  • DirectoryGranit.Webhooks.Endpoints/
    • DirectoryEndpoints/
      • WebhookEventTypeEndpoints.cs GET /event-types
      • WebhookSubscriptionReadEndpoints.cs GET /subscriptions/{id}
      • WebhookSubscriptionWriteEndpoints.cs Create / Update / Delete
      • WebhookSubscriptionLifecycleEndpoints.cs Activate / Suspend / Deactivate
      • WebhookSubscriptionOperationEndpoints.cs Rotate secret, test ping, 24h stats
      • WebhookSigningKeyEndpoints.cs Dual-key rotation (/keys CRUD)
    • DirectoryDtos/ Request + response records
    • DirectoryValidators/ FluentValidation, auto-applied via MapGranitGroup
    • DirectoryPermissions/ AIPermissions-style constants
    • DirectoryOptions/ WebhooksEndpointsOptions (route prefix, OpenAPI tag)
    • DirectoryLocalization/ 18 cultures
  • Granit.Webhooks Core abstractions (subscription readers/writers, signing keys)
  • Granit.Webhooks.EntityFrameworkCore Persistence + queryable source
Program.cs
app.MapGranitWebhooks();
// With custom options:
app.MapGranitWebhooks(opts =>
{
opts.RoutePrefix = "admin/webhooks";
opts.TagName = "Webhook Administration";
});
// Redelivery surface (mapped separately so hosts can scope its auth differently):
app.MapGranitWebhooksRedelivery();

MapGranitWebhooks() returns the parent RouteGroupBuilder so you can chain framework filters (CORS, rate limit policy, additional metadata) on the whole admin surface.

OptionDefaultDescription
RoutePrefix"webhooks"Route prefix for every admin endpoint
TagName"Webhooks"OpenAPI tag for endpoint grouping

Two permissions, declared by WebhooksPermissionDefinitionProvider and auto-discovered by Granit.Authorization:

PermissionEndpoints
Webhooks.Subscriptions.ReadDiscovery, Read, Query, Stats, list signing keys
Webhooks.Subscriptions.ManageCreate / Update / Delete, Lifecycle, Rotate secret, Test ping, rotate / revoke signing keys

The route group applies Read at the top level; mutating sub-groups layer Manage via RequireAuthorization (ISO 27001 A.5.15 — least privilege).

MethodRouteDescription
GET/event-typesList every event type registered via IWebhookEventTypeDefinitionProvider, with localized display name + description + category

The frontend hits this once to populate the “Event type” dropdown when creating a subscription. Labels respect the Accept-Language header. Unknown event types are rejected on POST /subscriptions — call this first.

MethodRouteDescription
GET/subscriptions/{id:guid}Get a subscription by ID
POST/subscriptions/queryMapGranitQuery<WebhookSubscription> — filter, sort, page, groupBy

The query endpoint inherits all QueryEngine features (saved views, presets, metadata endpoint).

MethodRouteDescriptionHTTP
POST/subscriptionsCreate — returns the signing secret once201
PUT/subscriptions/{id:guid}Update target URL200
DELETE/subscriptions/{id:guid}Hard-delete a subscription204
// POST /webhooks/subscriptions
{
"targetUrl": "https://customer-app.example.com/hooks/granit",
"eventType": "Invoicing.InvoicePaid"
}
// 201 Created
{
"id": "5b1d8e26-…",
"targetUrl": "https://customer-app.example.com/hooks/granit",
"eventType": "Invoicing.InvoicePaid",
"status": "Active",
"signingSecret": "wh_sec_HxF6KqRz…"
}
stateDiagram-v2
    [*] --> Active: Create
    Active --> Suspended: Suspend
    Suspended --> Active: Activate
    Active --> Deactivated: Deactivate (reason required)
    Suspended --> Deactivated: Deactivate (reason required)
MethodRouteUse when
POST/subscriptions/{id:guid}/suspendConsumer is down or you want to pause without losing config
POST/subscriptions/{id:guid}/activateResume a Suspended subscription
POST/subscriptions/{id:guid}/deactivatePermanent end-of-life (reason required, audited) — no return to Active

Deactivate requires a reason in the body (WebhookSubscriptionDeactivateRequest, max 1000 chars). The reason is persisted on the audit trail — useful when a customer later asks “why did our webhook stop firing six months ago?”.

MethodRouteDescription
POST/subscriptions/{id:guid}/rotate-secretRotate the legacy single-secret HMAC (returns new secret once)
POST/subscriptions/{id:guid}/test-pingStripe-style test ping → returns { success, httpStatusCode, durationMs }
GET/statsAggregate stats: total subscriptions, active/suspended/deactivated counts, last-24h deliveries, success rate, avg response time

test-ping is the single feature operators ask for most. It posts a synthetic event to the target URL and reports the consumer’s actual HTTP response — invaluable for “is your endpoint up?” support conversations.

// POST /webhooks/subscriptions/{id}/test-ping
// 200 OK
{ "success": true, "httpStatusCode": 200, "durationMs": 187 }

The single-secret model breaks every consumer when you rotate. The dual-key model ships keys with an overlap grace period: when you rotate, both the old and the new key are accepted for WebhooksOptions.RetiredKeyGracePeriod (default 24 hours), so consumers can update their verification code without downtime.

MethodRoutePermissionDescription
GET/subscriptions/{id:guid}/keysReadList keys (Active, Retired, Revoked). The secret is never returned.
POST/subscriptions/{id:guid}/keysManageRotate: mint a new Active, move the previous Active to Retired for the grace period. Returns the plain secret once (201).
DELETE/subscriptions/{id:guid}/keys/{keyId:guid}ManageRevoke a key. The last Active key cannot be revoked — rotate first.
stateDiagram-v2
    [*] --> Active: Rotate
    Active --> Retired: Rotate (overlap)
    Retired --> [*]: Grace period elapses / Revoke
    Active --> [*]: Revoke

Verification accepts signatures from both Active and Retired keys until the Retired window closes. See Signing keys for the full algorithm, verification fallback, and consumer migration guide.

Mapped separately via MapGranitWebhooksRedelivery() (own route scope so hosts can apply stricter auth — e.g. step-up MFA):

MethodRouteDescription
POST/deliveries/{deliveryId:guid}/retryRetry a previously failed delivery attempt

Responses: 202 Accepted, 404 not found, 409 if not retryable (already succeeded or terminal failure).

When Granit.Webhooks.EntityFrameworkCore is registered, both subscriptions and delivery attempts are exposed through MapGranitQuery<T>:

RouteBacked by
POST /subscriptions/queryWebhookSubscription
POST /deliveries/queryWebhookDeliveryAttempt

Filter, sort, paginate, groupBy — the same surface admins use everywhere else in Granit. Saved views work out of the box.

DTODirectionUsed by
WebhookEventTypeResponseOutputGET /event-types
WebhookSubscriptionCreateRequestInputPOST /subscriptions
WebhookSubscriptionCreatedResponseOutputPOST /subscriptions — includes SigningSecret
WebhookSubscriptionUpdateRequestInputPUT /subscriptions/{id}
WebhookSubscriptionResponseOutputGET /subscriptions/{id}, lifecycle endpoints
WebhookSubscriptionDeactivateRequestInputPOST /deactivate (mandatory Reason)
WebhookSubscriptionRotateSecretResponseOutputPOST /rotate-secret
WebhookSubscriptionTestPingResponseOutputPOST /test-ping
WebhookSubscriptionStatsResponseOutputGET /stats
WebhookSigningKeyResponseOutputGET /subscriptions/{id}/keys
WebhookSigningKeyCreatedResponseOutputPOST /subscriptions/{id}/keys — includes PlainSecret

Every request DTO is auto-validated via FluentValidation through MapGranitGroup():

  • Target URL — HTTPS only, absolute URI, max 2048 chars.
  • SSRF protection (registration) — blocks private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 100.64.0.0/10), localhost, link-local IPv6, and reserved TLDs (.local, .internal, .onion). Powered by Granit.Http.Security.
  • SSRF protection (delivery)SocketsHttpHandler.ConnectCallback validates the resolved IP at connection time, defeating DNS rebinding.
  • Event type — non-empty, max 200 chars, must exist in the event-type registry. Unknown types are rejected with Granit:Validation:UnknownWebhookEventType — always call GET /event-types before submitting a create form.
  • Deactivation reason — non-empty, max 1000 chars.

The signing key rotation endpoints (POST /keys, DELETE /keys/{keyId}) carry [Idempotent(Required = false)] — clients may send Idempotency-Key to deduplicate retries. See Idempotency for the framework’s replay protection.