Connect to a Moonbeam RPC Endpoint and Read Contract State
A practical guide to calling a Moonbeam RPC endpoint from Node.js 18+ or curl, decoding a real eth_call response, handling JSON-RPC errors, and knowing when you need an indexer instead of raw RPC.
The fastest way to confirm a Moonbeam RPC endpoint works is to send a single JSON-RPC request and read the response, not to wire up a full dApp. This guide does exactly that: you will call eth_chainId and eth_call against a Moonbeam endpoint, decode the returned hex into a usable value, and handle the failure modes you will actually hit. It also draws the line between RPC access (asking the chain a question right now) and indexing (querying history across many blocks), because mixing those up is the most common source of confusion when people first connect to Moonbeam.
Step 1: Verify the endpoint answers
Before you build anything, confirm the endpoint you were given actually responds and is on the network you expect. The cheapest check is eth_chainId, which returns the chain ID as a hex string. Moonbeam's chain ID is 128, which is 0x80 in hex. If you get a different value, you are pointed at a different network (Moonriver, Moonbase Alpha, or something else entirely).
You need an endpoint URL from your provider. OnFinality publishes a Moonbeam network page where you can find the RPC details for the network; do not hardcode an endpoint you found in a random tutorial, because public endpoints change and rate limits vary. Keep the URL in an environment variable so you can swap it without editing code.
- eth_chainId is a read-only call and costs nothing to run
- 0x80 = 128 = Moonbeam mainnet
- A timeout or non-JSON response usually means a wrong URL, not a wrong method
# Replace with the endpoint from your provider's Moonbeam page
export MOONBEAM_RPC="https://your-moonbeam-endpoint.example"
curl -sS --max-time 10 -X POST "$MOONBEAM_RPC" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'Expected result: {"jsonrpc":"2.0","id":1,"result":"0x80"}. If result is anything other than 0x80, stop and check which network your endpoint targets before continuing.
Step 2: Read contract state with eth_call
eth_call executes a function against contract state without submitting a transaction, so it returns data immediately and costs no gas. This is the workhorse for reading balances, config values, and token metadata. You need three things: the contract address, the function selector (the first 4 bytes of the keccak256 hash of the signature), and the ABI-encoded arguments.
The example below reads the name() of a token contract, which takes no arguments, so the calldata is just the selector 0x06fdde03. The response is ABI-encoded, so you must decode it rather than printing raw hex. Node.js 18+ has fetch built in, so no dependencies are required for the request itself; the decode step uses a tiny manual parser to keep the example dependency-free, but in real projects use a library like ethers or viem.
- eth_call is a point-in-time read, not a historical query
- The 'to' field is the contract address; 'data' is selector plus encoded args
- Decode the result — raw hex is not a string
// Node.js 18+ (global fetch available)
const RPC = process.env.MOONBEAM_RPC;
if (!RPC) throw new Error('Set MOONBEAM_RPC first');
async function rpc(method, params) {
const res = await fetch(RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (body.error) throw new Error(`RPC error ${body.error.code}: ${body.error.message}`);
return body.result;
}
// Minimal ABI decode for a single dynamic string return value
function decodeString(hex) {
const data = hex.slice(2);
const offset = parseInt(data.slice(0, 64), 16) * 2;
const length = parseInt(data.slice(offset, offset + 64), 16);
const strHex = data.slice(offset + 64, offset + 64 + length * 2);
return Buffer.from(strHex, 'hex').toString('utf8');
}
const TOKEN = '0x0000000000000000000000000000000000000000'; // replace with a real token address
const chainId = await rpc('eth_chainId', []);
if (chainId !== '0x80') throw new Error(`Wrong network: ${chainId}`);
const raw = await rpc('eth_call', [{ to: TOKEN, data: '0x06fdde03' }, 'latest']);
console.log('name():', decodeString(raw));Expected result: a line like name(): Some Token Name, decoded from the hex returned by eth_call. If the contract does not implement name(), you will get a revert or empty data — that is a contract issue, not an endpoint issue.
Step 3: Handle the failures you will actually see
JSON-RPC failures come in three shapes and each needs different handling. A transport failure (DNS, timeout, connection reset) means the endpoint is unreachable — retry with backoff, then fall back to another endpoint. An HTTP error (401, 403, 429) usually means an auth or rate-limit problem with your provider. A JSON-RPC error object (body.error) means the endpoint worked but rejected the request — for example, a malformed address or a revert.
Do not treat all three the same. Retrying a revert is pointless; retrying a 429 immediately makes it worse. The snippet below separates them so your logs tell you which one happened. If you want to compare how different endpoints behave under your own workload, measure it yourself: time N sequential eth_call requests and record p50/p95 latency and error counts per endpoint rather than trusting any published number.
- Transport error → retry with exponential backoff
- HTTP 429 → back off, do not hammer
- body.error → fix the request, retrying will not help
- Measure latency and error rate yourself; do not assume benchmarks
async function rpcWithRetry(method, params, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(process.env.MOONBEAM_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(10_000),
});
if (res.status === 429 || res.status >= 500) {
throw Object.assign(new Error(`HTTP ${res.status}`), { retryable: true });
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (body.error) {
// Request-level error: do not retry
throw new Error(`RPC ${body.error.code}: ${body.error.message}`);
}
return body.result;
} catch (err) {
const retryable = err.retryable || err.name === 'TimeoutError' || err.name === 'TypeError';
if (!retryable || i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, 500 * 2 ** i));
}
}
}Expected result: transient failures are retried and eventually succeed or throw a clear error; reverts and bad requests fail fast with the RPC error message intact.
RPC vs indexing: know which one you need
An RPC endpoint answers questions about current or specific block state. It is the right tool for reading a balance, calling a view function, or submitting a transaction. It is the wrong tool for questions like 'list every transfer this address received last month' — that requires scanning many blocks, which is slow and will hit rate limits.
That is what indexing is for. An indexer ingests blocks and stores queryable records so you can ask historical, filtered questions efficiently. If your task is a single read, stay on RPC. If your task is history or aggregation, use an indexer. If you are unsure which fits, the OnFinality Learn library covers the distinction in more depth, and the RPC Assistant can help you sanity-check a request before you build around it.
- RPC: current state, single calls, transactions
- Indexing: history, filtering, aggregation across blocks
- Do not loop eth_getLogs over huge ranges on a shared endpoint