Vatly
Guides

Webhooks

In this guide, we will look at how to register and consume webhooks to integrate your app with Vatly.

Registering webhooks

To register a new webhook, you need to have a URL in your app that Vatly can call. You can configure a new webhook from the Vatly dashboard under API settings. Give your webhook a name, pick the events you want to listen for, and add your URL.

You can also manage the endpoint programmatically with the Webhook Endpoints API — handy for provisioning from CI / infrastructure-as-code or pointing an ephemeral preview environment at its own public URL. There is at most one endpoint per mode (test and live), and you supply the signing secret when you register it.

Now, whenever something of interest happens in your app, a webhook is fired off by Vatly. In the next section, we'll look at how to consume webhooks.

For security reasons, your webhook URL needs to be HTTPS with a valid certificate and reachable from Vatly.

Consuming webhooks

Each delivery is an HTTP POST whose body is a webhook_event object — the same shape returned by the Get webhook event endpoint. Check eventName to see what happened, and use entityType / entityId (or the embedded object snapshot) to act on the affected resource.

Example delivery body
{
  "id": "webhook_event_Qk8pRtSvWm2NjLhYcZaE",
  "resource": "webhook_event",
  "eventName": "order.paid",
  "entityType": "order",
  "entityId": "order_Hn5xWqVfKm8RjTgYbUcP",
  "testmode": false,
  "createdAt": "2024-01-15T10:30:00Z",
  "object": {
    "id": "order_Hn5xWqVfKm8RjTgYbUcP",
    "resource": "order",
    "testmode": false,
    "status": "paid",
    "total": { "value": "29.99", "currency": "EUR" },
    "subtotal": { "value": "24.79", "currency": "EUR" }
  },
  "links": {
    "self": {
      "href": "https://api.vatly.com/v1/webhook-events/webhook_event_Qk8pRtSvWm2NjLhYcZaE",
      "type": "application/json"
    }
  }
}
The body is the webhook_event object shown above (its fields are top-level — there is no data wrapper). For the full WebhookDelivery schema, see the OpenAPI spec.

Every request also carries two headers you should use:

HeaderPurpose
Vatly-SignatureLets you verify the delivery really came from Vatly. See Verifying signatures.
Vatly-Event-IdThe event's ID (also in the body as id). It is stable across retries, so use it as the key to deduplicate deliveries.
Respond with a 2xx status as soon as you've stored the event. Vatly retries deliveries that don't return 2xx, and the same event may arrive more than once — deduplicate on Vatly-Event-Id and do any heavy work asynchronously.

Event types

The eventName field identifies what happened. The available events are:

EventDescription
checkout.paidA checkout was paid successfully.
checkout.failedA checkout payment failed.
checkout.canceledA checkout was canceled.
checkout.expiredA checkout session expired.
order.paidAn order payment was successful.
order.canceledAn order was canceled.
order.chargeback_receivedA chargeback was received for an order.
order.chargeback_reversedA chargeback was reversed.
order.payment_failedA payment failed and a dunning process was initiated for the order.
refund.completedA refund was processed successfully.
refund.failedA refund failed to process.
refund.canceledA refund was canceled.
subscription.startedA subscription was started.
subscription.canceled_immediatelyA subscription was canceled immediately.
subscription.canceled_with_grace_periodA subscription was canceled; the customer keeps access until the period ends.
subscription.cancellation_grace_period_completedThe grace period after a cancellation ended.
subscription.resumedA canceled subscription was resumed during its grace period.
subscription.billing_updatedA subscription's billing details changed — in practice the mandate (payment method on file), e.g. a refreshed masked payment-method identifier.
subscription.updatedA subscription changed immediately (effective now, not at the next cycle) — a plan, price, interval, or quantity change. object is the subscription with its new values.
subscription.update_scheduledA plan, price, or quantity change was scheduled to take effect at the next billing cycle. object is the subscription as it is today; the target values are carried in object.scheduledUpdate.
webhook.setupA verification call Vatly sends when you register a webhook endpoint or change its URL. See the note below.
object is the affected resource, keyed by its own resource field (order, chargeback, refund, subscription, checkout, or webhook). For chargeback events this differs from entityType: order.chargeback_received / order.chargeback_reversed carry entityType: order (the order the chargeback belongs to) but the object is a Chargeback.
subscription.update_scheduled carries the subscription's current state in object, plus the future target values in object.scheduledUpdate (subscriptionPlanId, name, description, basePrice, quantity, interval, intervalCount). scheduledUpdate is present only on this delivery — it is never returned by the REST API. The matching subscription.updated event (for changes that take effect immediately) has no scheduledUpdate; its object already reflects the new values.
webhook.setup is delivered when you register an endpoint (or change its URL) to confirm it's reachable. It arrives as a normal, signed delivery (entityType: webhook, object is the secret-free endpoint config), so there's nothing special to parse — just acknowledge it with a 2xx. Unlike event deliveries, the setup call is a one-shot verification check: a non-2xx response fails endpoint registration and is not retried. It is also the one event that is never persisted, so it does not appear on the Get webhook event endpoint.

