Vatly
Php

Webhooks

Vatly PHP SDK - Webhooks

Vatly sends webhooks to notify your application when events happen — for example, an order being paid, a refund completing, or a subscription being canceled.

Webhook events

The eventName field on a delivery identifies what happened. See Vatly\API\Types\WebhookEventName for the constants.

EventDescription
order.paidOrder payment was successful.
order.canceledOrder was canceled.
order.chargeback_receivedChargeback was received for an order.
order.chargeback_reversedChargeback was reversed.
order.payment_failedA payment failed and a dunning process was initiated for the order.
refund.completedRefund was processed successfully.
refund.failedRefund processing failed.
refund.canceledRefund was canceled.
subscription.startedSubscription was started.
subscription.canceled_immediatelySubscription was canceled immediately.
subscription.canceled_with_grace_periodSubscription was canceled, customer keeps access until the period ends.
subscription.cancellation_grace_period_completedGrace period after cancellation ended.
subscription.resumedA canceled subscription was resumed during its grace period.
checkout.paidCheckout was paid successfully.
checkout.failedCheckout payment failed.
checkout.canceledCheckout was canceled.
checkout.expiredCheckout session expired.
webhook.setupVerification call sent when an endpoint is registered or its URL is updated. entityType is webhook; object is the (secret-free) endpoint config.

The webhook.setup event

When you register a webhook endpoint (or change its URL), Vatly sends a signed webhook.setup event to confirm the endpoint is reachable. It is delivered as a normal webhook — same envelope, same signature, same Vatly-Event-Id header — so there is nothing special to parse: Webhook::parse() returns an ordinary WebhookPayload. Just acknowledge it with a 2xx and take no action.

For strongly-typed handling, this package ships a WebhookSetupReceived event DTO (Vatly\API\Webhooks\Events\WebhookSetupReceived) that carries the webhook envelope (id, resource, eventName, entityType, entityId, testmode, createdAt, object). Build it from a WebhookReceived via WebhookSetupReceived::fromWebhook(); object is the (secret-free) endpoint config and may be empty.


The WebhookEvent resource

Every delivery carries a WebhookEvent JSON object in the request body. This is the same shape returned by GET /v1/webhook-events/:id.

Properties

NameTypeDescription
idstringUnique identifier for the webhook event (webhook_event_...).
resourcestringAlways webhook_event.
eventNamestringOne of the events listed above (e.g. order.paid).
entityTypestringType of the related resource (e.g. order, refund, subscription).
entityIdstringID of the related resource (e.g. order_Hn5xWqVfKm8RjTgYbUcP).
objectobject|nullThe full resource payload at the time of the event. Shape depends on entityType.
createdAtstringWhen the event occurred (ISO 8601).
testmodeboolWhether this event was generated in test mode.
linksobjectHATEOAS links — links.self.href points to this webhook event.

Example payload

{
    "id": "webhook_event_Qk8pRtSvWm2NjLhYcZaE",
    "resource": "webhook_event",
    "eventName": "order.paid",
    "entityType": "order",
    "entityId": "order_Hn5xWqVfKm8RjTgYbUcP",
    "object": {
        "id": "order_Hn5xWqVfKm8RjTgYbUcP",
        "resource": "order",
        "status": "paid",
        "total": { "value": "29.99", "currency": "EUR" }
    },
    "createdAt": "2026-01-11T10:50:50+02:00",
    "testmode": true,
    "links": {
        "self": {
            "href": "https://api.vatly.com/v1/webhook-events/webhook_event_Qk8pRtSvWm2NjLhYcZaE",
            "type": "application/json"
        }
    }
}

Delivery headers

Each webhook request includes two Vatly-specific headers.

HeaderDescription
Vatly-SignatureStructured signature value: t=<unix_seconds>,v1=<hex_hmac_sha256>. Verify this before trusting the payload.
Vatly-Event-IdThe id of the underlying webhook event. Stable across retry attempts — use it as your idempotency / dedup key.

The signature scheme prefix (v1=) leaves room for future algorithm versions; receivers that verify against v1 will keep working if additional versions appear alongside it.


Handling webhooks

The SDK ships Webhook::parse() — a one-shot helper that verifies the signature, decodes the JSON, and returns a typed WebhookPayload ready to dispatch on.

Verification is performed against the raw request body bytes. JSON that is parsed and re-encoded will not match the signature — read the body directly (e.g. file_get_contents('php://input')) before any framework deserialises it.

use Vatly\API\Exceptions\InvalidSignatureException;
use Vatly\API\Webhooks\Webhook;

