Skip to content

Webhooks

First-class webhook primitive — both directions. Inbound: a spec block wires a PSR-15 middleware that verifies an HMAC / Ed25519 signature, enforces a timestamp replay window, and dedupes by event id in a pluggable store. Outbound: a WebhookDispatcher signs the payload, dispatches asynchronously over Symfony Messenger, retries failed deliveries with exponential / linear backoff, dead-letters after max_attempts, and exposes bin/altair webhook:replay <id> to re-send. Round-trips through OpenAPI 3.1 via x-altair-webhook so the policy survives spec:emit-openapiopenapi:import byte-for-byte.

Composer: univeros/webhooks Namespace: Altair\Webhooks

PHP frameworks ship no native primitive for webhooks. Laravel has none. Symfony has none. Slim has none. Yet every API that talks to another system eventually needs them — Stripe, GitHub, Slack, Twilio, Shopify and Square all ship both inbound and outbound webhook contracts. Teams either roll their own (and re-invent HMAC verification, timestamp-window protection, idempotent delivery, retry curves and dead-letter semantics) or stitch together three uncoordinated community packages.

The agent-era cost is sharper. An agent asked to integrate Stripe’s webhooks has to discover the signing scheme, find a verification library, wire the timestamp check, dedupe deliveries and 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 behaviour from scratch.

This package ships the contract for both directions:

# inbound — verify what arrives
webhook:
direction: in
signing: hmac-sha256
secret_name: stripe
dedupe_ttl: 24h
timestamp_window: 5m
# outbound — sign and dispatch what you emit
webhook:
direction: out
signing: hmac-sha256
retry:
max_attempts: 5
backoff: exponential
dead_letter: webhook.deadletter

That YAML is the source of truth. Run bin/altair spec:scaffold and the scaffolder wires the right artifacts for the declared direction. No hand-rolled signing middleware, no invented retry curve.

Four pieces make the design honest:

  1. Multi-scheme signing. SignerInterface has three operations — name, sign, verify — and the package ships HmacSha256Signer, HmacSha512Signer and Ed25519Signer. HMAC signatures are hex-encoded to match Stripe / GitHub; verify() is constant-time (hash_equals / libsodium) and tolerantly parses the Stripe t=<ts>,v1=<hex> header format as well as a bare hex digest.
  2. Pluggable storage. Inbound dedupe (InboundDeduplicatorInterface) and outbound delivery state (DeliveryStoreInterface) each ship an InMemory adapter for tests and a Redis adapter for production. The inbound dedupe primitive is SET key value NX EX ttl — concurrent identical deliveries see exactly one handler invocation.
  3. No hand-rolled dispatcher. WebhookDispatcher records a Delivery, dispatches a WebhookMessage over Symfony Messenger, and WebhookHandler performs the signed POST with retry + dead-letter. Delivery state (id, attempts, last response, status) is persisted so webhook:replay works.
  4. Round-trips through OpenAPI. The x-altair-webhook extension carries the policy through OpenAPI 3.1 (see docs/guides/openapi/extensions.md); the round-trip drift gate (bin/altair openapi:roundtrip) refuses to merge a regression that drops the block.

What this package deliberately does not do:

  • Webhook subscription management. 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.
  • Signature scheme negotiation. The spec block declares a single fixed scheme; mixed-scheme support waits until a real consumer asks for it.

Standalone:

Terminal window
composer require univeros/webhooks

The package requires PHP 8.3+ and depends only on the PSR HTTP interfaces (psr/http-message, psr/http-factory, psr/http-server-middleware), symfony/messenger (outbound dispatch) and univeros/configuration + univeros/container for DI wiring. The Redis adapters need ext-redis plus a reachable Redis instance; Ed25519 signing needs ext-sodium (the Ed25519Signer throws at construction when it is absent, and SignerRegistry::default() simply omits it).

Secrets resolve from the environment by default:

Terminal window
# EnvSecretResolver maps secret_name 'stripe' → WEBHOOK_SECRET_STRIPE
WEBHOOK_SECRET_STRIPE=whsec_xxx
WEBHOOK_SECRET_PARTNER_X=... # secret_name 'partner-x' folds non-alphanumerics to '_'
endpoint:
method: POST
path: /webhooks/stripe
summary: Receive Stripe events
tags: [webhooks]
domain:
class: App\Webhook\ReceiveStripe
webhook:
direction: in
signing: hmac-sha256
secret_name: stripe
header: Stripe-Signature
dedupe_ttl: 24h
timestamp_window: 5m
Terminal window
bin/altair spec:scaffold api/webhooks/stripe.yaml

