Developer guides
Events and webhooks
Separate in-process domain events, signed outbound deliveries, and incoming payment-provider callbacks.
On this page
Three different message boundaries
| Mechanism | Direction | Purpose |
|---|---|---|
| PHP domain event | Inside the application | Coordinate Core and Module behavior |
| Outbound webhook | Agovena to your HTTPS endpoint | Notify an external integration |
| Payment webhook | Provider to Agovena | Verify and apply a provider's payment status |
These mechanisms have different payloads, authentication, and retry behavior. Do not reuse an outbound signing algorithm to implement a provider's incoming callback unless that provider actually specifies it.
Internal domain events
Modules can register listeners through ModuleContext::listen(). For example, Inventory listens to OrderPlacing, OrderCreated, and OrderCancelled for stock validation, reservation, and restocking.
The timing is part of the contract:
- OrderPreflight carries priced lines and a mutable
checksarray.PlaceOrderdispatches it before entering its order transaction. - OrderPlacing carries lines, an optional order, and optional preflight context. It is dispatched while the order is being persisted and lets a listener reject placement before commit.
- OrderCreated implements
ShouldDispatchAfterCommit. Its call site is insidePlaceOrder, but the event contract defers dispatch until commit. Do not rely on an after-commit listener to roll back an already committed order.
Use explicit domain policies for refunds and cancellation. A refund notification does not universally mean a subscription, service, download, or ticket must be revoked. The Module owning that entitlement decides the consequence.
Keep transaction-sensitive validation local and deterministic. Queue external side effects only after committed state is available. Test listener behavior as well as the fact that an event was emitted. Sources: PlaceOrder, ModuleContext, and AvailabilityCapability.
Outbound event catalog
The public outbound catalog currently contains:
| Event type | Fields under data |
|---|---|
order.created |
order_id, order_number, status, payment_status, total, currency |
order.paid |
Same order fields |
order.cancelled |
Same order fields |
payment.recorded |
payment_id, order_id, status, amount, currency |
refund.recorded |
refund_id, order_id, amount, currency, status |
credit_note.issued |
credit_note_id, invoice_id, order_id, amount, currency |
IDs are record identifiers, amounts are minor units, and statuses are domain enum values. Order payment_status may be null. Notice that order events use total, while other event payloads use amount; neither is the full API resource.
There is no generic “every PHP event becomes a webhook” mechanism. The supported names are explicit in WebhookEventCatalog, with payloads selected by WebhookEventSubscriber.
HTTP envelope and headers
An ordinary HTTP destination receives this envelope. Values are illustrative:
{
"id": "00000000-0000-4000-8000-000000000001",
"type": "order.created",
"created_at": "2026-01-01T12:00:00+00:00",
"data": {
"order_id": 42,
"order_number": "EXAMPLE-42",
"status": "pending",
"payment_status": "pending",
"total": 1200,
"currency": "EUR"
}
}
| Header | Meaning |
|---|---|
Content-Type |
application/json |
X-Agovena-Event |
Event type |
X-Agovena-Delivery |
Delivery UUID |
X-Agovena-Timestamp |
Unix timestamp in seconds for this attempt |
X-Agovena-Signature |
v1= followed by a hexadecimal HMAC-SHA256 |
The payload id and delivery UUID are separate. The publisher creates payload and delivery identifiers for each matching endpoint, not one globally shared event ID for every destination. Active endpoints subscribe to explicit event names or *. The publisher redacts sensitive data and queues delivery after commit.
Discord destinations use a different body, an embeds array derived from the event type and data. The signature always covers the actual transmitted body, including any destination formatting. Sources: WebhookEventPublisher and WebhookPayloadFormatter.
Verify before processing
The signed message is the decimal timestamp, a literal period, and the raw HTTP body. The secret is the endpoint's configured signing secret:
v1=HMAC-SHA256(secret, timestamp + "." + raw_body)
Do not parse and re-encode JSON before verification. Whitespace and escaping affect the signature. Use a constant-time comparison and reject stale timestamps. Core's WebhookSigner defaults to a 300-second tolerance and accepts signature candidates separated by commas or semicolons.
A standalone PHP verification function following that contract:
<?php
function verifyAgovenaWebhook(
string $secret,
string $timestampHeader,
string $rawBody,
string $signatureHeader,
int $now,
): bool {
if ($secret === '' || ! ctype_digit($timestampHeader)) {
return false;
}
$timestamp = (int) $timestampHeader;
if (abs($now - $timestamp) > 300) {
return false;
}
$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach (preg_split('/[;,]/', $signatureHeader) ?: [] as $candidate) {
if (hash_equals($expected, trim($candidate))) {
return true;
}
}
return false;
}
Pass your server time, not a client-supplied now. After verification, validate the envelope, persist the delivery identifier with a uniqueness constraint, and durably accept the work before returning a 2xx response. Repeated delivery must not create duplicate fulfillment or accounting entries. Keep signing secrets in a secret store and exclude raw credential-bearing payloads from logs.
Delivery and retry behavior
DeliverWebhook claims a delivery with a lease before sending. It uses a five-second connection timeout and ten-second overall timeout. HTTPS destinations must pass public-address validation; redirects are disabled and the validated address is pinned for the request.
- Any successful 2xx response marks the delivery delivered.
- Transport failures, HTTP 429, and server errors are retryable.
- Other unsuccessful responses, including redirects and non-429 client errors, are not retryable.
- The delivery limit is five attempts, with delays of 60, 300, 1800, and 7200 seconds.
- Exhausted or non-retryable deliveries move to
dead_letter. - Disabled destinations are skipped; response bodies are not retained by this delivery job.
A fresh timestamp and signature are generated on a retry. Deduplicate by delivery identity, not by signature. The scheduler runs agovena:deliver-webhooks every minute to recover pending work. An active scheduler and queue worker are operational requirements, not optional client features.
Incoming payment callbacks
Core registers POST /webhooks/payments/{gateway} outside /api/v1. It is excluded from CSRF checking because providers do not hold a browser session. Authentication comes from the gateway's verifyWebhook() implementation, not a customer bearer token.
HandlePaymentWebhook rejects unknown or webhook-incapable gateways, verifies before parsing, normalizes the payload, stores replay identity, and processes payment state under its lifecycle controls. A successful PaymentWebhookController response contains ok, stored event id, and duplicate. Verification failures return 403; unexpected processing errors return 500.
There is no provider-neutral curl body that can legitimately confirm payment. Each adapter must implement and test its provider's signature and payload protocol. Never create a fake “paid” example that bypasses verification.
Test the failure paths
php artisan test tests/Unit/OutboundWebhookContractTest.php
php artisan test tests/Feature/OutboundWebhookDeliveryTest.php
php artisan test tests/Feature/WebhookDeadLetterTest.php
php artisan test tests/Feature/PaymentGatewayWebhookTest.php
OutboundWebhookContractTest covers valid, stale, and malformed signatures plus rejected SSRF-shaped destinations. Add receiver tests for a modified body, duplicate deliveries, durable acceptance before acknowledgment, and retry after a temporary outage.