Skip to content
ArcNS docs
DocsResolver API

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.

Testnet

https://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.

Rule

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

RouteAnswer
GET /health{"ok":true,"service":"api","version":"…"}; decided without any RPC call, exempt from rate limiting, cache-control: no-store
GET /versionbuild, chain id, RPC host and the current chain head
GET /resolve/:namecontroller, address record and metadata for @name, name.arc or name.circle
GET /reverse/:addressthe one primary name of an address, forward-confirmed
GET /nft/:nameERC-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·GET /health
curl -s https://api.arcns.io/health
RESPONSE · 200 (live)
{  "ok": true,  "service": "api",  "version": "0.1.0"}
No RPC call — pure liveness. Captured live by running api/ locally (see note above).

Status contract

StatusMeaningCached
200foundyes
400invalid name or address shape; error names the rule (HyphenAtEdge, NonAscii, …), or invalid address, input too long, malformed percent-encodingno
404not registered, no primary set, primary stale, subname has no token; returned only after a successful chain read proving absenceyes
405method other than GET, HEAD, OPTIONSno
409ambiguous: both a handle and a domain shape (@nike.arc, decided with no chain read) or conflict: registries disagree with every candidates entryyes
410tld sunset: the TLD is retired, or its sunset date has passed by chain time; body carries tld, sunsetAt, migrationTargetyes
429rate limited; retry-after: 1no
500internal error: an unexpected failure inside a routeno
502the RPC could not be read, a record could not be decoded, or the chain head is older than 30 secondsnever
503server busy: more than 128 requests are being handled at once; retryno

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

GET/resolve/:name
namestringRequired

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:

  • namespace is handle, arc or circle. registry is the contract that holds the name: the HandleRegistry for a handle, the TLD's registrar for a TLD name. source labels which product's registry answered (arcns, or the pinned peer khenzarr/arcns).
  • owner is the controller (ownerOf / registry owner(node)). It is never used as a payment address. addr is the only payment address and is null for a name whose owner has not set a record.
  • addr is the Arc record (ENSIP-11 coin type 2152525650), falling back to the coin-type-60 record; verified is 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 with verified: false.
  • epoch is the name's ownership epoch; records from an older epoch are never returned. It is null when the name's resolver is not the ArcNS resolver.
  • expires is null for a permanent registration. handleType is Human, Merchant, Org or Agent. tokenized and locked are handle flags.
  • sunset: { "sunsetAt": … } is present while a TLD is inside its sunset window and still resolving.
  • candidates is present on a 200 when 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

GET/reverse/:address
addressstringRequired

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

GET/nft/:name
namestringRequired

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

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 with cache-control: public, max-age=5. Everything else is no-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.
  • 502 is 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.

VariableDefaultMeaning
ARC_CHAIN_IDrequired5042002 for Arc testnet
ARC_RPC_URLrequiredhttp(s) JSON-RPC endpoint, e.g. https://rpc.testnet.arc.io
TLDSarc,circlecomma-separated TLD labels to serve
API_BIND0.0.0.0:8812listen address
API_TRUST_PROXY01 to key the rate limiter on cf-connecting-ip
RESERVED_NAMES_PATH/srv/reserved-names.jsonsanitized 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_ADDRESSrequired, no defaultthe ArcNS contract set
TLD_<TLD>_BASE_REGISTRAR_ADDRESS, TLD_<TLD>_CONTROLLER_ADDRESSrequired per TLD in TLDSe.g. TLD_ARC_BASE_REGISTRAR_ADDRESS, TLD_CIRCLE_CONTROLLER_ADDRESS
COMPETITOR_REGISTRIESthe pinned tableoptional 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].