Skip to content
GitHub

Architecture

Internal design of the lexigram-webhook package.


flowchart BT
    Contracts[lexigram-contracts<br/>WebhookSubscription · WebhookEvent · DeliveryAttempt<br/>WebhookSubscriptionStoreProtocol · WebhookDeliveryStoreProtocol]
    Webhook[lexigram-webhook<br/>Delivery pipeline · Subscription management<br/>HMAC security · Dead-letter queue]
    Events[lexigram-events<br/>Domain event bus]
    Admin[lexigram-admin<br/>Admin dashboard]

    Webhook --> Contracts
    Events --> Webhook
    Admin --> Webhook

lexigram-webhook is the outbound webhook delivery layer. It provides subscription CRUD, event fan-out with exponential-backoff retry, HMAC signature signing and verification, storage backends (in-memory, SQL), and dead-letter queue management.


lexigram-webhook/src/lexigram/webhook/
├── __init__.py # Public surface — re-exports + WebhookModule, WebhookConfig
├── config.py # WebhookConfig — 18 fields with env overrides
├── constants.py # Default values + StrEnums (StoreBackend, DeliveryStatus)
├── decorators.py # @webhook_event decorator for event emission
├── events.py # Domain events (WebhookDeliveredEvent, WebhookDeliveryFailedEvent)
├── exceptions.py # Leaf exceptions (SubscriptionNotFoundError, etc.)
├── hooks.py # Lifecycle hook payloads (before/after delivery, subscription change)
├── module.py # WebhookModule — DynamicModule factory
├── protocols.py # Re-exports from lexigram.contracts.webhook
├── types.py # Internal types (DeliveryBatch, RetrySchedule)
├── bridge/
│ └── event_bus.py # EventBusWebhookBridge — domain event to WebhookEvent bridge
├── delivery/
│ ├── dead_letter.py # DeadLetterManager — DLQ listing + counting
│ ├── sender.py # WebhookSender — HTTP POST with HMAC signing
│ └── service.py # WebhookDeliveryService — fan-out + retry orchestration
├── di/
│ ├── bundle_provider.py # WebhookBundleProvider — composite entry point
│ └── sub_providers/
│ ├── admin_provider.py # WebhookAdminProvider — admin contributor
│ ├── core_provider.py # WebhookCoreProvider — config + stores + subscription
│ ├── delivery_provider.py # WebhookDeliveryProvider — sender + delivery + DLQ
│ └── verification_provider.py # WebhookVerificationProvider — HMAC verifier
├── store/
│ ├── memory.py # InMemoryWebhookStore — both subscription + delivery (default)
│ └── sql.py # SqlWebhookSubscriptionStore + SqlWebhookDeliveryStore
├── subscription/
│ ├── secret.py # generate_webhook_secret — cryptographically secure hex
│ └── service.py # WebhookSubscriptionService — CRUD + secret rotation
└── verification/
├── helpers.py # WebhookHeaders, extract_webhook_headers, verify_webhook_payload
└── hmac.py # HMACSignatureVerifier — compute + constant-time verify

sequenceDiagram
    participant Source as Event Source
    participant Bridge as EventBusWebhookBridge
    participant Service as WebhookDeliveryService
    participant Store as SubscriptionStore
    participant Sender as WebhookSender
    participant Target as Subscriber Endpoint

    Source->>Bridge: forward(event)
    Bridge->>Service: dispatch(WebhookEvent)
    Service->>Store: list(active_only=True, event_type)
    Store-->>Service: matching subscriptions

    loop For each subscription
        Service->>Service: _deliver_with_retry(event, sub)

        loop attempt=1..max_attempts
            Service->>Sender: send(event, sub, attempt_number)
            Sender->>Sender: compute HMAC signature
            Sender->>Target: POST payload + signature headers
            Target-->>Sender: HTTP response
            Sender-->>Service: DeliveryAttempt

            alt status == DELIVERED
                Service->>Store: record_attempt(attempt)
            else status == FAILED and not last
                Service->>Store: record_attempt(attempt)
                Service->>Service: exponential backoff sleep
            else status == FAILED and last
                Service->>Store: record_attempt(DEAD_LETTER)
                Service->>Service: _check_auto_disable()
            end
        end
    end

The pipeline fans out a single WebhookEvent to all matching active subscriptions concurrently. Each delivery runs independently with exponential-backoff retry. Exhausted retries escalate to dead-letter status and may trigger auto-disable of the subscription if the failure threshold is exceeded.

DeliveryAttempt is a frozen dataclass in lexigram.contracts.webhook.types:

@dataclass(frozen=True)
class DeliveryAttempt:
attempt_id: str
subscription_id: str
event_id: str
event_type: str
status: DeliveryStatus # pending | delivered | failed | dead_letter
status_code: int | None # HTTP status (None if connection failed)
attempt_number: int
attempted_at: datetime
next_retry_at: datetime | None
error_message: str | None
duration_ms: float | None

