Skip to main content
The X402rMerchant class provides three subscription methods for watching blockchain events in real-time. Each returns an object with an unsubscribe function for cleanup.

Watch Refund Requests

Use watchRefundRequests() to subscribe to RefundRequested events emitted by the RefundRequest contract. The callback receives a RefundRequestEventLog object for each event.
const { unsubscribe } = merchant.watchRefundRequests((event) => {
  console.log('Event:', event.eventName);
  console.log('Payment hash:', event.args.paymentInfoHash);
  console.log('Payer:', event.args.payer);
  console.log('Receiver:', event.args.receiver);
  console.log('Amount:', event.args.amount);
  console.log('Nonce:', event.args.nonce);
  console.log('Block:', event.blockNumber);
  console.log('Tx hash:', event.transactionHash);
});

// Later: stop watching
unsubscribe();
The RefundRequestEventLog type has the following shape:
interface RefundRequestEventLog {
  eventName: 'RefundRequested' | 'RefundRequestStatusUpdated' | 'RefundRequestCancelled';
  args: {
    paymentInfoHash?: `0x${string}`;
    payer?: `0x${string}`;
    receiver?: `0x${string}`;
    amount?: bigint;
    nonce?: bigint;
    status?: number;
  };
  address: `0x${string}`;
  blockNumber: bigint;
  transactionHash: `0x${string}`;
  logIndex: number;
}
watchRefundRequests() requires the refundRequestAddress to be configured when creating the X402rMerchant instance.

Example: Auto-respond to Small Refund Requests

import { X402rMerchant } from '@x402r/merchant';
import { RequestStatus } from '@x402r/core';

const AUTO_APPROVE_THRESHOLD = BigInt('5000000'); // 5 USDC

const { unsubscribe } = merchant.watchRefundRequests(async (event) => {
  const amount = event.args.amount;
  const paymentHash = event.args.paymentInfoHash;

  console.log(`New refund request: ${paymentHash}, amount: ${amount}`);

  if (amount && amount < AUTO_APPROVE_THRESHOLD) {
    console.log('Auto-approving small refund request');
    // You would look up the paymentInfo from your database
    // and call merchant.approveRefundRequest(paymentInfo, nonce)
  } else {
    console.log('Queuing for manual review');
  }
});

Watch Releases

Use watchReleases() to subscribe to ReleaseExecuted events emitted by the PaymentOperator contract. The callback receives a PaymentOperatorEventLog object for each event.
const { unsubscribe } = merchant.watchReleases((event) => {
  console.log('Release executed!');
  console.log('Payment hash:', event.args.paymentInfoHash);
  console.log('Amount:', event.args.amount);
  console.log('Payer:', event.args.payer);
  console.log('Receiver:', event.args.receiver);
  console.log('Block:', event.blockNumber);
  console.log('Tx hash:', event.transactionHash);
});

// Later: stop watching
unsubscribe();
The PaymentOperatorEventLog type has the following shape:
interface PaymentOperatorEventLog {
  eventName: 'ReleaseExecuted' | 'RefundInEscrowExecuted' | 'RefundPostEscrowExecuted'
    | 'AuthorizationCreated' | 'ChargeExecuted';
  args: {
    paymentInfoHash?: `0x${string}`;
    payer?: `0x${string}`;
    receiver?: `0x${string}`;
    amount?: bigint;
  };
  address: `0x${string}`;
  blockNumber: bigint;
  transactionHash: `0x${string}`;
  logIndex: number;
}

Example: Revenue Tracking

let totalReleased = 0n;

const { unsubscribe } = merchant.watchReleases((event) => {
  const amount = event.args.amount ?? 0n;
  totalReleased += amount;

  console.log(`Release: +${amount} tokens`);
  console.log(`Total released: ${totalReleased}`);
});

Watch Freeze Events

Use watchFreezeEvents() to subscribe to PaymentFrozen and PaymentUnfrozen events from a specific Freeze contract. You must provide the Freeze contract address as the first argument.
const freezeAddress: `0x${string}` = '0xFreezeContract...';

const { unsubscribe } = merchant.watchFreezeEvents(
  freezeAddress,
  (event) => {
    if (event.eventName === 'PaymentFrozen') {
      console.log('Payment FROZEN:', event.args.paymentInfoHash);
      console.log('Frozen by:', event.args.caller);
      // Alert: a dispute may be in progress
    } else if (event.eventName === 'PaymentUnfrozen') {
      console.log('Payment UNFROZEN:', event.args.paymentInfoHash);
      console.log('Unfrozen by:', event.args.caller);
      // The payment can now be released
    }
  }
);

// Later: stop watching
unsubscribe();
The FreezeEventLog type has the following shape:
interface FreezeEventLog {
  eventName: 'PaymentFrozen' | 'PaymentUnfrozen';
  args: {
    paymentInfoHash?: `0x${string}`;
    caller?: `0x${string}`;
  };
  address: `0x${string}`;
  blockNumber: bigint;
  transactionHash: `0x${string}`;
  logIndex: number;
}
The freezeAddress parameter is the address of the Freeze condition contract, not the PaymentOperator. You can retrieve it from your operator config via merchant.getOperatorConfig().

Combining Multiple Watchers

You can run all three watchers simultaneously to build a comprehensive monitoring system. Store all unsubscribe functions for coordinated cleanup.
import { X402rMerchant } from '@x402r/merchant';

class MerchantEventMonitor {
  private unsubscribers: (() => void)[] = [];

