Reference Implementations
Server-side JavaScript and Python modules for exact Block lookup, list paging, relationship summaries, bounded neighborhoods, and an application route. Every call is a plain HTTP GET against the XNDR Network REST API, so no client package is required; the JavaScript uses the global fetch in Node.js 18+, and the Python uses only the standard library.
Shared Client Setup
Define one request helper and reuse the helper across modules. The base URL comes from XNDR_API_BASE_URL and an optional operator-issued key from XNDR_API_KEY.
The helper:
- Sends the key as
X-XNDR-API-Key. - Applies
scope=defaultunless a request sets another scope. - Throws an
Errorcarryingstatus,code, andretryAfterfrom the response.
- JavaScript / TypeScript
- Python
// Trailing slashes are stripped so a path join never produces a double slash.
const BASE_URL = (process.env.XNDR_API_BASE_URL ?? 'https://xndr.network/api/v1').replace(/\/+$/, '');
const API_KEY = process.env.XNDR_API_KEY;
export async function xndrGet(path, params = {}) {
const url = new URL(`${BASE_URL}${path}`);
url.searchParams.set('scope', 'default');
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
const headers = { Accept: 'application/json' };
if (API_KEY) headers['X-XNDR-API-Key'] = API_KEY;
const response = await fetch(url, { headers });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// Carrying code and Retry-After on the Error lets callers branch without reading the body again.
const error = new Error(body.error?.message ?? `XNDR Network request failed with HTTP ${response.status}`);
error.status = response.status;
error.code = body.error?.code ?? 'unknown';
error.retryAfter = Number(response.headers.get('Retry-After')) || null;
throw error;
}
return body;
}
export function hasStatus(error, status) {
return Boolean(error && typeof error === 'object' && 'status' in error && error.status === status);
}
import json
import os
import urllib.error
import urllib.parse
import urllib.request
# Trailing slashes are stripped so a path join never produces a double slash.
BASE_URL = os.environ.get("XNDR_API_BASE_URL", "https://xndr.network/api/v1").rstrip("/")
API_KEY = os.environ.get("XNDR_API_KEY")
class XndrError(Exception):
def __init__(self, status, code, message, retry_after=None):
super().__init__(message)
self.status = status
self.code = code
self.retry_after = retry_after
def xndr_get(path, params=None):
query = {"scope": "default"}
query.update({key: str(value) for key, value in (params or {}).items() if value is not None})
url = f"{BASE_URL}{path}?{urllib.parse.urlencode(query)}"
headers = {"Accept": "application/json"}
if API_KEY:
headers["X-XNDR-API-Key"] = API_KEY
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.load(response)
# urllib raises on 4xx and 5xx, so the error body is read from the exception.
except urllib.error.HTTPError as http_error:
try:
detail = json.load(http_error).get("error", {})
except ValueError:
detail = {}
retry_after = http_error.headers.get("Retry-After")
raise XndrError(
http_error.code,
detail.get("code", "unknown"),
detail.get("message", http_error.reason),
int(retry_after) if retry_after else None,
) from None
def has_status(error, status):
return getattr(error, "status", None) == status
Exact Block Lookup
Validate the identifier before the lookup, and keep the returned blockHash as the stable link target.
- JavaScript / TypeScript
- Python
import { xndrGet, hasStatus } from './client.js';
// The API accepts only a full 0x-prefixed, 64-character hexadecimal hash.
const HASH_PATTERN = /^0x[0-9a-f]{64}$/;
export async function lookupBlock(blockHash) {
const normalized = String(blockHash).trim().toLowerCase();
if (!HASH_PATTERN.test(normalized)) {
return { status: 'invalid', message: 'Expected a full 0x-prefixed 64-hex Block hash.' };
}
try {
const { data, scope } = await xndrGet(`/blocks/${normalized}`);
return {
status: 'found',
block: data.block,
verification: data.verification,
scopeId: scope.id,
indexRevision: scope.indexRevision,
permalink: `/blocks/${data.block.blockHash}`,
};
} catch (error) {
if (hasStatus(error, 404)) {
return { status: 'not-observed', message: 'No match was found in the selected scope.' };
}
throw error;
}
}
import re
from client import has_status, xndr_get
# The API accepts only a full 0x-prefixed, 64-character hexadecimal hash.
HASH_PATTERN = re.compile(r"^0x[0-9a-f]{64}$")
def lookup_block(block_hash):
normalized = str(block_hash).strip().lower()
if not HASH_PATTERN.match(normalized):
return {"status": "invalid", "message": "Expected a full 0x-prefixed 64-hex Block hash."}
try:
envelope = xndr_get(f"/blocks/{normalized}")
except Exception as error:
if has_status(error, 404):
return {"status": "not-observed", "message": "No match was found in the selected scope."}
raise
data = envelope["data"]
return {
"status": "found",
"block": data["block"],
"verification": data["verification"],
"scope_id": envelope["scope"]["id"],
"index_revision": envelope["scope"]["indexRevision"],
"permalink": f"/blocks/{data['block']['blockHash']}",
}
Paging
One pager serves every list route. The pager pins atRevision so every page comes from one snapshot, waits out 429, and restarts from the first page at the current revision when a 409, or a 400 on a cursor, reports that the revision moved. The rules the pager applies are under Pagination and Snapshots.
- JavaScript / TypeScript
- Python
import { xndrGet, hasStatus } from './client.js';
const wait = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
// maxPages caps the walk so one call cannot page an unbounded list.
export async function pageAll(path, params = {}, { atRevision, maxPages = 20, maxRestarts = 3 } = {}) {
let revision = atRevision ?? (await xndrGet('/status')).scope.indexRevision;
let items = [];
let cursor;
let pages = 0;
let page;
let restarts = 0;
while (true) {
try {
const envelope = await xndrGet(path, { ...params, atRevision: revision, cursor });
items.push(...envelope.data);
page = envelope.page;
cursor = page.nextCursor;
pages += 1;
if (!cursor || pages >= maxPages) break;
} catch (error) {
if (hasStatus(error, 429)) {
await wait(error.retryAfter ?? 60);
} else if ((hasStatus(error, 409) || (hasStatus(error, 400) && cursor)) && restarts < maxRestarts) {
// The revision moved; the cursor is void, so the walk starts over at the current revision.
restarts += 1;
revision = (await xndrGet('/status')).scope.indexRevision;
items = [];
cursor = undefined;
pages = 0;
} else {
throw error;
}
}
}
return { items, atRevision: revision, truncated: page.truncated, nextCursor: cursor ?? null };
}
import time
from client import has_status, xndr_get
# max_pages caps the walk so one call cannot page an unbounded list.
def page_all(path, params=None, at_revision=None, max_pages=20, max_restarts=3):
revision = at_revision or xndr_get("/status")["scope"]["indexRevision"]
items = []
cursor = None
pages = 0
page = None
restarts = 0
while True:
try:
envelope = xndr_get(path, {**(params or {}), "atRevision": revision, "cursor": cursor})
items.extend(envelope["data"])
page = envelope["page"]
cursor = page["nextCursor"]
pages += 1
if not cursor or pages >= max_pages:
break
except Exception as error:
if has_status(error, 429):
time.sleep(getattr(error, "retry_after", None) or 60)
elif (has_status(error, 409) or (has_status(error, 400) and cursor)) and restarts < max_restarts:
# The revision moved; the cursor is void, so the walk starts over at the current revision.
restarts += 1
revision = xndr_get("/status")["scope"]["indexRevision"]
items = []
cursor = None
pages = 0
else:
raise
return {"items": items, "at_revision": revision, "truncated": page["truncated"], "next_cursor": cursor}
Relationship Summary
Retrieve the Block, then page the Block's declared parents and indexed children on the snapshot the Block view came from.
- JavaScript / TypeScript
- Python
import { xndrGet } from './client.js';
import { pageAll } from './paging.js';
export async function getRelationshipSummary(blockHash) {
const focus = await xndrGet(`/blocks/${blockHash}`);
const options = { atRevision: focus.scope.indexRevision, maxPages: 5 };
const [parents, children] = await Promise.all([
pageAll(`/blocks/${blockHash}/parents`, { limit: 5 }, options),
pageAll(`/blocks/${blockHash}/children`, { limit: 5 }, options),
]);
return {
block: focus.data.block,
declaredParents: focus.data.block.parentHashes,
parents: parents.items,
unavailableParents: parents.items.filter((parent) => !parent.available).map((parent) => parent.blockHash),
children: children.items,
moreChildren: Boolean(children.nextCursor),
indexedChildCount: focus.data.indexedChildCount,
scopeId: focus.scope.id,
indexRevision: focus.scope.indexRevision,
};
}
from client import xndr_get
from paging import page_all
def get_relationship_summary(block_hash):
focus = xndr_get(f"/blocks/{block_hash}")
revision = focus["scope"]["indexRevision"]
parents = page_all(f"/blocks/{block_hash}/parents", {"limit": 5}, at_revision=revision, max_pages=5)
children = page_all(f"/blocks/{block_hash}/children", {"limit": 5}, at_revision=revision, max_pages=5)
block = focus["data"]["block"]
return {
"block": block,
"declared_parents": block["parentHashes"],
"parents": parents["items"],
"unavailable_parents": [item["blockHash"] for item in parents["items"] if not item["available"]],
"children": children["items"],
"more_children": bool(children["next_cursor"]),
"indexed_child_count": focus["data"]["indexedChildCount"],
"scope_id": focus["scope"]["id"],
"index_revision": revision,
}
Present Block Context
Keep canonical identifiers and source-scoped observation context visible together. Pass the data and scope members of a /blocks/ envelope.
React Example
export function BlockSummary({ data, scope }) {
const { block, firstObservedAt, verification } = data;
return (
<article>
<h2>Block <code>{block.blockHash}</code></h2>
<dl>
<dt>Content hash</dt>
<dd><code>{block.contentHash}</code></dd>
<dt>Declared parents</dt>
<dd>{block.parentHashes.length > 0 ? block.parentHashes.join(', ') : 'None declared'}</dd>
<dt>Index scope</dt>
<dd>{scope.id} at revision {scope.indexRevision}</dd>
<dt>First observed in this scope</dt>
<dd>{firstObservedAt ?? 'Not reported'}</dd>
<dt>Lineage check</dt>
<dd>{verification.lineage}</dd>
</dl>
</article>
);
}
Bounded Neighborhood
Request a bounded parent or child neighborhood instead of walking relationships one hop at a time. Neighborhood parameters and caps are in the neighborhood table.
- JavaScript / TypeScript
- Python
import { xndrGet } from './client.js';
// Server ceilings. Clamping here turns a would-be 400 into a valid request.
const MAX_DEPTH = 8;
const MAX_NODES = 10000;
export async function getNeighborhood(blockHash, options = {}) {
const { direction = 'both', depth = 1, maxNodes = 50, status } = options;
const boundedDepth = Math.min(Math.max(Math.trunc(depth), 0), MAX_DEPTH);
const boundedNodes = Math.min(Math.max(Math.trunc(maxNodes), 1), MAX_NODES);
const { data, scope } = await xndrGet(`/blocks/${blockHash}/neighborhood`, {
direction,
depth: boundedDepth,
maxNodes: boundedNodes,
status,
});
return {
rootHash: data.rootHash,
nodes: data.nodes,
edgeCount: data.edges.length,
budgetTruncated: data.budgetTruncated,
unavailable: data.nodes.filter((node) => !node.available).map((node) => node.blockHash),
scopeId: scope.id,
};
}
from client import xndr_get
# Server ceilings. Clamping here turns a would-be 400 into a valid request.
MAX_DEPTH = 8
MAX_NODES = 10000
def get_neighborhood(block_hash, direction="both", depth=1, max_nodes=50, status=None):
bounded_depth = min(max(int(depth), 0), MAX_DEPTH)
bounded_nodes = min(max(int(max_nodes), 1), MAX_NODES)
envelope = xndr_get(
f"/blocks/{block_hash}/neighborhood",
{"direction": direction, "depth": bounded_depth, "maxNodes": bounded_nodes, "status": status},
)
data = envelope["data"]
return {
"root_hash": data["rootHash"],
"nodes": data["nodes"],
"edge_count": len(data["edges"]),
"budget_truncated": data["budgetTruncated"],
"unavailable": [node["blockHash"] for node in data["nodes"] if not node["available"]],
"scope_id": envelope["scope"]["id"],
}
Treat budgetTruncated: true as a signal to narrow direction, lower depth, or apply a filter.
Server-Side HTTP Route
Keep REST API credentials on the server and translate expected upstream responses into application-level responses.
- JavaScript / TypeScript
- Python
import { xndrGet, hasStatus } from './client.js';
const HASH_PATTERN = /^0x[0-9a-f]{64}$/;
export async function GET(request) {
const hash = new URL(request.url).searchParams.get('hash')?.toLowerCase();
if (!hash || !HASH_PATTERN.test(hash)) {
return Response.json({ error: 'A full 0x-prefixed Block hash is required' }, { status: 400 });
}
try {
const { data, scope } = await xndrGet(`/blocks/${hash}`);
return Response.json({
block: data.block,
verification: data.verification,
scope: { id: scope.id, indexRevision: scope.indexRevision },
});
} catch (error) {
if (hasStatus(error, 404)) {
return Response.json({ error: 'No match in the selected scope' }, { status: 404 });
}
if (hasStatus(error, 429)) {
return Response.json(
{ error: 'Rate limit reached' },
{ status: 429, headers: { 'Retry-After': String(error.retryAfter ?? 60) } },
);
}
return Response.json({ error: 'XNDR Network request failed' }, { status: 502 });
}
}
import re
from fastapi import FastAPI, HTTPException, Query
from client import has_status, xndr_get
app = FastAPI()
HASH_PATTERN = re.compile(r"^0x[0-9a-f]{64}$")
@app.get("/api/block")
def get_block(block_hash: str = Query(alias="hash", min_length=66, max_length=66)):
normalized = block_hash.lower()
if not HASH_PATTERN.match(normalized):
raise HTTPException(400, "A full 0x-prefixed Block hash is required")
try:
envelope = xndr_get(f"/blocks/{normalized}")
except Exception as error:
if has_status(error, 404):
raise HTTPException(404, "No match in the selected scope")
if has_status(error, 429):
raise HTTPException(429, "Rate limit reached", headers={"Retry-After": str(error.retry_after or 60)})
raise HTTPException(502, "XNDR Network request failed")
return {
"block": envelope["data"]["block"],
"verification": envelope["data"]["verification"],
"scope": {"id": envelope["scope"]["id"], "indexRevision": envelope["scope"]["indexRevision"]},
}
Implementation Guidelines
Validate behavior against the OpenAPI description served at /api/v1/openapi.yaml. Scope, availability, and completeness are defined in the API Overview.