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 for this order failed and Vatly's automated payment recovery (dunning) has started. Delivered once, when recovery begins — not on each retry. If recovery succeeds you receive order.paid for the same order; if exhausted the related subscription is canceled. See Handling failed subscription payments.
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 with immediate effect at the merchant's request. object is the subscription and carries cancellationReason: merchant_request.
subscription.canceled_for_nonpaymentPayment recovery for a failed renewal was exhausted, so the subscription was canceled with immediate effect. object is the subscription and carries cancellationReason: payment_failure.
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.
one_off_product.update_submittedAn update to a one-off product was submitted and is awaiting review. object is the one-off product, with the requested changes in object.pendingUpdates and object.updateStatus: pending.
one_off_product.update_approvedA submitted one-off product update was approved and is now live. object is the one-off product with the new values applied and object.pendingUpdates: null.
one_off_product.update_rejectedA submitted one-off product update was rejected. object is the unchanged one-off product with object.pendingUpdates: null.
one_off_product.archivedA one-off product was archived and can no longer be sold. object is the one-off product with a non-null object.archivedAt.
one_off_product.unarchivedAn archived one-off product was put back on sale. object is the one-off product with object.archivedAt: null.
subscription_plan.update_submittedAn update to a subscription plan was submitted and is awaiting review. object is the subscription plan, with the requested changes in object.pendingUpdates and object.updateStatus: pending.
subscription_plan.update_approvedA submitted subscription plan update was approved and is now live. object is the subscription plan with the new values applied and object.pendingUpdates: null.
subscription_plan.update_rejectedA submitted subscription plan update was rejected. object is the unchanged subscription plan with object.pendingUpdates: null.
subscription_plan.archivedA subscription plan was archived and closed to new business. object is the subscription plan with a non-null object.archivedAt.
subscription_plan.unarchivedAn archived subscription plan was re-opened to new business. object is the subscription plan with object.archivedAt: null.
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, one_off_product, subscription_plan, 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, effectiveAt). The same scheduledUpdate object is also returned on the subscription resource by the REST API, so you can reconcile a pending change at any time without relying on this once-delivered event. The matching subscription.updated event (for changes that take effect immediately) has no pending 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"
    }
  }
}

Handling failed subscription payments

When a subscription renewal payment fails, Vatly runs an automated payment recovery process (dunning) on your behalf. You never have to schedule your own retries — but you do decide when to restrict access.

Here is what happens, and what you receive:

  1. The renewal payment fails and recovery begins. You receive a single order.payment_failed webhook for the renewal order — sent once, when recovery starts, not on each retry. The subscription's status stays active throughout recovery: it does not move to a "past due" state, and on_grace_period is never used for payment failure — that status means a voluntary cancellation that keeps access until the period ends.
  2. Vatly attempts to recover the payment, and how it does so depends on why the charge failed:
    • Soft decline (e.g. insufficient funds, a temporary decline): the payment method is still valid, so Vatly automatically re-attempts the charge over a multi-week window, emailing the customer along the way.
    • Hard decline (e.g. an expired, canceled, or invalid payment method): retrying cannot succeed, so Vatly instead prompts the customer to supply a new payment method, on a shorter window.
  3. If recovery succeeds, the original renewal order is paid and you receive order.paid for that same order. Because the subscription was never interrupted, access simply continues — nothing else is needed.
  4. If recovery is exhausted (the customer never resolves it), Vatly cancels the subscription: you receive subscription.canceled_for_nonpayment and the subscription status becomes canceled with cancellationReason: payment_failure.

Gate access on the events, not on a timer you keep in sync with ours:

  • On order.payment_failed: keep access on, and start your own grace period if your product has one.
  • On order.paid for the same order: recovery succeeded — clear that grace period.
  • If your grace period elapses with no matching order.paid: restrict access on your side if you wish. Vatly's recovery keeps running underneath, so a later order.paid lets you restore access automatically.
  • On the subscription's cancellation: remove access permanently.
The number of retry attempts and the exact retry cadence are operational details that Vatly tunes over time. Integrate against these webhook events rather than against specific durations or attempt counts.

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