  constructor(
    private merchant: X402rMerchant,
    private freezeAddress: `0x${string}`
  ) {}

  start() {
    // Watch for new refund requests
    const { unsubscribe: unwatchRefunds } = this.merchant.watchRefundRequests(
      (event) => {
        console.log('[REFUND REQUEST]', event.args.paymentInfoHash);
        console.log('  Amount:', event.args.amount);
        console.log('  Payer:', event.args.payer);
      }
    );
    this.unsubscribers.push(unwatchRefunds);

    // Watch for releases
    const { unsubscribe: unwatchReleases } = this.merchant.watchReleases(
      (event) => {
        console.log('[RELEASE]', event.args.paymentInfoHash);
        console.log('  Amount:', event.args.amount);
      }
    );
    this.unsubscribers.push(unwatchReleases);

    // Watch for freeze/unfreeze events
    const { unsubscribe: unwatchFreezes } = this.merchant.watchFreezeEvents(
      this.freezeAddress,
      (event) => {
        console.log(`[${event.eventName.toUpperCase()}]`, event.args.paymentInfoHash);
        console.log('  Caller:', event.args.caller);
      }
    );
    this.unsubscribers.push(unwatchFreezes);

    console.log('Merchant event monitor started (3 watchers active)');
  }

  stop() {
    for (const unsub of this.unsubscribers) {
      unsub();
    }
    this.unsubscribers = [];
    console.log('Merchant event monitor stopped');
  }
}

// Usage
const monitor = new MerchantEventMonitor(merchant, freezeAddress);
monitor.start();

// Graceful shutdown
process.on('SIGINT', () => {
  monitor.stop();
  process.exit(0);
});

process.on('SIGTERM', () => {
  monitor.stop();
  process.exit(0);
});

Dashboard Server Example

This example integrates all three watchers into an Express server that exposes a real-time dashboard API.
import express from 'express';
import { X402rMerchant } from '@x402r/merchant';
import { getNetworkConfig } from '@x402r/core';

const app = express();

// In-memory state (use a database in production)
const state = {
  pendingRefunds: [] as string[],
  recentReleases: [] as { hash: string; amount: bigint; timestamp: number }[],
  frozenPayments: new Set<string>(),
};

// Set up watchers
const { unsubscribe: unwatchRefunds } = merchant.watchRefundRequests((event) => {
  const hash = event.args.paymentInfoHash;
  if (hash && !state.pendingRefunds.includes(hash)) {
    state.pendingRefunds.push(hash);
  }
});

const { unsubscribe: unwatchReleases } = merchant.watchReleases((event) => {
  if (event.args.paymentInfoHash && event.args.amount) {
    state.recentReleases.push({
      hash: event.args.paymentInfoHash,
      amount: event.args.amount,
      timestamp: Date.now(),
    });

    // Keep only last 100
    if (state.recentReleases.length > 100) {
      state.recentReleases.shift();
    }
  }
});

const freezeAddress: `0x${string}` = '0xFreezeContract...';
const { unsubscribe: unwatchFreezes } = merchant.watchFreezeEvents(
  freezeAddress,
  (event) => {
    const hash = event.args.paymentInfoHash;
    if (!hash) return;

    if (event.eventName === 'PaymentFrozen') {
      state.frozenPayments.add(hash);
    } else {
      state.frozenPayments.delete(hash);
    }
  }
);

// API endpoint
app.get('/api/dashboard', (_req, res) => {
  res.json({
    pendingRefundCount: state.pendingRefunds.length,
    frozenPaymentCount: state.frozenPayments.size,
    recentReleases: state.recentReleases
      .slice(-10)
      .map((r) => ({
        hash: r.hash,
        amount: r.amount.toString(),
        timestamp: r.timestamp,
      })),
  });
});

// Cleanup on shutdown
function cleanup() {
  unwatchRefunds();
  unwatchReleases();
  unwatchFreezes();
}

process.on('SIGINT', () => { cleanup(); process.exit(0); });
process.on('SIGTERM', () => { cleanup(); process.exit(0); });

app.listen(3000, () => {
  console.log('Merchant dashboard running on port 3000');
});

Event Types Reference

MethodEvent NameCallback TypeUse Case
watchRefundRequestsRefundRequestedRefundRequestEventLogDetect incoming refund requests
watchReleasesReleaseExecutedPaymentOperatorEventLogTrack revenue and release confirmations
watchFreezeEventsPaymentFrozen / PaymentUnfrozenFreezeEventLogMonitor dispute-related freezes
All three watchers use viem’s watchContractEvent under the hood, which maintains a WebSocket connection to the RPC provider. Make sure your provider supports WebSocket subscriptions for real-time event delivery.

Cleanup Best Practices

Always unsubscribe from watchers when they are no longer needed. Failing to unsubscribe leads to memory leaks and orphaned WebSocket connections.
// Store references for later cleanup
const watchers = {
  refunds: merchant.watchRefundRequests((e) => { /* ... */ }),
  releases: merchant.watchReleases((e) => { /* ... */ }),
  freezes: merchant.watchFreezeEvents(freezeAddress, (e) => { /* ... */ }),
};

// Clean up all watchers
function cleanupAll() {
  watchers.refunds.unsubscribe();
  watchers.releases.unsubscribe();
  watchers.freezes.unsubscribe();
}
If your server restarts, you will miss events that occurred while the watchers were inactive. Consider supplementing real-time subscriptions with periodic polling via getPendingRefundRequests() to catch any missed events.

Next Steps