Skip to main content
Guides

Real-time Balance Updates

Push live XRP and token balance changes directly to browser clients the instant a payment lands on-chain, with no polling required.

Overview

There are two common approaches for delivering real-time balance data to your frontend:

Approach 1: Webhook-driven (this guide)

XRNotify pushes a payment event to your server. Your server updates the database and emits a Server-Sent Event (SSE) to connected browser clients. Near-instant latency, no wasted requests.

Approach 2: Client polling

The frontend polls your balance API on a fixed interval. Simpler to implement but introduces latency equal to the poll interval and generates unnecessary requests.

This guide covers Approach 1: XRNotify webhook → server handler → SSE stream → React hook. The end result is a balance that updates on-screen within a few seconds of a payment confirming on-chain.

Setting up the webhook

Subscribe to payment events and trustline creation for each wallet address you want to monitor. You can add multiple addresses to account_filters, or omit it entirely to monitor all activity.

curl -X POST https://api.xrnotify.io/v1/webhooks \
  -H "X-XRNotify-Key: xrn_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourapp.com/webhooks/balance",
    "event_types": ["payment.xrp", "payment.issued", "trustline.created"],
    "account_filters": ["rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"]
  }'

Server-side handler (Node.js)

The webhook handler verifies the signature, updates the balance in the database, then broadcasts the delta to any SSE clients listening on that address.

app.post('/webhooks/balance', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req.body, req.headers['x-xrnotify-signature'], process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }

  // Idempotency: skip if already processed
  const deliveryId = req.headers['x-xrnotify-delivery-id'];
  if (await cache.get(`delivery:${deliveryId}`)) return res.sendStatus(200);

  const event = JSON.parse(req.body);
  const { receiver, amount, currency, issuer } = event.payload;

  switch (event.event_type) {
    case 'payment.xrp': {
      const xrp = (parseInt(amount) / 1_000_000).toFixed(6);
      await updateXrpBalance(receiver, xrp);
      await broadcastBalanceUpdate(receiver, 'XRP', null, xrp);
      break;
    }
    case 'payment.issued': {
      const { value } = event.payload;
      await updateTokenBalance(receiver, currency, issuer, value);
      await broadcastBalanceUpdate(receiver, currency, issuer, value);
      break;
    }
    case 'trustline.created': {
      // A new trustline was established - client may want to show zero balance
      await broadcastTrustlineCreated(receiver, currency, issuer);
      break;
    }
  }

  await cache.set(`delivery:${deliveryId}`, 'done', 86400);
  res.sendStatus(200);
});

Broadcasting to connected clients (SSE)

Server-Sent Events are a lightweight, browser-native alternative to WebSockets for one-way server-to-client streaming. They reconnect automatically on disconnect and work through HTTP/2.

import { EventEmitter } from 'events';
export const balanceEmitter = new EventEmitter();
balanceEmitter.setMaxListeners(0); // Unlimited concurrent SSE clients

export async function broadcastBalanceUpdate(address, currency, issuer, delta) {
  balanceEmitter.emit('update', { address, currency, issuer, delta });
}

// SSE endpoint - authenticate before registering the listener
app.get('/api/balance-stream', requireAuth, (req, res) => {
  const userId = req.user.id;

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering

  // Send a heartbeat comment every 15s to keep the connection alive
  const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 15_000);

  const listener = (update) => {
    // Only send updates belonging to this authenticated user's addresses
    if (isAddressOwnedByUser(update.address, userId)) {
      res.write(`data: ${JSON.stringify(update)}\n\n`);
    }
  };

  balanceEmitter.on('update', listener);

  req.on('close', () => {
    clearInterval(heartbeat);
    balanceEmitter.off('update', listener);
  });
});

Frontend React hook

This hook opens a single SSE connection for the authenticated user and applies incoming balance deltas to local state, giving a live-updating balance without any polling.

import { useState, useEffect } from 'react';

interface BalanceUpdate {
  address: string;
  currency: string;
  issuer: string | null;
  delta: string;
}

function useRealtimeBalance(address: string, initialBalance: number = 0) {
  const [balance, setBalance] = useState<number>(initialBalance);

  useEffect(() => {
    if (!address) return;

    const es = new EventSource('/api/balance-stream');

    es.onmessage = (e) => {
      const update: BalanceUpdate = JSON.parse(e.data);
      if (update.address === address && update.currency === 'XRP') {
        setBalance(prev => Math.max(0, prev + parseFloat(update.delta)));
      }
    };

    es.onerror = () => {
      // EventSource auto-reconnects - no manual retry needed
      console.warn('SSE connection lost, reconnecting...');
    };

    return () => es.close();
  }, [address]);

  return balance;
}

// Usage in a component:
function WalletBalance({ address }: { address: string }) {
  const balance = useRealtimeBalance(address, 0);
  return <span>{balance.toFixed(6)} XRP</span>;
}

Extending to token balances

For issued currency tokens, key the balance by the (currency, issuer) pair:

function useRealtimeTokenBalance(address: string, currency: string, issuer: string) {
  const [balance, setBalance] = useState<number>(0);

  useEffect(() => {
    const es = new EventSource('/api/balance-stream');

    es.onmessage = (e) => {
      const update: BalanceUpdate = JSON.parse(e.data);
      if (
        update.address === address &&
        update.currency === currency &&
        update.issuer === issuer
      ) {
        setBalance(prev => prev + parseFloat(update.delta));
      }
    };

    return () => es.close();
  }, [address, currency, issuer]);

  return balance;
}

Reconnection and idempotency

EventSource reconnects automatically. The browser will re-establish the SSE connection with exponential backoff if it drops. You do not need to implement reconnection logic manually.

During an SSE reconnect, the browser sends the last seen Last-Event-ID header if you set event IDs server-side. However, a simpler strategy is to initialize the balance from your REST API on mount, then apply SSE deltas on top. This way a reconnect just means a brief gap with no missed net balance change:

function useRealtimeBalance(address: string) {
  const [balance, setBalance] = useState<number | null>(null);

  // 1. Fetch current balance on mount
  useEffect(() => {
    fetch(`/api/balances/${address}`)
      .then(r => r.json())
      .then(data => setBalance(data.xrp_balance));
  }, [address]);

  // 2. Apply live deltas on top
  useEffect(() => {
    const es = new EventSource('/api/balance-stream');
    es.onmessage = (e) => {
      const update = JSON.parse(e.data);
      if (update.address === address && update.currency === 'XRP') {
        setBalance(prev => prev !== null ? prev + parseFloat(update.delta) : null);
      }
    };
    return () => es.close();
  }, [address]);

  return balance;
}

Because XRNotify uses the X-XRNotify-Delivery-Id header for every delivery (including retries), your webhook handler's idempotency cache prevents the same delta from being applied twice to the database, ensuring the REST API always returns an accurate balance even during retry storms.

Next steps