A Webhook subscription is not merely a URL setting. It is the security boundary that says which tenant authorizes which API Client to receive which events. Endpoint Resource/Action authorizes the manager; tenant, Client state, and Client Scope decide whether registration and every later delivery remain admissible.
1. Subscription aggregate contract
WebhookSubscription maps directly to SysWebhookSubscription:
| Field | Constraint and meaning |
|---|---|
| SubscriptionId | aggregate key; Create starts at 0, ORM Add assigns a real id |
| TenantId | trusted current tenant; max 64, no surrounding whitespace |
| ClientId | Identity API Client; max 64 and immutable after creation |
| Name | trimmed, required, max 120 |
| TargetUrl | absolute HTTPS URI, AbsoluteUri max 512 |
| EventTypesJson | required, ordinal, unique strict JSON string array |
| ScopesJson | required, ordinal, unique strict JSON string array |
| IpAllowlistJson | optional; aggregate rejects blanks, not invalid CIDR syntax |
| SecretHash | SHA-256 of current plaintext secret; excluded from summaries |
| SecretMaterial | expected DataProtection ciphertext; excluded from summaries |
| PreviousSecretMaterial/ExpiresAt | rotation overlap fields; unused by current send/verify paths |
| Status | Active, Suspended, or Deleted |
| RotatedAt | most recent rotation time |
ORM materialization uses WebhookStorageJson. Empty required arrays, duplicates, surrounding whitespace, invalid JSON, and former line-delimited storage throw instead of silently becoming an empty collection. That exposes bad migrations early, but requires a pre-deployment data audit.
// Serialization writes the single canonical array representation expected by both ORMs.var canonical = WebhookStorageJson.SerializeRequired( [WebhookEventTypes.FileFinalized, WebhookEventTypes.FileDeleted]);// => ["files.finalized","files.deleted"]
var restored = WebhookStorageJson.DeserializeRequired(canonical);
// Legacy line format and an empty required collection must surface migration defects.Should.Throw<InvalidOperationException>(() => WebhookStorageJson.DeserializeRequired("files.finalized\nfiles.deleted"));Should.Throw<InvalidOperationException>(() => WebhookStorageJson.DeserializeRequired("[]"));2. Two authorization layers
Management authorization provides subscription View/Create/Update/Delete and delivery View/Update. Suspend, Resume, RotateSecret, and Update share Update; RetryDelivery also uses Update. ResourceDescriptor is webhooks/subscription or webhooks/delivery.
External-app admission then requires:
- an Active current tenant;
- every EventType registered;
- a resolvable ClientId;
- a serviceable Client owned by the current tenant;
- every event’s required scope on that Client;
- every explicitly declared scope on that Client.
Update cannot replace ClientId. The Handler loads the original subscription and validates new EventTypes/Scopes against its original Client. Moving ownership requires a new subscription and explicit cutover.
3. Registered events and required scopes
The in-memory registry starts with:
| EventType | Required Client Scope |
|---|---|
files.finalized | files.webhooks.deliver |
files.deleted | files.webhooks.deliver |
billing.invoice-issued | billing.webhooks.deliver |
tickets.opened | tickets.webhooks.deliver |
workflow.started | workflow.webhooks.deliver |
workflow.completed | workflow.webhooks.deliver |
api.version.deprecated has a constant and CAP consumer but is not registered and has no Webhook Scope constant. Its example message therefore reaches a consumer that returns Webhook.EventTypeNotRegistered; the subscription API cannot register it either.
Subscription Scopes need not equal or contain event-required scopes. The guard checks the two lists independently, so a Client with Files scope may declare some unrelated, also-authorized scope. If Scopes is intended as an externally visible authorization snapshot, derive it from EventTypes or validate the inclusion relationship.
4. Create/update validation order
Create invokes the guard before aggregate Create. Consequences:
- if JSON binds
eventTypesorscopesto null, the guard foreach/Any can throw instead of returningWebhook.CollectionsRequired; - an event with surrounding whitespace fails exact registry matching before aggregate normalization.
There is no Create/Update IRequestRule. Guard and aggregate carry most validation but do not share one shape/error-order contract. Public HTTP should first validate nullability, count, length, URI, and CIDR syntax, then run cross-port admission.
// Request rules reject malformed HTTP shape before tenant/Client stores are contacted.public static class WebhookErrors{ public static readonly Error EventTypesRequired = Error.Validation("Webhook.EventTypesRequired", "Select at least one event.");
public static readonly Error ScopesRequired = Error.Validation("Webhook.ScopesRequired", "Declare at least one scope.");}
public sealed class CreateWebhookSubscriptionRule : IRequestRule<CreateSubscription.Command>{ public ValueTask<Result> ValidateAsync( CreateSubscription.Command request, CancellationToken cancellationToken) { if (request.EventTypes is not { Count: > 0 }) return ValueTask.FromResult(Result.Failure(WebhookErrors.EventTypesRequired));
if (request.Scopes is not { Count: > 0 }) return ValueTask.FromResult(Result.Failure(WebhookErrors.ScopesRequired));
// Shape only; tenant, Client, and ownership remain guard responsibilities. return ValueTask.FromResult(Result.Success()); }}5. URL and allowlist responsibilities
The aggregate proves only that TargetUrl is absolute HTTPS and within length. It does not reject URI userinfo, unusual ports, localhost/private/link-local/cloud-metadata destinations, IDN risk, fragments, redirects, or DNS changes.
Creation rejects blank IpAllowlist entries but accepts not-a-cidr; production CIDR policy dead-letters it only during delivery. Management should reuse the same parser before save. Also label this as a destination-address allowlist—not a list of callers allowed to invoke BitzOrcas.
6. Three-state lifecycle
Suspend and Resume are assignment-style success for every non-Deleted state, not strict transition conflicts. Delete sets both domain status and soft-delete fields. Public Find excludes Deleted, so later calls return NotFound. Deleted cannot rotate, suspend, or resume.
Suspended subscriptions can be updated before resume. Update does not change state. List includes Active and Suspended but excludes Deleted.
7. Query and disclosure boundaries
Both lists use currentUser.User.TenantId. Cross-tenant identifiers become NotFound. Subscription summaries reveal TargetUrl, EventTypes, Scopes, IpAllowlist, ClientId, and status—network topology that should be restricted to integration administrators.
Neither list has PageIndex/PageSize. ListAsync loads all tenant rows and sorts in memory by CreateTime/OccurredAt. There is no Id tie-breaker, so equal timestamps have unstable order. Production needs provider-neutral Query Shape paging with explicit maxima.
8. Lifecycle use case
// Stop selection first so no new event starts against a half-migrated endpoint.var suspended = await mediator.Send( new SuspendSubscription.Command(subscriptionId), cancellationToken);if (suspended.IsFailure) return suspended.Error;
var updated = await mediator.Send( new UpdateSubscription.Command( subscriptionId, Name: "ERP v2", TargetUrl: new Uri("https://hooks.erp.example/v2/events"), EventTypes: [WebhookEventTypes.FileFinalized], Scopes: [WebhookScopes.FilesDeliver], IpAllowlist: ["203.0.113.64/28"]), cancellationToken);if (updated.IsFailure) return updated.Error;
// Resume does not probe the target; a separate challenge should have established readiness.// Resume changes status only; there is no endpoint handshake or probe.return await mediator.Send( new ResumeSubscription.Command(subscriptionId), cancellationToken);There is no test-delivery endpoint. Do not probe a new URL with a real business event: that creates a real idempotency key, delivery fact, and possible downstream side effect. Add an explicit challenge/handshake contract.
9. Required authorization tests
- Allow/deny all six permission actions.
- Cross-tenant subscription/delivery ids always become NotFound.
- Inactive/expired Client, wrong-tenant Client, and missing required scope.
- Duplicate/case-varied/null/empty/blank EventTypes.
- Declared scopes missing required, unrelated-but-granted, and ungranted.
- HTTPS userinfo, port, IDN, fragment, loopback/private/link-local.
- Invalid CIDR should fail registration rather than become delayed dead letter.
- Suspended update, idempotent Resume, and all Deleted operations.
- Stable paging at tenant scale and equal timestamps.
- No summary/serializer ever exposes any secret field.
10. Review commands
# Keep route actions and the permission catalog in one review.rg -n "GenerateEndpoint|ResourceDescriptor|AuthorizationAction" \ src/Platform/Webhooks/BitzOrcas.Platform.Webhooks.Application
rg -n "Register\(|CapSubscribe|WebhookEventTypes|WebhookScopes" \ src/Platform/Webhooks -g '*.cs'
# The empty result is intentional until shape rules are implemented.# Expected after correction: Create/Update rules exist and null never reaches the guard.rg -n "IRequestRule<CreateSubscription|IRequestRule<UpdateSubscription" \ src/Platform/Webhooks -g '*.cs'Back to Webhooks · signatures and secrets · network security