Vatly
JavaScript SDK

Webhooks

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

Vatly sends webhooks to notify your application when events happen — an order being paid, a refund completing, a subscription being canceled. Every delivery is HMAC-signed; the SDK verifies the signature and hands you a typed event.

For event fields and endpoint options, see the Webhook Events and Webhook Endpoints API references. The OpenAPI spec is also available for tooling.

Webhook events

The eventName field identifies what happened. Names are available as constants on WebhookEventName.

Eventobject typeDescription
order.paidOrderOrder payment was successful.
order.canceledOrderOrder was canceled.
order.payment_failedOrderA payment failed and dunning started.
order.chargeback_receivedChargebackA chargeback was received for an order.
order.chargeback_reversedChargebackA chargeback was reversed.
refund.completedRefundRefund was processed successfully.
refund.failedRefundRefund processing failed.
refund.canceledRefundRefund was canceled.
subscription.startedSubscriptionSubscription was started.
subscription.billing_updatedSubscriptionBilling details (e.g. the mandate) changed.
subscription.canceled_immediatelySubscriptionCanceled immediately.
subscription.canceled_with_grace_periodSubscriptionCanceled; access until period end.
subscription.cancellation_grace_period_completedSubscriptionGrace period ended.
subscription.resumedSubscriptionA canceled subscription was resumed.
subscription.canceled_for_nonpaymentSubscriptionPayment recovery exhausted.
subscription.updatedSubscriptionImmediate/scheduled change or renewal; inspect reason.
subscription.update_scheduledSubscriptionChange scheduled; inspect scheduledUpdate.
one_off_product.update_submitted / update_approved / update_rejectedOneOffProductProduct change review lifecycle (each suffix uses the same prefix).
one_off_product.archived / unarchivedOneOffProductProduct availability changed.
subscription_plan.update_submitted / update_approved / update_rejectedSubscriptionPlanPlan change review lifecycle.
subscription_plan.archived / unarchivedSubscriptionPlanPlan availability changed.
checkout.paidCheckoutCheckout was paid.
checkout.failedCheckoutCheckout payment failed.
checkout.canceledCheckoutCheckout was canceled.
checkout.expiredCheckoutCheckout session expired.
webhook.setupWebhookVerification call sent when an endpoint is registered or its URL changes.

Verify and process in one step

constructWebhookEventFromRequest reads the raw body and Vatly-Signature header off a Fetch Request, verifies the HMAC, and returns a discriminated event. Check event.known first, then switch on event.eventName to narrow event.object to the exact resource — no casts, no follow-up API call (Vatly sends fat, signed payloads, so object is the full GET /…/{id} snapshot).

import { constructWebhookEventFromRequest } from "@getvatly/sdk/webhooks";

export async function POST(request: Request): Promise<Response> {
  let event;
  try {
    event = await constructWebhookEventFromRequest(request, process.env.VATLY_WEBHOOK_SECRET!);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  if (!event.known) {
    // Retain the verified envelope durably for triage, then acknowledge it.
    // Until retention succeeds, retry rather than silently losing the delivery.
    return new Response("unsupported event requires triage", { status: 503 });
  }
  switch (event.eventName) {
    case "order.paid":
      event.object.total; // Money — typed as Order
      break;
    case "subscription.billing_updated":
      event.object.mandate; // typed as Subscription
      break;
    case "webhook.setup":
      return new Response("ok"); // verification handshake — just acknowledge
  }
  return new Response("ok");
}

If you already have the raw bytes (e.g. a non-Fetch framework), use constructWebhookEvent(rawBody, signatureHeader, secret). To verify only and get the raw WebhookDelivery envelope, use constructEvent / constructEventFromRequest, then narrow later with toWebhookEvent(delivery).

VatlyWebhookEvent includes KnownWebhookEvent (known: true) and UnsupportedWebhookEvent (known: false, eventName: string, object: unknown). The SDK adds this discriminator to a shallow copy after verification, overwriting any wire-supplied flag. It classifies the event name, not the resource schema. Both sides of if (event.known) narrow normally; no custom type guards are needed. Unknown event names can appear before an SDK upgrade. Keep them for triage and decide acknowledgement policy explicitly; a successful HMAC is not a reason to discard an event you cannot yet process.

Always verify against the raw body bytes. Re-serializing the JSON changes the bytes (whitespace, key order, number formatting) and breaks the HMAC.

The webhook.setup event

When you register a webhook endpoint (or change its URL), Vatly sends a signed webhook.setup call to confirm the endpoint is reachable. It's delivered as a normal webhook — same envelope, same signature, same Vatly-Event-Id header — so there's nothing special to parse: verify it and return a 2xx. Its object is the (secret-free) webhook endpoint config and links is omitted (it's not a persisted event).

Signature scheme

Vatly-Signature: t=1700000000,v1=<hex_hmac_sha256>
Vatly-Event-Id:  webhook_event_Qk8pRtSvWm2NjLhYcZaE
  • t is unix seconds, captured fresh on each delivery attempt.
  • v1 is HMAC-SHA256("{t}.{rawBody}", webhookSecret), lowercase hex.
  • The SDK rejects signatures where |now − t| > 300 seconds by default (5-minute clock-skew tolerance). Tune it with { toleranceSeconds }.
  • Future algorithm rotation ships v1=…,v2=… simultaneously; the SDK verifies any version it recognizes and ignores the rest.

Deduplication is your job

The SDK is stateless — it can't remember which events it has verified. The clock-skew check bounds how long a leaked signature is replayable, but does not stop replays inside that window. For full replay defense, dedupe on the retry-stable, signed event.id in your own store after verification. The Vatly-Event-Id header is not signed and is for correlation only:

const event = await constructWebhookEventFromRequest(request, secret);
const eventId = event.id;
if (await store.has(eventId)) return new Response("ok"); // already processed

Record successful processing and deduplication atomically; do not mark an event complete before its business effects succeed. Concurrent deliveries need a unique constraint or equivalent transactional claim in your store.

Manage webhook endpoints

const endpoint = await vatly.webhookEndpoints.create({
  url: "https://yourapp.com/api/webhooks/vatly",
  secret: process.env.VATLY_WEBHOOK_SECRET!,
  enabledEvents: ["order.paid", "subscription.updated"],
});
await vatly.webhookEndpoints.update(endpoint.id, { enabledEvents: [] }); // dormant
const page = await vatly.webhookEndpoints.list();
await vatly.webhookEndpoints.delete(endpoint.id); // 204

Secrets are write-only: store the one you supplied. HTTPS URLs must be unique within the storefront/mode, with up to five endpoints per mode. New event names are not automatically subscribed; update enabledEvents explicitly.

Rotating secrets

Every verification helper accepts a single secret or an array. During a rotation, pass both — the SDK accepts whichever signed the delivery:

const event = await constructWebhookEventFromRequest(request, [
  process.env.VATLY_WEBHOOK_SECRET_OLD!,
  process.env.VATLY_WEBHOOK_SECRET_NEW!,
]);

Fetching a persisted event

Persisted events are available via the API. Note these are returned as the WebhookEvent shape (GET /webhook-events/{id}), which — unlike an inbound delivery — never includes the webhook.setup call and always carries links:

const event = await vatly.webhookEvents.get("webhook_event_abc123");
Copyright © 2026