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 nameWebhookEventTypeHeard fromdata
organization.invitation_createdOrganizationInvitationCreatedOrganizationInvitationCreatedIntegrationEventinvitationId, invitedBy
organization.member_removedOrganizationMemberRemovedOrganizationMemberRemovedIntegrationEventmemberId
workspace.createdWorkspaceCreatedWorkspaceCreatedIntegrationEventid, 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:

HeaderValue
X-Blouppy-Signaturesha256= followed by the lowercase hex HMAC-SHA256
X-Blouppy-TimestampUnix time, in seconds, at the moment of the attempt
X-Blouppy-EventThe wire name
X-Blouppy-DeliveryThe delivery id, repeated as delivery in the signed body
X-Blouppy-Webhook-IdThe webhook's id

A 2xx answer is a success; anything else, a timeout or a refused connection is a failed attempt.

How it works

ProjectHolds
Blouppy.Webhooks.ModelsWebhook, WebhookDelivery, WebhookEventType, WebhookRules (every number of the feature)
Blouppy.Webhooks.AbstractionsIWebhooksService (create, update, rotate, test, delete), IWebhookDeliveryService
Blouppy.WebhooksThe services, validators, WebhookEventDispatcher and one handler per offered event
Blouppy.Infrastructure.Webhooks[.Abstractions]IWebhookSender (HttpWebhookSender) and IWebhookUrlPolicy (WebhookUrlPolicy)
Blouppy.Infrastructure.SchedulingWebhookDeliverySweepJob 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

SubscriberSendWhenWebhookDeliveryRequested… handlerPostgreSQLDispatchWebhooksWhen… handlerDomainSubscriberSendWhenWebhookDeliveryRequested… handlerPostgreSQLDispatchWebhooksWhen… handlerDomainchange + integration event (outbox)event delivered by the busone WebhookDelivery row per subscribed webhook, already claimed — COMMITWebhookDeliveryRequestedIntegrationEvent(deliveryId, claimId)read the row, check the claimone signed POSTrecord the attempt — COMMIT
SubscriberSendWhenWebhookDeliveryRequested… handlerPostgreSQLDispatchWebhooksWhen… handlerDomainSubscriberSendWhenWebhookDeliveryRequested… handlerPostgreSQLDispatchWebhooksWhen… handlerDomainchange + integration event (outbox)event delivered by the busone WebhookDelivery row per subscribed webhook, already claimed — COMMITWebhookDeliveryRequestedIntegrationEvent(deliveryId, claimId)read the row, check the claimone signed POSTrecord the attempt — COMMIT
  • 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 WebhookEventDispatcher commits the rows first. A crash in between leaves a row nobody was told about; it was created claimed for WebhookRules.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() pushes NextAttemptAt out by the lease and stamps a fresh ClaimId. SendAsync skips a message whose claim no longer matches, and webhook_deliveries carries xmin as a concurrency token, so two writers claiming the same row cannot both commit.
  • One attempt is one request.SendAsync records a failure instead of throwing, so the bus never retries an attempt. The consumer of WebhookDeliveryRequestedIntegrationEvent has 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

  1. At save time, WebhookUrlPolicy refuses 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.
  2. At connect time, the webhooks client's primary handler, PublicOnlyHttpHandler (Blouppy.Aspire.ServiceDefaults/Security/Outbound/), resolves the host itself, keeps only the addresses PublicAddressGuard allows, and opens the socket to one of them. A name that resolves publicly at save time and privately later is refused later.
  3. 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:

SettingDefaultMeaning
Webhooks:SweepInterval00:00:30How often the sweep job runs. Must be positive.
Webhooks:SweepBatchSize200Rows claimed per sweep, between 1 and 1000.
Webhooks:AllowInsecureTransportfalseAccept http:// URLs. Forced to false outside Development.
Webhooks:AllowPrivateNetworkTargetsfalseAllow 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:

  1. Add a member to WebhookEventType (Blouppy.Webhooks.Models/Events/) with a new number. Never reuse one: the value is stored.
  2. Give it a wire name in WebhookEventTypeExtensions.ToWireName(). It is spelled out, not derived, because it is a public contract.
  3. Add a record to WebhookEventData (Blouppy.Webhooks/Events/) holding identifiers only.
  4. Add a DispatchWebhooksWhen…IntegrationEventHandler in Blouppy.Webhooks/IntegrationEventHandlers/, calling IWebhookEventDispatcher.DispatchAsync(organizationId, eventType, data, …). AddWebhooksModule() picks it up by assembly scan. If the API registers no consumer for that integration event yet, add one in BlouppyMessagingServiceCollectionExtensions (see Events and messaging).
  5. Mirror it in the front end: WebhookEventType, WEBHOOK_EVENT_TYPES and WEBHOOK_EVENT_WIRE_NAMES in front/application/src/app/core/models/webhooks/webhook.model.ts, and the label under webhooks.eventName in assets/i18n/en.json and fr.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.