Resolver API
Testnetapi.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.
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
| Route | Answer | Paid? |
|---|---|---|
GET /health | {"ok":true,"service":"api","version":"…"}; decided without any RPC call, exempt from rate limiting, cache-control: no-store | free |
GET /version | build, chain id, RPC host and the current chain head | free |
GET /resolve/:name | controller, address record and metadata for @name, name.arc or name.circle | x402 |
GET /reverse/:address | the one primary name of an address, forward-confirmed | x402 |
GET /nft/:name | ERC-721 metadata JSON (name, description, inline SVG image, attributes) for a registered name | x402 |
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 blocklist | x402 |
GET /schema/resolve.json | the JSON Schema (draft 2020-12) every /resolve body conforms to; static, cache-control: public, max-age=3600 | free |
GET /openapi.json | OpenAPI 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 yet | free |
GET /x402/discovery, GET /.well-known/x402 | the x402 resource catalogue: one item per priced route with its accepts[] and schema | free |
POST /raas/register, GET /raas/register/:id, GET /raas/health | registration-as-a-service | x402 (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 readReal 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.
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 |
402 | payment required (a non-first-party caller without a key or a valid payment); code says why — see x402 | 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) | 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. 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
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:
{
"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:
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 — alwaysarcns: 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.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.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(Unknownis reserved for a contract enum value newer than this API build).tokenizedandlockedare 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
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
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
A @handle, name.arc or name.circle to price.
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):
{
"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
{
"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:
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 bodyThe 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.
code | status | meaning |
|---|---|---|
PAYMENT_REQUIRED | 402 | no payment presented; read PAYMENT-REQUIRED |
PAYMENT_INVALID | 402 | payload malformed, unsupported network, accepted does not match ours, or the facilitator rejected it — reason in error |
SETTLEMENT_FAILED | 402 | the facilitator refused to settle; details in PAYMENT-RESPONSE |
PAYER_BLOCKLISTED | 402 | payer is on the USDC blocklist on Arc |
X402_V1_NOT_ACCEPTED | 402 | an X-PAYMENT (x402 v1) header; v2 only |
PAYMENT_ID_CONFLICT / PAYMENT_ID_CONSUMED | 409 | payment-identifier reuse problems |
RATE_LIMITED | 429 | paid-path window exceeded |
X402_FACILITATOR_UNAVAILABLE / X402_FACILITATOR_ERROR | 502 | the facilitator could not be reached or answered nonsense; retry later |
X402_SETTLE_TIMEOUT | 502 | settlement timed out — indeterminate; do not re-sign, contact us with your payment-identifier |
RPC_FAILURE | 502 | the 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
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.
The name to register: @handle, name.arc or name.circle (JSON body, content-type: application/json).
The address the name is minted to — yours, never the signer's. Screened against the USDC blocklist.
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:
{
"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:
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.
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 withcache-control: public, max-age=5on the free paths; a paid answer isprivate, 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. 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 apiConfiguration 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 — take the values from the Contracts page |
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 |
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].