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