# .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://cellula.id/api OpenAPI: https://cellula.id/api/openapi.json Human version of this page: https://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. ## Worked examples ### Resolve a name and pay it ```js const r = await fetch('https://cellula.id/api/resolve/cellula.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://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: 'cellula', // 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://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) // 'cellula.cell', proven against the chain ``` ### Watch for a name to come free ```js const { names } = await fetch('https://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, and `domain` when the name claims a website, which you should show only when its `verdict` is `answers`. 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 - `/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 that name's website proof to be looked at again, at most once a minute - `/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 /report` report a name under the disputes policy. `{name, claim, statement, evidence, contact}`. Acknowledged within five working days. - `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` once registration opens there. `/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-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 0xd96cee56727a2bb9a21408c154d278df5095fb4b4dcfd50516156424479bfe54 hash_type type args 0xb4f4302965b7d6421481a520ee7eb5971a5e808c 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.