The generated Action exposes the policy via a static webhook() accessor, which the ActionAwareWebhookVerifyMiddleware reads per request.

Register WebhooksConfiguration so the signer registry, secret resolver and dedupe store resolve, then add the auto-wiring middleware after DispatcherMiddleware (which publishes the resolved Action) and before ActionMiddleware (which invokes it):

config/configurations.php
return [
// ...
new \Altair\Webhooks\Configuration\WebhooksConfiguration(),
];
$middleware->add(new \Altair\Webhooks\Middleware\ActionAwareWebhookVerifyMiddleware(
signers: $container->get(\Altair\Webhooks\Signing\SignerRegistry::class),
secrets: $container->get(\Altair\Webhooks\Contracts\SecretResolverInterface::class),
deduplicator: $container->get(\Altair\Webhooks\Contracts\InboundDeduplicatorInterface::class),
responseFactory: $container->get(\Psr\Http\Message\ResponseFactoryInterface::class),
streamFactory: $container->get(\Psr\Http\Message\StreamFactoryInterface::class),
));

Endpoints without a webhook: { direction: in } block are passed straight through.

Terminal window
# Valid signature, fresh event id → handler runs (201).
curl -X POST http://localhost:8080/webhooks/stripe \
-H 'Stripe-Signature: <hex hmac-sha256 of the raw body>' \
-H 'X-Timestamp: 1700000000' \
-H 'X-Event-Id: evt_123' \
-d '{"type":"payment_intent.succeeded"}'
# HTTP/1.1 201 Created
# Same event id again → absorbed without re-processing.
# HTTP/1.1 200 OK
# Webhook-Replayed: true
# Tampered signature → rejected.
# HTTP/1.1 401 Unauthorized
# {"error":"webhook signature verification failed"}
# Timestamp outside the 5m window → rejected.
# HTTP/1.1 400 Bad Request

When the X-Event-Id header is absent the middleware synthesises a stable id from sha256(body + timestamp), so dedupe still works for senders that don’t supply one.

A webhook: { direction: out } block on a creating endpoint wires a WebhookDispatcher binding; application code emits through it:

$dispatcher = $container->get(\Altair\Webhooks\Dispatcher\WebhookDispatcher::class);
$dispatcher->dispatch(
eventName: 'post.created',
payload: ['id' => $post->id, 'title' => $post->title],
subscriberUrl: 'https://subscriber.example/hooks',
secretName: 'partner-x',
signerName: 'hmac-sha256', // optional; defaults to hmac-sha256
);

dispatch() records a Pending Delivery and puts a WebhookMessage on the bus. Outbound dispatch needs Symfony\Component\Messenger\MessageBusInterface bound (apply MessengerConfiguration alongside WebhooksConfiguration).

Terminal window
bin/altair worker # the WebhookHandler signs + POSTs each delivery

WebhookHandler adds these headers to the outbound POST:

HeaderValue
Content-Typeapplication/json
X-Signaturesignature of the payload under the chosen scheme + secret
X-Timestampunix timestamp at send time
X-Event-Idthe delivery id (ULID)
X-Delivery-Idthe delivery id (ULID)
Terminal window
bin/altair webhook:show-failed # list dead-lettered deliveries, oldest first
bin/altair webhook:replay <delivery-id> # re-dispatch one (accepts an unambiguous id prefix)

webhook:replay resets the delivery to Pending (attempts 0) and puts a fresh WebhookMessage back on the bus.

SchemeClassOutputNotes
hmac-sha256HmacSha256Signerhex HMAC-SHA256Default. Matches Stripe / GitHub.
hmac-sha512HmacSha512Signerhex HMAC-SHA512Stronger digest, same wire shape.
ed25519Ed25519Signerhex detached signatureAsymmetric: sign() takes the hex 64-byte secret key, verify() the hex 32-byte public key. Needs ext-sodium.

All implement SignerInterface:

interface SignerInterface
{
public function name(): string; // 'hmac-sha256', ...
public function sign(string $payload, string $secret): string; // hex
public function verify(string $payload, string $signature, string $secret): bool; // constant-time
}

verify() returns false rather than throwing on mismatch, and the HMAC signers accept either a bare hex digest or a Stripe-style t=<ts>,v1=<hex> header (the v1= component is extracted). Resolve a scheme by name through the registry:

$registry = \Altair\Webhooks\Signing\SignerRegistry::default(); // HMAC always; Ed25519 when ext-sodium is loaded
$signer = $registry->get('hmac-sha256');

