Resolver API
api.arcns.io, the read-only ArcNS resolver API. Every route with its real response fields, the status contract (404 is proof of absence, 502 is an outage), caching, CORS, rate limits, and how to run your own instance.
Testnethttps://api.arcns.io is a read-only resolver for @handle, .arc and .circle names. It holds no keys and no database: every data route reads Arc testnet over JSON-RPC at one pinned block and returns what the contracts say. It exists for callers that cannot run an EVM client in-process (a recipient field in a wallet, an explorer row, a bot). The SDK never depends on it.
Resolution truth comes from chain reads. The REST API is the canonical HTTP contract for those reads: a 404 is returned only after a successful chain read proves absence; an RPC failure is 502, never 404. "Not registered" and "cannot tell right now" are different answers and are never merged. An indexer or subgraph is a speed layer in front of this contract, never the source of truth.
Routes
| Route | Answer |
|---|---|
GET /health | {"ok":true,"service":"api","version":"…"}; decided without any RPC call, exempt from rate limiting, cache-control: no-store |
GET /version | build, chain id, RPC host and the current chain head |
GET /resolve/:name | controller, address record and metadata for @name, name.arc or name.circle |
GET /reverse/:address | the one primary name of an address, forward-confirmed |
GET /nft/:name | ERC-721 metadata JSON (name, description, inline SVG image, attributes) for a registered name |
Only GET, HEAD and OPTIONS are accepted (405 otherwise). @ must be URL-encoded as %40 in a path: /resolve/%40nike. A path segment longer than 256 characters or with malformed percent-encoding is 400. Any other path is 404 {"error":"not found","input":"/…"}.
Try it: what the API guarantees today
These are real requests against the ArcNS resolver, not invented examples — see the note in each tab. The full status contract and every field are on this page above; SDK usage and error codes are on the Quickstart.
curl -s https://api.arcns.io/health{ "ok": true, "service": "api", "version": "0.1.0"}
Status contract
| Status | Meaning | Cached |
|---|---|---|
200 | found | yes |
400 | invalid name or address shape; error names the rule (HyphenAtEdge, NonAscii, …), or invalid address, input too long, malformed percent-encoding | no |
404 | not registered, no primary set, primary stale, subname has no token; returned only after a successful chain read proving absence | yes |
405 | method other than GET, HEAD, OPTIONS | no |
409 | ambiguous: both a handle and a domain shape (@nike.arc, decided with no chain read) or conflict: registries disagree with every candidates entry | yes |
410 | tld sunset: the TLD is retired, or its sunset date has passed by chain time; body carries tld, sunsetAt, migrationTarget | yes |
429 | rate limited; retry-after: 1 | no |
500 | internal error: an unexpected failure inside a route | no |
502 | the RPC could not be read, a record could not be decoded, or the chain head is older than 30 seconds | never |
503 | server busy: more than 128 requests are being handled at once; retry | no |
Every error body is {"error": "<message>", "input": "<what you sent>", …} with route-specific extras. A 502 is never cached and must never be treated as "free". A 404 is the only answer that means absence.
GET /resolve/:name
A @handle, name.arc or name.circle. @ must be URL-encoded as %40. A mixed shape such as @name.arc is refused as 409 ambiguous before any chain read.
@handle (200):
{
"input": "@nike",
"name": "@nike",
"namespace": "handle",
"registry": "0x…",
"resolver": "0x…",
"chainId": 5042002,
"blockNumber": 60812722,
"epoch": 3,
"owner": "0x…",
"addr": { "coinType": 2152525650, "address": "0x…", "verified": true },
"tokenized": true,
"locked": false,
"handleType": "Human",
"expires": null
}
.arc / .circle (200):
{
"input": "nike.arc",
"name": "nike.arc",
"namespace": "arc",
"chainId": 5042002,
"blockNumber": 60812722,
"source": "arcns",
"registry": "0x…",
"ensRegistry": "0x…",
"resolver": "0x…",
"owner": "0x…",
"addr": { "coinType": 2152525650, "address": "0x…", "verified": true },
"epoch": 3,
"expires": null
}
Field notes:
namespaceishandle,arcorcircle.registryis the contract that holds the name: theHandleRegistryfor a handle, the TLD's registrar for a TLD name.sourcelabels which product's registry answered (arcns, or the pinned peerkhenzarr/arcns).owneris the controller (ownerOf/ registryowner(node)). It is never used as a payment address.addris the only payment address and isnullfor a name whose owner has not set a record.addris the Arc record (ENSIP-11 coin type2152525650), falling back to the coin-type-60 record;verifiedis true only when the owner proved control of that address on chain. See Verified records. A record from the peer registry is always the coin-type-60 record withverified: false.epochis the name's ownership epoch; records from an older epoch are never returned. It isnullwhen the name's resolver is not the ArcNS resolver.expiresisnullfor a permanent registration.handleTypeisHuman,Merchant,OrgorAgent.tokenizedandlockedare handle flags.sunset: { "sunsetAt": … }is present while a TLD is inside its sunset window and still resolving.candidatesis present on a200when more than one pinned registry holds the name and they agree; each candidate carries the full per-registry answer above.
Not registered (404): the body proves what was consulted.
{
"error": "name not registered",
"input": "zzqx7k2mfresh.arc",
"name": "zzqx7k2mfresh.arc",
"namespace": "arc",
"chainId": 5042002,
"blockNumber": 60812722,
"registriesConsulted": [
{ "source": "arcns", "registry": "0x…" },
{ "source": "khenzarr/arcns", "registry": "0x…" }
]
}
Registries disagree (409): every registered candidate, nothing chosen.
{
"error": "conflict: registries disagree",
"input": "alice.arc",
"name": "alice.arc",
"namespace": "arc",
"chainId": 5042002,
"blockNumber": 60812722,
"candidates": [ { "source": "arcns", "registry": "0x…", "owner": "0x…", "addr": { … }, … }, { "source": "khenzarr/arcns", … } ]
}
Render every candidate with its source and registry and let the user choose. Never auto-select.
GET /reverse/:address
A 20-byte hex address. Anything else is 400 invalid address.
{
"input": "0xce42…54AC",
"address": "0xce42…54AC",
"name": "alice.arc",
"namespace": "arc",
"registry": "0x…",
"chainId": 5042002,
"blockNumber": 60812722,
"epoch": 3,
"verified": true
}
There is one primary per address across every namespace. It is returned only after forward confirmation: @x requires ownerOf(tokenId) == address; x.arc / x.circle requires the name's Arc record (then coin type 60) to equal the address in our registry. Otherwise: 404 no primary set, 404 primary is stale (forward record does not match) (with the stale primary in the body), or 404 primary is not a known name shape. 400 invalid address for anything that is not a 20-byte hex address. verified on this route is always true because the answer exists only when the forward check passed.
GET /nft/:name
A registered @handle or first-level .arc / .circle name. A subname answers 404 subnames have no token.
ERC-721 metadata for a registered @handle or first-level .arc / .circle name: name, description, image (a self-contained data:image/svg+xml;base64,… card), external_url, and attributes (Namespace, Handle Type for handles, Tokenized, Rarity, Bucket, Length, Registry). The body also carries the provenance fields every answer has: chainId, blockNumber, registry, namespace, input. A subname answers 404 subnames have no token.
GET /version
{
"service": "api",
"version": "…",
"chainId": 5042002,
"rpc": "rpc.testnet.arc.io",
"head": { "number": 60812722, "timestamp": 1757200000, "ageSeconds": 2 }
}
rpc is the host only, never the path or query where provider credentials would live. This route reads the head, so it answers 502 when the RPC is down or stale, which makes it a truthful probe; /health is process liveness only.
Caching
- Cacheable statuses (
200,404,409,410) are served withcache-control: public, max-age=5. Everything else isno-store. - The server keeps its own response cache for at most 5 seconds, keyed on the path and the chain head block number, so an answer can never outlive the block it was read at; an ownership change is a new block and therefore a new key.
- The head itself is re-read at most every 2 seconds and every route reads at that pinned block. A head older than 30 seconds is
502. 502is never cached, by construction: the head is read before the cache is consulted, and a head failure never reaches it.
CORS
access-control-allow-origin: * on every response; OPTIONS answers 204 with access-control-allow-methods: GET, OPTIONS. There is no write path and no key for a cross-origin caller to reach.
Rate limits
A per-client token bucket: 20 requests per second sustained, burst 60. A limited request answers 429 {"error":"rate limited"} with retry-after: 1. The client is the socket address, or cf-connecting-ip when the instance runs behind the tunnel with API_TRUST_PROXY=1. Above 128 requests in flight the instance answers 503 server busy immediately rather than queueing. Bulk consumers should read the chain directly with the SDK or run their own instance.
Run your own instance
The API is the api/ directory of fortiblox/fortiblox-arcns (MIT). It is a Node 24 service with a single runtime dependency (viem), built into a node:24-bookworm-slim image that runs as an unprivileged user and health-checks itself with curl http://127.0.0.1:8812/health.
git clone https://forgejo.selfhsted.com/fortiblox/fortiblox-arcns
cd fortiblox-arcns/api
npm ci && npm run build && npm start # or: docker compose -f ../api/compose.yml up
Configuration is environment only. Every problem is reported in one message at boot, and the service binds its socket only after eth_chainId matches ARC_CHAIN_ID, so a misconfigured instance never looks healthy.
| Variable | Default | Meaning |
|---|---|---|
ARC_CHAIN_ID | required | 5042002 for Arc testnet |
ARC_RPC_URL | required | http(s) JSON-RPC endpoint, e.g. https://rpc.testnet.arc.io |
TLDS | arc,circle | comma-separated TLD labels to serve |
API_BIND | 0.0.0.0:8812 | listen address |
API_TRUST_PROXY | 0 | 1 to key the rate limiter on cf-connecting-ip |
RESERVED_NAMES_PATH | /srv/reserved-names.json | sanitized reserved index used by /nft for rarity buckets; absent file is a warning, not a failure |
HANDLE_REGISTRY_ADDRESS, HANDLE_CONTROLLER_ADDRESS, ENS_REGISTRY_ADDRESS, ARCNS_RESOLVER_ADDRESS, REVERSE_REGISTRAR_ADDRESS, TLD_DIRECTORY_ADDRESS, PRICE_ORACLE_ADDRESS, TREASURY_ADDRESS | required, no default | the ArcNS contract set |
TLD_<TLD>_BASE_REGISTRAR_ADDRESS, TLD_<TLD>_CONTROLLER_ADDRESS | required per TLD in TLDS | e.g. TLD_ARC_BASE_REGISTRAR_ADDRESS, TLD_CIRCLE_CONTROLLER_ADDRESS |
COMPETITOR_REGISTRIES | the pinned table | optional JSON array overriding the pinned peer-registry table; entries for another chain id are ignored |
Contract addresses have no defaults: a defaulted address is one an attacker can substitute. Take the values from the deployment file described on the Contracts page once it is published.
Questions
Questions: [email protected].

