Vatly
JavaScript Fluent

Webhooks

Record payments and subscription changes from signed webhooks, not checkout redirects.

Record payments and subscription changes from signed webhooks, not checkout redirects. Pass a configured Vatly to this Fetch handler, or export the returned function as a Next.js Route Handler's POST:

import { VatlyInvalidSignatureError } from '@getvatly/sdk';
import { Vatly } from '@getvatly/fluent';

export function webhookRoute(vatly: Vatly) {
  return async function POST(request: Request): Promise<Response> {
    try {
      const processed = await vatly.webhookProcessor().handleRequest(request);
      return new Response(null, { status: processed ? 201 : 200 });
    } catch (error) {
      if (error instanceof VatlyInvalidSignatureError) {
        return new Response('Invalid signature', { status: 400 });
      }
      // Failed storage/reactions must be retried; do not acknowledge success.
      return new Response('Webhook processing failed', { status: 500 });
    }
  };
}

Set webhookSecret, webhookCalls, and the repositories you want updated in configuration. This route needs no browser session; signatures authenticate deliveries. Pass the original unread request, not reserialized JSON. For raw bytes, use processor.handle(rawBody, signatureHeader).

The SDK checks Vatly-Signature HMAC and timestamp before touching storage. Keep timestamp checks enabled. To rotate secrets, set webhookSecret to an array of accepted secrets.

Idempotency and one transaction

Implement WebhookCalls.process(event, work) in one transaction shared by all storage drivers:

  1. Lock or claim the signed event.id. If already committed, return false.
  2. Retain the verified envelope, including unknown events.
  3. Await work() to run reactions and dispatch.
  4. Mark the ID processed and commit resource changes, bindings, audit, and outbox together. Return true. Roll back everything on failure.

Never commit the deduplication row before the work; a failure would lose the retry. Use signed event.id, not the unsigned Vatly-Event-Id header. Scope keys and rows by tenant, storefront, and mode. Timestamp checks alone do not deduplicate.

Write to an outbox in events.dispatch. Send email and make network calls from a worker after commit. Deliveries can arrive out of order; reconcile conflicting snapshots with the API.

What Fluent persists

EventBuilt-in action when the repository is wired
subscription.startedCreate or update a local subscription; a new saved row emits local.subscription_created.
subscription.updated, subscription.billing_updatedUpdate plan, name, quantity, status, and non-null mandate on an existing row; otherwise skip.
subscription.resumedUpdate an existing row and clear endsAt.
Cancellation events and grace-period completionUpdate an existing row's endsAt and cancellation reason.
order.paid, order.payment_failedCreate or update an order using the signed status; a newly stored paid order emits local.order_created.
order.canceledUpdate status and cancellation reason on an existing order; otherwise skip.
refund.completed, refund.failed, refund.canceledUpsert a refund if refunds is wired.
order.chargeback_received, order.chargeback_reversedUpsert a chargeback if chargebacks is wired.
Scheduled subscription updates, catalogue, checkout, webhook.setupDispatch only; no built-in resource write.

Reactions use the signed snapshot without an API GET. Missing repositories skip writes, not dispatch. Null billing-event mandates leave the local mandate unchanged; subscription sync() reconciles removal.

Before using subscription state to grant or revoke access, read the grace-period limitation in this alpha.

Application events and extensions

import type { DomainEvent, EventDispatcher } from '@getvatly/fluent';

export function billingEvents(
  enqueueInCurrentTransaction: (event: DomainEvent) => Promise<void>,
): EventDispatcher {
  return { dispatch: event => enqueueInCurrentTransaction(event) };
}

Check event.known before switching on eventName. The union includes SDK deliveries and the two local-created events above. Unknown signed events have known: false and object: unknown; retain and dispatch them without built-in writes. Types do not validate resource fields at runtime.

additionalWebhookReactions takes { supports(event), handle(event) } objects. They run after built-ins and before dispatch, in the same transaction. Throw to roll back and allow retry.

For event fields, see Webhook Events. Register endpoints using Webhook Endpoints; the webhooks guide describes delivery and signatures.

Copyright © 2026