Vatly
Laravel

Webhooks

Vatly Laravel Package - Webhooks

Vatly sends webhooks to notify your application of events like subscription starts, cancellations, and payment updates. Vatly Laravel handles webhook verification, storage, and event dispatching automatically.

Endpoint

The package registers a webhook endpoint at:

POST /webhooks/vatly

Configure this URL in your Vatly dashboard. Make sure to set your VATLY_WEBHOOK_SECRET in .env.

CSRF protection

Exclude the webhook route from CSRF verification. In Laravel 11+, this is typically done in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhooks/vatly',
    ]);
})

Events

When a webhook is received, the driver's LaravelEventDispatcher forwards the typed domain events straight onto Laravel's event bus, so you listen for the DTO classes directly. The webhook event DTOs live in vatly-api-php under the Vatly\API\Webhooks\Events\ namespace (so a payload change is a single api-php release); the exceptions are SubscriptionWasCreatedFromWebhook and OrderWasCreatedFromWebhook, internal fluent signals under Vatly\Fluent\Events\:

Event (Vatly\API\Webhooks\Events\…)Dispatched when
SubscriptionStartedA subscription.started webhook is received
SubscriptionBillingUpdatedA subscription.billing_updated webhook is received — the stored mandate is refreshed
SubscriptionResumedA subscription.resumed webhook is received — the stored end date is cleared
SubscriptionCanceledImmediatelyA subscription.canceled_immediately webhook is received
SubscriptionCanceledWithGracePeriodA subscription.canceled_with_grace_period webhook is received
SubscriptionCancellationGracePeriodCompletedA subscription.cancellation_grace_period_completed webhook is received — the grace period stamped by the cancellation has now elapsed (carries customerId, subscriptionId, endsAt)
OrderPaidAn order.paid webhook is received (enriched with the full tax breakdown)
OrderCanceledAn order.canceled webhook is received — the local order status is mirrored to canceled
OrderPaymentFailedAn order.payment_failed webhook is received — typically the start of dunning (enriched with the full tax breakdown)
OrderChargebackReceived / OrderChargebackReversedAn order.chargeback_received / order.chargeback_reversed webhook is received — enriched with customerId, dispute status, totals and taxSummary; persisted to vatly_chargebacks
RefundCompleted / RefundFailed / RefundCanceledA refund.completed / refund.failed / refund.canceled webhook is received — each carries the full taxSummary; persisted to vatly_refunds
CheckoutPaidA checkout.paid webhook is received — the hosted checkout was paid (fires before order.paid's enrichment GET; carries checkoutId, nullable customerId / orderId, status, metadata)
CheckoutFailedA checkout.failed webhook is received — the hosted checkout's payment failed (route into a retry flow)
CheckoutCanceledA checkout.canceled webhook is received — the customer abandoned the hosted checkout (cart-abandonment hook)
CheckoutExpiredA checkout.expired webhook is received — the hosted checkout session timed out without completion
WebhookSetupReceivedA webhook.setup verification call is received — Vatly confirms a newly registered (or re-pointed) endpoint is reachable. There's no resource to enrich and no local row to touch; just acknowledge with a 2xx. Carries only the webhook envelope (eventName / object)
UnsupportedWebhookReceivedA webhook arrives that has no typed mapping (carries the raw eventName / object)
SubscriptionWasCreatedFromWebhook (in Vatly\Fluent\Events\)A new local Subscription row was just created from a subscription.started webhook (application-level event; carries the stored $subscription)
OrderWasCreatedFromWebhook (in Vatly\Fluent\Events\)The order analogue of SubscriptionWasCreatedFromWebhook: a new local Order row was just created from an order.paid webhook (fires once on a brand-new order; carries the stored $order). A clean hook for receipts / fulfillment

Money fields. On the order and refund events (OrderPaid, OrderPaymentFailed, RefundCompleted / RefundFailed / RefundCanceled), total and subtotal are non-null Vatly\API\Types\Money value objects (a decimal-string value plus a currency); read the currency via $event->total->currency and minor units via $event->total->toCents(). These events no longer carry a standalone currency field. The chargeback events (OrderChargebackReceived / OrderChargebackReversed) carry nullable ?Money total / subtotal and keep their standalone currency field.

Exactly one of the webhook events above is dispatched per incoming webhook (UnsupportedWebhookReceived is the fallback for unmapped events). SubscriptionWasCreatedFromWebhook and OrderWasCreatedFromWebhook fire additionally, from the subscription- and order-sync reactions, only when a brand-new local row is created.

Built-in reactions

Before the event is dispatched, the package keeps your local tables in sync automatically via fluent's standard webhook reactions. These are wired by WebhookProcessorFactory inside the Vatly composition root — no registration needed on your side. They live under Vatly\Fluent\Webhooks\Reactions\:

  • SyncSubscriptionOnStarted -- On SubscriptionStarted, creates (or updates) the local Subscription row, then dispatches SubscriptionWasCreatedFromWebhook for newly-created rows.
  • CancelSubscriptionOnCanceled -- On SubscriptionCanceledImmediately / SubscriptionCanceledWithGracePeriod, sets the local subscription's ends_at.
  • StoreOrderOnPaid -- On OrderPaid, stores (or updates) the local Order row, then dispatches OrderWasCreatedFromWebhook for newly-created rows.
  • StoreOrderOnPaymentFailed -- On OrderPaymentFailed, stores (or updates) the local Order row, mirroring the upstream order status verbatim.
  • EndSubscriptionOnGracePeriodCompleted -- On SubscriptionCancellationGracePeriodCompleted, stamps the actual ends_at onto the local subscription. Usually an idempotent re-write of what CancelSubscriptionOnCanceled already stored, but it self-heals a missed/out-of-order subscription.canceled_with_grace_period webhook (which would otherwise leave ends_at null and the subscription looking active forever) and corrects any drift between the scheduled and actual end.

The checkout events (CheckoutPaid / CheckoutFailed / CheckoutCanceled / CheckoutExpired) ship no built-in reaction — they touch no local table. The checkout payloads carry the full Checkout resource (so they're dispatched without an enrichment GET), and there is no local checkout entity to keep in sync. Listen for them directly to drive receipt/retry/cart-abandonment UI or to flip your own application-level state. SubscriptionCancellationGracePeriodCompleted is likewise dispatched (on top of the reaction above) for consumers that want to flip their own application-level "fully ended" status.

Custom listeners

Listen for the events in your EventServiceProvider or using the Event facade:

use Illuminate\Support\Facades\Event;
use Vatly\API\Webhooks\Events\SubscriptionStarted;

Event::listen(SubscriptionStarted::class, function (SubscriptionStarted $event) {
    // $event->customerId
    // $event->subscriptionId
    // $event->planId
    // $event->type
    // $event->name
    // $event->quantity

    // Send welcome email, provision features, etc.
});

Order events (OrderPaid / OrderPaymentFailed) carry the full, API-enriched order — including the tax breakdown — so you can materialize an invoice without a follow-up API call:

use Illuminate\Support\Facades\Event;
use Vatly\API\Webhooks\Events\OrderPaid;

Event::listen(OrderPaid::class, function (OrderPaid $event) {
    // $event->orderId
    // $event->customerId
    // $event->status
    // $event->total              // Vatly\API\Types\Money
    // $event->subtotal           // Vatly\API\Types\Money
    // $event->total->currency    // e.g. "EUR"
    // $event->total->value       // decimal string, e.g. "99.00"
    // $event->total->toCents()   // minor units (cents), e.g. 9900
    // $event->taxSummary
    // $event->invoiceNumber
    // $event->paymentMethod
    // $event->metadata
});

Webhook call storage

Every webhook is recorded in the vatly_webhook_calls table with:

  • vatly_id -- The webhook event ID (unique; use this as your dedup key)
  • resource -- The wrapper resource type (always webhook_event)
  • event_name -- The webhook event type (e.g., subscription.started)
  • entity_type -- The resource type the event relates to (e.g., subscription)
  • entity_id -- The Vatly resource ID the event relates to
  • testmode -- Whether the event was raised against a testmode entity
  • vatly_created_at -- When the webhook event was created at Vatly
  • vatly_customer_id -- The associated customer ID, when present
  • object -- The full resource payload at the time of the event (JSON)
Copyright © 2026