Webhooks
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.
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.
{
"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"
}
}
}
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:
| Header | Purpose |
|---|---|
Vatly-Signature | Lets you verify the delivery really came from Vatly. See Verifying signatures. |
Vatly-Event-Id | The event's ID (also in the body as id). It is stable across retries, so use it as the key to deduplicate deliveries. |
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:
| Event | Description |
|---|---|
checkout.paid | A checkout was paid successfully. |
checkout.failed | A checkout payment failed. |
checkout.canceled | A checkout was canceled. |
checkout.expired | A checkout session expired. |
order.paid | An order payment was successful. |
order.canceled | An order was canceled. |
order.chargeback_received | A chargeback was received for an order. |
order.chargeback_reversed | A chargeback was reversed. |
order.payment_failed | A 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.completed | A refund was processed successfully. |
refund.failed | A refund failed to process. |
refund.canceled | A refund was canceled. |
subscription.started | A subscription was started. |
subscription.canceled_immediately | A subscription was canceled with immediate effect at the merchant's request. object is the subscription and carries cancellationReason: merchant_request. |
subscription.canceled_for_nonpayment | Payment 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_period | A subscription was canceled; the customer keeps access until the period ends. |
subscription.cancellation_grace_period_completed | The grace period after a cancellation ended. |
subscription.resumed | A canceled subscription was resumed during its grace period. |
subscription.billing_updated | A subscription's billing details changed — in practice the mandate (payment method on file), e.g. a refreshed masked payment-method identifier. |
subscription.updated | A 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_scheduled | A 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_submitted | An 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_approved | A 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_rejected | A submitted one-off product update was rejected. object is the unchanged one-off product with object.pendingUpdates: null. |
one_off_product.archived | A 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.unarchived | An archived one-off product was put back on sale. object is the one-off product with object.archivedAt: null. |
subscription_plan.update_submitted | An 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_approved | A 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_rejected | A submitted subscription plan update was rejected. object is the unchanged subscription plan with object.pendingUpdates: null. |
subscription_plan.archived | A subscription plan was archived and closed to new business. object is the subscription plan with a non-null object.archivedAt. |
subscription_plan.unarchived | An archived subscription plan was re-opened to new business. object is the subscription plan with object.archivedAt: null. |
webhook.setup | A 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
{
"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:
- The renewal payment fails and recovery begins. You receive a single
order.payment_failedwebhook for the renewal order — sent once, when recovery starts, not on each retry. The subscription'sstatusstaysactivethroughout recovery: it does not move to a "past due" state, andon_grace_periodis never used for payment failure — that status means a voluntary cancellation that keeps access until the period ends. - 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.
- If recovery succeeds, the original renewal order is paid and you receive
order.paidfor that same order. Because the subscription was never interrupted, access simply continues — nothing else is needed. - If recovery is exhausted (the customer never resolves it), Vatly cancels the subscription: you receive
subscription.canceled_for_nonpaymentand the subscriptionstatusbecomescanceledwithcancellationReason: payment_failure.
Recommended integration
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.paidfor 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 laterorder.paidlets you restore access automatically. - On the subscription's cancellation: remove access permanently.
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>
tis the Unix timestamp (in seconds) when the signature was generated.v1isHMAC-SHA256("{t}.{rawBody}", yourWebhookSecret), hex-encoded — the timestamp and a literal.prepended to the exact raw request body.
To verify a delivery:
- Parse
tandv1out of the header. - Reject the request if
tis more than 300 seconds (5 minutes) from your server's current time — this guards against replay. - 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). - Compare your value against
v1using a constant-time comparison.
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.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')
}
}
import hashlib
import hmac
import time
def verify_vatly_signature(raw_body: str, header: str, secret: str, tolerance_seconds: int = 300) -> None:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
raise ValueError("Malformed Vatly-Signature header")
# Replay protection
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
raise ValueError("Signature timestamp outside tolerance window")
message = f"{timestamp}.{raw_body}".encode()
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid Vatly signature")
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_VATLY_SIGNATURE'] ?? '';
$toleranceSeconds = 300;
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, null);
$parts[trim((string) $key)] = trim((string) $value);
}
$timestamp = $parts['t'] ?? null;
$signature = $parts['v1'] ?? null;
if (! $timestamp || ! $signature || abs(time() - (int) $timestamp) > $toleranceSeconds) {
http_response_code(400);
exit('Invalid signature');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (hash_equals($expected, $signature)) {
// Request is verified
} else {
http_response_code(400);
exit('Invalid 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.