SecretResolverInterface::resolve(string $name): string turns a secret_name into the actual secret. EnvSecretResolver reads WEBHOOK_SECRET_<NAME> (configurable prefix; non-alphanumerics in the name fold to _) and throws WebhookException::missingSecret() when unset. Bind your own implementation to back secrets with a KMS / secret manager — the secret value never travels through OpenAPI, only the secret_name lookup key does.

ConcernInMemoryRedis
Inbound dedupeInMemoryDeduplicator (tests, single-worker dev)RedisDeduplicator — atomic SET … NX EX ttl, key prefix webhook:dedupe:
Outbound delivery stateInMemoryDeliveryStore (tests)RedisDeliveryStore — serialized at webhook:delivery:<id>, dead-letter index as a sorted set scored by createdAt

The Redis adapters take a pre-configured \Redis client so connection lifecycle stays the host’s responsibility. WebhooksConfiguration binds the InMemory adapters by default; swap to Redis by re-binding in your own Configuration:

$container->factory(
\Altair\Webhooks\Contracts\DeliveryStoreInterface::class,
static function (): \Altair\Webhooks\Storage\RedisDeliveryStore {
$redis = new \Redis();
$redis->connect((string) (getenv('REDIS_HOST') ?: '127.0.0.1'), (int) (getenv('REDIS_PORT') ?: 6379));
return new \Altair\Webhooks\Storage\RedisDeliveryStore($redis);
},
);

WebhookVerifyMiddleware handles every meaningful state in one place. Defaults: dedupe_ttl 1h, timestamp_window 5m.

SituationResponse
Signature header absent401 Unauthorized (opaque {error} envelope)
Signature mismatch / secret missing401 Unauthorized (opaque — never leak which check failed)
Timestamp header absent (requireTimestamp=true)400 Bad Request
Timestamp non-numeric400 Bad Request
Timestamp outside the window (past or future)400 Bad Request
Event id already claimed within TTL200 OK, empty body, Webhook-Replayed: true
Fresh event, handler succeedsHandler’s response (e.g. 201)
Fresh event, handler throwsClaim released; exception re-thrown so retry is re-processed
Fresh event, handler returns 5xx5xx; claim released so the sender’s retry is re-processed

The request body is read for verification and then re-streamed from position 0 so the downstream handler sees the full payload. Dedupe is claim-once: the first caller wins, later identical deliveries within the TTL are absorbed with 200 OK.

WebhookHandler drives delivery state through the RetryPolicy (defaults: max_attempts 5, exponential backoff, base_delay 30s).

SituationDelivery statusMessenger action
2xx responseDelivered (nextAttemptAt cleared)message acknowledged
4xx responseDeadLettered immediatelyUnrecoverableMessageHandlingException → failure transport
5xx / network error, attempt < max_attemptsFailed (nextAttemptAt scheduled)RecoverableMessageHandlingException → redelivered after the backoff delay
5xx / network error, attempt ≥ max_attemptsDeadLetteredUnrecoverableMessageHandlingException → failure transport
delivery row missingUnrecoverableMessageHandlingException (not retried)

Backoff delay before the n-th attempt: exponential = base_delay × 2^(n-1) (30s, 60s, 120s, 240s…), linear = base_delay × n (30s, 60s, 90s…). A 4xx is treated as a permanent rejection and dead-letters without burning the retry budget; only 5xx and transport-level failures are retried.

ActionAwareWebhookVerifyMiddleware reads the resolved Action from the request attribute (altair:http:action). When that Action exposes a static webhook() accessor with direction: in, the middleware builds a per-request WebhookVerifyMiddleware from the policy (signer, secret name, dedupe TTL, timestamp window, header names — durations parsed by DurationParser). It passes through when there is no Action, no webhook() accessor, or the policy is outbound. This is the inbound equivalent of ActionAwareIdempotencyMiddleware (see idempotency.md).

When a spec carries webhook:, the forward emitter (spec:emit-openapi) writes an x-altair-webhook block on the operation; the reverse importer (openapi:import) reconstructs the webhook: block. direction and signing always travel; every other field is written only when it differs from its default, and the importer re-applies those defaults — so the block is byte-stable across the round-trip. The shared secret itself never appears in OpenAPI; only secret_name carries through.

The drift gate (openapi:roundtrip) compares x-altair-webhook on both sides; a regression that drops or changes the block produces a kind: extension_drift entry and fails CI in --check mode.

See docs/guides/openapi/extensions.md for the extension contract and docs/guides/openapi/roundtrip.md for the gate.

  • Subscription management UI. Listing / adding / removing subscribers is a host concern.
  • Cross-region replication of the dedupe / delivery store. Adapters target single-region clusters.
  • WebSocket / SSE signing. Different transport, different semantics.
  • Signature scheme negotiation. The spec block declares one fixed scheme.