Part of #184. Depends on #186 (inbound middleware) and #187 (outbound dispatcher).
Goal
Add a webhook: block to Altair YAML specs, wire the parser / validator to accept it, and update the scaffolder so a spec carrying the block produces an Action that exposes its webhook policy via a static webhook() accessor (mirroring the idempotency() pattern from #174).
The auto-wiring middleware from #186 (ActionAwareWebhookVerifyMiddleware) reads that accessor at request time and configures the verify pipeline. For outbound specs, the scaffolder emits a dispatcher binding the application can use.
Shape
YAML spec block — inbound
webhook:
direction: in
signing: hmac-sha256 # required when direction=in
secret_name: stripe # required when direction=in; resolves via SecretResolverInterface
header: X-Signature # optional; default 'X-Signature'
timestamp_header: X-Timestamp # optional; default 'X-Timestamp'
event_id_header: X-Event-Id # optional; default 'X-Event-Id'
dedupe_ttl: 1h # optional; default '1h'
timestamp_window: 5m # optional; default '5m'
YAML spec block — outbound
webhook:
direction: out
signing: hmac-sha256
retry:
max_attempts: 5 # optional; default 5
backoff: exponential # optional; 'exponential' | 'linear'; default 'exponential'
base_delay: 30s # optional; default '30s'
dead_letter: webhook.dlq # optional; transport name for dead-lettering
When absent: no metadata emitted on the Action, no dispatcher binding emitted. Backward-compatible.
AST node
Altair\Scaffold\Spec\Ast\WebhookSpec:
final readonly class WebhookSpec
{
public const string DIRECTION_IN = 'in';
public const string DIRECTION_OUT = 'out';
public function __construct(
public string $direction,
public string $signing,
public ?string $secretName = null, // inbound only
public string $signatureHeader = 'X-Signature',
public string $timestampHeader = 'X-Timestamp',
public string $eventIdHeader = 'X-Event-Id',
public string $dedupeTtl = '1h',
public string $timestampWindow = '5m',
public int $retryMaxAttempts = 5, // outbound only
public string $retryBackoff = 'exponential',
public string $retryBaseDelay = '30s',
public ?string $deadLetterTransport = null,
) {}
}
Add ?WebhookSpec $webhook = null to Altair\Scaffold\Spec\Ast\Spec.
Parser / Validator
- Parser reads the block; builds
WebhookSpec; attaches to Spec.
- Validator enforces:
direction is in or out
signing is one of the known signers (hmac-sha256, hmac-sha512, ed25519)
secret_name is non-empty when direction=in
dedupe_ttl / timestamp_window / retry.base_delay match ^[0-9]+(ms|s|m|h|d)$ (same pattern as idempotency TTL)
retry.max_attempts is positive
retry.backoff is exponential or linear
Scaffolder integration
For direction=in: ActionEmitter adds a static webhook() accessor on the generated Action exposing the policy. Host's ActionAwareWebhookVerifyMiddleware reads it.
For direction=out: a new WebhookDispatcherBindingEmitter writes a small PHP file under app/Webhooks/<EndpointName>Dispatcher.php that constructs a WebhookDispatcher configured per the spec, ready for the application to call.
Acceptance criteria
Out of scope
- The
x-altair-webhook round-trip activation \xe2\x80\x94 separate issue (it depends on this one landing first to have something to round-trip)
- Docs + benchmark variant \xe2\x80\x94 own issue, lands after round-trip
Notes
Naming for the static accessor: webhook() (singular) matches idempotency() from #174. The method returns the full policy array ['direction' => ..., 'signing' => ..., ...]. The middleware filters by direction === 'in'; the dispatcher binding doesn't read the accessor at all (it's emitted as configuration, not introspected at runtime).
The dispatcher-binding emission is the new pattern in this issue. Mirrors the way EntityEmitter / MigrationEmitter work \xe2\x80\x94 it's an additional emitted file rather than a tweak to the Action. Keep it under app/Webhooks/ so the host has a clear namespace for these.
Part of #184. Depends on #186 (inbound middleware) and #187 (outbound dispatcher).
Goal
Add a
webhook:block to Altair YAML specs, wire the parser / validator to accept it, and update the scaffolder so a spec carrying the block produces an Action that exposes its webhook policy via a staticwebhook()accessor (mirroring theidempotency()pattern from #174).The auto-wiring middleware from #186 (
ActionAwareWebhookVerifyMiddleware) reads that accessor at request time and configures the verify pipeline. For outbound specs, the scaffolder emits a dispatcher binding the application can use.Shape
YAML spec block — inbound
YAML spec block — outbound
When absent: no metadata emitted on the Action, no dispatcher binding emitted. Backward-compatible.
AST node
Altair\Scaffold\Spec\Ast\WebhookSpec:Add
?WebhookSpec $webhook = nulltoAltair\Scaffold\Spec\Ast\Spec.Parser / Validator
WebhookSpec; attaches toSpec.directionisinoroutsigningis one of the known signers (hmac-sha256,hmac-sha512,ed25519)secret_nameis non-empty whendirection=indedupe_ttl/timestamp_window/retry.base_delaymatch^[0-9]+(ms|s|m|h|d)$(same pattern as idempotency TTL)retry.max_attemptsis positiveretry.backoffisexponentialorlinearScaffolder integration
For
direction=in:ActionEmitteradds a staticwebhook()accessor on the generated Action exposing the policy. Host'sActionAwareWebhookVerifyMiddlewarereads it.For
direction=out: a newWebhookDispatcherBindingEmitterwrites a small PHP file underapp/Webhooks/<EndpointName>Dispatcher.phpthat constructs aWebhookDispatcherconfigured per the spec, ready for the application to call.Acceptance criteria
WebhookSpecAST node lands;Specgrows?WebhookSpec $webhook = nullwith backwards-compatible defaultParserreads the block;Validatorrejects every malformed combination with actionable messageswebhook()accessor whendirection=in; output is byte-for-byte identical to today's scaffold when the block is absentdirection=out; the resulting PHP file constructs a configuredWebhookDispatcherParser+Validatorkeeps the new field workingOut of scope
x-altair-webhookround-trip activation \xe2\x80\x94 separate issue (it depends on this one landing first to have something to round-trip)Notes
Naming for the static accessor:
webhook()(singular) matchesidempotency()from #174. The method returns the full policy array['direction' => ..., 'signing' => ..., ...]. The middleware filters bydirection === 'in'; the dispatcher binding doesn't read the accessor at all (it's emitted as configuration, not introspected at runtime).The dispatcher-binding emission is the new pattern in this issue. Mirrors the way
EntityEmitter/MigrationEmitterwork \xe2\x80\x94 it's an additional emitted file rather than a tweak to the Action. Keep it underapp/Webhooks/so the host has a clear namespace for these.