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 — 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