The retry policy is fully configurable via WebhookConfig:

ParameterDefaultDescription
retry_max_attempts5Maximum delivery attempts before dead-letter
retry_base_delay1.0sInitial retry delay
retry_max_delay60.0sCeiling on exponential backoff
retry_backoff_factor2.0Multiplier applied each retry
disable_after_consecutive_failures50Auto-disable threshold within window
failure_window_hours24Sliding window for failure counting

Backoff formula: delay = min(base * factor^(attempt-1), max_delay)

When a subscription accumulates disable_after_consecutive_failures failures (FAILED + DEAD_LETTER) within failure_window_hours, the delivery service automatically deactivates it.


WebhookSubscriptionService provides CRUD operations via Result[T, E]:

service = WebhookSubscriptionService(store, config)
result = await service.create(
url="https://example.com/hooks",
event_types=frozenset({"order.created", "order.updated"}),
)
if result.is_ok():
sub = result.unwrap() # WebhookSubscription with auto-generated secret
await service.rotate_secret(sub.subscription_id) # Grace period honored
await service.deactivate(sub.subscription_id) # Stop deliveries
await service.activate(sub.subscription_id) # Resume

rotate_secret() stores the previous secret in metadata["previous_secret"] with an expiry timestamp (metadata["previous_secret_expires"]). Both old and new secrets are accepted during the grace period (secret_rotation_grace_hours, default 24h). This allows consumers to transition without downtime.

# Subscription after rotation:
# secret = "new_hex_secret"
# metadata["previous_secret"] = "old_hex_secret"
# metadata["previous_secret_expires"] = "2026-06-02T12:00:00+00:00"

WebhookSender computes an HMAC-SHA256 signature for every outgoing payload:

# X-Webhook-Signature: sha256=<hex_digest>
signature = HMACSignatureVerifier().compute_signature(payload_bytes, secret)

Every HTTP delivery includes four standard headers:

HeaderConfig FieldPurpose
X-Webhook-Signaturesignature_headerHMAC-SHA256 of payload body
X-Webhook-Event-Typeevent_type_headerEvent type for routing
X-Webhook-Event-IDevent_id_headerUnique event ID for deduplication
X-Webhook-Timestamptimestamp_headerISO-8601 timestamp of delivery

The HMACSignatureVerifier supports both prefixed (sha256=abc123) and bare (abc123) signature formats using constant-time comparison to prevent timing side-channel attacks.

# Verify inbound webhook
from lexigram.webhook.verification.helpers import verify_webhook_payload
is_valid = await verify_webhook_payload(
payload=await request.body(),
signature_header=request.headers.get("X-Webhook-Signature", ""),
secret=subscription.secret,
)

Two store backends implement WebhookSubscriptionStoreProtocol and WebhookDeliveryStoreProtocol:

BackendValuePersistenceWhen to Use
In-memory"memory" (default)NoneDev, tests, single-process
SQL"sql"SQL databaseProduction (requires lexigram-webhook[sql])
# Select backend via config
WebhookConfig(store_backend="sql")

The WebhookCoreProvider selects the backend based on config.store_backend. The SQL backend uses DatabaseProviderProtocol from lexigram-contracts and creates two tables (webhook_subscriptions, webhook_delivery_attempts) with appropriate indexes.

EventBusWebhookBridge connects the domain event bus to the webhook delivery pipeline. It receives WebhookEvent instances and forwards them to WebhookDeliveryService.dispatch():

bridge = EventBusWebhookBridge(delivery_service)
await bridge.forward(WebhookEvent(
event_id=str(uuid4()),
event_type="order.created",
payload={"order_id": "123"},
))

The package implements protocols from lexigram.contracts.webhook:

ProtocolImplemented ByPurpose
WebhookSubscriptionStoreProtocolInMemoryWebhookStore, SqlWebhookSubscriptionStoreSubscription CRUD
WebhookDeliveryStoreProtocolInMemoryWebhookStore, SqlWebhookDeliveryStoreDelivery attempt logging + DLQ
WebhookDeliveryServiceProtocolWebhookDeliveryServiceEvent fan-out + retry orchestration

Types (WebhookSubscription, WebhookEvent, DeliveryAttempt, DeliveryStatus) are defined in lexigram.contracts.webhook.types. The base exception WebhookError lives in lexigram.contracts.webhook.exceptions.


WebhookBundleProvider (priority ProviderPriority.COMMS) composes four sub-providers:

