Skip to content

Webhook framework — inbound verify + outbound dispatch + spec block (epic) #184

@tonydspaniard

Description

@tonydspaniard

Goal

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.

What success looks like

Inbound

webhook:
  direction: in
  signing: hmac-sha256
  header: X-Signature
  dedupe_ttl: 1h
  timestamp_window: 5m

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.

Outbound

webhook:
  direction: out
  signing: hmac-sha256
  retry:
    max_attempts: 5
    backoff: exponential
  dead_letter: webhook.deadletter

Re-scaffold. The emitted code includes a WebhookDispatcher integration that:

  • Signs outbound POST requests with HMAC-SHA256 (X-Signature header).
  • Adds X-Timestamp, X-Event-Id, X-Delivery-Id headers.
  • 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.

Sub-issues

Acceptance criteria

  • composer require univeros/webhooks ships storage + signing primitives + middleware + dispatcher
  • 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)
  • x-altair-webhook extension key + schema (already shipped \xe2\x80\x94 #163)
  • The idempotency primitive (Idempotency-Key primitive — Stripe-style middleware + storage + spec block (epic) #171, shipped) — internal architecture parallel; webhook dedupe uses a separate store but mirrors the contract shape

Notes

Sub-package layout follows univeros/idempotency, univeros/cookie, univeros/session:

src/Altair/Webhooks/
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Contracts/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 SignerInterface.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 InboundDeduplicatorInterface.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 DeliveryStoreInterface.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 SecretResolverInterface.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Signing/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 HmacSha256Signer.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 HmacSha512Signer.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Ed25519Signer.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 SignerRegistry.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Storage/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 InMemoryDeduplicator.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 RedisDeduplicator.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 InMemoryDeliveryStore.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 RedisDeliveryStore.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Middleware/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookVerifyMiddleware.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 ActionAwareWebhookVerifyMiddleware.php   # auto-wire from Action::webhook()
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Dispatcher/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookDispatcher.php
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookMessage.php       # Messenger DTO
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookHandler.php       # Messenger handler
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 RetryPolicy.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Cli/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookReplayCommand.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 WebhookShowFailedCommand.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Exception/
\xe2\x94\x82   \xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookException.php
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 SignatureVerificationException.php
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 Configuration/
\xe2\x94\x82   \xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 WebhooksConfiguration.php
\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 composer.json

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).

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: in and direction: out wire different artifacts).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions