How to Build a Stellar Payment Status Page with LumenQuery APIs
A payment status page is one of the most valuable things you can add to any application that moves money on Stellar. Whether you are building a remittance platform, an e-commerce checkout, or an internal treasury tool, your users need to know: did the payment go through, is it still pending, or did it fail? This guide shows you how to build one using the Horizon API.
What a Payment Status Page Shows
A good payment status page answers four questions:
Checking Transaction Status
After submitting a transaction, you get back a hash. Use it to poll for status:
const HORIZON = 'https://horizon.stellar.org';
async function getTransactionStatus(txHash) {
const res = await fetch(`${HORIZON}/transactions/${txHash}`);
if (res.status === 404) {
return { status: 'pending', message: 'Transaction not yet included in a ledger' };
}
const tx = await res.json();
return {
status: tx.successful ? 'success' : 'failed',
hash: tx.hash,
ledger: tx.ledger,
createdAt: tx.created_at,
feeCharged: parseInt(tx.fee_charged),
operationCount: tx.operation_count,
memo: tx.memo || null,
resultCodes: tx.result_codes || null,
};
}A 404 response means the transaction has not been included in a ledger yet. It could still be in the submission queue or it may have been rejected before reaching consensus.
Counting Confirmations
On Stellar, a ledger closes roughly every 5 seconds. Each new ledger after your transaction's ledger is a confirmation:
async function getConfirmations(txLedger) {
const res = await fetch(`${HORIZON}/ledgers?limit=1&order=desc`);
const data = await res.json();
const currentLedger = data._embedded.records[0].sequence;
return {
confirmations: currentLedger - txLedger,
currentLedger,
txLedger,
estimatedSeconds: (currentLedger - txLedger) * 5,
};
}Most applications treat a transaction as final after 1 confirmation on Stellar, because Stellar's consensus protocol (SCP) provides deterministic finality — unlike proof-of-work chains where you need to wait for multiple blocks.
Tracking Payment Operations
A transaction can contain multiple operations. To show what actually happened, fetch the operations:
async function getPaymentOperations(txHash) {
const res = await fetch(`${HORIZON}/transactions/${txHash}/operations`);
const data = await res.json();
return data._embedded.records.map(op => ({
id: op.id,
type: op.type,
amount: op.amount,
asset: op.asset_type === 'native' ? 'XLM' : `${op.asset_code}:${op.asset_issuer?.slice(0, 4)}`,
from: op.from,
to: op.to,
sourceAccount: op.source_account,
}));
}Handling Failed Transactions
When a transaction fails, the result_codes field tells you exactly why:
const ERROR_MESSAGES = {
tx_failed: 'One or more operations failed',
tx_bad_auth: 'Invalid signature or authorization',
tx_bad_seq: 'Sequence number mismatch — transaction may have been submitted twice',
tx_insufficient_balance: 'Sender does not have enough XLM to cover amount + fees',
tx_no_source_account: 'Source account does not exist on the network',
tx_too_late: 'Transaction expired (timebounds exceeded)',
op_underfunded: 'Not enough balance to complete the payment',
op_no_trust: 'Recipient has not established a trustline for this asset',
op_no_destination: 'Destination account does not exist',
op_line_full: 'Recipient trustline balance limit exceeded',
};
function getErrorMessage(resultCodes) {
if (!resultCodes) return 'Unknown error';
const txCode = resultCodes.transaction;
const opCodes = resultCodes.operations || [];
const messages = [
ERROR_MESSAGES[txCode] || txCode,
...opCodes.map(code => ERROR_MESSAGES[code] || code),
];
return messages.join('. ');
}Building the Status Page Component
Here is a React component that ties everything together:
function PaymentStatusPage({ txHash }) {
const [status, setStatus] = useState(null);
const [confirmations, setConfirmations] = useState(0);
useEffect(() => {
const poll = async () => {
const txStatus = await getTransactionStatus(txHash);
setStatus(txStatus);
if (txStatus.status === 'success') {
const conf = await getConfirmations(txStatus.ledger);
setConfirmations(conf.confirmations);
}
};
poll();
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, [txHash]);
if (!status) return <div>Loading...</div>;
return (
<div>
<h2>Payment Status</h2>
<StatusBadge status={status.status} />
{status.status === 'pending' && <p>Waiting for network confirmation...</p>}
{status.status === 'success' && (
<>
<p>Settled in ledger #{status.ledger}</p>
<p>{confirmations} confirmations ({confirmations * 5}s ago)</p>
<p>Fee: {status.feeCharged} stroops</p>
</>
)}
{status.status === 'failed' && (
<p className="error">{getErrorMessage(status.resultCodes)}</p>
)}
</div>
);
}Streaming Status Updates with SSE
Instead of polling, you can use Horizon's Server-Sent Events to get instant updates:
function streamTransactionStatus(txHash, onUpdate) {
const es = new EventSource(
`${HORIZON}/transactions/${txHash}?cursor=now`
);
es.onmessage = (event) => {
const tx = JSON.parse(event.data);
onUpdate({
status: tx.successful ? 'success' : 'failed',
ledger: tx.ledger,
createdAt: tx.created_at,
});
es.close();
};
return es;
}Displaying Settlement Progress
A progress bar shows users how their payment is moving through the pipeline:
| Stage | Description | Visual |
|---|---|---|
| Submitted | Transaction sent to Horizon | Step 1 of 4 |
| Accepted | Horizon accepted the transaction | Step 2 of 4 |
| Settled | Included in a closed ledger | Step 3 of 4 |
| Confirmed | Additional ledgers closed after settlement | Step 4 of 4 |
Production Considerations
Timeout handling: If a transaction is not confirmed within 30 seconds, it likely failed or was not submitted. Check the account sequence number to determine if it was applied.
Idempotency: Use the memo field or a unique ID to prevent duplicate payments. Before resubmitting, check if the original transaction was already applied.
Rate limits: If you are polling Horizon directly, keep requests under the rate limit. LumenQuery's managed API gives you higher throughput with built-in caching.
Multi-currency: For path payments, the source and destination assets may differ. Show both the sent and received amounts.
Next Steps
*Build reliable payment experiences on Stellar. LumenQuery provides managed Horizon API access with higher rate limits and sub-100ms response times. Start free.*