Developer Guide

Building Real-Time Apps on Stellar: Horizon API vs Stellar RPC

When building on Stellar, developers face a crucial architectural decision: when to use Horizon API versus Stellar RPC. Both provide access to the Stellar network, but they're optimized for different use cases. This guide breaks down the differences with practical examples.

Understanding the Two Services

Horizon API

Horizon is Stellar's REST API server. It maintains a full index of the Stellar network's history and provides:

  • RESTful endpoints for accounts, transactions, operations, effects
  • Historical queries with pagination and filtering
  • Real-time streaming via Server-Sent Events (SSE)
  • Multi-network support (mainnet, testnet, futurenet)
  • Stellar RPC

    Stellar RPC (formerly Soroban RPC) is a JSON-RPC 2.0 service optimized for:

  • Real-time state queries without full historical indexing
  • Transaction simulation before submission
  • Smart contract interactions (Soroban)
  • Lightweight deployment with minimal storage requirements
  • When to Use Each

    Use CaseHorizonStellar RPCWhy
    Get account balancesBoth work; Horizon has richer data
    Transaction historyHorizon indexes all history
    Submit paymentsBoth can submit; RPC is faster
    Invoke smart contractsRPC has simulation support
    Query contract stateRPC reads ledger entries directly
    Stream transactionsHorizon has SSE streaming
    Get fee estimatesRPC has getFeeStats
    Build explorersHorizon has complete history
    Build walletsUse both for full functionality

    Practical Code Examples

    Example 1: Getting Account Balance

    Using Horizon (REST)

    // horizon-client.ts
    const HORIZON_URL = 'https://api.lumenquery.io';
    
    async function getAccountBalances(accountId: string) {
      const response = await fetch(
        `${HORIZON_URL}/accounts/${accountId}`,
        {
          headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! }
        }
      );
    
      if (!response.ok) {
        if (response.status === 404) {
          return { balances: [], exists: false };
        }
        throw new Error(`Horizon error: ${response.status}`);
      }
    
      const account = await response.json();
      return {
        balances: account.balances.map((b: any) => ({
          asset: b.asset_type === 'native' ? 'XLM' : `${b.asset_code}:${b.asset_issuer}`,
          balance: b.balance,
        })),
        exists: true,
        sequence: account.sequence,
        thresholds: account.thresholds,
      };
    }

    Using Stellar RPC (JSON-RPC)

    // stellar-rpc-client.ts
    import { Address, xdr } from '@stellar/stellar-sdk';
    
    const STELLAR_RPC_URL = 'https://rpc.lumenquery.io';
    
    async function getAccountBalancesRpc(accountId: string) {
      // Create the account ledger key
      const accountKey = xdr.LedgerKey.account(
        new xdr.LedgerKeyAccount({
          accountId: Address.fromString(accountId).toScAddress().accountId(),
        })
      ).toXDR('base64');
    
      const response = await fetch(STELLAR_RPC_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': process.env.LUMENQUERY_API_KEY!,
        },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: 1,
          method: 'getLedgerEntries',
          params: { keys: [accountKey] },
        }),
      });
    
      const { result } = await response.json();
    
      if (!result.entries || result.entries.length === 0) {
        return { balances: [], exists: false };
      }
    
      // Parse the XDR response
      const entry = xdr.LedgerEntryData.fromXDR(
        result.entries[0].xdr,
        'base64'
      );
    
      const account = entry.account();
      return {
        balances: [{
          asset: 'XLM',
          balance: (BigInt(account.balance().toString()) / 10000000n).toString()
        }],
        exists: true,
      };
    }

    Verdict: Horizon is easier for account data—it returns JSON directly. Use RPC when you need raw ledger state or are already in a Soroban context.

    Example 2: Transaction History

    Using Horizon (the only option)

    interface Transaction {
      id: string;
      hash: string;
      created_at: string;
      source_account: string;
      fee_charged: string;
      operation_count: number;
      successful: boolean;
      memo?: string;
    }
    
    async function getTransactionHistory(
      accountId: string,
      options: { limit?: number; cursor?: string } = {}
    ): Promise<{ transactions: Transaction[]; nextCursor: string | null }> {
      const limit = options.limit || 20;
      const cursor = options.cursor || '';
    
      const url = new URL(`${HORIZON_URL}/accounts/${accountId}/transactions`);
      url.searchParams.set('limit', limit.toString());
      url.searchParams.set('order', 'desc');
      if (cursor) url.searchParams.set('cursor', cursor);
    
      const response = await fetch(url.toString(), {
        headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! }
      });
    
      const data = await response.json();
      const records = data._embedded?.records || [];
    
      return {
        transactions: records.map((tx: any) => ({
          id: tx.id,
          hash: tx.hash,
          created_at: tx.created_at,
          source_account: tx.source_account,
          fee_charged: tx.fee_charged,
          operation_count: tx.operation_count,
          successful: tx.successful,
          memo: tx.memo,
        })),
        nextCursor: records.length === limit ? records[records.length - 1].paging_token : null,
      };
    }
    
    // Usage with pagination
    async function getAllTransactions(accountId: string) {
      const allTransactions: Transaction[] = [];
      let cursor: string | null = null;
    
      do {
        const result = await getTransactionHistory(accountId, {
          limit: 200,
          cursor: cursor || undefined
        });
        allTransactions.push(...result.transactions);
        cursor = result.nextCursor;
      } while (cursor);
    
      return allTransactions;
    }

    Verdict: Stellar RPC doesn't index transaction history—use Horizon for any historical queries.

    Example 3: Submitting a Payment

    Using Horizon

    import { Keypair, Networks, Operation, TransactionBuilder } from '@stellar/stellar-sdk';
    
    async function submitPaymentViaHorizon(
      sourceKeypair: Keypair,
      destination: string,
      amount: string
    ) {
      // Load account to get sequence number
      const accountResponse = await fetch(
        `${HORIZON_URL}/accounts/${sourceKeypair.publicKey()}`,
        { headers: { 'X-API-Key': process.env.LUMENQUERY_API_KEY! } }
      );
      const sourceAccount = await accountResponse.json();
    
      // Build transaction
      const transaction = new TransactionBuilder(
        {
          accountId: () => sourceAccount.id,
          sequenceNumber: () => sourceAccount.sequence,
          incrementSequenceNumber: () => {},
        },
        {
          fee: '100',
          networkPassphrase: Networks.PUBLIC,
        }
      )
        .addOperation(
          Operation.payment({
            destination,
            asset: Asset.native(),
            amount,
          })
        )
        .setTimeout(30)
        .build();
    
      transaction.sign(sourceKeypair);
    
      // Submit via Horizon
      const submitResponse = await fetch(`${HORIZON_URL}/transactions`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-API-Key': process.env.LUMENQUERY_API_KEY!,
        },
        body: `tx=${encodeURIComponent(transaction.toXDR())}`,
      });
    
      return submitResponse.json();
    }

    Using Stellar RPC

    async function submitPaymentViaRpc(signedXdr: string) {
      const response = await fetch(STELLAR_RPC_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': process.env.LUMENQUERY_API_KEY!,
        },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: 1,
          method: 'sendTransaction',
          params: { transaction: signedXdr },
        }),
      });
    
      const { result } = await response.json();
      return result;
    }
    
    // Poll for completion
    async function waitForTransaction(hash: string, timeoutMs = 30000) {
      const start = Date.now();
    
      while (Date.now() - start < timeoutMs) {
        const response = await fetch(STELLAR_RPC_URL, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': process.env.LUMENQUERY_API_KEY!,
          },
          body: JSON.stringify({
            jsonrpc: '2.0',
            id: 1,
            method: 'getTransaction',
            params: { hash },
          }),
        });
    
        const { result } = await response.json();
    
        if (result.status === 'SUCCESS') return { success: true, result };
        if (result.status === 'FAILED') return { success: false, result };
    
        await new Promise(r => setTimeout(r, 1000));
      }
    
      throw new Error('Transaction timeout');
    }

    Verdict: Both work. RPC is slightly faster for submission and better for Soroban transactions. Horizon gives richer response data.

    Example 4: Invoking a Smart Contract

    Using Stellar RPC (the only option)

    import { Contract, Address, nativeToScVal, Networks } from '@stellar/stellar-sdk';
    
    async function invokeContract(
      contractId: string,
      method: string,
      args: any[],
      sourceKeypair: Keypair
    ) {
      const contract = new Contract(contractId);
    
      // Build the invocation
      const operation = contract.call(
        method,
        ...args.map(arg => nativeToScVal(arg))
      );
    
      // Get account for sequence
      const accountResponse = await fetch(STELLAR_RPC_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: 1,
          method: 'getLedgerEntries',
          params: { keys: [createAccountKey(sourceKeypair.publicKey())] },
        }),
      });
    
      // Build transaction
      const transaction = new TransactionBuilder(sourceAccount, {
        fee: '1000000',
        networkPassphrase: Networks.PUBLIC,
      })
        .addOperation(operation)
        .setTimeout(30)
        .build();
    
      // Simulate first
      const simResponse = await fetch(STELLAR_RPC_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: 1,
          method: 'simulateTransaction',
          params: { transaction: transaction.toXDR() },
        }),
      });
    
      const { result: simulation } = await simResponse.json();
    
      if (simulation.error) {
        throw new Error(`Simulation failed: ${simulation.error}`);
      }
    
      // Prepare transaction with simulation results
      const preparedTx = SorobanRpc.assembleTransaction(
        transaction,
        simulation
      );
    
      preparedTx.sign(sourceKeypair);
    
      // Submit
      const submitResponse = await fetch(STELLAR_RPC_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: 1,
          method: 'sendTransaction',
          params: { transaction: preparedTx.toXDR() },
        }),
      });
    
      return submitResponse.json();
    }

    Verdict: Smart contracts require Stellar RPC for simulation. Horizon can index the results after the fact.

    Example 5: Real-Time Streaming

    Using Horizon SSE

    function streamTransactions(
      accountId: string,
      onTransaction: (tx: Transaction) => void,
      onError: (err: Error) => void
    ) {
      const url = `${HORIZON_URL}/accounts/${accountId}/transactions?cursor=now`;
    
      const eventSource = new EventSource(url);
    
      eventSource.onmessage = (event) => {
        const tx = JSON.parse(event.data);
        onTransaction(tx);
      };
    
      eventSource.onerror = (error) => {
        onError(new Error('Stream connection error'));
      };
    
      return () => eventSource.close();
    }
    
    // Usage
    const stopStreaming = streamTransactions(
      'GABC...XYZ',
      (tx) => console.log('New transaction:', tx.hash),
      (err) => console.error('Stream error:', err)
    );
    
    // Later: stopStreaming();

    Verdict: Horizon is the only option for streaming. Stellar RPC is pull-based.

    Architecture Patterns

    Pattern 1: Wallet Application

    ┌─────────────────────────────────────────┐
    │           Wallet Frontend               │
    ├─────────────────────────────────────────┤
    │                                         │
    │  ┌─────────────┐    ┌───────────────┐   │
    │  │   Horizon   │    │  Stellar RPC  │   │
    │  └──────┬──────┘    └───────┬───────┘   │
    │         │                   │           │
    │         ▼                   ▼           │
    │  • Account info       • Simulate tx     │
    │  • Tx history         • Submit tx       │
    │  • Stream updates     • Contract calls  │
    │  • Asset metadata     • Fee estimates   │
    │                                         │
    └─────────────────────────────────────────┘

    Pattern 2: DeFi Application

    ┌─────────────────────────────────────────┐
    │           DeFi Protocol UI              │
    ├─────────────────────────────────────────┤
    │                                         │
    │  Stellar RPC (Primary)                  │
    │  • Contract state queries               │
    │  • Transaction simulation               │
    │  • Swap execution                       │
    │  • LP position management               │
    │                                         │
    │  Horizon (Secondary)                    │
    │  • Trade history                        │
    │  • Volume analytics                     │
    │  • Price charts (historical)            │
    │                                         │
    └─────────────────────────────────────────┘

    Pattern 3: Block Explorer

    ┌─────────────────────────────────────────┐
    │           Block Explorer                │
    ├─────────────────────────────────────────┤
    │                                         │
    │  Horizon (Primary)                      │
    │  • All historical data                  │
    │  • Transaction search                   │
    │  • Account lookup                       │
    │  • Operations & effects                 │
    │  • Asset issuance history               │
    │                                         │
    │  Stellar RPC (Secondary)                │
    │  • Contract code viewer                 │
    │  • Contract state inspector             │
    │  • Real-time ledger info                │
    │                                         │
    └─────────────────────────────────────────┘

    Performance Considerations

    MetricHorizonStellar RPC
    Cold query latency50-200ms20-50ms
    State query depthFull historyCurrent + recent
    Storage requirements1TB+~50GB
    Memory usage8-16GB2-4GB
    Concurrent connections~1000~5000

    Conclusion

    Choose your data access layer based on your use case:

  • Building a wallet? Use both—Horizon for history/streaming, RPC for transactions
  • Building DeFi? Primarily Stellar RPC, with Horizon for analytics
  • Building an explorer? Primarily Horizon, with RPC for contract inspection
  • Building monitoring tools? Stellar RPC for real-time, Horizon for historical metrics
  • The best applications use both services strategically, leveraging each for its strengths.


    *Need both Horizon and Stellar RPC with a single API key? LumenQuery provides unified access to the complete Stellar infrastructure stack.*