Webhooks
Blouppy.Webhooks is the module that sends an organization's outgoing webhooks: an
HTTP POST to a URL an administrator typed, every time something they subscribed to
happens. It is a module in the shape of a domain (see
Domains and modules), with its own .Models,
.Abstractions and implementation projects, and no .PublicData: nothing in the
application asks it anything.
What it does
For an organization administrator, webhooks live under the organization's
settings (/<slug>/settings/webhooks). They can add an endpoint, choose its events,
add up to ten custom headers, pause it, send a test delivery, rotate its secret, read
the delivery history and redeliver a delivery that is no longer pending. The secret
is shown once, at creation and at each rotation. Only an administrator reaches any of
it: the controller checks Policies.Manage on every action.
For the subscriber, the starter offers three events:
| Wire name | WebhookEventType | Heard from | data |
|---|---|---|---|
organization.invitation_created | OrganizationInvitationCreated | OrganizationInvitationCreatedIntegrationEvent | invitationId, invitedBy |
organization.member_removed | OrganizationMemberRemoved | OrganizationMemberRemovedIntegrationEvent | memberId |
workspace.created | WorkspaceCreated | WorkspaceCreatedIntegrationEvent | id, actorId |
Every body is the same envelope (WebhookEventEnvelope), serialized in camelCase with
fixed options (WebhookPayloadSerialization) because the body is signed:
{
"event": "workspace.created",
"delivery": "3f2a…",
"organizationId": 42,
"occurredAt": "2026-11-02T09:14:03.512Z",
"data": { "id": 12, "actorId": "…" }
}
Payloads carry identifiers, not personal data: no e-mail, and never the
invitation token, which is a bearer credential. A subscriber reads the details from
the API under its own credentials. A test delivery uses the webhook's first event name
with data set to { "isTest": true, "message": "…" }.
Each request carries these headers (WebhookRules), which a custom header cannot
override:
| Header | Value |
|---|---|
X-Blouppy-Signature | sha256= followed by the lowercase hex HMAC-SHA256 |
X-Blouppy-Timestamp | Unix time, in seconds, at the moment of the attempt |
X-Blouppy-Event | The wire name |
X-Blouppy-Delivery | The delivery id, repeated as delivery in the signed body |
X-Blouppy-Webhook-Id | The webhook's id |
A 2xx answer is a success; anything else, a timeout or a refused connection is a
failed attempt.
How it works
| Project | Holds |
|---|---|
Blouppy.Webhooks.Models | Webhook, WebhookDelivery, WebhookEventType, WebhookRules (every number of the feature) |
Blouppy.Webhooks.Abstractions | IWebhooksService (create, update, rotate, test, delete), IWebhookDeliveryService |
Blouppy.Webhooks | The services, validators, WebhookEventDispatcher and one handler per offered event |
Blouppy.Infrastructure.Webhooks[.Abstractions] | IWebhookSender (HttpWebhookSender) and IWebhookUrlPolicy (WebhookUrlPolicy) |
Blouppy.Infrastructure.Scheduling | WebhookDeliverySweepJob and WebhookDeliveryPurgeJob, Quartz.NET jobs |
The Web reaches it through WebhooksController, under
/api/v1/organizations/{organizationId}/webhooks. The rows live in webhooks and
webhook_deliveries (Blouppy.Infrastructure.Database/Webhooks/).
The delivery, in three hops
- The row is the source of truth; the message is a nudge. The payload is written into the row when it is created; the URL, secret and headers are read from the webhook at send time. A rotated secret applies to the next attempt, and nothing sensitive sits in a queue.
- Commit, then publish. Inside a bus consumer a publish does not go through the
outbox, so
WebhookEventDispatchercommits the rows first. A crash in between leaves a row nobody was told about; it was created claimed forWebhookRules.ClaimLease(2 minutes), so when that lease expires the sweep job picks it up. - Idempotent dispatch. The delivery id is the first 16 bytes of a SHA-256 over the
bus message id and the webhook id, so a redelivered event finds its rows
(
GetExistingIdsAsync) and adds none. Outside a consumer the id is random. - Claims.
WebhookDelivery.Claim()pushesNextAttemptAtout by the lease and stamps a freshClaimId.SendAsyncskips a message whose claim no longer matches, andwebhook_deliveriescarriesxminas a concurrency token, so two writers claiming the same row cannot both commit. - One attempt is one request.
SendAsyncrecords a failure instead of throwing, so the bus never retries an attempt. The consumer ofWebhookDeliveryRequestedIntegrationEventhas its own endpoint, capped at 16 concurrent messages per replica (WebhookDeliveryRequestedConsumer.MaxConcurrentAttempts).
Signing
The timestamp is signed with the body, so a captured delivery cannot be replayed with
a fresh timestamp (Blouppy.Infrastructure.Webhooks/WebhookSignature.cs):
var key = Encoding.UTF8.GetBytes(secret);
var message = Encoding.UTF8.GetBytes($"{timestamp}.{payload}");
return Prefix + Convert.ToHexStringLower(HMACSHA256.HashData(key, message));
The key is the secret string as shown to the administrator (32 random bytes, Base64),
taken as UTF-8. At rest, the secret and every custom header value are encrypted with
ISecretProtector (purpose Blouppy.Webhooks.Secrets.v1) by an EF value converter,
WebhookSecretsProtection.
Retries and retention
A failed attempt writes its next due time into the row, following
WebhookRules.RetryBackoff: 1 min, 5 min, 30 min, 2 h, 6 h — six attempts in
all. After the last one the delivery is Failed until somebody redelivers it, which
starts a full budget again without rewriting the attempt count. Nothing holds a timer:
WebhookDeliverySweepJob asks every Webhooks:SweepInterval what is due — an elapsed
backoff or an expired lease — claims up to Webhooks:SweepBatchSize rows and queues
them through the outbox. WebhookDeliveryPurgeJob runs hourly and deletes deliveries
settled more than 30 days ago (WebhookRules.DeliveryRetention); pending rows are never
purged. Both jobs carry [DisallowConcurrentExecution] on Quartz.NET's clustered
store, so one replica runs each at a time.
What the server refuses to dial
- At save time,
WebhookUrlPolicyrefuses a URL that is not HTTPS, one with credentials in it, and a host any of whose addresses is refused — so the form can say why. - At connect time, the
webhooksclient's primary handler,PublicOnlyHttpHandler(Blouppy.Aspire.ServiceDefaults/Security/Outbound/), resolves the host itself, keeps only the addressesPublicAddressGuardallows, and opens the socket to one of them. A name that resolves publicly at save time and privately later is refused later. - No redirects, no proxy, no cookies, pooled connections for one minute, a 5-second connect timeout, a 10-second budget per attempt, and at most 64 KiB of the response read and stored.
PublicAddressGuard judges addresses, not names: IPv4 loopback, private, CGNAT,
link-local (the cloud metadata endpoint), documentation, benchmarking, multicast and
reserved ranges are refused, IPv4-mapped IPv6 addresses are judged as IPv4, and IPv6
must be global unicast (2000::/3) outside Teredo, documentation and 6to4.
Audit and privacy
Creating, updating, rotating the secret of and deleting a webhook are domain events
that the module publishes for the audit log, with the URL
stripped of user-info, query string and fragment (WebhookRules.UrlWithoutSecrets)
and header names without their values. Deleting a webhook cascades to its deliveries,
and deleting an organization to its webhooks; see Privacy.
Configuration
The Webhooks section (WebhookOptions), read by the API:
| Setting | Default | Meaning |
|---|---|---|
Webhooks:SweepInterval | 00:00:30 | How often the sweep job runs. Must be positive. |
Webhooks:SweepBatchSize | 200 | Rows claimed per sweep, between 1 and 1000. |
Webhooks:AllowInsecureTransport | false | Accept http:// URLs. Forced to false outside Development. |
Webhooks:AllowPrivateNetworkTargets | false | Allow private and loopback targets. Forced to false outside Development. |
The two escape hatches exist so a developer can point a webhook at a local receiver.
A PostConfigure resets them in every other environment, so a copied
appsettings file or a stray environment variable cannot turn the server into a proxy
into its own network. Everything else — timeouts, backoff, retention, header limits —
is a constant in WebhookRules.
Extending it
An event is offered when a domain already publishes an integration event for it. Offering a new one:
- Add a member to
WebhookEventType(Blouppy.Webhooks.Models/Events/) with a new number. Never reuse one: the value is stored. - Give it a wire name in
WebhookEventTypeExtensions.ToWireName(). It is spelled out, not derived, because it is a public contract. - Add a record to
WebhookEventData(Blouppy.Webhooks/Events/) holding identifiers only. - Add a
DispatchWebhooksWhen…IntegrationEventHandlerinBlouppy.Webhooks/IntegrationEventHandlers/, callingIWebhookEventDispatcher.DispatchAsync(organizationId, eventType, data, …).AddWebhooksModule()picks it up by assembly scan. If the API registers no consumer for that integration event yet, add one inBlouppyMessagingServiceCollectionExtensions(see Events and messaging). - Mirror it in the front end:
WebhookEventType,WEBHOOK_EVENT_TYPESandWEBHOOK_EVENT_WIRE_NAMESinfront/application/src/app/core/models/webhooks/webhook.model.ts, and the label underwebhooks.eventNameinassets/i18n/en.jsonandfr.json.
The dispatcher, the delivery, the retries and the history need no change.
Limits
- Events are not ordered. Two changes can arrive in either order; a subscriber reads the current state from the API.
- Pausing drops, it does not buffer. A paused webhook is not subscribed, so events raised meanwhile are never queued for it.
- One retention for everybody: 30 days, a constant rather than a plan entitlement.
- At-least-once. The delivery id is stable across retries and redeliveries of the same event; subscribers deduplicate on it.