Skip to content
agovena.
agovena.
Get started
Community

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 checks array. PlaceOrder dispatches 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 inside PlaceOrder, 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:

json
{
  "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:

text
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
<?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

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

Search documentation

Search guides, commands and API endpoints

What are you looking for?

Documentation