solana-mainnet exposes six tables. Request each as a data selector in the body (instructions, transactions, balances, tokenBalances, rewards, logs) and pick its columns under fields:
Table
Holds
instructions
Top-level and inner (CPI) program instructions
transactions
Transaction-level data (signatures, fee payer, err, fee, compute units)
balances
Pre/post native SOL balances per account
tokenBalances
Pre/post SPL token balances per account
rewards
Staking and validator rewards credited in the slot
logs
Program log lines
The logs table holds free-text program log lines (kind: "log" messages like Instruction: Swap, plus kind: "data" base58 payloads), not EVM-style topic-keyed events. There is no address/signature topic index, so filter logs by programId and kind, then parse the message text yourself.
fields: { instruction: { programId: true, // Program address accounts: true, // Account addresses data: true, // Instruction data (base58) transactionIndex: true, // Transaction index (use to match with transactions) instructionAddress: true, // Instruction address in call tree isCommitted: true, // Whether the instruction's transaction committed (landed) error: true, // Instruction fault message, null if it did not fault }}
isCommitted and error are independent signals.isCommitted answers “did this instruction land on chain” (its transaction was committed); error answers “did this instruction itself fault”. An instruction can be isCommitted: false with error: null, meaning it was rolled back because its atomic transaction failed elsewhere, not because the instruction faulted. Treating “uncommitted” as “errored” biases analytics, so filter on the field you actually mean.
// Good: 10k-50k slots per query for most use casesconst BATCH_SIZE = 10000;// Adjust based on:// - More filters = larger batches OK// - More fields = smaller batches// - More activity (mainnet) = smaller batches
GET /datasets/{dataset}/timestamps/{timestamp}/block returns the number of the first block whose timestamp is greater than or equal to the given Unix timestamp (in seconds). Use it to turn a wall-clock time into a fromBlock for a stream query.
Resolution prefers archival data and falls back to the real-time source when available; the x-sqd-data-source response header reports which one served the result (network or real_time). The endpoint returns 404 if no block at or after the timestamp exists yet.
A single /stream response is a batch, not the complete range. The server may close the connection at any point: when a worker’s range ends, when a connection is recycled, or at the current dataset head. Clients that treat one HTTP response as the whole query will silently drop slots.To stream a range to completion, loop:
async function streamRange(from: number, to: number | undefined, body: any) { let currentFrom = from; while (to === undefined || currentFrom <= to) { const res = await fetch( "https://portal.sqd.dev/datasets/solana-mainnet/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, fromBlock: currentFrom, toBlock: to }), } ); if (res.status === 204) break; // range is above the dataset head if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const lines = (await res.text()).trim().split("\n").filter(Boolean); if (lines.length === 0) break; let lastBlock = currentFrom - 1; for (const line of lines) { const block = JSON.parse(line); // ... process block ... lastBlock = block.header.number; } currentFrom = lastBlock + 1; }}
Key points:
Read the last block’s header.number, issue the next request with fromBlock = lastNumber + 1.
Keep toBlock, fields, and filters identical across retries.
A 204 No Content response means the range is above the dataset head. For historical queries, you’re done; for open-ended queries, wait and retry.
When a request starts at or near the chain tip, the block you asked for may no longer be on the canonical chain by the time Portal processes the query. To detect this, pass parentBlockHash with each request (the hash of the block immediately before fromBlock):
const body = { type: "solana", fromBlock: lastBlock.number + 1, parentBlockHash: lastBlock.hash, // hash of lastBlock, i.e. parent of fromBlock fields: { block: { number: true, hash: true, parentHash: true } }, instructions: [ /* filters */ ],};
If the chain has reorged away from parentBlockHash, Portal returns HTTP 409 with a list of blocks that are on the current canonical chain:
Find a block in your local state whose (number, hash) matches one of previousBlocks.
Roll back your pipeline to that block.
Resume the stream with fromBlock = matched.number + 1 and parentBlockHash = matched.hash.
Streams opened against /finalized-stream (instead of /stream) never return 409: only finalized blocks are served, so there’s nothing to reorg. Use that endpoint when you don’t need real-time data.