sequenceDiagram
    participant App as Application.start()
    participant Bundle as WebhookBundleProvider
    participant Core as WebhookCoreProvider
    participant Delivery as WebhookDeliveryProvider
    participant Verify as WebhookVerificationProvider
    participant Admin as WebhookAdminProvider

    App->>Bundle: register(container)
    Bundle->>Core: register()
    Bundle->>Delivery: register()
    Bundle->>Verify: register()
    alt config.enable_admin=True
        Bundle->>Admin: register()
    end

    App->>Bundle: boot(container)
    Bundle->>Core: boot()
    Bundle->>Delivery: boot()
    Bundle->>Verify: boot()
    Bundle->>Admin: boot()

    App->>Bundle: shutdown()
    Bundle->>Admin: shutdown()
    Bundle->>Verify: shutdown()
    Bundle->>Delivery: shutdown()
    Bundle->>Core: shutdown()

WebhookCoreProvider:

  • WebhookConfig (singleton)
  • WebhookSubscriptionStoreProtocol (singleton — InMemoryWebhookStore)
  • WebhookDeliveryStoreProtocol (singleton — same instance)
  • WebhookSubscriptionService

WebhookDeliveryProvider:

  • WebhookSender
  • WebhookDeliveryServiceProtocolWebhookDeliveryService
  • DeadLetterManager

WebhookVerificationProvider:

  • HMACSignatureVerifier

WebhookAdminProvider (only if config.enable_admin=True):

  • WebhookAdminContributor

All sub-providers share priority COMMS so they boot after infrastructure and security providers. The admin provider uses priority LOW to resolve after the DI container is fully populated.

# WebhookModule wires everything:
app.use(WebhookModule.configure(WebhookConfig(store_backend="sql")))

The WebhookModule.configure() returns a DynamicModule exporting WebhookSubscriptionStoreProtocol, WebhookDeliveryServiceProtocol, and WebhookSubscriptionService.


constants.py defines:

SymbolDescription
StoreBackendEnum: MEMORY, SQL
DeliveryStatusEnum: PENDING, DELIVERED, FAILED, DEAD_LETTER, CANCELLED
SignatureAlgorithmEnum: SHA256, SHA512
ENV_PREFIXLEX_WEBHOOK__
DEFAULT_RETRY_MAX_ATTEMPTS5
DEFAULT_RETRY_BASE_DELAY1.0
DEFAULT_RETRY_MAX_DELAY60.0
DEFAULT_RETRY_BACKOFF_FACTOR2.0
DEFAULT_SECRET_LENGTH32 bytes
DEFAULT_SIGNATURE_HEADERX-Webhook-Signature
__version__Package version

The delivery pipeline emits three domain events:

EventWhenPayload
WebhookDeliveredEventSuccessful deliveryattempt_id, subscription_id, event_type
WebhookDeliveryFailedEventFailed attemptattempt_id, subscription_id, event_type, error, attempt_number
WebhookSubscriptionCreatedEventNew subscriptionsubscription_id, url

Consumers subscribe via EventBusProtocol for metrics, logging, and alerts:

event_bus.subscribe(WebhookDeliveryFailedEvent, handle_delivery_failure)

Three hook payloads allow custom behavior at key points in the delivery lifecycle:

HookWhenFuel For
WebhookBeforeDeliveryHookBefore each attemptRequest enrichment, audit
WebhookDeliveryCompletedHookAfter each attemptMetrics, logging
WebhookSubscriptionChangedHookSubscription CRUDCache invalidation

Register handlers via the framework’s HookRegistryProtocol:

hook_registry.register_handler("webhook.before_delivery", my_enricher)

flowchart LR
    subgraph Contracts[lexigram-contracts]
        WHE[WebhookError<br/>Base domain exception]
    end
    subgraph Package[lexigram-webhook]
        SN[SubscriptionNotFoundError]
        SI[SubscriptionInactiveError]
        IW[InvalidWebhookURLError]
        DA[DeliveryAttemptNotFoundError]
        SR[SecretRotationError]
    end
    subgraph Domain[Domain Services]
        RT["Result[T, E]<br/>Recoverable domain failures"]
    end

    WHE --> SN
    WHE --> SI
    WHE --> IW
    WHE --> DA
    WHE --> SR
    SN --> RT
    IW --> RT
    DA --> RT

WebhookError lives in lexigram.contracts.webhook.exceptions as the base. Leaf exceptions in lexigram.webhook.exceptions extend it. Domain operations return Result[T, WebhookError] — callers handle at the service boundary.


PointMechanismExample
Custom store backendImplement WebhookSubscriptionStoreProtocol + WebhookDeliveryStoreProtocolRedis-backed store
Custom HTTP senderReplace WebhookSender in containerMock sender for testing
Delivery hooksRegister WebhookBeforeDeliveryHook / WebhookDeliveryCompletedHook via HookRegistryProtocolRequest enrichment, audit logging
Domain eventsSubscribe to WebhookDeliveredEvent / WebhookDeliveryFailedEvent via EventBusProtocolMetrics, alerts
Admin dashboardconfig.enable_admin=True registers WebhookAdminContributorPre-built, wire via entry points
Secret rotation callbackOverride WebhookSubscriptionService.rotate_secret()External secret store
Custom security schemeReplace HMACSignatureVerifier in containerCustom signing algorithm