Skip to content
Arc Testnet — names have no monetary value yet
ArcNS / Resolver API

Resolver API

Testnet

api.arcns.io, the ArcNS resolver API. Every route with its real response fields, the status contract (404 is proof of absence, 502 is an outage), who pays and who reads free (x402 for agents), registration-as-a-service, caching, rate limits, and how to run your own instance.

GET/resolve/{name}

https://api.arcns.io is a read-only resolver for @handle, .arc and .circle names, plus one paid write path for agents (registration-as-a-service). It holds no keys for you and no database of names: 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, an agent). 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.

Who pays

Reads are free for browsers on the ArcNS sites (arcns.io, app.arcns.io, docs.arcns.io, …) and for callers that present an ArcNS-issued X-API-Key. Everyone else — a script, a bot, an agent — gets 402 Payment Required on the data routes and pays $0.001 per answer over x402 v2, settled in USDC. /health, /version, /schema/resolve.json, /openapi.json and the x402 discovery routes are free for everyone. Details under Agents: pay per read with x402.

Routes

RouteAnswerPaid?
GET /health{"ok":true,"service":"api","version":"…"}; decided without any RPC call, exempt from rate limiting, cache-control: no-storefree
GET /versionbuild, chain id, RPC host and the current chain headfree
GET /resolve/:namecontroller, address record and metadata for @name, name.arc or name.circlex402
GET /reverse/:addressthe one primary name of an address, forward-confirmedx402
GET /nft/:nameERC-721 metadata JSON (name, description, inline SVG image, attributes) for a registered namex402
GET /quote/:name[?owner=0x…]registration quote: validity, availability, live price, commit window, controller and registry to call; ?owner= also screens the prospective owner against the USDC blocklistx402
GET /schema/resolve.jsonthe JSON Schema (draft 2020-12) every /resolve body conforms to; static, cache-control: public, max-age=3600free
GET /openapi.jsonOpenAPI 3.1 for /health, /version, /resolve, /reverse, /nft, /quote and the x402 discovery routes, including their 402 responses; the /raas/* routes below aren't in it yetfree
GET /x402/discovery, GET /.well-known/x402the x402 resource catalogue: one item per priced route with its accepts[] and schemafree
POST /raas/register, GET /raas/register/:id, GET /raas/healthregistration-as-a-servicex402 (the POST)

Only GET, HEAD and OPTIONS are accepted on the read routes (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

Try it out

Live read

Real answers from Arc testnet, captured against the live API. Browsers on the ArcNS sites read https://api.arcns.io free; agents pay $0.001 a call over x402; the SDK reads chain state directly. Registration is a signed transaction — see the API reference.

Read-only. No wallet and no key for browsers on the ArcNS sites; $0.001 per answer for agents. Registering a name needs a wallet, or registration-as-a-service — see the API reference.
cURL·GET /health
curl -s https://api.arcns.io/health
RESPONSE · 200 (free)
{  "ok": true,  "service": "api",  "version": "0.1.0"}
No RPC call — pure liveness, free for everyone. See the API reference for every route and the payment flow.

Status contract

StatusMeaningCached
200foundyes
400invalid name or address shape; error names the rule (HyphenAtEdge, NonAscii, …), or invalid address, input too long, malformed percent-encodingno
402payment required (a non-first-party caller without a key or a valid payment); code says why — see x402no
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)yes
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. A 4xx from a route itself (malformed name, not registered, mixed shape) or a 502 is returned unpaid — a payment presented with it is never settled.

Decision order

How one GET /resolve/:name is decided, in the order the server does it (api/src/http.ts, api/src/x402/gate.ts, api/src/resolve.ts): the payment gate first for a caller that is not first-party and holds no key, then the shape rules with no RPC, then the chain at one pinned head. Any RPC failure, a stale head or a TLD missing from TldDirectory is 502 at whichever step reads the chain — never 404, never cached. Nothing a route refuses is ever settled.

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) — a live answer for @circle, a reserved genesis name held by the treasury with no payment address set, read on 2026-09-09:

GET https://api.arcns.io/resolve/%40circle
{
  "input": "@circle",
  "name": "@circle",
  "namespace": "handle",
  "registry": "0x520Ef0D49F2e2A66d99B460E05EC5A8C743215d2",
  "resolver": "0x2841Ca947CC17eB3759df4950a548C217dA6287a",
  "chainId": 5042002,
  "blockNumber": 61199205,
  "epoch": 1,
  "owner": "0x3dDF29e9757FaD73F1174391926cb3794AcF3A17",
  "addr": null,
  "tokenized": false,
  "locked": false,
  "handleType": "Human",
  "expires": null
}

A name whose owner has set and verified an Arc record carries it as "addr": { "coinType": 2152525650, "address": "0x…", "verified": true }.

.arc / .circle (200):

{
  "input": "nike.arc",
  "name": "nike.arc",
  "namespace": "arc",
  "chainId": 5042002,
  "blockNumber": 61199205,
  "source": "arcns",
  "registry": "0x38Ed7B5d7582d7314aAD4A9Fa55A89c843c65369",
  "ensRegistry": "0x0a1DfA7f63fCd0526ed3bA81Af3d31E05Aa42FE4",
  "resolver": "0x2841Ca947CC17eB3759df4950a548C217dA6287a",
  "owner": "0x…",
  "addr": { "coinType": 2152525650, "address": "0x…", "verified": true },
  "epoch": 1,
  "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 — always arcns: resolution is single-registry and authoritative (CEO decision 2026-09-09, single-registry / winner-take-all), so no other registry on Arc is ever consulted or compared against.
  • 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.
  • 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 (Unknown is reserved for a contract enum value newer than this API build). tokenized and locked are handle flags.
  • sunset: { "sunsetAt": … } is present while a TLD is inside its sunset window and still resolving.

Not registered (404): returned only after a successful chain read on our own registry proved absence.

{
  "error": "name not registered",
  "input": "zzqx7k2mfresh.arc",
  "name": "zzqx7k2mfresh.arc",
  "namespace": "arc",
  "chainId": 5042002,
  "blockNumber": 61199205
}

For agents

Every body this route can answer — the two 200 shapes above, 404, 409, 410, 400, 402 and every failure string — is described by one JSON Schema (draft 2020-12) at /schema/resolve.json. Compile it with any 2020-12 validator and reject a body that does not validate; the schema is strict (no unknown fields, a closed list of error strings), and the repository's CI re-captures every fixture through the HTTP layer against it, so the served shape cannot drift from the document.

Two fields decide what a machine consumer may do. addr.verified is per record: true only when the current owner proved control of that address for that coin type on chain, cleared by any record write or ownership change, never set by the API (Verified records). handleType (@handle only) is the type the registrant declared — Human, Merchant, Org or Agent — verified by nobody: it says how to address a name, never whether to pay it. In the SDK the same two facts are Resolved.verification and Resolved.handleType, and isAgentHandle(result) is true only for a handle whose type is Agent; it accepts this route's body as well.

An agent name carries its endpoints and identity as text records on the name — agent.manifest, agent.x402, agent.mcp, agent.a2a, agent.erc8004 — with the payment address always the on-chain addr record this route returns; the convention, the manifest shape and the verification ladder are on the MCP page and in the repository at docs/architecture/agent-records.md. This route does not read text records; the SDK's text(name, key) and the MCP server's get_records do.

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": "0x38Ed7B5d7582d7314aAD4A9Fa55A89c843c65369",
  "chainId": 5042002,
  "blockNumber": 61199205,
  "epoch": 1,
  "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 /quote/:name

GET/quote/:name
namestringRequired

A @handle, name.arc or name.circle to price.

owner0x… addressoptional

Optional. The prospective owner; when given, it is screened against the USDC blocklist on Arc and a blocklisted address answers 400 with a plain reason (ownerBlocklisted is null when not asked).

A live quote for @alice on 2026-09-09 (5 characters, launch price — see Fees):

GET https://api.arcns.io/quote/%40alice
{
  "input": "@alice",
  "name": "@alice",
  "namespace": "handle",
  "label": "alice",
  "chainId": 5042002,
  "blockNumber": 61198646,
  "controller": "0xF84B693cE8c9230BCacC0422cA98eAf19f7c46f0",
  "registry": "0x520Ef0D49F2e2A66d99B460E05EC5A8C743215d2",
  "valid": true,
  "available": true,
  "priceWei": "10000000000000000000",
  "priceUsdc": "10",
  "minCommitmentAge": 60,
  "maxCommitmentAge": 86400,
  "owner": null,
  "ownerBlocklisted": null
}

priceWei is the exact msg.value the controller's register call takes (Arc's native asset is USDC with 18 decimals on chain); priceUsdc is the same figure in decimal USDC. controller and registry are the contracts to call and to expect the name in. A quote does not commit to anything: the name is checked available() again at registration.

GET /version

GET/version
{
  "service": "api",
  "version": "0.1.0",
  "chainId": 5042002,
  "rpc": "rpc.testnet.arc.io",
  "head": { "number": 61198646, "timestamp": 1788936494, "ageSeconds": 0 }
}

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.

Agents: pay per read with x402

Four routes — /resolve, /reverse, /nft, /quote — are sold to agents over x402 v2 at $0.001 per call (amount: "1000" in 6-decimal USDC units). The gateway decides per request, in this order: first-party browser traffic (a Sec-Fetch-Site: same-origin|same-site request, or an Origin / Referer on an ArcNS site) is free; an ArcNS-issued X-API-Key is free; a valid PAYMENT-SIGNATURE is paid; otherwise 402. The first-party check is a convenience for browsers, not a security boundary; the per-IP token bucket bounds abuse.

The challenge. An unpaid request answers 402 with a PAYMENT-REQUIRED header (base64 of a JSON PaymentRequired) and the same document in the body. This is the live challenge for /resolve/%40alice on 2026-09-09, extensions elided:

{
  "x402Version": 2,
  "error": "payment required: read the PAYMENT-REQUIRED header (x402 v2) and retry with PAYMENT-SIGNATURE",
  "code": "PAYMENT_REQUIRED",
  "resource": { "url": "https://api.arcns.io/resolve/%40alice", "mimeType": "application/json", "serviceName": "ArcNS", "description": "…" },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:5042002",
      "asset": "0x3600000000000000000000000000000000000000",
      "amount": "1000",
      "payTo": "0x54f71A9F3D54Df9f8454492f9Fe2B5C383E751D9",
      "maxTimeoutSeconds": 604900,
      "extra": { "name": "GatewayWalletBatched", "version": "1", "verifyingContract": "0x0077777d7eba4688bdef3e311b846f25870a19b9" }
    }
  ]
}

The rail. network: eip155:5042002 is Arc, gas-free, via Circle Nanopayments: you pay from a Circle Gateway balance (deposit USDC once on Arc testnet), and every payment is an off-chain EIP-712 signature over the GatewayWalletBatched domain that Circle batches for settlement. Gateway requires an EOA signature (no ERC-1271 smart accounts) and authorizations must stay valid for at least seven days (maxTimeoutSeconds). A second rail — Base or Solana USDC through the Coinbase CDP facilitator — appears in accepts only when the operator has configured it; today the live challenge lists Arc only. With @circle-fin/x402-batching the whole flow is three lines:

pay.ts
import { GatewayClient } from "@circle-fin/x402-batching/client";

const client = new GatewayClient({ chain: "arcTestnet", privateKey });
await client.deposit("1.00");                                            // once: fund the Gateway balance
const res = await client.pay("https://api.arcns.io/resolve/%40alice");   // 402 → sign → retry → 200
console.log(res.data);                                                   // the /resolve body

The paid answer is 200 with the normal body plus PAYMENT-RESPONSE (base64 JSON {success, transaction, network, payer, …} — transaction is the Circle Gateway settlement id; keep it, it is your receipt) and Cache-Control: private, max-age=5. Pick one entry of accepts, sign it, and retry the same request with PAYMENT-SIGNATURE: base64 of a JSON PaymentPayload {x402Version: 2, accepted, payload, extensions?}; accepted must match the server's entry field for field or the answer is 402 PAYMENT_INVALID with a fresh challenge. You only pay for an answer: a 4xx from the route itself or a 502 is returned unpaid.

Idempotency and limits. Put extensions["payment-identifier"].info.id (16–128 chars, [A-Za-z0-9_-]) in your payload: the same id + the same request within 15 minutes replays the cached answer with X-Idempotent-Replay: true and is not charged again; the same id with a different request is 409 PAYMENT_ID_CONFLICT. Paid requests are additionally limited to 30/min per IP and 60/min per payer (429 with Retry-After). A payer that USDC reports as blocklisted on Arc is refused with 402 PAYER_BLOCKLISTED before anything settles.

codestatusmeaning
PAYMENT_REQUIRED402no payment presented; read PAYMENT-REQUIRED
PAYMENT_INVALID402payload malformed, unsupported network, accepted does not match ours, or the facilitator rejected it — reason in error
SETTLEMENT_FAILED402the facilitator refused to settle; details in PAYMENT-RESPONSE
PAYER_BLOCKLISTED402payer is on the USDC blocklist on Arc
X402_V1_NOT_ACCEPTED402an X-PAYMENT (x402 v1) header; v2 only
PAYMENT_ID_CONFLICT / PAYMENT_ID_CONSUMED409payment-identifier reuse problems
RATE_LIMITED429paid-path window exceeded
X402_FACILITATOR_UNAVAILABLE / X402_FACILITATOR_ERROR502the facilitator could not be reached or answered nonsense; retry later
X402_SETTLE_TIMEOUT502settlement timed out — indeterminate; do not re-sign, contact us with your payment-identifier
RPC_FAILURE502the chain read behind the blocklist check failed

GET /x402/discovery (and /.well-known/x402) lists every priced route with its accepts[] and input/output schema, paged with ?limit (1–100) and ?offset; GET /openapi.json is the OpenAPI 3.1 document for the read routes and their 402 responses (/raas/* isn't in it yet — see Registration-as-a-service below for that contract). The full protocol write-up, including the operator side, is docs/x402.md in the repository.

Registration-as-a-service

POST/raas/register

An agent that has a Circle Gateway balance but no wallet, no USDC and no gas on Arc can have a name registered to its own address by the API: pay once over x402, and the API's own signer runs the commit-reveal registration and mints the name to owner. The request is the dynamically priced x402 resource: without a payment it answers 402 with the name's live total; with a valid PAYMENT-SIGNATURE it settles on the same Arc Gateway rail as the reads, creates the order and returns immediately.

namestringRequired

The name to register: @handle, name.arc or name.circle (JSON body, content-type: application/json).

owner0x… addressRequired

The address the name is minted to — yours, never the signer's. Screened against the USDC blocklist.

handleTypeuint8Required

The handle type to record (0 Human, 1 Merchant, 2 Org, 3 Agent). Ignored for .arc / .circle.

The live 402 for @zzqx7k2mfresh on 2026-09-09 — the total is the on-chain price plus the gas of both transactions plus a small margin, stated up front:

POST https://api.arcns.io/raas/register → 402
{
  "error": "payment required: read the PAYMENT-REQUIRED header and retry with PAYMENT-SIGNATURE",
  "code": "PAYMENT_REQUIRED",
  "resource": {
    "url": "https://api.arcns.io/raas/register",
    "description": "Register @zzqx7k2mfresh (price 2500000000000000000 wei + gas 14653650000000000000 wei + margin) — completes in ~2 minutes, poll GET /raas/register/:id",
    "mimeType": "application/json",
    "serviceName": "ArcNS"
  },
  "accepts": [ { "scheme": "exact", "network": "eip155:5042002", "asset": "0x3600000000000000000000000000000000000000", "amount": "17203650", "payTo": "0x54f71A9F3D54Df9f8454492f9Fe2B5C383E751D9", "maxTimeoutSeconds": 604900, "extra": { "name": "GatewayWalletBatched", "version": "1", "verifyingContract": "0x0077777d7eba4688bdef3e311b846f25870a19b9" } } ]
}

Once paid, the answer is 202 { "orderId", "status", "name" } (a replayed payment-identifier returns the same order with "replay": true). Then:

GET/raas/register/:id

Poll it until status is registered. The order moves paid → committed → maturing (waiting at least 60 seconds of chain time after the commit) → registered, about 75–95 seconds end to end. If the name is taken, the price moved or a transaction cannot be completed, the order goes to refund_pending → refunded and your payment comes back; an order that needs a human is manual. No order reaches registered without a NameRegistered event whose owner is your address. An unknown id is 404 RAAS_ORDER_NOT_FOUND; a malformed body is 400 RAAS_BAD_REQUEST with the field named.

GET/raas/health

200 {"ok":true, "float_usdc", "reserved_usdc", "available_usdc", "gas_price_gwei", "in_flight": {…}, "failed_last_24h", "reasons": []} while the signer's float can cover new orders; 503 when it cannot, in which case no 402 is issued for a registration that could not be delivered. The MCP server's prepare_register is the alternative for an agent that does have a wallet: it returns the unsigned commit and register calls instead.

Caching

  • Cacheable statuses (200, 404, 409, 410) are served with cache-control: public, max-age=5 on the free paths; a paid answer is private, 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. The x402 headers (PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-Idempotent-Replay, Retry-After) are exposed to cross-origin callers.

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 — viem for the chain reads, plus pg and the @x402/@circle-fin packages that back the optional x402 and registration-as-a-service paths — 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. x402 and registration-as-a-service are off by default in your own instance (X402_ENABLED, RAAS_ENABLED); without them every read is free and there is no write path.

Repository access

The fortiblox-arcns repository is private to FortiBlox Labs as of 2026-09-14 — git clone below will 404 without org access. The steps are accurate for engineers who have it; there is no public source drop yet.

git clone https://forgejo.selfhsted.com/fortiblox/fortiblox-arcns
cd fortiblox-arcns/api && npm ci && npm run build && npm start
# or, from the repo root (api/compose.yml's build context needs it): docker compose up api

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 — take the values from the Contracts page
TLD_<TLD>_BASE_REGISTRAR_ADDRESS, TLD_<TLD>_CONTROLLER_ADDRESSrequired per TLD in TLDSe.g. TLD_ARC_BASE_REGISTRAR_ADDRESS, TLD_CIRCLE_CONTROLLER_ADDRESS

Contract addresses have no defaults: a defaulted address is one an attacker can substitute. The x402 and RaaS variables are documented in .env.example and docs/plan/environment.md in the repository.

Questions

Questions: [email protected].

Browse the docs