$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_VATLY_SIGNATURE'] ?? '';
$secret    = getenv('VATLY_WEBHOOK_SECRET');

try {
    $event = Webhook::parse($payload, $signature, $secret);
} catch (InvalidSignatureException $e) {
    http_response_code(401);
    exit('Invalid signature');
}

// Dedupe with Vatly-Event-Id (stable across retry attempts).
$eventId = $_SERVER['HTTP_VATLY_EVENT_ID'] ?? $event->id;
if (alreadyProcessed($eventId)) {
    http_response_code(200);
    exit;
}

match ($event->eventName) {
    'order.paid'         => handleOrderPaid($event),
    'refund.completed'   => handleRefundCompleted($event),
    'checkout.expired'   => handleCheckoutExpired($event),
    default              => null,
};

markProcessed($eventId);
http_response_code(200);

Webhook::parse() throws Vatly\API\Exceptions\InvalidSignatureException when the signature header is malformed, the timestamp is outside the tolerance window, or the HMAC does not match. It throws InvalidArgumentException when the body is not valid JSON or is missing required fields.

Typed event DTOs

Webhook::parse() returns the raw, generic WebhookPayload. For consumers that prefer a strongly-typed, per-event object, this package also owns immutable event DTOs under Vatly\API\Webhooks\Events. Each DTO:

  • exposes a VATLY_EVENT_NAME constant (a WebhookEventName value) for matching;
  • carries from*() factory methods — fromApiOrder(), fromApiRefund(), fromApiChargeback(), fromApiSubscription() to build from an enriched API resource, and/or fromWebhook(WebhookReceived $webhook) to build from the raw payload;
  • carries money as Money values (decimal-string + currency) and the per-rate VAT breakdown as a TaxSummaryCollection. Flatten to integer cents at your persistence edge via Money::toCents().
use Vatly\API\Resources\Order;
use Vatly\API\Webhooks\Events\OrderPaid;

// $order is a fully-hydrated API Order resource (e.g. from $vatly->orders->get(...))
$event = OrderPaid::fromApiOrder($order);

$event->total;             // Vatly\API\Types\Money
$event->total->value;      // decimal string, e.g. "49.99"
$event->total->currency;   // e.g. "USD"
$event->total->toCents();  // int cents, e.g. 4999
$event->taxSummary;        // Vatly\API\Types\TaxSummaryCollection
$event->lines;             // Vatly\API\Types\OrderLineData[]
$event->testmode;          // bool — test (true) vs live (false), mirrored from the source record

Every business event (order/subscription/refund/chargeback/checkout) carries testmode, sourced from the enriched resource ($resource->testmode) or the webhook envelope ($webhook->testmode). Persist it alongside the record so test and live data stay segregated and the matching API key can be selected per record.

These DTOs are the canonical, framework-agnostic event shapes that higher-level integrations (e.g. vatly-fluent-php) build on. The line-item DTO lives at Vatly\API\Types\OrderLineData; its basePrice/total/subtotal are Money too.

The full set of incoming webhook payloads is also described in the webhooks OpenAPI spec under the top-level webhooks: section — one entry per WebhookEventName, each referencing the WebhookDelivery envelope.

Replay-window tolerance

The signed timestamp (t=...) lets receivers reject stale deliveries. By default signatures more than 300 seconds old are rejected. If you need a custom window — for example when replaying captured fixtures in a test suite — instantiate WebhookSignatureValidator directly:

use Vatly\API\Webhooks\WebhookSignatureValidator;

$validator = new WebhookSignatureValidator($secret, toleranceSeconds: 60);
$validator->verify($payload, $signature);

Keep the default in production. A tighter window makes a leaked signature less useful; a much wider window weakens the replay-defense guarantee.

Lower-level access

If you only need signature verification (e.g. handling the decoded body yourself, or operating on a non-standard payload shape), use WebhookSignatureValidator directly. It exposes verify(), isValid(), and calculateSignature(), plus header-name constants:

WebhookSignatureValidator::SIGNATURE_HEADER_NAME; // 'Vatly-Signature'
WebhookSignatureValidator::EVENT_ID_HEADER_NAME;  // 'Vatly-Event-Id'

Best practices

  1. Always verify signatures before processing webhook payloads.
  2. Verify against the raw body, not parsed-and-reserialised JSON.
  3. Dedupe with Vatly-Event-Id — retries reuse the same event id, while the signature deliberately rotates per attempt.
  4. Return 200 quickly to avoid timeout retries. Offload long-running work to a queue.
  5. Log webhook events for debugging and auditing.
Copyright © 2026