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:
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.
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
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.