Example: subscription.started

subscription.started
{
  "id": "webhook_event_b167W0AChY7Z0Amr",
  "resource": "webhook_event",
  "eventName": "subscription.started",
  "entityType": "subscription",
  "entityId": "subscription_QdEpFhdSrG4Y3DnfsdqsH",
  "testmode": false,
  "createdAt": "2024-01-15T10:30:00Z",
  "object": {
    "id": "subscription_QdEpFhdSrG4Y3DnfsdqsH",
    "resource": "subscription",
    "customerId": "customer_Bm7xNvPwKr3YjTgHcZaE",
    "subscriptionPlanId": "subscription_plan_Rk5pQrSvWm8NjLhYbUcP",
    "testmode": false,
    "name": "Premium Plan",
    "status": "active",
    "basePrice": { "value": "99.99", "currency": "EUR" },
    "quantity": 1,
    "interval": "month",
    "intervalCount": 1,
    "startedAt": "2024-01-15T10:30:00Z",
    "nextRenewalAt": "2024-02-15T10:30:00Z",
    "links": {
      "self": {
        "href": "https://api.vatly.com/v1/subscriptions/subscription_QdEpFhdSrG4Y3DnfsdqsH",
        "type": "application/json"
      },
      "customer": {
        "href": "https://api.vatly.com/v1/customers/customer_Bm7xNvPwKr3YjTgHcZaE",
        "type": "application/json"
      }
    }
  },
  "links": {
    "self": {
      "href": "https://api.vatly.com/v1/webhook-events/webhook_event_b167W0AChY7Z0Amr",
      "type": "application/json"
    }
  }
}

Inspecting webhooks

To make integrating Vatly as smooth as possible, you can inspect a detailed webhook history in your Vatly dashboard.

If you store the event ID from a webhook delivery, you can also retrieve the full stored event later via the API:

That endpoint returns the event metadata plus the exact resource snapshot that was delivered at the time.

Testing webhook flows

For recurring billing flows, Vatly also exposes a test helper so you can trigger webhook-producing scenarios in test mode:

This is useful when you want to verify renewals and the events they produce without waiting for real billing dates.


Verifying signatures

To know for sure that a webhook was sent by Vatly and not a malicious actor, verify the signature on every delivery. Vatly signs each request with the Vatly-Signature header, which has the structured form:

Vatly-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
  • t is the Unix timestamp (in seconds) when the signature was generated.
  • v1 is HMAC-SHA256("{t}.{rawBody}", yourWebhookSecret), hex-encoded — the timestamp and a literal . prepended to the exact raw request body.

To verify a delivery:

  1. Parse t and v1 out of the header.
  2. Reject the request if t is more than 300 seconds (5 minutes) from your server's current time — this guards against replay.
  3. Recompute the HMAC over "{t}.{rawBody}" using your webhook secret and the raw request body bytes (not a re-serialized copy — re-encoding JSON changes the bytes and breaks the signature).
  4. Compare your value against v1 using a constant-time comparison.
Future signature schemes will ship alongside v1 (e.g. t=…,v1=…,v2=…). Match on the v1= key specifically rather than assuming the order or the only scheme present, so your code keeps working when new versions are added.
If you use a Vatly SDK, signature verification is built in — for example PHP's Vatly\API\Webhooks\Webhook::parse($rawBody, $signatureHeader, $secret) or the Node SDK's constructEvent(). They parse the header, enforce the tolerance window, and verify the HMAC for you, throwing on failure.

To verify it yourself:

import crypto from 'node:crypto'

// `rawBody` must be the exact bytes Vatly sent (e.g. express.raw()), not a parsed object.
function verifyVatlySignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
  const timestamp = Number(parts.t)
  const signature = parts.v1
  if (!timestamp || !signature) throw new Error('Malformed Vatly-Signature header')

  // Replay protection
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    throw new Error('Signature timestamp outside tolerance window')
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    throw new Error('Invalid Vatly signature')
  }
}

Keep your webhook secret safe and never commit it to source control. If it leaks, you can no longer trust that a given webhook was sent by Vatly.

Copyright © 2026