# .cell names on Nervos CKB
> Resolve .cell names on the Nervos blockchain over HTTP. No account, no API key, no package, no blockchain code. CORS is open, so a browser page can call it directly. The chain is the source of truth and this is a cache in front of it; every answer carries a proof you can check against any CKB node.
Base URL: https://testnet.cellula.id/api
OpenAPI: https://testnet.cellula.id/api/openapi.json
Human version of this page: https://testnet.cellula.id/developers
## What you are talking to
### A name is a cell, and this API is a cache
Every `.cell` name is one live cell on Nervos CKB carrying a fixed type script. This service reads them and caches for fifteen seconds. It holds no keys and signs nothing, so it cannot forge a name, but it can be stale, be down, or in principle lie. Every `/resolve` answer carries a `proof` object with the outpoint, the full type script, the data hash, and the number and hash of the block that committed the cell: one `get_live_cell` on any CKB node settles whether the answer is true, and a light client can fetch the transaction by hash and watch from that block. If an answer would move money, check it.
### Names expire, and a lapsed name is not free
A name is paid for in whole years. When it lapses it stays its owner's for a further thirty days: the cell is still live, it still resolves, anybody may renew it (a sub-name, only the owner of its parent), and nobody else may take it. Only after that can it be recycled and the label registered by someone new. `/expiring` names the three states (`expiring`, `grace`, `free`) and gives `freeAt` as a timestamp, so never infer availability from the expiry alone.
### Records live in the witness, not in the cell
A name's records (addresses, Lightning, profile) sit in the `output_type` field of the WitnessArgs of the transaction that created the cell. The cell's first 32 bytes are that payload's hash, so the chain commits to the records without paying rent for them. Keys are SLIP-44 shaped: `address.309` is CKB, `address.0` Bitcoin, `address.60` Ethereum; `lightning.address` is a Lightning Address.
### Ownership is a lock hash, so any wallet can own a name
A name records the first twenty bytes of its owner's lock script hash. Nothing restricts which lock that is: a plain CKB key, an EVM or Bitcoin key through Omnilock, a passkey, or a post-quantum SPHINCS+ key. A name may also delegate a manager, who can edit records but cannot transfer or sell.
### There are no accounts and no API keys
Nothing here identifies a caller. There is no sign-up, no token, no per-caller quota, and no way to subscribe to anything: the rate limit is per IP address (120 a minute, then a 429) and that address is not stored. If you need a notification, poll `/expiring` or follow its RSS feed.
### A name can be paid on more than one chain, and the address alone will not say which
`/resolve` returns an `addresses` map: `ckb`, `btc`, `eth`, and whatever else the owner published. One Ethereum address is the same on every EVM chain, so `addresses.eth` cannot tell you whether the owner watches Base, Ethereum or Arbitrum. A signed payment request settles it, because its `unit` is part of what the owner signed: `ckb`, `sat`, `eur`, `brl`, `inr`, or `usdc-base`, `usdc-eth`, `usdc-arb`. Without a request, the payer has to be asked rather than guessed at. Bitcoin has the same shape of problem on testnet only, where testnet3 and testnet4 share their address format.
### A meal is a payment that says which name it is for
A payment counts as a meal for a name only when it goes into the lock of that name’s deed (the proxy lock over its Spore, decision 0036, which is the name’s owner while it is held as a deed) and carries an extra witness after the witnesses of the inputs: `cells:meal:1`, a zero byte, then `{"to":"alice.cell","note":"..."}` as UTF-8 JSON. The note is one line of at most 80 characters and is optional. Add it once the inputs are chosen and before the fee is completed, so the fee covers it (`addMeal` in cellula-sdk). Not in witness 0’s `output_type`: a JoyID sub key keeps its unlock proof there. What a being eats stays under that lock until the deed’s holder empties it (`emptyBelly`), and goes with the deed. A payment into the address a name publishes, a name owned by a plain key, and money the holder moves out of the lock are not meals. `GET /meals/:name` lists them.
### A .cell name is an address in any CCC app
`@ckb-ccc/core` 1.23.0 added `addressResolver` to the client config (ckb-devrel/ccc#575): `Address.fromString` asks it about a string it cannot parse. `cellResolver()` from `cellula-sdk` is the `.cell` one: `ccc.ClientPublicMainnet.open({ addressResolver: cellResolver() }).value`, then `Address.fromString(’alice.cell’, client)` gives the CKB address the name publishes. It asks this API for `proof.outPoint` and checks it against the client: live cell, the namespace’s type, the label in the data, the payout in records the cell commits to; a wrong hint falls through to the chain. Unregistered, expired, withdrawn or without a CKB payout is `not found`.
## Worked examples
### Resolve a name and pay it
```js
const r = await fetch('https://testnet.cellula.id/api/resolve/maria.cell').then(r => r.json())
if (r.registered && r.addresses.ckb) payTo(r.addresses.ckb)
// r.proof carries the outpoint, type script and data hash to check this against a node
```
### Show a name instead of a raw address
```js
const { name } = await fetch('https://testnet.cellula.id/api/primary/' + address).then(r => r.json())
// name is that address's own verified .cell, or null. Checked both ways, so it cannot be spoofed.
```
### Let somebody sign in as a name
```js
// 1. your server issues a challenge and remembers it
const nonce = '0x' + crypto.randomUUID().replace(/-/g, '')
// 2. the person's wallet signs these exact bytes, key order included
const login = {
t: 'cells.login.v1',
ns: CONFIG_TYPE_HASH, // GET /health, or the deployment you target
name: 'maria', // no .cell suffix
domain: 'example.com', // bare host, no scheme, no path
nonce,
issued: Math.floor(Date.now() / 1000),
expires: Math.floor(Date.now() / 1000) + 300,
statement: 'Sign in to Example'
}
const sig = await wallet.signMessage(JSON.stringify(login))
// 3. your server spends the nonce, then checks
const v = await fetch('https://testnet.cellula.id/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ signed: { login, sig }, domain: 'example.com', nonce })
}).then(r => r.json())
if (v.ok) signIn(v.name) // 'maria.cell', proven against the chain
```
### Watch for a name to come free
```js
const { names } = await fetch('https://testnet.cellula.id/api/expiring?state=free').then(r => r.json())
// every label anybody can register right now. state=grace is lapsed but NOT yet takeable.
// Or follow the feed: /api/expiring?days=30&format=rss
```
## Every route
- `/resolve/:name` the name decoded: addresses, lightning, fiber, expiry, owner. Also carries `dispute` when a notice applies, `quantumOwner` when the owner is a post-quantum key, `domain` when the name claims a website, which you should show only when its `verdict` is `answers`, and `accounts`, the accounts on X, GitHub and Bluesky the name claims (decision 0040), each shown only when its own `verdict` is `answers`, with the `url` of the post, gist or file that carries the account's half. Every answer carries a `proof`: outpoint, full type script, data hash, and the block that committed the cell.
- `/resolve/:name?coinType=309` one address by SLIP-44 coin (0 btc, 60 eth, 309 ckb)
- `/primary/:address` the address's own verified name, or null
- `POST /primary` the same answer for up to fifty addresses at once. `{addresses: [...]}` in, `{results: [...]}` out, same order, each shaped like a single answer. One unreadable address carries `error` and does not fail the rest. For a list on a page, one call instead of one per row.
- `/reverse/:address?coinType=309` names that publish this address as a payout
- `/name/:name` the raw on-chain record set
- `/names` every registered name
- `/market` every name its owner has listed for sale, cheapest first. Carries the split the contract enforces on each price, so a storefront can show what the seller receives without computing it. Names withdrawn under the disputes policy are absent and a name under a notice carries it, which a scan of the chain cannot know. 503 until the first scan finishes, which is not the same as nothing being for sale. Buying without this service is docs/SALE-LOCK.md.
- `/health` snapshot freshness, and the discount in force
- `/price` what a coin is worth. Cached here so a browser need not ask a rate service itself.
- `/verify` does the running contract match the published build?. Live, and checkable against the chain yourself.
- `/.well-known/lnurlp/:name` the Lightning Address lookup, forwarded unchanged
- `/.well-known/lnurlp/:name/invoice?amount=` an invoice for that many millisats, from the name’s own wallet. Forwarded and returned byte for byte: the invoice commits to that wallet’s own metadata, so nothing is re-signed here.
- `/avatar/:name` the name's picture, always an image: its own, or the mark drawn from its id
- `/cover/:name` the wide band across the top of its page, always an image. Its own picture, or a band drawn from its id. `?fallback=none` returns 404 instead of drawing one.
- `/profile/:name` what a name says about itself, and where its picture came from, as JSON
- `/archive/:name` every version of a name’s records this service has seen. Oldest first, so a change to where a name points is visible rather than silent.
- `/latest?limit=20` names registered most recently, newest first
- `/directory?q=&sale=&pay=&page=1&size=10` the names a page at a time, searched and filtered, in label order. From the snapshot the resolver refreshes every few seconds, so a list of thousands is one call instead of a round trip per name. Withdrawn names are left out; `exists` says whether a name spelled exactly like `q` exists on the chain, because a withdrawn name is not free. Every row carries the outpoint it was read at. 503 until the first snapshot.
- `/meals/:name?limit=50` who fed the being of a name, newest first. Payments into the lock of the name’s deed that carry a meal naming it (decision 0037: the witness `cells:meal:1`, a zero byte, then `{ to, note }` as JSON). Kept by reading each block a few blocks behind the tip; `readTo` is the last block read and `behind` how far that is from the tip. A payment into the address the name publishes, a payment for another name, a name not held as a deed, and money the holder moves out of the lock are not meals. Each payer carries their own verified .cell when they have one. Returns 503 until the first blocks have been read.
- `/stealth?from=22665400` every private payment to a name since a block, oldest first. Transactions carrying the stealth witness (decision 0051: `cells:stealth:1`, a zero byte, the payer’s one-time point R in 33 bytes, then a view tag in 1 byte), with their outputs under the default lock. It names nobody: a keeper’s app reads all of them and finds its own with its scan key, so every caller gets the same rows. `next` is the block the next page starts at. Kept by reading each block a few blocks behind the tip; returns 503 until the first blocks have been read.
- `/dob/0xffffb305f0519f378389c5833fe4b001262e415e2336005e536555ce0f92e330` a Spore’s DOB drawn as one SVG, for an
. A DOB/0 or DOB/1 Spore, decoded by a DOB decoder: a DOB/1 as the decoder composites its layers, a DOB/0 as a card of its traits over its background image. Every image is inlined (ipfs:// and btcfs:// through their public gateways); scripts and event handlers are taken out, and the answer carries a policy under which none could run. Drawn once and kept. Testnet only, where a decoder is configured; 503 elsewhere, 404 for a Spore no decoder can read.
- `/token/0x1fed7ded0619c2d083d2248b80acf77161b587f6cf41098fbc348a9788f3d24d` what an xUDT or sUDT token is called, by its type hash. The symbol, name and decimals the issuer published, and the tags the explorer gives it (rgb++ among them), read from the Nervos explorer, which a page cannot ask itself. 404 when nobody named the token. A known answer is kept an hour.
- `/expiring?days=30&state=all` names running out, and the ones that are free to register now. Three states. `expiring` is still paid up; `grace` has lapsed but for thirty days more nobody else may take it; `free` is takeable. `state` filters to one, `days` bounds the future only, `format=rss` gives a feed.
- `/recheck/:name` ask for a name's website and account proofs to be looked at again. At most once a minute. It queues the check rather than running one; the answer turns up on `/resolve`.
- `/disputes` every name under a notice or withdrawn from this service, and why
- `/quantum` the owners proven to be post-quantum keys. A name missing from the list is not shown to be, never shown not to be. Also reports whether the code those names obey is still the code we recorded.
- `POST /login` check a sign-in signature. `{signed, domain, nonce, allowManager?}`. You issue and spend the nonce; this service keeps none and cannot tell a fresh one from a used one.
- `POST /statement` check a statement a name signed. `{signed}`, `{block}` or `{token}` (decision 0039). Says whose key signed it: the owner today, a former owner and the block it let go, the manager, or nobody. Never when it was signed; the date inside is the signer's word. The same check is `verifyStatement` in the SDK.
- `GET /deed/:name` is the name held as a deed, and by whom. A name owned by a proxy lock over a Spore is held by whoever holds that Spore (decision 0036). `deed` is null for a name owned by a key; the same link is `deedOf` in the SDK.
- `GET /deeds/lease` a plain cell of the deeds key for a new deed to spend. A deed may spend one plain cell of the key that holds the deeds cluster, handed back unchanged, instead of the cluster cell itself, so deeds made at the same moment do not collide. Lent to one caller for three minutes and never cached; a lease is a hint, not a lock.
- `POST /deed/cosign` the deeds cluster's signature on a transaction that makes a deed. `{name, tx}`, the transaction in the node's JSON shape. Refused unless it is exactly a deed for that name (`checkDeedTx` in the SDK); the key signs the cluster cell and nothing else.
- `POST /account` retired: a wallet's own account costs nothing to make since lock v6. `{pub}`, the passkey's 64-byte P-256 key. Makes the account's settings cell under the ckb-smart-account lock and pays the 420 CKB it holds; answers `{id, address, txHash, configCell}`. A few a day per address. On mainnet a wallet the person has pays (`buildAccountCreation` in the SDK).
- `POST /report` report a name under the disputes policy. `{name, claim, statement, evidence, contact}`. Acknowledged within five working days.
- `POST /statement/keep` hold a statement so its link can be short. As JSON `{token}`. Stored as it is, since a statement is public by nature, under twelve characters derived from it (the first nine bytes of the SHA-256 of its token), for ten years. Answers `{id, link}`. The long link, with the statement in it, works without this.
- `/statement/:id` a statement held under a short name. `{token, expires}`, or 404 when nothing is held under it.
- `POST /r` hold a sealed payment request so its link can be short. The id is a hash of a key this service never sees and the blob is ciphertext under it, so nothing held here is readable to us.
- `/r/:id` a sealed payment request, for whoever holds the key that names it
- `/openapi.json` this same list as an OpenAPI 3.1 document, for a generator, a typed client or an agent
## Things that trip people up
- A name nobody registered answers 404 with `{ registered: false }`. That is an answer, not an error.
- `:name` takes `alice` or `alice.cell`. A sub-name is `shop.alice`, and only ever in that form.
- The registration fee is a ceiling in CKB that one on-chain cell may discount. Read `price.factorBps` from `/health` rather than hardcoding the schedule: 10000 means no discount, 9000 means ten percent off.
- The bytes a login signs are built in a fixed key order and begin with `t`. A payment request begins with `v`. Neither can be replayed as the other, and reordering the keys breaks the signature.
- `/expiring` never drops a lapsed name because of the `days` window: the window bounds the future only.
- The rate limit is 120 requests a minute per calling IP, not per name and not per user, then a 429. Read that twice if you call from a server: all your users share one budget. A list on a page is one `POST /primary` with up to fifty addresses, not fifty calls. Answers are cached for ten seconds; errors never are.
- `/primary` reads the chain, so an answer it does not already hold takes seconds rather than milliseconds. Two caches sit in front of it and they are not the same one: `cache-control` asks your side to reuse an answer for ten seconds, and we keep our own copy of each address for a minute, so a name can be about seventy seconds old at worst and a first lookup is always the slow one. Ask it once where you already know whose address it is, keep the answer beside the address, and leave the path that draws a page alone. Give the call a timeout in seconds: a cap under the real answer time drops names in silence.
- Treat every failure as no name, a 200 that is not JSON included. A host that is up but not serving the API can answer HTML with a 200, so code that branches on the status code alone reads a web page as an identity. Parse first, then check the shape. The two honest answers are a 200 with `name: null`, meaning that address has no name, and a 400 with the same `name: null` and an `error`, meaning the address itself could not be read. Show the same thing for both; only the second one says the bug is on your side.
- Ask `/health` once when your service starts and log one line with the answer. `ok` proves you reached this API and not a host that answers 200 with a web page, and `network` tells you whether you are on `pudge`, the test network, where names cost nothing and belong to nobody. Treating every failure as no name is the right advice and is also what makes a broken integration silent, so this one call is what separates 'connected, nobody has a name yet' from 'nothing ever arrives'.
- Do not make a `.cell` the fallback for a name your app already has. The first site to integrate counted twenty-five of its twenty-seven authors as having typed a display name, so showing the `.cell` only when that was empty would have reached two of them, and never on the byline of somebody who had paid for one. Show both, the `.cell` under the name, the way a handle does: a nickname says who somebody is, and only the `.cell` can be paid.
- `/primary` takes a CKB address (`ckb1…` on mainnet, `ckt1…` on the test network) or an Ethereum `0x…` address, which is asked as the two OmniLock locks that key owns CKB under, the same two CCC's EVM signer derives, so an EVM app needs no CKB knowledge. A Bitcoin address, or a key behind a passkey lock, cannot be derived from its address and is a 400; for those, `/reverse/:address?coinType=0` lists names that publish the address as a payout, unverified by nature, a hint and never an identity.
- A name is lowercase a to z, digits and hyphens inside a part, at most forty characters before `.cell`, with at most one dot: `shop.alice.cell` is a sub-name and `a.b.c.cell` cannot exist. Check what a resolver returns against that before storing or rendering it; it is somebody else's service and its answer lands on your page.
- `/primary` says when a name runs out (`expiredAt`, `expires`, `expired`, the same fields as `/resolve`) and keeps returning an expired name, flagged, until somebody else registers it. What it cannot say is whether the name is still theirs next month: a name can be sold or handed on while the address underneath never moves. Keep the address as your key and the name as a label on it, and ask again whenever you already know who you are talking to, usually at sign-in.
- Two networks, one API. The test network answers at `https://testnet.cellula.id/api`, where a name costs nothing; mainnet answers the same routes at `https://cellula.id/api`, open since 2026-09-21. `/health` returns `network` (`pudge` is the test network) so a service can tell which one it is pointed at.
- Paying in USDC is an ordinary ERC-20 `transfer` to `addresses.eth`; nothing here holds or forwards it. When it settles a signed request, the request's fingerprint is appended to the transfer's calldata, which is what lets a transaction be matched to an order without trusting whoever claimed it. Paying in Bitcoin is a plain transfer to `addresses.btc`, built in the browser and signed by the payer's own wallet, and no request travels with it.
## What changed
- 2026-09-29: `cellula-sdk` 0.2.0 is on npm: `cellResolver`, `deploymentFor(network)` so a write needs no configuration, deeds (with `@ckb-ccc/spore` as a peer), signed statements and meals. The mainnet deployment points at the contracts upgraded on 2026-09-28.
- 2026-09-28: `POST /statement/keep` holds a statement under twelve characters derived from it, and `/statement/:id` gives it back: the check page `/signed/` and its link preview work from either. The long link keeps working without the resolver.
- 2026-09-27: `/resolve` carries `accounts`: accounts on X, GitHub and Bluesky a name claims under `proof.x`, `proof.github` or `proof.bluesky`, each with its `verdict`, its `why` and the `url` of the post, gist or file that carries the account's half, a statement the name's owner signed (decision 0040). A statement's link now carries it in the query, `/signed?s=`, so a shared link's preview says who signed it; links after `#` still open.
- 2026-09-26: `/health` carries `namespace`: `configTypeHash`, the 32-byte namespace id a sign-in message commits to as `ns`, and `args`, its first 20 bytes, the account type script’s args. The sign-in example on the developers page pointed at `/health` for it before it was there.
- 2026-09-26: `cellula-sdk` gains `cellResolver`, a `.cell` resolver for the `addressResolver` hook `@ckb-ccc/core` 1.23.0 added (ckb-devrel/ccc#575). It takes `proof.outPoint` from `/resolve` as a hint and checks it against the app’s own client before answering; a wrong or stale hint falls through to the chain. In the next release of the package.
- 2026-09-23: The service behind this API is open source at github.com/LusoCryptoLabs/cells-resolver, the copy that runs here, and `docker run -e CKB_NETWORK=mainnet -p 8787:8787 cells-resolver` is a complete second instance. The contracts it reads are public at github.com/LusoCryptoLabs/cells-contracts with byte-for-byte reproducible builds.
- 2026-09-19: `addresses` carries `btc` and `eth` where the owner published them, and a signed request may name `usdc-base`, `usdc-eth` or `usdc-arb` as its `unit`. No route changed: this is what the existing fields now carry.
- 2026-09-17: `/primary` says when the name runs out: `expiredAt`, `expires`, `expired`, the same three fields as `/resolve`. An expired name is still returned, flagged, until somebody else registers it.
- 2026-09-17: `/primary/0x…` accepts an Ethereum address and asks as the OmniLock locks that key owns CKB under. It used to answer 400.
- 2026-09-17: `POST /primary` answers up to fifty addresses in one call, in order, one unreadable address not failing the rest.
- 2026-09-16: `llms.txt` and `llms-full.txt` are written at build time from the same list as this page and the OpenAPI document, and a test fails if the three disagree.
## Reading a name without this service
A name is one live cell carrying this type script, so reading is by type script and an
upgrade cannot break a reader:
code_hash 0xe0706b176678181d982290d93dfcd82098e60cceaa4a87f10f32dcbcc91df1d9
hash_type type
args 0x2510c78057479c9b023fe6e98ce43979e92a1353
A name carries no id: the id IS the label, hashed. It is the first 20 bytes of
blake2b-256 of the label, personalised with `ckb-default-hash`, which is CKB's own
hash. The label is in the cell, at the end, and the header before it is fixed:
data[ 0.. 1] layout version, currently 3
data[ 1.. 33] hash of the witness holding the records
data[ 33.. 53] the next id in the ordered ring
data[ 53.. 58] expiry, unix seconds, little endian
data[ 58.. 78] owner's lock hash, truncated
data[ 78.. 98] manager's lock hash, equal to the owner's when undelegated
data[ 98.. ] the label, to the end of the data
The records are not in the cell. They are in the `output_type` field of the WitnessArgs
at the cell's own index in the transaction that created it, and the cell's first 32 bytes
are that payload's hash. The payload is a u16 count, then per record a u8-length key, a
u8-length label, a u16-length value and a u32 ttl.
That is the whole read path, and reimplementing it is the only way to be certain nobody
is lying to you.