# Resolver API
URL: https://docs.arcns.io/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.

<Pill>Testnet</Pill>

<LlmActions />

`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](/quickstart/) never depends on it.

<Callout title="Rule" type="info">
  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.
</Callout>

## 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

<TryIt />

## 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`

<Endpoint method="GET" path="/resolve/:name" />

<Params>
  <Param name="name" type="string" required>
    A <code>@handle</code>, <code>name.arc</code> or <code>name.circle</code>. <code>@</code> must be URL-encoded as <code>%40</code>. A mixed shape such as <code>@name.arc</code> is refused as <code>409 ambiguous</code> before any chain read.
  </Param>
</Params>

**`@handle` (200):**

```json
{
  "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):**

```json
{
  "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](/verified/). 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.

```json
{
  "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.

```json
{
  "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`

<Endpoint method="GET" path="/reverse/:address" />

<Params>
  <Param name="address" type="string" required>
    A 20-byte hex address. Anything else is <code>400 invalid address</code>.
  </Param>
</Params>

```json
{
  "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`

<Endpoint method="GET" path="/nft/:name" />

<Params>
  <Param name="name" type="string" required>
    A registered <code>@handle</code> or first-level <code>.arc</code> / <code>.circle</code> name. A subname answers <code>404 subnames have no token</code>.
  </Param>
</Params>

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`

<Endpoint method="GET" path="/version" />

```json
{
  "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](https://forgejo.selfhsted.com/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`.

```bash
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](/contracts/) page once it is published.

## Questions

Questions: [support@arcns.io](mailto:support@arcns.io).
