Blog

Developer Guide

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:

  • Was the transaction submitted? Did it reach the network at all?
  • Was it included in a ledger? Which ledger, and when did it close?
  • Did it succeed or fail? If it failed, why?
  • How many confirmations does it have? How many ledgers have closed since settlement?
  • 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:

    StageDescriptionVisual
    SubmittedTransaction sent to HorizonStep 1 of 4
    AcceptedHorizon accepted the transactionStep 2 of 4
    SettledIncluded in a closed ledgerStep 3 of 4
    ConfirmedAdditional ledgers closed after settlementStep 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

  • Use the LumenQuery Live Transaction Viewer to see decoded operations in real time
  • Explore Network Analytics for settlement time trends
  • Check out the Horizon API docs for the complete endpoint reference

  • *Build reliable payment experiences on Stellar. LumenQuery provides managed Horizon API access with higher rate limits and sub-100ms response times. Start free.*