You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Ship a first-class webhook framework: a small univeros/webhooks sub-package providing storage contracts + signing primitives + an inbound PSR-15 verify middleware + an outbound dispatcher with retry / dead-letter / replay + a webhook: spec block the scaffolder wires automatically. Closes the second "Laravel AI-era property-gap" called out in the marketing strategy (alongside idempotency, which shipped under #171).
The x-altair-webhook OpenAPI extension key already exists (#163); this epic delivers the runtime that consumes it and the spec block that produces it. Same shape as the idempotency epic (#171).
Why
Every API that talks to other systems eventually needs webhooks. Stripe, GitHub, Slack, Twilio, Shopify, Square — every payments / SaaS / commerce platform ships both inbound and outbound webhook contracts. PHP frameworks ship no native primitive for either: Laravel has none, Symfony has none, Slim has none. Teams either roll their own (and re-invent HMAC verification + timestamp-window protection + idempotent delivery + retry curves) or stitch together three uncoordinated community packages.
The agent-era problem amplifies the cost. An agent asked to integrate with Stripe's webhooks has to discover the signing scheme, find a verification library, wire timestamp checks, dedupe deliveries, write replay handling — all from spec-less prose. An agent asked to emit webhooks for a third-party integration has to invent retry curves and dead-letter semantics from scratch.
Shipping this as a sub-package — pluggable storage, multi-scheme signers, agent-readable spec block, round-trips through OpenAPI — is the framework's most credible "ready for production integrations" claim, and the second of two property-gap-closing primitives.
Re-scaffold. The emitted Action wires WebhookVerifyMiddleware which:
Reads X-Signature from request headers.
Verifies HMAC-SHA256 against the shared secret (resolved via DI binding).
Rejects with 401 Unauthorized when the signature does not match.
Reads a X-Timestamp header and rejects with 400 Bad Request when outside timestamp_window (replay protection).
Computes an event id (from X-Event-Id header, or hash of body when absent) and rejects with 200 OK (idempotently absorbed) when the id has been seen in the last dedupe_ttl.
On success, marks the event id as processed and passes the verified payload to the domain handler.
Dispatches asynchronously via Symfony Messenger (existing univeros/messaging integration).
Retries with exponential backoff on 5xx or network failure.
Dead-letters to the named transport after max_attempts.
Records delivery state (id, attempts, last response, status) so bin/altair webhook:replay <delivery-id> works.
The spec:emit-openapi chain writes x-altair-webhook: { ... } on the operation; openapi:import recovers the spec block; openapi:roundtrip (#164) gains the extension to its compared set so dropping it in a refactor fails the gate.
A spec carrying webhook: { direction: in, signing: hmac-sha256 } scaffolds an Action that verifies inbound HMAC signatures end-to-end
A spec carrying webhook: { direction: out, signing: hmac-sha256 } scaffolds a WebhookDispatcher binding the host can dispatch through
Inbound: concurrent identical deliveries (same X-Event-Id) see exactly one handler invocation; subsequent ones return 200 without re-processing
Outbound: a failing subscriber (5xx) is retried with the configured backoff and dead-lettered after max_attempts
bin/altair webhook:replay <delivery-id> re-dispatches a dead-lettered delivery
openapi:roundtrip catches a regression that drops x-altair-webhook
80%+ coverage on the new sub-package
Determinism: same spec \xe2\x86\x92 byte-identical scaffold + emitted OpenAPI
Out of scope
Webhook subscription management UI. Listing / adding / removing subscribers is a host-application concern; the framework provides the dispatch primitive, not the admin surface.
Cross-region replication of the dedupe / delivery store. Adapters target single-region clusters; multi-region is the host's call.
WebSocket / SSE signing. Different transport, different semantics.
Webhook signature scheme negotiation. The spec block declares a fixed scheme; mixed-scheme support is out of scope until there's a real consumer asking for it.
Dependencies
univeros/http (PSR-15 middleware contract; for the inbound middleware)
univeros/messaging (Symfony Messenger bridge; for the outbound dispatcher and retry/DLQ)
univeros/cache (the Redis adapter for dedupe / delivery state can defer to existing CacheItemStorage implementations)
Then the scaffold sub-package gains a WebhookSpec AST node and the action emitter exposes a webhook() accessor on inbound Actions (mirrors the idempotency() pattern from #174).
Inbound and outbound can land in either order after the foundation; they share the signing primitive but otherwise operate independently. The spec block depends on both being shipped (since direction: in and direction: out wire different artifacts).
Goal
Ship a first-class webhook framework: a small
univeros/webhookssub-package providing storage contracts + signing primitives + an inbound PSR-15 verify middleware + an outbound dispatcher with retry / dead-letter / replay + awebhook:spec block the scaffolder wires automatically. Closes the second "Laravel AI-era property-gap" called out in the marketing strategy (alongside idempotency, which shipped under #171).The
x-altair-webhookOpenAPI extension key already exists (#163); this epic delivers the runtime that consumes it and the spec block that produces it. Same shape as the idempotency epic (#171).Why
Every API that talks to other systems eventually needs webhooks. Stripe, GitHub, Slack, Twilio, Shopify, Square — every payments / SaaS / commerce platform ships both inbound and outbound webhook contracts. PHP frameworks ship no native primitive for either: Laravel has none, Symfony has none, Slim has none. Teams either roll their own (and re-invent HMAC verification + timestamp-window protection + idempotent delivery + retry curves) or stitch together three uncoordinated community packages.
The agent-era problem amplifies the cost. An agent asked to integrate with Stripe's webhooks has to discover the signing scheme, find a verification library, wire timestamp checks, dedupe deliveries, write replay handling — all from spec-less prose. An agent asked to emit webhooks for a third-party integration has to invent retry curves and dead-letter semantics from scratch.
Shipping this as a sub-package — pluggable storage, multi-scheme signers, agent-readable spec block, round-trips through OpenAPI — is the framework's most credible "ready for production integrations" claim, and the second of two property-gap-closing primitives.
What success looks like
Inbound
Re-scaffold. The emitted Action wires
WebhookVerifyMiddlewarewhich:X-Signaturefrom request headers.401 Unauthorizedwhen the signature does not match.X-Timestampheader and rejects with400 Bad Requestwhen outsidetimestamp_window(replay protection).X-Event-Idheader, or hash of body when absent) and rejects with200 OK(idempotently absorbed) when the id has been seen in the lastdedupe_ttl.Outbound
Re-scaffold. The emitted code includes a
WebhookDispatcherintegration that:X-Signatureheader).X-Timestamp,X-Event-Id,X-Delivery-Idheaders.univeros/messagingintegration).5xxor network failure.max_attempts.bin/altair webhook:replay <delivery-id>works.The
spec:emit-openapichain writesx-altair-webhook: { ... }on the operation;openapi:importrecovers the spec block;openapi:roundtrip(#164) gains the extension to its compared set so dropping it in a refactor fails the gate.Sub-issues
univeros/webhooks: storage contracts + adapters + signing primitives (HMAC-SHA256, HMAC-SHA512, Ed25519)WebhookVerifyMiddleware: inbound PSR-15 (signature + timestamp window + dedupe)WebhookDispatcher+ Messenger handler: outbound POST with retry / dead-letter / replaywebhook:spec block + scaffolder integration (AST, parser, validator, ActionEmitter + dispatcher binding)x-altair-webhookround-trip activation: forward emit + reverse import + drift gatedocs/packages/webhooks.md+ tokens-to-ship benchmark variant exercising both directionsAcceptance criteria
composer require univeros/webhooksships storage + signing primitives + middleware + dispatcherwebhook: { direction: in, signing: hmac-sha256 }scaffolds an Action that verifies inbound HMAC signatures end-to-endwebhook: { direction: out, signing: hmac-sha256 }scaffolds aWebhookDispatcherbinding the host can dispatch throughX-Event-Id) see exactly one handler invocation; subsequent ones return200without re-processingmax_attemptsbin/altair webhook:replay <delivery-id>re-dispatches a dead-lettered deliveryopenapi:roundtripcatches a regression that dropsx-altair-webhookOut of scope
Dependencies
univeros/http(PSR-15 middleware contract; for the inbound middleware)univeros/messaging(Symfony Messenger bridge; for the outbound dispatcher and retry/DLQ)univeros/cache(the Redis adapter for dedupe / delivery state can defer to existing CacheItemStorage implementations)x-altair-webhookextension key + schema (already shipped \xe2\x80\x94 #163)Notes
Sub-package layout follows
univeros/idempotency,univeros/cookie,univeros/session:Then the scaffold sub-package gains a
WebhookSpecAST node and the action emitter exposes awebhook()accessor on inbound Actions (mirrors theidempotency()pattern from #174).Suggested order
Storage + signers \xe2\x86\x92 Inbound middleware \xe2\x86\x92 Outbound dispatcher \xe2\x86\x92 Spec block + scaffolder \xe2\x86\x92 Round-trip activation \xe2\x86\x92 Docs + benchmark.
Inbound and outbound can land in either order after the foundation; they share the signing primitive but otherwise operate independently. The spec block depends on both being shipped (since
direction: inanddirection: outwire different artifacts).