# Welcome to X1ID URL: /docs/x1id > Own your name on X1 — yours forever. One @handle that replaces long wallet addresses and works across X1, Solana, Ethereum, and Bitcoin. Pay once, no renewals, no expiry.
One handle — @you — that resolves to your address across X1, Solana, Ethereum, and Bitcoin. Claim it once; it's yours forever. Try the resolver, then jump into the docs.} ctaHref="/docs/x1id/api" ctaLabel="Read the Resolve API" /> X1ID is live on **X1 mainnet** — claim your handle at [app.x1id.io](https://app.x1id.io). The registry is a public on-chain program you can verify yourself; see [Trust & transparency](/docs/x1id/trust) for exactly how. ## What X1ID is A wallet address is 44 unreadable characters, and one wrong character sends funds nowhere. X1ID replaces it with a name you can read and share: register `@you` once and people pay it instead of copying an address, apps show you as `@you` instead of raw base58, and a single handle resolves to the right address on whichever chain the sender is on. It's **permanent** — no renewals, no expiry, on a standard registration (an optional [lease-to-own](/docs/x1id/faq#lease-to-own) path exists for spreading the cost) — and it carries a profile you own, so a wallet can finally prove who it belongs to. Start with [Why get an X1ID](/docs/x1id/why), then [claim your handle](/docs/x1id/get-started). **`@you` is a handle, not a domain.** X1ID (`@jack`) and X1NS (`jack.x1`) are complementary — different shapes for different jobs, and many people own both. See [how they fit together](/docs/x1id/faq#x1id-and-x1ns).
--- # Why get an X1ID URL: /docs/x1id/why > A handle is more than a nickname — it's a permanent, multi-chain, verifiable identity you own outright. Here's what that gives you as a holder. # Why get an X1ID An X1ID handle is a small thing to claim and a big thing to have. But the reason it matters most comes first: ## You own it — forever Most names you can register somewhere are really **rented**. Miss a renewal and your name lapses, and someone else can grab it. For a name people send money to, that's dangerous: an expired handle picked up by a stranger silently redirects payments meant for you. X1ID doesn't work that way by default. A standard registration is **paid once and owned outright** — no renewals, no expiry, no subscription, no chance of losing it because you forgot to pay or stopped using it. It changes hands only when *you* decide to transfer or sell it. That permanence is the whole point, and it's what sets X1ID apart from every naming system that expires. X1ID also offers an optional **lease-to-own** path for whoever would rather spread the cost out: pay a fraction of the price up front, then keep paying it down — every payment builds equity toward the same, pinned permanent price, and paying it off in full converts the handle to permanent immediately. It keeps the same protection standard registration is built on: a 30-day grace period means a missed payment never means an instant, silent loss the way an ordinary domain lapse does. See [the FAQ](/docs/x1id/faq#lease-to-own) for the details. ## What else you get ## Who it's for * **Anyone who receives crypto.** Creators, freelancers, merchants, friends splitting a bill — hand out `@you` once instead of a fresh address every time. * **People who use more than one chain.** If you hold on Solana or Ethereum as well as X1, a single handle covers all of them. * **Anyone tired of copy-pasting addresses.** Set `@you` as your primary name and apps show your handle instead of a string of characters. * **People who want to be recognizable on-chain.** A handle plus a profile is how others know a wallet is really yours. You don't have to use every feature. Many people claim a handle just to receive payments to a name they can share. The profile, verification, subnames, and marketplace are all there when you want them — and cost nothing extra to skip. ## Ready? --- # Claim your handle URL: /docs/x1id/get-started > Search a name, register it, point it at your wallets, and set it as your primary — a step-by-step walkthrough on app.x1id.io. # Claim your handle Getting an X1ID takes a few minutes. Everything happens at [app.x1id.io](https://app.x1id.io). You'll need a wallet — or just an email or social account, which creates one for you — and a little XNT (X1's native token) to cover the one-time registration fee. **Registrations and marketplace actions may be paused.** The registry has an emergency lockdown, used for example during a security audit. While it's on, the program rejects new registrations, NFT mints, reserved-name and gift claims, TLD creation, and new listings, offers and auctions (`ProtocolLockedDown` / `MarketPaused`). Resolution, transfers, `set_primary` and records keep working, as do exits such as cancelling a listing, completing an existing sale, and lease renewal. Check the banner on [app.x1id.io](https://app.x1id.io), or read the flag from the `Config` account with the SDK's `readPauseState`. ### Open the app and sign in Go to [app.x1id.io](https://app.x1id.io). You can sign in two ways: * **Connect your own wallet** — the wallet you connect becomes the owner of your handle, so use one you control and intend to keep. Your wallet is your identity; no password or email is required. * **Sign in with email or a social account** — never used crypto? Sign in with an email or a social login and X1ID creates a non-custodial **embedded wallet** for you automatically — no seed phrase to manage, no extension to install. That embedded wallet owns your handle just like any other, and the same email/social sign-in brings it back on any X1ID surface. Either way, the wallet you sign in with is the one that will own your handle. Signed in with **email or social**? That's one shared session across X1ID surfaces (for example [app.x1id.io](https://app.x1id.io) and [developers.x1id.io](https://developers.x1id.io)) — signing out of one signs you out of all. A directly connected wallet keeps each site independent. See the [FAQ](/docs/x1id/faq). ### Search for a name you like Type the name you want — say `you` — into the search box. The app tells you whether `@you` is available and what it costs. Shorter names cost more (see [Fees](/docs/x1id/pricing)). If it's taken, try a variation until you find one that's free. Handles are **letters, numbers, and simple characters only** — no emoji and no lookalike symbols from other alphabets. That's deliberate: it stops anyone from registering a name that *looks* identical to yours. More on that in [Trust & transparency](/docs/x1id/trust). ### Register it Confirm the registration in your wallet. You pay the one-time fee once, and `@you` is yours from that moment on — no renewals, ever, on a standard registration (an optional [lease-to-own](/docs/x1id/faq#lease-to-own) path spreads the cost instead). A standard registration also **includes a tradeable capability pNFT**, minted to your wallet in the same transaction at no extra cost (no separate mint fee). The name is now registered on-chain to your wallet. ### Point it at your addresses Open your handle and add the addresses you want it to resolve to — your X1 address, and optionally your Solana, Ethereum, and Bitcoin addresses. Now when someone sends to `@you`, their wallet looks up the right address for the chain they're on. You can prove you control each address so others see it as **verified**. Verified addresses are the ones people can trust — see [What you can do](/docs/x1id/features#prove-its-really-you). ### Build your profile (optional) Add an avatar, a website, your social links, and a link to your decentralized site. This is what turns `@you` from a routing label into an identity people recognize. ### Set it as your primary name Mark `@you` as your **primary** name. This is the reverse link: apps that support X1ID will now show your wallet as `@you` instead of a raw address, everywhere you appear. ## That's it You now have a name that works across chains, a profile behind it, and an identity apps can display. From here you might want to: **Looking someone up?** The search on [app.x1id.io](https://app.x1id.io) works two ways: type a name to check if it's free to register, or look up a handle that already exists to see whose it is, what it points to, and whether it's for sale. It's how you find a name to claim — and how you check a handle before you trust or buy it. --- # Get paid to your handle URL: /docs/x1id/get-paid > Turn @you into a way to get paid. X1ID gives every handle a QR code and a shareable payment link, so anyone can pay you by scanning or tapping — no copying a long address. # Get paid to your handle Once your handle points at an address, `@you` becomes something people can pay in a tap. Every handle gets a **QR code** and a **shareable payment link** — no one ever has to copy a wallet address again.
Example X1ID pay QR for the placeholder @yourname — not a real payment destination An example of an X1ID pay QR.
This QR is an **example only** — it encodes the placeholder `@yourname`, which isn't a real handle, so scanning it doesn't point at anyone or move any funds. Your handle's own page generates a QR for **your** address. ## How to get paid ### Link a payment address Add your X1 address to your handle (see [Claim your handle](/docs/x1id/get-started)). That's the address your QR and link point at — until it's set, they wait quietly rather than sending anyone to the wrong place. ### Open your handle's payment page Your handle page shows your QR code, your address with a one-tap copy button, and the buttons to copy or share your payment link. ### Share it any way you like Show the QR to pay in person, or send the link online. Optionally set an **amount** first and it's carried in the link, so the payer sees exactly what to send. Because the link and QR are tied to your **handle**, they keep working for as long as `@you` is yours. Change which address receives, and the same link and code follow — no need to reprint or re-share. Your QR encodes your plain X1 address, so wallets can scan it today. The payment link carries `@you` (plus any amount you set) — a smart wallet re-checks the handle when it's opened, so a shared link always resolves to the current `@you` rather than a stale address. ## Great for ## Take payments on your site Selling something? Create an **on-chain invoice** for the exact amount, add the [checkout widget](/docs/x1id/checkout) to your page with one script tag, and confirm payment on your server with `verifyInvoicePaid` from the SDK. To be told when you're paid instead of checking, register a [Pay webhook](/docs/x1id/pay-webhooks) for your wallet or `@name`: you get a signed `POST` for every invoice paid and every direct transfer to your payment address. ## Agents can pay too (x402) Payments to `@you` aren't limited to people. When a service answers a request with HTTP **402 Payment Required**, an AI agent can read the payment requirements, pay on-chain, and retry — the **x402** flow. On X1ID's own API that is four steps: 1. **Get the `402`.** Call with `X-Payer: `. The challenge's `accepts[].extra` carries a request-bound `reference` and a `redeemMessage`. 2. **Pay with the reference.** One `TransferChecked` to `payTo` with `extra.reference` appended as a read-only, non-signer account, signed and sent by your wallet. 3. **Sign `redeemMessage`** with the same wallet (`signMessage`). 4. **Retry the identical request** with a `PAYMENT-SIGNATURE` header carrying the payment's signature and that message signature as `payload.payerSig`. [Pay-per-call with x402](/docs/x1id/platform#pay-per-call-with-x402) has the exact transaction shape and a complete client. The [`@x1id/resolve`](https://www.npmjs.com/package/@x1id/resolve) SDK (0.22.0+) ships `buildX402Requirements`, `parseX402Response` and `x402RedeemMessage` for both sides of that exchange, and the [MCP server](/docs/x1id/mcp) exposes matching `prepare_x402_payment` and `verify_x402_payment` tools for agents. **Breaking for older clients (2026-10-11).** A retry without `payload.payerSig` is refused with `REDEEM_SIGNATURE_REQUIRED`. Your payment is not consumed: sign `redeemMessage` and resend the same payment. You only pay for a call that succeeds. A paid x402 call settles the payment **only on a `200`** — if the resource fails after your payment verifies (a `424` unreachable manifest, a `5xx` upstream error), the payment is **not** consumed. You can retry the *same* payment within the **redeem window: 600 s** from the payment's on-chain block time (the challenge's `maxTimeoutSeconds` stays 60 s; that is how fresh the quote is, not how long a landed payment stays redeemable). The challenge's reference also expires 600 s after it was issued. Past that, request a new challenge and send a new payment. A buyer-side mistake (a malformed request) is rejected before any payment is taken. --- # What you can do URL: /docs/x1id/features > Multi-chain addresses, a primary name, a web3 profile, subnames, verification, permanent ownership, recovery, locks, gifting, and a built-in marketplace — everything an X1ID handle gives you. # What you can do Your handle is the starting point. Here's everything you can do with it — use as much or as little as you like. ## Receive on any chain Point `@you` at your addresses on **X1, Solana, Ethereum, and Bitcoin**. When someone sends to `@you`, their wallet looks up the correct address for the chain they're paying from — no more copy-pasting a raw base58 string (the long run of letters and numbers a wallet address is made of, e.g. `7Np1jK…pQ4mZ`). You hand out one name; it works everywhere you hold funds. Your home chain. `@you` resolves to your X1 address so anyone can pay you on X1 by name. Add your Solana address and receive there too. On X1 and Solana the same wallet key works, so this usually needs no new setup. Add your Ethereum address so people on Ethereum can send to `@you` instead of a `0x…` string. Add your Bitcoin address and collect BTC under the same handle as everything else. You choose which chains to add — one or all four. Add more later at any time; your handle stays the same. ## Get paid with a QR code and a link Every handle comes with a **QR code** and a **shareable payment link**, so people can pay `@you` by scanning or tapping instead of copying an address. Show the QR in person, or drop the link in a bio, a message, or an invoice. ## Be shown as @you everywhere Set `@you` as your **primary name** and apps that support X1ID display your wallet as `@you` instead of a long address — in transaction histories, profiles, and anywhere your wallet appears. It's the difference between being `7Np1jK…pQ4mZ` and being a name people recognize. ## Build a web3 profile Behind your handle sits a profile you control: ## Subnames One handle can branch into purpose-built names — **`pay.you`**, **`docs.you`**, **`shop.you`** — all under the handle you already own, at **no extra registration fee**. Each one can carry its own addresses, so you can keep your money and your projects neatly separated under a single identity. Subnames go a second level deep, too: `pay.you` can have its own **`deep.pay.you`** sub-subname, for whoever needs to split a subname further (a payments subname handing out per-client or per-project addresses, say) without touching the parent handle. * **They're free to create** — no registration fee beyond the handle you already own. * **You're in control.** As the owner of `@you`, you create and remove its subnames whenever you like — and as the owner of a subname, you likewise control its own sub-subnames. * **They live under your handle.** Subnames follow the parent — there's nothing separate to renew or manage. * **Two levels deep.** `pay.you` is a subname; `deep.pay.you` is a sub-subname of it. That's as deep as it goes. ## Prove it's really you Anyone can *claim* to be `@you`. Verification lets you **prove** it — two ways: Verified records are the ones others should trust. An unverified address is shown as unverified so no one mistakes an unproven claim for a confirmed one — honesty is built in, not bolted on. ## Own it permanently Register a handle the standard way — the default — and it's yours to keep for a single, one-time payment. There's no renewal to remember and no expiry date that could hand your name to a stranger. This is a deliberate choice: a name people send money to must never quietly change owners because a payment was missed. See [Fees](/docs/x1id/pricing) for how one-time pricing works. **Or lease-to-own it.** At registration you can instead pay a fraction of the price up front — about a quarter, by default — and build equity toward that same permanent price over time. Pay it off in full at any point and it converts to permanent immediately, no more payments. Miss a payment and you still have a 30-day grace period before the handle becomes reclaimable — the same anti-inaction protection standard registration is built on, just with a lower bar to get started. See [the FAQ](/docs/x1id/faq#lease-to-own) for the mechanics. ## A safety net Losing access to a wallet is the nightmare scenario in crypto. X1ID gives you two tools for it: Set up a recovery option in advance, and if you lose access to your wallet you can take your handle back. It's your insurance policy against a lost or inaccessible key — set it up while everything is fine, so it's there when you need it. **Recovery applies to name-only handles** — a lease-to-own or a plain (un-minted) registration. Once a handle is a **capability NFT** — the default for a standard registration, and anytime you mint a name-only handle — on-chain recovery no longer applies: control *is* whoever holds the NFT, and the program refuses `set_recovery` / `initiate_recovery` on a tokenized name. For a tokenized handle, the equivalent protection is to **hold the NFT in a hardware wallet or a multisig**, and to use a **Lock** (below) if you think the holding wallet is at risk. If you think your key might be compromised, lock your handle. **On a name-only handle** a lock freezes it so a thief can't move it or redirect your addresses while you sort things out. Unlocking has a built-in waiting period, so an attacker can't simply lock and immediately steal. **On a tokenized handle (the default), today a lock is narrower:** it blocks setting a new primary name, but it does **not** stop the NFT itself from being sent (sending the NFT moves control), and it does **not** freeze records. If the holding wallet is compromised, the real protection is securing that wallet; a lock is a partial brake, not a full freeze. A true on-chain freeze of a tokenized handle's NFT is a planned change — this page will say "fully freezes" once it ships. ## Gift a handle You can **pre-pay a handle for someone else** — a great way to bring a friend onto X1. You cover the registration price up front; when they claim, the expensive part is already paid, and all they need is a tiny amount of XNT for the claim transaction's fee and the handle account's rent (unless someone relays the claim for them). ### Pick a name and pre-pay it Choose the handle you want to give and pay its registration fee up front. The amount is set aside for that specific name. ### Send the gift Send it to a friend to claim. You can tie the gift to a specific wallet so the handle lands there no matter who opens it. ### They claim it — registration prepaid Your friend claims the handle without paying any registration fee — just the transaction fee and account rent, a tiny amount of XNT. It becomes theirs, fully owned, from that moment. A gift is set aside for the name you chose, and it has an expiry of at most 366 days. If it's never claimed, the amount you pre-paid comes back to you — you're not out anything for an unclaimed gift. ## Send XNT by email Send XNT to someone who's **never used crypto** — no wallet address, no app, no seed phrase. Enter their email and an amount; we set up their x1id wallet, you send to it from your own wallet, and they sign in at [app.x1id.io](https://app.x1id.io) with that email to find the funds already there. ### Enter an email and an amount On the Send page, type the recipient's email and how much XNT to send. We pregenerate their x1id wallet for that email (or reuse it if they already have one). ### Send from your own wallet Your wallet signs a normal transfer to that address — X1ID never holds the funds. No escrow, no claim link. ### They sign in and it's there Whenever the recipient signs in at app.x1id.io with that email, their wallet appears automatically with the XNT already in it — a non-custodial wallet in their own name, no claim transaction needed. Because the recipient never submits a transaction to receive, this works even for a brand-new wallet with no XNT of its own — it costs the recipient **0 XNT** to receive, so the "empty wallet can't pay its own fee" problem simply doesn't arise. **Treat it like any crypto transfer.** The send is a real, non-custodial, on-chain transfer to a real address — there's no escrow, no claim link, and **no way to reverse it**. The email controls a genuine wallet holding genuine funds, so double-check the address you're sending to exactly as you would for any crypto payment. ## Buy and sell handles X1ID has a **built-in marketplace** at [app.x1id.io/marketplace](https://app.x1id.io/marketplace) — no third-party site needed. Handles change hands right inside X1ID, so a sale is as safe and final as any on-chain payment. **Registrations and marketplace actions may be paused.** The registry has an emergency lockdown, used for example during a security audit. While it's on, the program rejects new registrations, NFT mints, reserved-name and gift claims, TLD creation, and new listings, offers and auctions (`ProtocolLockedDown` / `MarketPaused`). Resolution, transfers, `set_primary` and records keep working, as do exits such as cancelling a listing, completing an existing sale, and lease renewal. Check the banner on [app.x1id.io](https://app.x1id.io), or read the flag from the `Config` account with the SDK's `readPauseState`. **List** a handle you own at a fixed price. When someone buys it, it's theirs and the proceeds come to you, minus the fee. A tokenized handle (the default) changes hands as an NFT transfer; a name-only handle transfers directly within X1ID. While a tokenized handle is **listed**, its NFT is **frozen** — you can't send it to someone else until you cancel the listing first. **Buy** a listed handle outright at its asking price and it's yours immediately — the perfect way to get a name someone else already registered. Select several listings at once and buy them all in a single transaction and signature; if one has changed or sold since you selected it, that row is dropped and named before you sign, and the rest still go through. **Make an offer** on a handle that **isn't** a capability NFT — a name-only handle (lease-to-own), a plain un-minted registration, or a treasury-held name. The owner can accept whenever they like; your offer is held safely until then. Offers (and auctions) are **not available on tokenized handles** — the default for a standard registration — which sell only by fixed-price listing or by a direct NFT transfer. **Bid** in an auction for a sought-after name (a name-only or treasury-held name — auctions, like offers, don't apply to tokenized handles). The rules the program enforces: a run lasts between **10 minutes and 30 days**; each bid must clear the reserve and beat the current high bid by at least **5%**; the seller can't bid on their own auction; and once it has bids it can't be cancelled, and the name can't be transferred, released or recovered until the auction settles (likewise, no bid lands while a recovery is pending). If the seller no longer owns the name (it was transferred, released or tokenized), anyone can close that seller's stale auction as long as it has no bids; the rent goes back to the seller. Anti-sniping: a bid in the **final 5 minutes** pushes the end out to 5 minutes from that bid (`end = max(end, now + 5 min)`), so a last-second snipe can't win uncontested. Premium and reserved names — short names, brands, and well-known words — are released through the marketplace rather than by ordinary registration. A **2% marketplace fee** applies to name-only sales; a tokenized handle carries a **2.5% on-chain royalty** instead (it replaces the fee). Details are on [Fees](/docs/x1id/pricing). Your handle is a tradeable NFT — a standard registration includes it, minted in the same transaction — so you can sell it on the X1ID marketplace or on standard Solana NFT marketplaces (it's Metaplex-compatible), with nothing extra to do. One thing to know: while a handle is **listed** on the X1ID marketplace its NFT is frozen, so cancel the listing before sending or re-listing it elsewhere. Name-only handles (from lease-to-own) trade on the X1ID marketplace and can be minted into an NFT anytime. See the [FAQ](/docs/x1id/faq). ## Next --- # Fees URL: /docs/x1id/pricing > Pay once, own forever — no renewals. How X1ID pricing works — a one-time registration priced by name length that already includes a tradeable pNFT at no extra cost, and a 2.5% royalty on a tokenized sale. # Fees X1ID is designed to be simple: **you pay once and own your handle forever.** On a standard registration there are no renewals, no subscriptions, and no expiry dates. (An optional [lease-to-own](/docs/x1id/faq#lease-to-own) path exists for spreading the cost — see the table at the bottom.) Here's the full picture. ## One-time registration Registering a handle is a **single, one-time payment** in **XNT** (X1's native token). After that, the handle is yours to keep — you never pay again to hold it. Because there's no renewal, your handle can't lapse and can't be snatched by someone else because you missed a payment. That permanence is the whole point of a name people send money to. ### Priced by length Shorter names cost more. Here's the launch price at every length. A registration is **one payment** that already **includes a tradeable capability pNFT** — minted to your wallet in the same transaction, at **no extra cost**. There is **no separate mint fee**. | Length | Tier | Register (one-time, launch) | | -------------- | --------- | ----------------------------------------- | | 1 character † | Legendary | treasury-held — not directly registerable | | 2 characters | Uncommon | 7,500 XNT | | 3 characters | Uncommon | 1,500 XNT | | 4 characters | Uncommon | 300 XNT | | 5 characters | Common | 75 XNT | | 6–9 characters | Common | 20 XNT | | 10+ characters | Common | 10 XNT | **† Single characters aren't directly registerable.** All 26 one-character handles are held by the treasury (registered to it at genesis) and reach an owner only through the [marketplace](https://app.x1id.io/marketplace) — never by direct registration, so they have no length-tier registration price. **These are current launch prices — an introductory discount, about half the full price.** Registration is a one-time cost, and that cost **rises over time**: roughly +12.5 points of the full price each quarter, reaching the full tier price about a year after launch. Because you pay once and own the name forever, **claiming earlier locks in a lower price** — a name registered today never costs more later. These prices are set on-chain and read live — the exact figure is always shown in the app before you confirm, so you never pay anything you haven't seen. Reserved words and the premium names held by the treasury reach owners through the [marketplace](https://app.x1id.io/marketplace) rather than direct registration (see below); any short name that wasn't reserved registers directly, at its (high) tier price above. ## Premium & reserved names Some names aren't available for ordinary registration — brands, tickers, well-known words, and a curated set of short names were reserved or set aside for the treasury at launch. These reach new owners through the **marketplace** (a listing or an auction) rather than by direct registration. Short names that weren't reserved register directly like any other name — just at the higher price of their tier. If a premium name is what you want, look for it on [app.x1id.io/marketplace](https://app.x1id.io/marketplace). ## The sale royalty Because every standard registration now includes a tradeable **pNFT**, a handle you sell is a **tokenized** sale — and a tokenized sale carries a **2.5% on-chain royalty**, not a separate marketplace fee (the royalty replaces it). A plain **name-only** sale (a handle that was never tokenized) instead pays the flat **2% marketplace fee**. Either way, if you're just registering and holding your own handle, nothing applies — it only ever touches a sale. ## The tradeable pNFT, included by default A registration already **includes a tradeable capability pNFT** — minted to your wallet in the **same transaction** as the registration, at **no extra cost**. You don't mint anything separately, and there is **no separate mint fee**. The pNFT is what lets you trade your handle on standard NFT marketplaces if you ever choose to. (Name-only paths like [lease-to-own](/docs/x1id/faq#lease-to-own) tokenize when the handle becomes permanent — still at no extra cost.) See the [FAQ](/docs/x1id/faq) for what tokenizing does. **The X1 network fee.** Separate from the price above, a tokenized registration pays the ordinary X1 transaction fee for the compute it uses — about **0.0045 XNT** (it's two quick transactions, \~0.0003 + \~0.0042); claiming a gift is a single \~0.0042 XNT transaction. X1 charges the transaction's requested compute-unit limit × 10 lamports per compute unit. It goes to the network, not to X1ID, and it's the same kind of gas any on-chain action pays. ## At a glance | What | Cost | When | | ------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Register a handle | One-time, in XNT, priced by length (table above) | Once, when you claim it | | Lease-to-own instead (optional) | About a quarter of the permanent price ≈ your first year | Periodic payments (30-day grace if missed) that accumulate toward the pinned permanent price — pay it off anytime and it converts to permanent | | Hold your handle | **Free forever** once registered or paid off | No renewals, ever, on a standard registration | | Tradeable capability pNFT | **Included** with a standard registration — no extra cost, no separate mint fee | Minted in the same transaction | | Sell a handle | 2.5% royalty on a tokenized sale (replaces the fee); 2% marketplace fee on a name-only sale | Only when it sells | | X1 network fee | ≈ 0.0045 XNT per tokenized registration (two txs), ≈ 0.0042 XNT per gift claim (paid to the network) | Per on-chain transaction | Whatever you're about to pay is always shown in the app before you confirm. Nothing is charged without you approving it in your wallet first. --- # Resolve API URL: /docs/x1id/api > Look up the wallet a handle belongs to. Resolution is read-only and public — no wallet, no signature, and no API key. The base URL is https://api.x1id.io. Resolve API / Resolve a handle

Resolve a handle

Look up who owns a handle. Resolution is **read-only and public** — no wallet, no signature, and **no API key**. The base URL is `https://api.x1id.io`; there is no `/v1` prefix. The REST endpoint returns the handle's **owner**; for per-chain addresses (X1 / SOL / ETH / BTC) and richer results, use the [SDK](#sdk-multi-chain-records-and-writes) below. **Handles and domains, never confused.** X1ID handles (`@jack`) and X1NS domains (`jack.x1`) are complementary, and this endpoint resolves both — the response's `namespace` field always names which one answered, and a name that could be either shape is refused rather than guessed. See [how they fit together](/docs/x1id/faq#x1id-and-x1ns). ## Parameters The handle to resolve, with or without the leading @. Case-insensitive — x1 and @X1 resolve the same record. Given as a path segment: /resolve/x1. ## Returns Returns the resolution object for the handle. It reports the current **owner** and whether the handle is **tokenized** — it does **not** carry an addresses map. For a **tokenized** handle, `owner` is the **current NFT holder**, which this endpoint resolves for you — not the registry account's raw `owner` field. **Don't attribute a tokenized name from a raw owner field.** The registry account's `Handle.owner` is the **original registrant** and is **not updated** when a tokenized name is sold or the NFT is sent. Reading it directly — or using any indexer / "handles by owner" listing that filters on it — will attribute a traded name to the wrong wallet (and may report it as `tokenized: false` with a null `registered_at`). To find who controls a name right now, use this endpoint or the [`@x1id/resolve`](/docs/x1id/sdk) SDK, both of which resolve the live NFT holder. Indexer-derived sale/activity feeds also lag the chain by a few seconds to a minute — treat the resolver as the source of truth. For a handle on a **lease-to-own** term the result also includes an optional `lease` object (`{ expires_at, lapsed }`), so you can see the paid-through date and whether the term has lapsed; the field is **omitted** once the handle is owned permanently (as in the example below). A **dotted** name — a `.sol`/`.eth` name, or any subname like `alices.shib` — returns `400 { "error": "subnames aren't resolvable via this REST endpoint (top-level handles only) — resolve subnames with the @x1id/resolve SDK" }`; this endpoint answers top-level `@handles` and X1NS TLDs only. An unregistered handle returns `404 { "error": "handle not registered" }`. **Sending funds? Check `lease.lapsed`.** A leased handle past its paid-through date still resolves here (the registry hasn't reclaimed it yet), but it can be reclaimed by someone else after its grace period. This endpoint is the only resolver that reports it — the [SDK](/docs/x1id/sdk) and the keyed `/v1/resolve` are lease-unaware. Refuse a send when `lease.lapsed` is `true`. ```json title="GET https://api.x1id.io/resolve/x1" { "input": "x1", "name": "x1", "namespace": "handle", "owner": "E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6", "tokenized": false, "verification": "owner" } ``` | field | meaning | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | the raw value you passed | | `name` | the normalized handle (no `@`) | | `namespace` | which namespace answered: `handle` for an `@handle`. The endpoint also resolves X1NS domains, where this is the TLD (`x1`, `xnt`, or `xen`) | | `owner` | the wallet that owns the handle. For a **tokenized** handle this is informational — authority follows the NFT holder | | `tokenized` | whether the handle has been turned into an NFT | | `verification` | always the literal `owner` — a label, not a trust grade | | `lease` | *(optional)* present only while the handle is on a lease-to-own term: `{ expires_at, lapsed }`. Omitted for permanently-owned handles | **`@x1` is a held name — the example resolves to the X1ID treasury.** `E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6` is the treasury, which holds reserved and premium names until they reach an owner through the marketplace. A held name resolves like any other, with no flag. Before sending funds, check [`/quote/{name}`](#price-quote) and refuse when `status` is `"held"`. See [the pre-send checks](/docs/x1id/examples#step-4--check-before-you-sign). **Ownership you can audit.** Every handle is a record on the X1 registry program (`8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`), so a resolution can be verified independently on-chain. See [Trust & transparency](/docs/x1id/trust). ## Reverse lookup Go the other way — an address back to its `@handle`. Only the address the owner has explicitly marked as their **primary** reverse-resolves, which is what prevents impersonation. An address with no primary returns `404 { "error": "no primary set" }`. ```bash curl https://api.x1id.io/reverse/H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA # 404 → { "error": "no primary set", "input": "H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA" } ``` When a primary **is** set, it returns `200` with the shape `{ address, handle, name, tokenized, verified_at }`: `name` is the primary name without the leading `@`, `handle` is the address of that name's on-chain `Handle` account, `tokenized` is whether the name is NFT-backed, and `verified_at` is the Unix time the owner set this primary pointer. ```json title="GET https://api.x1id.io/reverse/3bT5AL71EB5QpLW2ztLtRSxc5cZ6ASDzSc5b3eavZeWu" { "address": "3bT5AL71EB5QpLW2ztLtRSxc5cZ6ASDzSc5b3eavZeWu", "handle": "CBSPwKRMmxjYZeg3ZKtZjbK6dUvupd4qSCw89KyWSvZd", "name": "420x169", "tokenized": true, "verified_at": 1791749936 } ``` There is no `GET /verify/` endpoint on this API — verification is read from on-chain attestations (see [Verification](/docs/x1id/verification)) or via the SDK. An unknown path returns the API's generic `404 { "code": "NOT_FOUND", "error": true, "message": "not found" }` (note the different shape from an endpoint's own `{ "error": "…", "input": "…" }`). ## Batch reverse lookup Reverse-resolve up to **100 addresses in one request**, for example every holder on a token page. Each address is answered exactly as [`/reverse/{address}`](#reverse-lookup) would answer it, so only an address with a **primary** name gets a name back. Like the rest of this API it is keyless and sends CORS headers, so it works from a browser. Send a JSON body with an `addresses` array of base58 addresses: ```bash title="terminal" curl -X POST https://api.x1id.io/reverse/batch \ -H 'Content-Type: application/json' \ -d '{"addresses":["GGdYrcMq9fsEXoamvWDU4dBF4iLhPV933B2vZCaaTvGw","E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6","3bT5AL71EB5QpLW2ztLtRSxc5cZ6ASDzSc5b3eavZeWu"]}' ``` ```ts title="batch.ts" const res = await fetch('https://api.x1id.io/reverse/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ addresses: holders }), // up to 100 base58 addresses }); if (res.status === 429 || res.status === 503) { const wait = Number(res.headers.get('Retry-After') ?? '2'); // back off for `wait` seconds, then retry } const items: { address: string; name: string | null; error?: string }[] = await res.json(); ``` The response is a JSON array with one item per address, **in the order you sent them** (a repeated address is looked up once and appears at each of its positions): ```json title="200 OK" [ { "address": "GGdYrcMq9fsEXoamvWDU4dBF4iLhPV933B2vZCaaTvGw", "name": "e2emainnetwalk02" }, { "address": "E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6", "name": null }, { "address": "3bT5AL71EB5QpLW2ztLtRSxc5cZ6ASDzSc5b3eavZeWu", "name": "420x169" } ] ``` The treasury `E2cELM…iUq6` owns `@x1` but has no primary name set, so its `name` is `null`, the same answer `/reverse` gives as a `404`. | field | meaning | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `address` | the address exactly as you sent it | | `name` | the address's primary name without the `@`, or `null` when it has none (or the lookup failed) | | `error` | *(optional)* present only when that one address could not be answered: `INVALID_ADDRESS` (not a valid base58 public key; don't retry it) or `LOOKUP_FAILED` (the lookup hit an upstream RPC problem; **retryable**, so retry that address later) | A `null` name with no `error` is a definitive "no primary name". The request as a whole can fail with: | Status | When | | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | | `400` | the body is not JSON, has no `addresses` array, the array is empty, an entry isn't a string, or it holds **more than 100** addresses | | `413` | the request body is larger than **8 KiB** (100 addresses fit comfortably) | | `429` | the batch costs more rate-limit tokens than you have left; wait the `Retry-After` seconds | | `503` | **every** lookup in the batch failed (upstream RPC busy or unavailable); retry after `Retry-After` | A batch of `n` addresses costs **ceil(n/10)** rate-limit tokens, so 1 to 10 addresses cost 1 token and a full batch of 100 costs 10 (see [Rate limits](#rate-limits)). Batch answers share the `/reverse` cache, and `?fresh=1` or `Cache-Control: no-cache` bypasses it for every address in the batch (see [Caching](#caching)). For sustained high volume, use the keyed [`POST /v1/reverse/batch`](/docs/x1id/platform) on the developer platform instead: the same request and item shape, up to 100 addresses, authenticated with an `X-API-Key`, and metered at **1 unit per 10 addresses** against your key's quota. ## Rate limits The keyless endpoints are rate-limited **per client IP**: **100 requests per second sustained, with a burst of 200**. Every response carries two headers so you can pace yourself: | Header | Meaning | | ----------------------- | ------------------------------------------------------------ | | `X-RateLimit-Limit` | your bucket size (the burst, `200`) | | `X-RateLimit-Remaining` | requests you can still make right now before you are limited | Two statuses ask you to slow down, and both carry a `Retry-After` header with the number of seconds to wait: | Status | `error` | Meaning | | ------ | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `429` | `"rate limited"` | you are over your per-IP limit | | `503` | `"upstream rpc busy, retry shortly"` or `"server busy, retry shortly"` | the API is shedding load because the upstream X1 RPC is busy; your request was not answered, not refused | On either one, wait at least the `Retry-After` seconds before retrying, and don't retry in a tight loop. A `503` is always safe to retry. Looking up many addresses? One [batch](#batch-reverse-lookup) of 100 costs 10 tokens, where 100 separate `/reverse` calls cost 100. ## Caching `/resolve` and `/reverse` answers (including each address in a batch) are cached for about **20 seconds**. Found names and definitive misses (`404`) are cached; errors never are, so a retry after a `503` always does a fresh lookup. The `X-Cache` response header on `/resolve` and `/reverse` tells you which you got: `HIT` (served from the cache) or `MISS` (read from the chain just now). To skip the cache and read the chain directly, add `?fresh=1` to the URL or send `Cache-Control: no-cache`: ```bash title="terminal" curl 'https://api.x1id.io/resolve/x1?fresh=1' curl -H 'Cache-Control: no-cache' https://api.x1id.io/resolve/x1 ``` **Read fresh before you move money.** A cached answer can be up to 20 seconds old, and a name can change hands in that window. Before any payment or transfer decision, resolve with `?fresh=1` (or `Cache-Control: no-cache`) so you pay the current owner. ## Health A liveness check for the service. ```bash curl https://api.x1id.io/health # → { "ok": true } ``` ## Version Reports the RPC endpoint the API reads from and that node's `solana_core` version. Returns `502 { "error": "rpc unreachable" }` if the RPC can't be reached. ```bash curl https://api.x1id.io/version # → { "rpc": "https://rpc.mainnet.x1.xyz", "solana_core": "…" } ``` ## NFT metadata Standard NFT metadata JSON for a handle — `name` (`@name`), `symbol` (`X1ID`), `description`, `external_url` (the handle's page on app.x1id.io), and `attributes` (`length`, `rarity_tier`, `handle_type`, and a `bucket` where one applies). The `image` is a self-contained SVG embedded as a `data:image/svg+xml;base64,…` URI, so nothing depends on external hosting. ```bash curl https://api.x1id.io/nft/x1 # → { "name": "@x1", "symbol": "X1ID", "description": "…", # "image": "data:image/svg+xml;base64,…", # "external_url": "https://app.x1id.io/handle?name=x1", # "attributes": [ { "trait_type": "length", "value": 2 }, … ] } ``` ## Price quote The live registration price for a name — the same figure the app shows before you register (the tier price with the on-chain time ramp applied). ```bash curl https://api.x1id.io/quote/alice # available → { "input": "alice", "name": "alice", "available": true, "status": "available", # "price_lamports": 75000000000, "tier_index": 4 } # already registered → { "input": "x1", "name": "x1", "available": false, # "status": "held" | "taken", "claimable": true | false, # "price_lamports": null, "tier_index": 0 } ``` An **available** name carries `available: true`, `status: "available"` and a live `price_lamports`. A name that's already on-chain is **not priced** (`price_lamports: null`): `status` is `"held"` (treasury-held, with `claimable` telling you whether it can be claimed via `claim_reserved`) or `"taken"` (owned by someone / tokenized). **Use `status: "held"` to protect a payment.** A held name resolves to the X1ID treasury, and nothing in a resolve result says so. Wallets and payment flows should call `/quote/{name}` before sending to an `@handle` and refuse the send when `status` is `"held"`. Availability is read from chain on every call. ## NFT collection Metadata for the "X1ID Handles" collection parent — the verified collection every tokenized handle belongs to. ## OpenAPI & JSON schemas Machine-readable contracts: the full OpenAPI document for this API, plus JSON Schemas (2020-12) for a `/resolve` response and the agent manifest. ## Gift email notification Emails a gift's claim link to a recipient. It's bound to a real, unclaimed on-chain voucher — so it can only ever notify about a gift that genuinely exists, never an open mail relay. Body: `{ "name": "", "to": "" }`. The `to` address must be a **bound email** — one that already resolves to an x1id embedded wallet (the same pregenerated wallet [`/privy/wallet`](#send-to-a-no-coiner-by-email) returns). The endpoint will not send a claim link to an arbitrary address; this is what keeps it from being an open relay. ```bash curl -X POST https://api.x1id.io/gift/notify \ -H 'content-type: application/json' \ -d '{"name":"alice","to":"them@example.com"}' # → { "ok": true } ``` | status | `code` | meaning | | ------ | ------------------- | -------------------------------------------------------------------------------- | | `400` | `INVALID_REQUEST` | missing/malformed `name` or `to`, or `to` is not a valid email. | | `403` | `EMAIL_NOT_BOUND` | `to` is not a bound email — no embedded wallet resolves to it. | | `404` | `NO_PENDING_GIFT` | no unclaimed on-chain voucher exists for that `name`. | | `424` | `EMAIL_SEND_FAILED` | the voucher is valid but the upstream mail provider rejected or failed the send. | | `429` | `RATE_LIMITED` | too many notify attempts; retry after the window. | ## Send to a no-coiner by email Pregenerates (or looks up) the recipient's x1id embedded Solana wallet for an email, so a sender can transfer XNT to a **real address before the recipient has ever used crypto**. The recipient later signs in at app.x1id.io with that email and the wallet — and the funds — are already there, with no claim step. Returns only the address. Body: `{ "email": "" }`. ```bash curl -X POST https://api.x1id.io/privy/wallet \ -H 'content-type: application/json' \ -d '{"email":"them@example.com"}' # → { "address": "…" } ``` ## SDK: multi-chain, records, and writes The REST endpoint answers "who owns this handle". For **per-chain addresses**, **records**, and **on-chain writes**, use the [`@x1id/resolve`](https://www.npmjs.com/package/@x1id/resolve) SDK. It reads chain state directly over RPC (it never depends on this API's uptime), and its writes are unsigned instruction builders you sign and relay yourself — the SDK never holds keys. The `@handle` registry is on **X1 mainnet** (`https://rpc.mainnet.x1.xyz`). This page targets **`@x1id/resolve@0.22.0`** — the current npm `latest`. For the full method-by-method reference see [SDK reference](/docs/x1id/sdk). ```bash npm install @x1id/resolve@0.22.0 ``` ### Load the WASM module Account derivation runs in a small Rust→WASM module — there is deliberately no JavaScript fallback, so `wasm` is a required field on `ResolverConfig`. The package ships the module at the subpath `@x1id/resolve/wasm/x1_resolve_wasm.wasm`. Load it once, at startup: ```ts title="resolver.ts" import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { WasmResolver, createResolver } from '@x1id/resolve'; const require = createRequire(import.meta.url); const wasmPath = require.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm'); const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath)); // testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz' export const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); ``` ```ts title="resolver.ts" // Vite / Rollup / webpack 5 — the `?url` suffix hands you the asset URL import wasmUrl from '@x1id/resolve/wasm/x1_resolve_wasm.wasm?url'; import { WasmResolver, createResolver } from '@x1id/resolve'; const wasm = await WasmResolver.fromBytes(await (await fetch(wasmUrl)).arrayBuffer()); // testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz' export const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); ``` ### Read: resolve across chains `resolve(input, { chain })` returns a `Resolved` object. X1/SOL come from the handle's authority; ETH/BTC from its on-chain records. ```ts title="resolve.ts" import { x1id } from './resolver'; // the loaded resolver from above const r = await x1id.resolve('@x1', { chain: 'SOL' }); // @x1 is held by the treasury — fine for a read, never a payment recipient. // { // input: '@x1', name: 'x1', namespace: 'handle', // address: 'E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6', // chain: 'SOL', verification: 'verified' // } // A handle with no record for the requested chain is explicit, never a wrong // address: resolve('@x1', { chain: 'ETH' }) throws // ResolveError { code: 'no-record-for-chain' }. ``` ### Read: reverse and records ```ts const name = await x1id.reverse(address); // 'nike' → the address's primary @handle (no '@') // null → no primary set const recs = await x1id.records('@x1'); // HandleRecord[]: { account, handle, coinType, chain, value, address, // verified, updatedAt, stale } ([] when none are set) ``` ### Write: sign and relay yourself **Registration is in the SDK.** The package ships instruction builders for every registration path — `buildRegisterIx` (permanent), `buildRegisterLeasedIx` (lease-to-own), and `buildCommitIx` / `buildRegisterRevealedIx` (front-run-safe commit–reveal) — alongside builders for the rest of the registry. Only **`transfer`** and **`set_primary`** are not yet in the SDK; run those from [app.x1id.io](https://app.x1id.io) for now. Each builder returns an unsigned `{ programId, keys, data }` you assemble, sign, and send: ```ts title="subname.ts" import { buildCreateSubnameIx } from '@x1id/resolve'; const ix = buildCreateSubnameIx({ programId, // 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P payer, owner, // signers — owner is the parent handle's authority parent, subname, // the parent and subname PDAs label: 'team', // issues team.you }); // add ix to a transaction, sign with payer + owner, send. ``` Also shipped, same build → sign → relay shape: gifts (`buildCreateVoucherIx` + `HandleType`), integrator rev-share (`buildAddIntegratorIx`), name locks (`buildLockHandleIx`), record delegation (`buildSetRecordDelegateIx`), typed text records (`buildCreateTextRecordIx`), and non-SVM record proofs (`buildVerifyRecordEthIx` / `buildVerifyRecordBtcIx`). **Which RPC?** Point the resolver at the chain that holds the names you want. `@handle` resolution is on **X1 mainnet** (`https://rpc.mainnet.x1.xyz`); the same registry is also deployed on X1 testnet (`https://rpc.testnet.x1.xyz`) for development. On an RPC where the registry is not deployed a handle is an honest `not-found`, never a fabricated address. ## The keyed `/v1` platform ### Free REST vs. keyed `/v1` | | Free REST (`api.x1id.io/resolve/…`) | Keyed `/v1` (`api.x1id.io/v1/…`) | | -------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Auth | none | `X-API-Key` (server-side only), or x402 pay-per-call when no key is sent | | Limits | per IP: 100 requests/s, burst 200 ([details](#rate-limits)) | per key: free 300/min and 15,000/day, partner 1,000/min and 100,000/day | | Over the limit | `429` | `429 RATE_LIMITED` — a keyed request never falls through to x402 | | Resolve | top-level `@handles`, X1NS, launched customer TLDs; returns `owner`, `tokenized`, `lease` | `/v1/resolve/{name}?chain=X1\|SOL\|ETH\|BTC` returns `address`, `chain`, `source`; no `lease` | | Also | `/reverse`, `POST /reverse/batch`, `/quote`, `/nft` | sign-in, Gate, agents, universal resolver | If you hold real funds, the SDK against your own RPC is the source of truth; both REST surfaces are a convenience on top of the same chain state. Use the free `/quote` and `/resolve` for the [held and lapsed checks](/docs/x1id/examples#step-4--check-before-you-sign) either way. Everything above is **public and keyless** — resolution is a read anyone can do. There's also a **keyed developer platform** under `https://api.x1id.io/v1`, where a key identifies your app so the platform can rate-limit, meter, and run higher-level flows the public endpoint can't: sign-in, access gating, agent discovery, and cross-namespace resolution. Send your key in the `X-API-Key` header, or pay per call with [x402](/docs/x1id/platform#pay-per-call-with-x402). --- # Invite friends URL: /docs/x1id/referrals > Share your referral link from app.x1id.io. When someone registers a handle through it, you're credited on-chain as their referrer — a simple way to grow your corner of X1. # Invite friends X1ID is more fun when the people you deal with have handles too — you can pay them by name, recognize them across apps, and stop trading raw addresses. Referrals make it easy to bring them in, and they record who introduced whom. ## How it works ### Grab your referral link Open the **/refer** page at [app.x1id.io/refer](https://app.x1id.io/refer). Your referral link is tied to your own handle or wallet. ### Share it Send it to friends, post it, add it to your bio — anywhere you'd invite someone to X1. ### They claim a handle through your link When someone registers their handle after arriving through your link, your referral is attached to that registration **on-chain**, crediting you as the person who brought them in. ## What you get **Referrals are recorded on-chain as attribution** — who introduced whom — and that credit is the basis for rewards. X1ID intends to reward its top referrers with **premium @names from the treasury**: the rarest identities on X1 go to the people who grew the network. The reward program isn't live yet, but your referrals are being counted on-chain toward it now — so invite people because you want them on X1 with you, and because growing the network is rewarded. Referral attribution is being rolled out across X1ID, so counts may take a little time to catch up as the system settles in. The credit itself is written on-chain with each registration. ## Next --- # Trust & transparency URL: /docs/x1id/trust > Permanent ownership, anti-impersonation by design, verified records, recovery, and the on-chain program address you can audit yourself. # Trust & transparency X1ID handles hold identity and route payments, so trust matters. Everything below is built in, and the final section gives you the on-chain address to check it all yourself. ## Your handle is permanently yours Once you register a handle the standard way — the default — it's yours: there's no renewal to miss and no expiry that could quietly transfer it to someone else. A name people send money to must never change owners through inaction, so ownership doesn't lapse. It only moves when **you** choose to transfer or sell it. X1ID also offers an optional lease-to-own path (pay a fraction up front, build equity toward the same permanent price over time) for whoever would rather spread the cost out. It keeps the same anti-inaction protection at its core: a 30-day grace period after a missed payment, so a leased handle can't be silently swooped in on the moment a payment is late — see [the FAQ](/docs/x1id/faq#lease-to-own). ## Built to resist impersonation Handles use **letters, numbers, and simple characters only** — no emoji and no lookalike letters borrowed from other alphabets. This closes a real trap. In many systems, an attacker can register a name that *looks* identical to yours using a lookalike character from another alphabet — for example a Cyrillic "а" in place of a Latin "a" in `@alice`. To the eye they're the same; to a computer they're different names. By allowing only one, plain character set, X1ID removes that whole class of attack instead of trying to spot it after the fact. ## Verified vs. unverified records Anyone can *type* an address into a name. X1ID lets you **prove** you control your addresses, and it shows the result honestly: a proven address appears as **verified**, and an unproven one is shown as **unverified**. Nothing unproven is ever dressed up as trusted, so people know what they're relying on before they send. ## Recovery, if you lose access For a **name-only** handle (lease-to-own or a plain un-minted registration), set up recovery in advance so that a lost or inaccessible wallet doesn't mean a lost handle. Once a handle is **tokenized** (the default for a standard registration), control *is* whoever holds the NFT, so on-chain recovery no longer applies — keep the holder wallet in a hardware wallet or multisig instead. Either way, a **Lock** is the emergency brake if you fear your key is compromised. See [What you can do](/docs/x1id/features#a-safety-net). ## You can audit it yourself X1ID runs as a public on-chain program. You don't have to take our word for anything — you can inspect it directly on the [FortiBlox explorer](https://explorer.fortiblox.com/address/8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P). This program is deployed on **X1 mainnet** (RPC `https://rpc.mainnet.x1.xyz`). You don't have to trust that the deployed binary is what we say it is — dump it from the chain and hash it yourself: ```bash title="terminal" solana program dump 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P x1id.so \ --url https://rpc.mainnet.x1.xyz # 1. the whole file, exactly as dumped (no trimming) sha256sum x1id.so # → 155c4f8e91e6ad5032f03310dc6c8fecf78936438790ad7c3c30270ccbccc69a (1,709,040 bytes) # 2. the program ELF only: the first 1,704,560 bytes head -c 1704560 x1id.so | sha256sum # → bfc230cdbfaf4910ab78c9d4f83408b69d100a621fe3e37956a692bd358e91ca # 3. confirm the rest is zero padding tail -c +1704561 x1id.so | tr -d '\0' | wc -c # → 0 ``` `solana program dump` writes the ProgramData account's bytes after its 45-byte header (the slot and upgrade authority), so the first hash is `sha256(programdata[45..])`, all 1,709,040 bytes. The account is larger than the program it currently holds: the ELF is 1,704,560 bytes and the remaining 4,480 bytes are zeros. The second hash covers only the ELF, and it matches the canonical build of source commit `5fb1e772`, so it's the one to compare against a binary you build yourself. The third command proves nothing else is hiding in the padding. These values are as of the upgrade executed by the treasury multisig at slot **85461348** on 2026-10-12 ([transaction](https://explorer.fortiblox.com/tx/oBdWiWkzukD1nSqTXUp3hRoC4ZxHmE1jJDiwfvCFW12CueZR1P7LTqNdjHvUhd6TpQX2qNkMGsqmL3Tqt1okE9J)). They change with each program upgrade: `solana program show` prints the "Last Deployed In Slot", and if that is later than slot 85461348 the program has been upgraded since this page was written and different hashes are expected. A few more facts you can check on-chain rather than take on faith: * **Treasury** — the program's `Config.treasury` is **immutable** (there is no instruction that can change it) and is set to `E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6`, a 2-of-5 multisig PDA. No single key can move registration proceeds. * **Upgrade authority** — the program's upgrade authority is the same 2-of-5 treasury multisig (`E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6`), so an upgrade needs two of five guardian approvals; no single key can change the program. The program remains upgradeable, and burning the upgrade authority is an option for later rather than a scheduled step. `solana program show` reports the current authority at any time, so you never have to take our word for its status. ## For developers If you're building an app and want to resolve `@handles` to addresses, there's a public package, **`@x1id/resolve`**, on npm. Start at [app.x1id.io](https://app.x1id.io). --- # Common questions URL: /docs/x1id/faq > Everything a holder asks — the basics, ownership and permanence, claiming a handle, using it day to day, safety and trust, the marketplace, and how X1ID fits alongside X1NS. # Common questions ## The basics X1ID is a naming system on X1 that gives you a single, human-readable handle — `@you` — in place of long wallet addresses. That one name can point to your addresses on several chains, carry a profile, and act as your identity across apps. You own it permanently. A handle is your `@name` — flat and simple, like `@jack`. It's the thing people send to, the name apps show for your wallet, and the identity your profile hangs off of. A wallet address is a long string like `7Np1jK…pQ4mZ` — unreadable, hard to remember, and unforgiving if a single character is wrong. `@you` is a name a person can actually read and share. Behind the scenes it resolves to your real addresses, so senders get the right destination without ever handling the raw string. A single handle can point to your addresses on **X1, Solana, Ethereum, and Bitcoin**. Someone paying you is routed to the correct address for the chain they're on. You choose which chains to add. Yes — X1ID is live on **X1 mainnet**. Handles you register are mainnet registrations, held by a public on-chain program you can verify yourself. See [Trust & transparency](/docs/x1id/trust). ## Ownership & permanence For a standard registration, yes. You pay once, and the handle is yours to keep — no renewals, no subscription, no expiry date. This is a deliberate design choice: a name people send money to must never quietly change owners because a payment was missed. Standard registration stays the default; see the next question for the one alternative. Not on a standard registration — a single one-time fee and nothing after that. If you instead choose **lease-to-own** at registration (a cheaper way to start), you do pay periodically until it's paid off — see [Lease-to-own](#lease-to-own) below. Not on a standard registration — there's no lapse for anyone to swoop in on; your handle changes hands only if you choose to transfer or sell it. A leased handle is different: miss a payment past its 30-day grace period and it becomes reclaimable — see [Lease-to-own](#lease-to-own). No. Your handle is owned by your wallet, on-chain. We don't hold it and can't move it. It's genuinely yours. Yes. You can transfer it to another wallet, or sell it on the built-in marketplace. See the marketplace questions below. ## Lease-to-own A cheaper way to start: instead of paying a handle's full permanent price up front, you pay a fraction of it — about a quarter, by default — as your first year's payment. Every payment you make builds equity toward that same permanent price, pinned at the price on the day you registered. Keep paying and it's the same handle, held the same way; pay off the rest at any point and it converts to permanent immediately, with no more payments ever. There are no refunds. You get a 30-day grace period after your paid-through date before the handle becomes reclaimable by someone else. Anyone can pay to renew a leased handle during that window — including someone other than the current holder, the same way most name registrars let a third party keep a name from lapsing. No. Standard, one-time permanent registration is still the default and the one most people should reach for — see "Is my handle really permanent?" above. Lease-to-own is an option you choose at registration time, for whoever would rather spread the cost out. ## Getting one Go to [app.x1id.io](https://app.x1id.io), connect your wallet, search for a name, and register it. The full walkthrough is on [Claim your handle](/docs/x1id/get-started). A single, one-time fee in XNT, priced by how short the name is — shorter names cost more. There's no charge to keep holding it. The app shows the exact price before you confirm, and the live pricing curve is at [x1id.io/pricing](https://x1id.io/pricing). Full details on [Fees](/docs/x1id/pricing). Yes — registration is paid in XNT, X1's native token, and you'll always need a little XNT for transaction fees. See [Fees](/docs/x1id/pricing). Most everyday names are open to register directly. Brands, tickers, well-known words, and a curated set of short names were reserved or set aside for the treasury at launch and reach new owners through the **marketplace** — a listing or an auction — rather than by direct registration. Any name that wasn't reserved, short ones included, registers directly. Short names are the rarest and most premium, and they cost the most. The ones reserved or held by the treasury at launch are released through the marketplace — look for those on [app.x1id.io/marketplace](https://app.x1id.io/marketplace). A short name that wasn't reserved can still be registered directly, at its (high) tier price. ## Using your handle They enter `@you` where they'd normally paste an address. Their wallet looks up the right address for the chain they're sending on and routes the payment there. You just share your handle. Your handle can resolve on X1, Solana, Ethereum, and Bitcoin, and any wallet or app that supports X1ID can use it. Support is growing across the ecosystem. Setting a handle as your **primary** creates the reverse link from your wallet back to your handle. After that, apps that support X1ID show your wallet as `@you` instead of a long address wherever you appear. A subname is a purpose-built name under the handle you already own — `pay.you`, `docs.you`, `shop.you`. There's no extra registration; they live under your handle and are yours to create or remove anytime. See [What you can do](/docs/x1id/features#subnames). Yes. Behind your handle is a profile you control: an avatar, your website, your social links, and a link to your decentralized site. It's what turns `@you` from a routing label into an identity people recognize. Yes — you can attach a link to your decentralized website to your handle, so `@you` carries your site along with everything else. Every handle comes with a **QR code** and a **shareable payment link**. Show the QR to be paid in person, or send the link online — either way people pay `@you` without copying an address. Set an optional amount and it rides along in the link. See [Get paid to your handle](/docs/x1id/get-paid). Use the search on [app.x1id.io](https://app.x1id.io). Type a name to see if it's free to register, or look up an existing handle to see whose it is, what addresses it points to, and whether it's listed for sale — worth doing before you trust or buy one. Yes. You can **pre-pay a handle** for someone — you cover the registration price up front, so the expensive part is already paid when they claim. They'll still need a tiny amount of XNT for the claim transaction's fee and the handle account's rent (unless someone relays the claim for them). A gift is set aside for the name you chose and has an expiry; if it's never claimed, your payment comes back to you. See [What you can do](/docs/x1id/features#gift-a-handle). Yes. Enter their **email** and an amount, and X1ID pregenerates an x1id embedded wallet for that email; your own wallet then sends the XNT straight to it. The recipient pays **0 XNT** to receive — they never submit a transaction, so the "an empty wallet can't pay its own fee" problem never arises — and the funds are simply there when they sign in at [app.x1id.io](https://app.x1id.io) with that email. **Treat it like any crypto transfer:** it's a real, non-custodial, on-chain send to a real address — there's no escrow, no claim step, and **no way to reverse it**, so double-check the email before you send. See [What you can do](/docs/x1id/features#send-xnt-by-email). When someone registers through your link from [app.x1id.io/refer](https://app.x1id.io/refer), you're credited on-chain as their referrer and your referrals are tracked. That attribution is the basis for rewards: X1ID intends to reward its top referrers with **premium @names from the treasury**. The reward program isn't live yet — but referrals are being counted on-chain toward it now. See [Invite friends](/docs/x1id/referrals). If you signed in with **email or social login**, yes — that's one shared session across X1ID surfaces (for example [app.x1id.io](https://app.x1id.io) and [developers.x1id.io](https://developers.x1id.io)), so signing out of one signs you out of all of them. Connecting your own wallet directly instead keeps each site's connection independent. Either way your handle is never affected — sign-out only ends the session, it never touches what your wallet owns on-chain. ## Safety & trust X1ID allows only plain letters, numbers, and simple characters — no emoji and no lookalike letters from other alphabets. That blocks the common trick of registering a name that *looks* identical to yours (for example, a Cyrillic "а" swapped into `@alice`). See [Trust & transparency](/docs/x1id/trust). It depends on whether your handle is a capability NFT. For a **name-only** handle (lease-to-own, or a plain un-minted registration), set up **recovery** ahead of time and you can get your handle back even if you lose the wallet. Once a handle is **tokenized** — the default for a standard registration — control *is* whoever holds the NFT, so on-chain recovery no longer applies: the protection is to hold the NFT in a **hardware wallet or a multisig**. Set recovery up while everything works, or secure the holding wallet from the start. See [What you can do](/docs/x1id/features#a-safety-net). **Lock** it. On a **name-only** handle a lock freezes it so it can't be moved or have its addresses redirected while you sort things out, with a built-in waiting period on unlocking so an attacker can't lock it and immediately steal it. On a **tokenized** handle (the default) a lock is narrower today — it blocks setting a new primary name but does **not** stop the NFT from being sent or records from changing — so for a tokenized handle the real protection is securing the wallet that holds the NFT. (A full on-chain freeze of a tokenized handle is a planned change.) Addresses can be **verified**. When someone proves they control an address, it shows as verified; an unproven one is shown as unverified. Nothing unproven is dressed up as trusted, so you know what you're relying on before you send. Yes. X1ID runs as a public on-chain program you can inspect on the X1 explorer at `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` (on X1 mainnet). See [Trust & transparency](/docs/x1id/trust). You don't do anything separate. A standard registration already **includes** the tradeable capability pNFT — it's minted to your wallet in the **same transaction** as the registration, at **no extra cost and with no separate mint fee**. That pNFT is what lets you trade your handle on standard NFT marketplaces if you ever choose to; whoever holds it is the owner. (Name-only paths like [lease-to-own](#lease-to-own) tokenize when the handle becomes permanent — still at no extra cost.) See [Fees](/docs/x1id/pricing). Yes — this is an optional, advanced feature. You can let a trusted person or service update your handle's records (for example, keep your addresses current) **without handing over ownership**. You stay the owner, and you can remove their access at any time. ## Marketplace Use the built-in marketplace at [app.x1id.io/marketplace](https://app.x1id.io/marketplace). You can list a handle you own at a fixed price and buy handles others are selling. **Offers and auctions apply only to name-only handles** (lease-to-own, plain registrations, or treasury-held names) — a **tokenized** handle (the default for a standard registration) sells only by a fixed-price listing or by sending its NFT, so you can't receive offers or run an auction on one. While a tokenized handle is listed its NFT is frozen; cancel the listing before sending it. A handle you sell is normally a **tokenized** sale — every standard registration now includes the tradeable pNFT — and a tokenized sale carries a **2.5% on-chain royalty** rather than a separate fee (the royalty replaces it). A plain **name-only** sale (a handle that was never tokenized) pays the flat **2% marketplace fee** instead. If you're just registering and holding your own handle, neither touches you — it only applies to sales. See [Fees](/docs/x1id/pricing). ## X1ID and X1NS **X1ID and X1NS are complementary, not competitors.** X1ID gives you `@`-style handles; X1NS gives you `.x1` domains. Many people own both. They're different *shapes* of name for different jobs. **X1ID** is the handle-shaped namespace — `@jack`, flat and human, built as a payment and identity handle. **X1NS** is the domain-shaped namespace — `jack.x1` (and `.xnt`, `.xen`), hierarchical and able to have subdomains. Think of it like a social handle versus a domain name: `@jack` is to `jack.x1` what your `@handle` on a social app is to a website domain. Not necessarily — but they complement each other, and many people will want both. X1ID covers the flat, memorable, cross-chain "pay me at `@you`" use case; X1NS covers domain-style, hierarchical naming. Owning one doesn't stop you owning the other. Not automatically — they're separate names and can have **different owners**. That's why well-behaved wallets never silently pick between them: they always show you which namespace matched — the `@` handle or the `.x1` domain — before you send. Treat it as a feature: you always know exactly which name you're paying. No. X1ID supplements X1NS by owning the flat, cross-chain-receive handle use case; X1NS keeps owning domain-style hierarchical naming. They work side by side. --- # Agents URL: /docs/x1id/agents > Give an AI agent an on-chain @handle identity — a validated manifest of its capabilities and endpoints, a chain-verified pay-to address for x402 payments, and an MCP endpoint a client can discover from the handle alone. Resolve it all through GET /v1/agents/:handle. # Agents Mainnet An **agent** is an ordinary `@handle` that advertises a machine-readable **manifest** — its capabilities, its endpoints (MCP, x402, A2A), and a pay-to address that's verified on-chain. A client holding only `@payday-bot` can discover how to talk to it, how to pay it, and what it can do. This is conventions and packaging on top of primitives X1ID already ships — **no new on-chain program**, no new account family. ## Resolve an agent Resolve a handle, read its on-chain `agent.manifest` record, fetch the off-chain manifest, validate it against the chain, and return it. Keyed or [x402-metered](/docs/x1id/platform#pay-per-call-with-x402), priced exactly like a resolve (`@` in the path is optional). ```bash title="GET /v1/agents/:handle" curl https://api.x1id.io/v1/agents/payday-bot \ -H "X-API-Key: $X1ID_API_KEY" ``` ```json { "name": "payday-bot", "handle": "", "handleType": 3, "agent": true, "manifestUrl": "https://payday.example/.well-known/x1id-agent.json", "endpoints": { "mcp": "https://mcp.example.com", "x402": "https://api.example.com/pay" }, "manifest": { "x1id": { "version": 1, "name": "payday-bot", "network": "mainnet", "handle": "" }, "...": "…" } } ``` | field | meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `handle` | the Handle PDA (base58) — the uniqueness anchor. | | `handleType` | the on-chain `Handle.handle_type`; `3` is the strong, immutable `Agent` flag. Require `handleType === 3` if you want only the strong signal. | | `manifestUrl` | the `agent.manifest` record value the document was fetched from (`https://` or `ipfs://`). | | `endpoints` | the agent's well-known endpoint records read straight off the handle — `mcp`, `x402`, `a2a`, `capabilities` — so you find one without parsing the manifest. Absent keys are omitted. | | `manifest` | the fetched document, validated for shape **and** agreement with the chain. | A handle that isn't an agent returns `404 NOT_AN_AGENT`; an agent with no manifest record `404 NO_MANIFEST`; a manifest that's unreachable `424 MANIFEST_UNREACHABLE`; one whose contents don't match the handle `422 INVALID_MANIFEST`. A `424` is a problem with the agent owner's off-chain manifest URL, not with the gateway — its message names the specific cause (host did not resolve, connection refused, timed out, TLS certificate could not be verified). The gateway already retries a transient fetch blip once internally, so a brief hiccup never surfaces as a `424`. **If you paid for the call with x402, a `424` does not consume your payment** — only a successful `200` settles it. You can retry the **same** payment within the 600-second redeem window, counted from the payment's on-chain block time (the quote's `maxTimeoutSeconds` stays 60 s). If the manifest is still unreachable after that, send a **new** payment. ### What makes a handle an agent Either signal counts: 1. **`HandleType::Agent`** (`handle_type == 3`) — set at registration, immutable, tamper-proof. The strong flag. 2. **An `agent.manifest` text record exists** — a soft flag so a handle registered as a person/org can *also* operate an agent without losing its name (since `handle_type` is immutable). ## The manifest The manifest is a JSON document hosted off-chain (it's larger than a 256-byte text record and changes often). The chain stores the **pointer** (`agent.manifest` → URL) and the **payment facts** (address records); the document itself is fetched and then checked against the chain. ```jsonc title="x1id agent manifest (v1)" { "x1id": { "version": 1, "name": "payday-bot", // the handle this manifest is FOR "network": "mainnet", // mainnet | testnet "handle": "" // the uniqueness anchor }, "identity": { "attestation": "", "description": "…" }, "endpoints": [ { "type": "mcp", "url": "https://mcp.example.com" }, { "type": "x402", "url": "https://api.example.com/pay" } ], "capabilities": { "schema": "https://…", "url": "https://…" }, "payment": { "addresses": [ { "coinType": 501, "address": "…", "verified": true } ], "x402": { "network": "solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT", "asset": "…", "scheme": "exact", "facilitator": "https://…" } } } ``` The `payment.x402.network` is a **CAIP-2** chain id, not a friendly label: on X1 **mainnet** it is `solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT` (the `solana:` form), which is exactly the `network` an x402 `402` challenge from `api.x1id.io/v1` advertises — match it byte-for-byte rather than hardcoding a name. **The chain wins.** A manifest self-identifies the exact handle it's for (`x1id.name` + `x1id.handle`), and a manifest served for the wrong name, handle, or network is **discarded**, never partially trusted — so a hosting compromise on one agent's server can't re-point a different agent's manifest at its own endpoints. A `verified: true` payment address the chain doesn't actually verify is rejected: the pay-to source of truth is the handle's on-chain, signature-verified address record, never the manifest's self-report. ### Validate a manifest before you host it An authoring aid: check a candidate manifest against a handle's derived identity **before** you host it and point the record at it. Pure (no RPC), so it works even before the handle is registered. ```bash title="POST /v1/agents/validate" curl -X POST https://api.x1id.io/v1/agents/validate \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "handle": "@payday-bot", "manifest": { "x1id": { "version": 1, "...": "…" } } }' # → { "name": "payday-bot", "handle": "", "network": "mainnet", # "valid": true, "errors": [], # "note": "shape + identity only — the on-chain verified-address cross-check runs at read time (GET /v1/agents/:handle)" } ``` ## x402 agent payments Agents charge per call through the **same x402 rail** X1ID's own API uses: an unpaid request to the agent's endpoint gets `402 Payment Required` with an `accepts[]` body; the caller pays and retries with a `PAYMENT-SIGNATURE` header (v1 name `X-PAYMENT`); the endpoint settles and serves. On X1ID's own API the retry must also carry the payer's signature over the challenge's `redeemMessage` ([details](/docs/x1id/platform#sign-the-redemption-with-the-paying-wallet)). The preferred `payTo` is the handle's **chain-verified** payment address — proved, not asserted — which is the strongest pay-to guarantee in the space. See [Getting paid (x402)](/docs/x1id/get-paid#agents-can-pay-too-x402). ## MCP discovery A client holding only `@payday-bot` finds its MCP endpoint two ways, cheapest first: 1. **The `agent.mcp` record** — one lookup, no document fetch. Read it directly, or take `endpoints.mcp` straight from the `GET /v1/agents/:handle` response. 2. **The manifest `endpoints[]`** — richer, with every endpoint and its `type`. The record and the manifest should agree; the record is the low-latency shortcut, the manifest the complete picture. ``` @payday-bot → GET /v1/agents/payday-bot → endpoints.mcp (direct, from the agent.mcp record) ← MCP URL → manifest.endpoints[] (type == "mcp") ← confirms / enriches → open an MCP session against that URL → paid tools speak x402, payTo = the handle's verified address ``` See the [MCP server](/docs/x1id/mcp) page for X1ID's own MCP surface and the agent-verification ladder (L0 declared → L1 attested → L2 pay-to confirmed). ## Next --- # Checkout widget URL: /docs/x1id/checkout > Accept X1ID payments on any website. Your server creates an on-chain invoice, a drop-in script opens a secure X1ID checkout popup, and your server confirms payment by reading the invoice from chain with verifyInvoicePaid. # Checkout widget Mainnet Accept X1ID payments on any website with one script tag. Your server creates an **on-chain invoice**, your page opens checkout for it, the payer pays from their own wallet in an X1ID popup, and your server confirms the payment by reading the invoice back from the chain. **Verify every payment on your server, by reading the invoice from the chain.** The widget tells your *page* how checkout ended, and anyone who controls a browser can fake that message. Ship goods only after your server calls [`verifyInvoicePaid(...)`](#4-verify-the-payment-on-your-server) and gets `paid: true`. ## How it works The trust sits on chain, not in the widget: 1. **Your server creates an invoice on chain**: payee (your wallet), exact amount, token, optional expiry. Sign it with your own wallet, or with a [merchant-delegate key](#1-one-time-setup-a-merchant-key) that can create invoices but can never receive or move funds. 2. **Your page opens checkout** for that invoice. The widget opens `app.x1id.io/invoice?address=&embed=1` in a **popup**. The payer sees the on-chain terms and pays from their own wallet. The X1ID registry program moves the funds payer → payee and marks the invoice Paid in the same instruction. 3. **The popup reports back** to your page with `postMessage`: `{ invoice, signature, status }`. 4. **Your server checks the chain** with `verifyInvoicePaid` and fulfils the order. ``` your server your page X1ID popup (app.x1id.io) X1 chain ─────────── ───────── ──────────────────────── ──────── create_invoice ─────────────────────────────────────────────────────────────────────────▶ Invoice (Open) { invoice } ──────────▶ X1IDCheckout.pay(invoice) ──────▶ /invoice?address=…&embed=1 payer reviews + signs ─────────▶ pay_invoice → Paid ◀── { status: "paid", … } ─────── reads Invoice back: Paid verifyInvoicePaid ◀──── POST /orders/:id/check getAccountInfo ───────────────────────────────────────────────────────────────────────▶ Paid ✓ fulfil the order ``` **Why a popup and not an iframe?** Wallet extensions work reliably in a top-level window, and a page that can be framed can be clickjacked. app.x1id.io sends `frame-ancestors 'none'` and `X-Frame-Options: DENY`; there is no iframe mode. The protocol fee is **0.25 %** (25 bps), taken from the amount: the payer pays exactly the invoice amount, and you receive `amount − fee`. You need [`@x1id/resolve`](https://www.npmjs.com/package/@x1id/resolve) **0.27.0** or later on your server: ```bash npm install @x1id/resolve@0.27.0 @solana/web3.js ``` ## 1. One-time setup: a merchant key Your server needs a key to sign `create_invoice`. Don't put your payee wallet on a web server. Instead, authorize a **merchant delegate** once, from your payee wallet: ```js import { PublicKey } from "@solana/web3.js"; import { buildCreateMerchantAuthorityIx, findMerchantAuthorityPda, DEFAULT_HANDLE_PROGRAM } from "@x1id/resolve"; const find = (seeds, programId) => PublicKey.findProgramAddressSync(seeds, new PublicKey(programId))[0].toBase58(); // Signed by the PAYEE wallet (e.g. in a wallet app), once per delegate key. const ix = buildCreateMerchantAuthorityIx({ programId: DEFAULT_HANDLE_PROGRAM, payee: PAYEE, // your wallet: where funds go delegate: SERVER_KEY_PUBKEY, merchantAuthority: await findMerchantAuthorityPda(find, DEFAULT_HANDLE_PROGRAM, PAYEE, SERVER_KEY_PUBKEY), }); ``` A delegate can create and cancel invoices whose payee is fixed to your wallet. If the key leaks, an attacker can create invoices that pay **you**; it cannot receive or move funds. Revoke it with `buildRevokeMerchantAuthorityIx`. ## 2. Create the invoice on your server ```js title="invoice.js" import { Connection, Keypair, PublicKey, Transaction, TransactionInstruction, ComputeBudgetProgram } from "@solana/web3.js"; import { DEFAULT_HANDLE_PROGRAM as PROGRAM, buildCreateInvoiceIx, findInvoicePda, findMerchantAuthorityPda, newInvoiceId, invoiceIdToHex, invoiceMemoHash, INVOICE_NATIVE_MINT, } from "@x1id/resolve"; const RPC = "https://rpc.mainnet.x1.xyz"; const connection = new Connection(RPC, "confirmed"); const serverKey = Keypair.fromSecretKey(/* from your secret store */); const PAYEE = "YourPayeeWallet…"; const find = (seeds, programId) => PublicKey.findProgramAddressSync(seeds, new PublicKey(programId))[0].toBase58(); const toWeb3 = (ix) => new TransactionInstruction({ programId: new PublicKey(ix.programId), keys: ix.keys.map((k) => ({ pubkey: new PublicKey(k.pubkey), isSigner: k.isSigner, isWritable: k.isWritable })), data: Buffer.from(ix.data), }); export async function createInvoiceForOrder(order) { const id = newInvoiceId(); // 16 random bytes const invoice = await findInvoicePda(find, PROGRAM, PAYEE, id); const ix = buildCreateInvoiceIx({ programId: PROGRAM, creator: serverKey.publicKey.toBase58(), // the delegate signs payee: PAYEE, // funds always go here merchantAuthority: await findMerchantAuthorityPda(find, PROGRAM, PAYEE, serverKey.publicKey.toBase58()), config: find([Buffer.from("config")], PROGRAM), invoice, id, mint: INVOICE_NATIVE_MINT, // or an SPL mint (e.g. USDC) amount: order.amountBaseUnits, // bigint, base units (XNT: 9 decimals) expiresAt: BigInt(Math.floor(Date.now() / 1000) + 30 * 60), // 30 min memoHash: invoiceMemoHash(`order ${order.id}`), }); const tx = new Transaction().add(ComputeBudgetProgram.setComputeUnitLimit({ units: 80_000 }), toWeb3(ix)); await connection.sendTransaction(tx, [serverKey]); // then confirm as usual // STORE these with the order. They, not anything from the browser, are what you verify against. await db.orders.update(order.id, { invoice, invoiceId: invoiceIdToHex(id), payee: PAYEE, mint: INVOICE_NATIVE_MINT, amount: order.amountBaseUnits.toString(), }); return invoice; } ``` The server key pays the transaction fee and the invoice's rent (refunded when the invoice is closed). Pass `rentPayer` to have another key fund the rent. ## 3. Add checkout to your page The widget is a single dependency-free script, about 9 KB and not minified: ``` https://app.x1id.io/widget/checkout-v1.js ``` ```html ``` Any element with `data-x1id-invoice` opens checkout on click, including elements added later. `data-x1id-app="https://…"` on the element, or `data-app` on the script tag, points at a different X1ID deployment (for example testnet). ```jsx title="PayButton.jsx" import { useState } from "react"; // Load https://app.x1id.io/widget/checkout-v1.js once (e.g. in your ). export function PayButton({ orderId, invoice }) { const [state, setState] = useState("idle"); async function onClick() { setState("open"); try { // Must run straight from the click, or the browser blocks the popup. const result = await window.X1IDCheckout.pay(invoice); // result.status is a hint only: always ask your server. const r = await fetch(`/orders/${orderId}/check`, { method: "POST" }).then((r) => r.json()); setState(r.paid ? "paid" : "idle"); } catch (e) { setState("idle"); // CheckoutPopupBlockedError: ask the user to allow popups } } return ( ); } ``` `X1IDCheckout.open(invoice)` returns `{ sid, result, abort() }` if you need to cancel from your side. With a bundler, the same controller ships in the SDK: `import { openCheckout } from "@x1id/resolve"` and call `openCheckout(window, { invoice })`. ### Results | `status` | Meaning | What to do | | ----------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `paid` | The popup read the Invoice account back as **Paid**. `signature` is the payment transaction when known. | Verify on your server, then fulfil. | | `cancelled` | The payer clicked Return without paying. | Verify anyway (it's cheap), then offer to retry. | | `closed` | The popup closed without a result. The payer may still have paid (e.g. closed it before confirmation). | **Verify on your server.** Poll for a short while if you expect a payment. | ## 4. Verify the payment on your server ```js title="server.js" import express from "express"; import { verifyInvoicePaid } from "@x1id/resolve"; const app = express(); app.post("/orders/:id/check", async (req, res) => { const order = await db.orders.get(req.params.id); // terms YOU stored in step 2 if (!order) return res.sendStatus(404); if (order.fulfilled) return res.json({ paid: true }); const r = await verifyInvoicePaid("https://rpc.mainnet.x1.xyz", order.invoice, { payee: order.payee, amount: BigInt(order.amount), // exact base units mint: order.mint, // omit for native XNT id: order.invoiceId, // binds this check to this one invoice // commitment: "finalized", // for high-value goods }); if (r.paid) { // Idempotent: fulfil once, even if this endpoint is hit twice. await db.orders.markFulfilledOnce(order.id, { paidBy: r.paidBy, paidAt: Number(r.paidAt) }); return res.json({ paid: true }); } res.json({ paid: false, reason: r.reason }); // e.g. "not-paid" (yet) }); ``` `verifyInvoicePaid(rpc, invoice, { payee, amount, mint?, id?, programId?, commitment? })` does one `getAccountInfo` and returns `paid: true` only when **all** of these hold: * the account is owned by the X1ID registry program (default `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`); * it decodes as an Invoice; * its payee, token and amount equal what you expect, and its id does too when you pass one; * its status is Paid. Otherwise it returns `paid: false` with a `reason`: `not-found`, `wrong-program`, `not-invoice`, `wrong-payee`, `wrong-amount`, `wrong-mint`, `wrong-id`, or `not-paid` (with the current `status`: open or cancelled). It **throws** on RPC errors and bad arguments, so a network failure can never read as paid. `rpc` is a URL or an `RpcFn`. A paid invoice can later be closed by its payee or rent payer to reclaim rent. After that the account is gone and verification returns `not-found`, so record fulfilment in your database the first time you see `paid: true`. Prefer push to polling? Register a [Pay webhook](/docs/x1id/pay-webhooks) for `invoice.paid` on your payee wallet or `@name`, and run the same `verifyInvoicePaid` check when it arrives. ## Security notes * **Never fulfil from the browser result.** The `x1id:checkout` event, `X1IDCheckout.pay()`'s result, a redirect, or anything else from the client is only a hint to call your server. Your server calls `verifyInvoicePaid`. * **Verify against your stored terms.** Take the invoice address, payee, amount, mint and id from your database, never from the request, and pass `id`. Otherwise someone could present a different invoice that was paid, such as a cheaper one or one already used for another order. * **Fulfil idempotently**, once per order. * **Use `finalized` for high-value goods.** `confirmed` (the default) is supermajority-voted and fast; `finalized` takes a few seconds more but cannot be rolled back. * **Expire invoices.** Set `expiresAt` so a stale checkout can't be paid hours later at an old price. The program refuses payment after expiry. * **Pinned terms.** The payer's wallet signs `pay_invoice` with the exact amount and token they were shown. If the invoice is replaced with different terms after they review it, the program refuses (`InvoiceTermsChanged`). The popup also re-reads the invoice right before signing. * **Origin checks, both ways.** The popup posts only to the window that opened it, with `targetOrigin` pinned to your page's origin, so no other site can read the result. Your page accepts a message only from that popup window, from exactly `https://app.x1id.io`, carrying the 128-bit session id of this checkout and the invoice you asked for. Malformed or unexpected messages are dropped. * **HTTPS only.** Checkout refuses to start from a non-`https:` page (`http://localhost` is allowed for development), because results could not be delivered to it safely. * **No iframe, no clickjacking.** app.x1id.io cannot be framed (`frame-ancestors 'none'`, `X-Frame-Options: DENY`). * **Pin the script if you like.** `/widget/checkout-v1.js` is served with CORS, so you can add Subresource Integrity, or self-host the file: ```html ``` Compute the hash with: ```bash curl -s https://app.x1id.io/widget/checkout-v1.js | openssl dgst -sha384 -binary | openssl base64 -A ``` * **The merchant key can't steal.** A merchant-delegate key only creates and cancels invoices that pay your wallet. ## Protocol reference (v1) You only need this if you build your own client instead of using the script or `openCheckout`. Popup URL: ``` https://app.x1id.io/invoice?address=&embed=1&origin=&sid=<32 hex> ``` Every message is `{ protocol: "x1id-checkout", version: 1, type, sid, … }` with exactly these fields: | `type` | Direction | Extra fields | | -------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `ready` | popup → page, after the invoice is read | `invoice` | | `ack` | page → popup, reply to `ready`. The popup then shows "Checkout for \" | none | | `result` | popup → page, once | `invoice`, `status` (`paid` \| `cancelled`), `signature` (base58 or null; always null for `cancelled`) | `closed` is never sent: the page reports it itself when the popup closes without a result. The popup sends `paid` only after the Invoice account reads back as Paid, not when the wallet returns a signature. After a payment is submitted, Return never reports `cancelled`. If the popup closes before confirmation, your page gets `closed`. `checkout-v1.js` is versioned: a breaking protocol change ships as `checkout-v2.js` next to it, and v1 keeps working. ## Next --- # Sign in with X1ID URL: /docs/x1id/connect > X1ID Connect — a passwordless sign-in where a wallet signature proves control of an @handle (or just the wallet). The /v1/auth challenge → sign → verify → session flow, the audience-bound strings a wallet signs, Sign In With Solana, the SDK's signInWithWallet, and how to keep your API key on your server. # Sign in with X1ID Mainnet **X1ID Connect** lets a user sign in to your app with their wallet — no password, no email. The user signs a one-time challenge; your app gets a session that names their `@handle` (and the platforms they've verified), or just their wallet address. It's the same "a wallet is the identity" model as [app.x1id.io](https://app.x1id.io), exposed as an API. Every Connect call is **keyed** — your [`X-API-Key`](/docs/x1id/platform#base-url-and-authentication) identifies which app is authenticating the user, and a challenge and session are bound to that key. Connect endpoints are not x402-metered; they always need a key. **What a session proves — and what it does not.** A successful sign-in proves the holder's **current control** of the `@handle` (a wallet signature over a single-use, epoch-bound challenge) and the handle's listed on-chain attestations. It is **not** proof of legal identity, personhood, or KYC. Every Connect response restates this in its `proves` and `disclaimer` fields; surface it honestly to your users. The underlying signing scheme is X1ID's control-proof primitive — a signature the registry is the source of truth for, so a copycat can't fake it. **Your API key stays on your server.** Never ship an X1ID API key in browser code. The browser talks to **your** backend; your backend calls `api.x1id.io/v1/auth/*` and adds `X-API-Key` there. See [Keep your key on the server](#keep-your-key-on-the-server) for the pattern, which works with the SDK helpers unchanged. ## The flow ### Request a challenge Your backend calls `POST /v1/auth/challenge` with the user's `handle` **or** `wallet`. You get back a one-time `challenge` string, a `nonce`, and the `audience` the challenge is bound to. ### The user signs it Your frontend asks the user's wallet to sign the **exact** string the server returned — `challenge` for a message signature, `memo` for a transaction signature. Never rebuild it locally: sign the server's bytes verbatim. ### Verify and get a session Your backend posts the proof to `POST /v1/auth/verify` (message), `/v1/auth/verify-tx` (memo transaction), or `/v1/auth/verify-signin` (Sign In With Solana). A valid proof returns a **Bearer session token**. ### Use the session Present the token as `Authorization: Bearer ` to `GET /v1/auth/session` (or your own protected routes) to read who's signed in. The quickest way to run all of this from a browser is the SDK's [`signInWithWallet`](#with-the-sdk), pointed at your own backend proxy. ## What the wallet signs Every challenge names its **audience** inside the signed bytes, so a signature is only redeemable by the application it names. The audience is your API key's **key id** (for the X1ID Developer Portal's own key it is `developers.x1id.io`), and the challenge response returns it as `audience`. | Path | Exact signed string | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | wallet control (`/verify`) | `x1id-connect:v2:wallet-control::
:` | | handle control (`/verify`) | `x1id-connect:v2:handle-control::` followed by `x1-handles:ctl:v1:::` | | memo transaction (`/verify-tx`) | memo data `x1id-verify::` | | Sign In With Solana (`/verify-signin`) | a standard SIWS message whose first line names an allowed domain (see [below](#sign-in-with-solana-verify-signin)) | The server re-derives the expected bytes from the **presenting** key on verify. The older unbound strings — `x1id-connect:v1:wallet-control:
:`, a memo of `x1id-verify:`, or a bare `x1-handles:ctl:v1:…` — and any string bound to a different audience are rejected with `401`. Always sign the `challenge` or `memo` the API returns; don't construct either yourself. ## Challenge Provide **exactly one** of `handle` or `wallet`, plus an optional `mode`. * **`{ "handle": "@jack" }`** — challenge the owner of a specific handle (a handle-control challenge). * **`{ "wallet": "" }`** — challenge a wallet. If that wallet has a primary `@handle`, you get a handle-control challenge for it; if it has **no** primary, you get an **address-control** challenge (`handle` is `null`) — the wallet proves control of itself, exactly as connecting a wallet is the identity on app.x1id.io. * **`"mode": "tx"`** (with `wallet`) — an address-control challenge plus a `memo` for the [transaction path](#sign-in-by-transaction-verify-tx). * **`"mode": "signin"`** (with `wallet`) — an address-control challenge for [Sign In With Solana](#sign-in-with-solana-verify-signin). `tx` and `signin` always prove **wallet** control, even when the wallet has a primary `@handle`; the session still reports that handle. ```bash title="POST /v1/auth/challenge (from your backend)" curl -X POST https://api.x1id.io/v1/auth/challenge \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "wallet": "" }' # a wallet with no primary @handle ``` ```json { "nonce": "7f3a…", "handle": null, "challenge": "x1id-connect:v2:wallet-control:::7f3a…", "audience": "", "signature_encoding": "base58", "expires_at": 1733270400, "proves": "wallet-control+attestations", "disclaimer": "Proves the holder's current control of this @handle (a wallet signature) and the listed on-chain attestations only. NOT proof of legal identity, personhood, or KYC." } ``` | field | meaning | | -------------------- | ---------------------------------------------------------------------------------------------------- | | `nonce` | single-use id for this challenge; pass it back on verify. | | `handle` | the canonical handle being proved, or `null` for an address-control challenge. | | `challenge` | the **exact** string the wallet must sign (raw UTF-8 bytes, no wrapping, no hashing). | | `audience` | the audience bound into the signed bytes: your key id, or `developers.x1id.io` for the portal's key. | | `memo` | `mode: "tx"` only — the exact memo data, `x1id-verify::`. | | `signature_encoding` | `base58` — send a message signature back base58-encoded. | | `expires_at` | Unix seconds the challenge expires (5 minutes by default). Request a fresh one after that. | ## Verify The message-signature path. Send the `nonce`, the base58 `signature` over the `challenge` string, and the `signer` (the signing wallet's base58 pubkey). `signer` is required **unless** the challenge was created from `{ wallet }` (then the bound wallet is used). Continuing the example above (a wallet with no primary `@handle`), the session is address-control: The challenge is consumed atomically — a replayed nonce is rejected. ```bash title="POST /v1/auth/verify (from your backend)" curl -X POST https://api.x1id.io/v1/auth/verify \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "nonce": "7f3a…", "signature": "", "signer": "" }' ``` ```json { "token": "", "token_type": "Bearer", "expires_at": 1733356800, "handle": null, "address": "", "verified": [], "source": "wallet", "proves": "wallet-control+attestations", "disclaimer": "Proves the holder's current control of this @handle (a wallet signature) and the listed on-chain attestations only. NOT proof of legal identity, personhood, or KYC." } ``` | field | meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | | `token` / `token_type` | the Bearer session token and its type. | | `expires_at` | Unix seconds the session expires (24 hours by default). | | `handle` | the signed-in handle, or `null` for an address-control session. | | `address` | the wallet that signed. | | `verified` | platform slugs the handle holds a live attestation for (`x`, `github`, …) — read best-effort, never blocks sign-in. | | `source` | `owner` (untokenized handle), `nft-holder` (tokenized handle held in its ATA), or `wallet` (address-control). | A bad proof returns `401 { "code": "CONTROL_PROOF_INVALID", … }`; a challenge bound to another audience returns `401 CHALLENGE_AUDIENCE_MISMATCH`; an expired or already-used challenge returns `401 CHALLENGE_EXPIRED` / `CHALLENGE_NOT_FOUND`. ## Sign in by transaction (`verify-tx`) **Use this path for Backpack, the X1 wallet, and Ledger.** Backpack's and the X1 wallet's **message** signatures do **not** verify on X1, so the `signMessage` → `/verify` path above fails control-proof for them. A Ledger signs messages, but wraps them in the Solana off-chain message envelope (SIMD-0048), so a signature over the raw `challenge` string doesn't verify on `/verify` either; for Ledger use this path or [SIWS](#sign-in-with-solana-verify-signin), which accepts the envelope. The fix is to prove control with a **transaction signature** instead: the wallet signs a memo-only transaction that carries the challenge, and the service verifies that signature. The transaction is **never broadcast**. **1. Ask for a tx challenge.** Call `POST /v1/auth/challenge` with `mode: "tx"` and the user's `wallet`. The response is an address-control challenge plus a `memo` field — the exact string to embed: ```bash title='POST /v1/auth/challenge {"wallet":"…","mode":"tx"}' curl -X POST https://api.x1id.io/v1/auth/challenge \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "wallet": "", "mode": "tx" }' ``` ```json { "nonce": "7f3a…", "handle": null, "challenge": "x1id-connect:v2:wallet-control:::7f3a…", "audience": "", "signature_encoding": "base58", "memo": "x1id-verify::7f3a…", "expires_at": 1733270400, "proves": "wallet-control+attestations", "disclaimer": "…" } ``` Embed `memo` exactly as returned. It is `x1id-verify::`; the unbound `x1id-verify:` form is rejected. **2. Build a memo-only transaction and sign it.** The transaction carries a single SPL-Memo instruction holding the `memo` string, with the wallet as the fee payer and only signer. It is never sent. Some wallets simulate a transaction before signing and refuse a placeholder blockhash, so use a recent one from your RPC: ```ts title="build + sign the memo transaction" import { Connection, Transaction, TransactionInstruction, PublicKey } from "@solana/web3.js"; const MEMO_PROGRAM_ID = new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); const connection = new Connection("https://rpc.mainnet.x1.xyz"); const tx = new Transaction(); tx.feePayer = wallet.publicKey; // the wallet proving control tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; // NEVER broadcast tx.add(new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(memo, "utf8"), // the server's memo, verbatim })); const signed = await wallet.signTransaction(tx); // signTransaction, NOT signMessage const signedTransaction = signed .serialize({ requireAllSignatures: false }) .toString("base64"); ``` **3. Verify.** Post the `nonce` and the base64 transaction back: ```bash title="POST /v1/auth/verify-tx (from your backend)" curl -X POST https://api.x1id.io/v1/auth/verify-tx \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "nonce": "7f3a…", "signedTransaction": "" }' ``` The service requires exactly one instruction (the SPL-Memo carrying this challenge's memo), a normal recent-blockhash transaction (no durable nonce, no address lookup tables, no ComputeBudget instruction), and a signature from the challenged wallet. It then returns the **same** session body as [`/verify`](#verify). A bad proof returns `401 CONTROL_PROOF_INVALID`; an expired or already-used nonce returns `401 CHALLENGE_EXPIRED` / `CHALLENGE_NOT_FOUND`. The SDK's `signInWithWallet` builds this transaction for you, with no `@solana/web3.js` dependency. ## Sign In With Solana (`verify-signin`) For wallets that implement the Wallet Standard `solana:signIn` feature (SIWS). The wallet renders a readable sign-in message and signs it. **1. Ask for a sign-in challenge** with `{ "wallet": "
", "mode": "signin" }`. Use its `nonce` in the SIWS input. **2. Have the wallet sign in** with `solana:signIn`, passing your `domain`, the wallet `address`, a `statement`, `uri`, `version: "1"`, the challenge `nonce`, and `issuedAt`. **3. Verify.** Post `nonce`, `signedMessage` (base64 of the exact bytes the wallet signed), and `signature` (the 64-byte ed25519 signature, base58; base64 is also accepted): ```bash title="POST /v1/auth/verify-signin (from your backend)" curl -X POST https://api.x1id.io/v1/auth/verify-signin \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "nonce": "7f3a…", "signedMessage": "", "signature": "" }' ``` The server checks that line 1 is ` wants you to sign in with your Solana account:` with an **allowed** domain, that line 2 is the challenged wallet, that a `Nonce:` line carries this challenge's nonce, and the ed25519 signature (over the raw bytes or the SIMD-0048 off-chain envelope). It returns the same session body as [`/verify`](#verify). **SIWS only works for allowlisted domains.** The SIWS domain must be on the deployment's allowlist. SIWS requires your domain on X1ID's allowlist (currently `developers.x1id.io` and `app.fortiblox.com`); ask X1ID to add yours. Any other domain is rejected with `401 CONTROL_PROOF_INVALID` ("… is not allowed here"). Otherwise, use the memo-transaction path of the SDK's [`signInWithWallet`](#with-the-sdk), which it falls back to when SIWS is rejected. A challenge requested **without** `mode: "signin"` for a wallet that has a primary `@handle` is a handle-control challenge, which this endpoint rejects with `400 SIGNIN_PATH_HANDLE_UNSUPPORTED` — always request SIWS challenges with `mode: "signin"`. ## With the SDK [`@x1id/resolve`](/docs/x1id/sdk) **0.21.0** ships the client side of all three paths. **Breaking: upgrade to 0.21.0.** The API now issues and accepts only the audience-bound strings above. `signinWithTx` / `buildVerifyTxChallenge` from SDK **0.20.x and earlier** throw on the bound memo, so transaction sign-in from those versions no longer works. Hand-rolled clients that sign the v1 `x1id-connect:v1:wallet-control:…` string or an unbound `x1id-verify:` memo are rejected too. Upgrade with `npm install @x1id/resolve@0.21.0`. **`signInWithWallet`** runs the full sign-in with fallbacks, in the order developers.x1id.io uses: 1. **SIWS** (`solana:signIn` → `/v1/auth/verify-signin`); 2. a signed, **never-sent memo transaction** (`solana:signTransaction` → `/v1/auth/verify-tx`) carrying the server's `x1id-verify::`; 3. **`signMessage`** over the server's `x1id-connect:v2:wallet-control::
:` (`/v1/auth/verify`). It skips any path the wallet doesn't implement. A user rejection or a wallet timeout stops the chain, so the user is never prompted again after saying no; any other failure (including a SIWS domain rejection) moves on to the next path. It refuses to sign a memo or challenge that isn't audience-bound, and returns the session plus the `method` that succeeded. **`signInWithSolana`** is SIWS only: it throws `SiwsError { code: "wallet-unsupported" }` for a wallet without `solana:signIn`, and `domain-not-allowed` when the domain isn't on the allowlist. Use it only on an allowlisted domain. ```ts title="browser" import { signInWithWallet, SiwsError } from "@x1id/resolve"; try { const { method, ...session } = await signInWithWallet({ wallet, // a Wallet Standard wallet object apiBase: `${window.location.origin}/api/x1id`, // YOUR backend proxy, not api.x1id.io apiKey: "server-held", // placeholder; your proxy replaces it rpcUrl: "https://rpc.mainnet.x1.xyz", // recent blockhash for the memo transaction // Not on the SIWS allowlist? Skip the SIWS prompt the server would reject: methods: ["memo-tx", "sign-message"], }); // session.token, session.address, session.handle; method = "siws" | "memo-tx" | "sign-message" } catch (e) { if (e instanceof SiwsError) { // e.code: "wallet-unsupported" | "user-rejected" | "domain-not-allowed" | "nonce-expired" // | "invalid-params" | "wallet-timeout" | "wallet-error" | "network" | "api-error" } } ``` Without `methods`, a non-allowlisted domain still signs in — the SIWS attempt is rejected and the chain falls back to the memo transaction — but the user sees one extra wallet prompt. On `nonce-expired`, start the sign-in again. The single steps are exported too (`requestConnectChallenge`, `requestSignInChallenge`, `buildSignInInput`, `buildSignInMemoTransaction`, `verifySolanaSignIn`, `verifySignInTransaction`, `verifySignInMessage`); every one takes the same `apiBase` / `apiKey` options. ## Keep your key on the server The SDK helpers send `X-API-Key` on every call, so point them at **your** backend with `apiBase` and give them a placeholder key. Your backend forwards only the Connect sign-in operations to `api.x1id.io`, drops whatever key header the browser sent, and attaches the real key. This is how the X1ID demo dApp is built: browser → your `/api/…` → `api.x1id.io/v1/auth/*` with `X-API-Key` added server-side. ```ts title="app/api/x1id/v1/auth/[op]/route.ts (Next.js App Router)" import { NextRequest, NextResponse } from "next/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; // Only the sign-in operations — this must not be an open proxy. const ALLOWED = new Set(["challenge", "verify", "verify-tx", "verify-signin"]); export async function POST(req: NextRequest) { const op = req.nextUrl.pathname.split("/").pop() ?? ""; if (!ALLOWED.has(op)) { return NextResponse.json({ code: "NOT_FOUND", message: "unsupported auth op" }, { status: 404 }); } const body = await req.json().catch(() => null); if (!body || typeof body !== "object" || Array.isArray(body)) { return NextResponse.json({ code: "BAD_REQUEST", message: "body must be a JSON object" }, { status: 400 }); } const res = await fetch(`https://api.x1id.io/v1/auth/${op}`, { method: "POST", headers: { "content-type": "application/json", "X-API-Key": process.env.X1ID_API_KEY!, // server-side env only, never NEXT_PUBLIC_* }, body: JSON.stringify(body), cache: "no-store", }); // Pass the upstream body through as-is: an error from a proxy or CDN in front // of the API may not be JSON. const text = await res.text(); return new NextResponse(text, { status: res.status, headers: { "content-type": res.headers.get("content-type") ?? "text/plain" }, }); } ``` The browser code is the `signInWithWallet` call [above](#with-the-sdk), with `apiBase` set to your origin plus `/api/x1id` — the SDK appends `/v1/auth/` to it. Read the session from your backend the same way: forward the user's `Authorization: Bearer ` to `GET /v1/auth/session` and add `X-API-Key` server-side. Rate-limit your proxy as you would any login endpoint; every forwarded call counts against your key's [limits](/docs/x1id/platform#tiers-and-rate-limits). ## Read the session Validate a session and read who's signed in. The session is only valid to the app (key) it was issued to, and the handle and attestations are re-read live. ```bash title="GET /v1/auth/session (from your backend)" curl https://api.x1id.io/v1/auth/session \ -H "X-API-Key: $X1ID_API_KEY" \ -H "Authorization: Bearer " # → { "handle": null, "address": "", "verified": [], # "source": "wallet", "proves": "wallet-control+attestations", "disclaimer": "…" } # A wallet whose primary is @jack (an untokenized handle) would instead read: # { "handle": "jack", "address": "", "verified": ["x","github"], "source": "owner", … } ``` An absent, malformed, expired, revoked, or forged token returns `401 { "code": "INVALID_SESSION", … }`. ## Next --- # Contracts URL: /docs/x1id/contracts > The X1ID registry program on X1 mainnet — its deployed address, the RPC endpoint it runs behind, and how to verify it yourself. # Contracts Mainnet X1ID runs as a single on-chain registry program on **X1 mainnet**. There is no separate "resolver" or "registrar" contract split — one program owns handle registration, records, and the delegate/authority rules the [SDK](/docs/x1id/api#sdk-multi-chain-records-and-writes) and [MCP server](/docs/x1id/mcp) both read and write through. ## Deployed | | | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Program | X1ID registry | | Program ID | 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P | | Network | X1 mainnet | | RPC | [https://rpc.mainnet.x1.xyz](https://rpc.mainnet.x1.xyz) | | Last deployed in slot | 85461348 (first deployed in slot 84124876) | | ProgramData | EofT1kv2yYManv3T1fHebDfZC72JzpSjH35uqmZKfnai | | Program ELF sha256 (as of slot 85461348) | bfc230cdbfaf4910ab78c9d4f83408b69d100a621fe3e37956a692bd358e91ca (first 1,704,560 bytes; the canonical build of commit 5fb1e772) | | Full ProgramData sha256 (as of slot 85461348) | 155c4f8e91e6ad5032f03310dc6c8fecf78936438790ad7c3c30270ccbccc69a (all 1,709,040 bytes, including zero padding) | | Upgrade authority | E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6 — the same 2-of-5 treasury multisig | | Treasury (immutable Config.treasury) | E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6 — a 2-of-5 multisig PDA | **Live on mainnet since 2026-10-07.** Handles you register live on X1 mainnet, with 19,572 reserved names seeded at genesis. The program's upgrade authority is the 2-of-5 treasury multisig (`E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6`), so any upgrade needs two of five guardian approvals rather than a single key. The program is upgradeable; burning the upgrade authority remains an option for later, not a scheduled step. See [Trust & transparency](/docs/x1id/trust) for how to verify all of this yourself. This is the same address every X1ID surface is wired to: it's what `@x1id/resolve` derives PDAs against, what the [MCP server](/docs/x1id/mcp) reads via its `X1ID_PROGRAM_ID` environment variable, and what the [Resolve API](/docs/x1id/api) ultimately reads through when it answers a lookup. ## Verify it yourself **Ownership you can audit.** Nothing here asks you to take our word for it — the program is public, and every handle it manages is a readable account on X1 mainnet. Point any Solana-compatible RPC client at the program ID above and the mainnet RPC to confirm it exists and is executable — the output also reports the ProgramData address, the deploy slot, and the current upgrade authority: ```bash title="terminal" solana program show 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P \ --url https://rpc.mainnet.x1.xyz ``` There are two hashes above because the ProgramData account is larger than the program it holds. The account holds 1,709,040 bytes (it was extended by 20,480 bytes for this upgrade); the current ELF is 1,704,560 bytes, and the loader fills the remaining 4,480 bytes with zeros. The **ELF sha256** covers only the program itself and is the one to compare against a build from source (the canonical build of commit `5fb1e772` produces exactly this file). The **full ProgramData sha256** covers everything `solana program dump` writes, padding included, and is the one you get by hashing the dump as is. To check both: ```bash title="terminal" solana program dump 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P x1id.so \ --url https://rpc.mainnet.x1.xyz sha256sum x1id.so # full ProgramData: 155c4f8e…bccc69a (1,709,040 bytes) head -c 1704560 x1id.so | sha256sum # ELF only: bfc230cd…58e91ca (1,704,560 bytes) tail -c +1704561 x1id.so | tr -d '\0' | wc -c # → 0: everything after the ELF is zero padding ``` Both hashes change with every program upgrade, so they're pinned to the slot they were taken at: if `solana program show` reports a later "Last Deployed In Slot" than 85461348, the program has been upgraded since and the hashes will differ. The most recent upgrade was executed by the treasury multisig at slot 85461348 on 2026-10-12 (transaction [`oBdWiWkz…qt1okE9J`](https://explorer.fortiblox.com/tx/oBdWiWkzukD1nSqTXUp3hRoC4ZxHmE1jJDiwfvCFW12CueZR1P7LTqNdjHvUhd6TpQX2qNkMGsqmL3Tqt1okE9J)). [Trust & transparency](/docs/x1id/trust#you-can-audit-it-yourself) walks through the same check. ## Testnet (for development) The same program, at the **same program ID**, is also deployed on X1 testnet — use it to develop and test without spending real XNT: | | | | ---------- | --------------------------------------------------------------------- | | Program ID | 8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P | | RPC | [https://rpc.testnet.x1.xyz](https://rpc.testnet.x1.xyz) | Everything in these docs works identically against testnet — point `rpcUrl` (SDK) or `X1_RPC_URL` (MCP) at the testnet RPC and nothing else changes. Testnet handles are a separate registry from mainnet handles and have no monetary value. The one exception is **X1NS** (`.x1` / `.xnt` / `.xen`): it exists on mainnet only, so X1NS names don't resolve against testnet. For the shape of individual accounts (a handle's registry entry, its text records), read through the [SDK](https://www.npmjs.com/package/@x1id/resolve) or the [MCP server's](/docs/x1id/mcp) read tools rather than parsing raw account bytes by hand — both derive the same PDAs this program expects and stay in sync with it as the source of truth. --- # Error reference URL: /docs/x1id/errors > Every error a caller can receive from the X1ID SDK, the REST API and x402 pay-per-call, what it means, and what a UI should do about it. # Error reference Both the SDK (`@x1id/resolve`) and the REST API surface a small, fixed set of error conditions. Neither ever falls back to guessing a namespace or returning a plausible-but-wrong address — an unresolved or ambiguous query is always an error, never a best-effort answer. **`ambiguous` is a security property, not an edge case.** When a query matches both the `@handle` shape and a known domain shape at once (e.g. `@jack.x1`), X1ID refuses to resolve it rather than picking one. `@jack` and `jack.x1` can be owned by different people. A UI must surface this and ask the user to disambiguate — it must never retry with one namespace stripped, never "try handle first, then domain," and never resolve to whichever namespace happens to have a registered record. ## SDK errors (`ResolveError`) `resolve()`, `reverse()` and `records()` throw a `ResolveError` with a `code` field from a fixed union — never a bare string — so a UI can branch on it directly instead of parsing a message. | `code` | Meaning | What a UI should do | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unrecognized` | From `resolve()`: the input isn't a handle or a known domain shape at all — probably a raw address or unrelated text. From `reverse()`: the input isn't a base58 32-byte address. | Don't show a resolution error. For `resolve()`, this is the expected result of skipping `looksLikeName()` against arbitrary input (e.g. a pasted address) — treat the input as a literal address. For `reverse()`, it's a caller bug — only pass addresses. | | `ambiguous` | The input matches **both** the `@handle` shape and a domain shape at once (e.g. `@jack.x1`). | Stop. Ask the user which namespace they mean (`@jack`, or `jack.x1`). Never auto-resolve either interpretation. | | `invalid-handle` | Right shape (`@`-prefixed), but the content fails handle validation (bad characters, length). | Show a normal "not a valid handle" validation message. | | `invalid-domain` | Right shape (dot + known TLD), but the label fails domain validation. | Show a normal "not a valid domain" validation message. | | `not-found` | The name is well-formed and in the correct namespace, but nothing is registered under it. May carry `reason: 'nft-burned'` — the handle is tokenized and its NFT was burned, so no one holds authority. | Show "not registered" — distinct from an invalid-input error. A legitimate terminal state, not a retry case. | | `no-record-for-chain` | The name resolves, but there is no live address record for the specific chain requested (e.g. asking for `ETH` on a handle that has set no ETH record). | Show "no address on file for this chain" rather than any address. This code exists precisely so a caller never receives a wrong address for a chain the handle hasn't set. | | `rpc-error` | Transport failure — the RPC endpoint was unreachable, returned a non-2xx status, or returned a malformed/short account that couldn't be parsed. | Treat as transient. Safe to retry with backoff; do **not** treat it as "not found." | `ResolveError` also carries the original `input` string where available, plus an optional `reason` (`'nft-burned'` today) for a finer-grained explanation when the code alone isn't enough. **What is *not* an error:** `reverse()` returns `null` — it does not throw — when an address has no primary, or when its primary pointer fails one of the three trust rules (stale after a release/re-register, or the address no longer holds the handle). "No primary" is a normal state, not a failure. See [reverse](/docs/x1id/sdk#reverse). ## REST API errors The [REST API](/docs/x1id/api) returns the same underlying conditions as plain HTTP status codes with a JSON `{ "error": "...", "input": "..." }` body. It is a read-only convenience over the same on-chain data the SDK reads. ### `GET /resolve/:name` | HTTP | `error` | Meaning | SDK equivalent | | ----- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `409` | `ambiguous: both a handle and a domain shape` | Input matches both namespace shapes. | `ambiguous` — refuse to guess, never retryable by picking one side. | | `400` | `invalid handle` | `@`-shaped (or bare-word) input fails handle validation. A bare word with no `@` and no TLD — `/resolve/nike` — is **treated as a handle** and resolves (`200`); it is not a `400`. (This differs from the SDK, where `resolve('nike')` throws `unrecognized`.) | `invalid-handle` | | `400` | `invalid domain` | Domain-shaped input fails domain parsing. | `invalid-domain` | | `404` | `handle not registered` | Well-formed `@handle`, nothing registered. | `not-found` | | `404` | `domain not registered` | Well-formed domain, nothing at that account — including on a testnet-pointed instance, where X1NS (mainnet) domains never exist. | `not-found` | | `502` | `malformed account` | The account was fetched but its data doesn't match the expected layout. | `rpc-error` — transient, safe to retry. | ### `GET /reverse/:address` | HTTP | `error` | Meaning | SDK equivalent | | ----- | ------------------ | ----------------------------------------------------------------- | --------------------------------------------------- | | `400` | `invalid address` | `:address` isn't a valid base58 public key. | `reverse()` throwing `unrecognized` — a caller bug. | | `404` | `no primary set` | No primary for this address (or the pointer failed a trust rule). | `reverse()` returns `null`. Show the bare address. | | `502` | `malformed handle` | The handle account exists but couldn't be parsed. | Transient; safe to retry. | ### Any route | HTTP | `error` | Meaning | | ----- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `404` | `not found` | Unknown route (no `input` field). A client/integration bug — check the path. | | `405` | `method not allowed` | The HTTP method isn't supported on **that** route — the public `/resolve` and `/reverse` lookups are `GET`-only, so a `POST` to them is a `405`. It does **not** mean the whole API is read-only: the public surface also has write routes (`POST /gift/notify`, `POST /privy/wallet`), and the keyed [`/v1` platform](/docs/x1id/platform) adds sign-in, gating, and more. A `405` is a client bug — check the method against the route. | | `502` | `rpc unreachable` | `/version` couldn't reach the configured RPC. Transient; retry with backoff. | ## x402 payments These come from the keyed [`/v1` platform](/docs/x1id/platform#pay-per-call-with-x402) when a request with no `X-API-Key` pays per call. A rejected payment is answered with `402` and a body of `{ x402Version, accepts, error, code, message }`: `error` and `code` both carry the code below, `message` explains it, and `accepts` is a **fresh challenge** you can pay straight away. The same object is base64-encoded in the `PAYMENT-REQUIRED` header. A `402` with no `error` field is a plain challenge, not a rejection. | HTTP | `code` | Meaning | What to do | | ----- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `402` | `PAYER_REQUIRED` | The challenge was requested without `X-Payer` (or with a malformed one), so it carries no `extra.reference`. | Re-request with `X-Payer: ` (or `?payer=`) and pay the reference that comes back. | | `402` | `PAYMENT_PAYLOAD_INVALID` | The `PAYMENT-SIGNATURE` / `X-PAYMENT` header is not base64 JSON of a payment payload with `payload.signature` or `payload.reference`. | A client bug. Fix the encoding; the payment, if any, is not consumed. | | `402` | `REDEEM_SIGNATURE_REQUIRED` | The payment header has no `payload.payerSig`. Since 2026-10-11 every redemption must be signed by the paying wallet. | Sign `accepts[].extra.redeemMessage` with the wallet that paid and send the base58 signature as `payload.payerSig` ([how](/docs/x1id/platform#sign-the-redemption-with-the-paying-wallet)). The payment was not consumed: resend the same request with the same payment. | | `402` | `REDEEM_SIGNATURE_INVALID` | `payload.payerSig` is not the paying wallet's signature over `extra.redeemMessage` (wrong wallet, wrong message, or not a base58 64-byte signature). | Sign the `redeemMessage` for this payment's reference with the wallet bound by `X-Payer`. The payment was not consumed. | | `402` | `PAYMENT_NETWORK_UNSUPPORTED` | The payload names a network no rail serves. | Pay one of the `accepts[]` entries and copy its `network`. | | `402` | `PAYMENT_NOT_FINALIZED` | The payment is not visible at `finalized` commitment yet. With `payload.reference`, no finalized transaction carrying the reference is a valid payment yet. | Retryable. Wait a few seconds and send the identical request with the same payment header. | | `402` | `PAYMENT_ALREADY_USED` | This payment already bought a successful response. Replaying the same signature returns this. | Final for that payment. Pay again against the fresh challenge. | | `402` | `PAYMENT_FAILED` | The payment transaction landed but failed on chain. | Pay again; nothing was transferred. | | `402` | `PAYMENT_STALE` | The payment is older than the redeem window. | Pay again against the fresh challenge. | | `402` | `PAYMENT_WRONG_ASSET` | The transfer moves a different mint than `accepts[].asset`. | Pay the advertised asset. | | `402` | `PAYMENT_WRONG_DESTINATION` | The transfer is not to the associated token account of `accepts[].payTo`. | Pay the advertised `payTo`. | | `402` | `PAYMENT_AMOUNT_TOO_LOW` | The transfer amount, or the balance actually credited to `payTo`, is below `accepts[].amount`. A Token-2022 transfer fee that leaves `payTo` short, or a Token-2022 payment whose credited amount can't be read, lands here. | Pay at least the advertised amount, net of any transfer fee. | | `402` | `PAYER_MISMATCH` | The transfer was not signed by the wallet the challenge was bound to with `X-Payer`. | Pay from that wallet, or request a new challenge naming the wallet you pay from. | | `402` | `PAYMENT_LAYOUT_INVALID` | The transaction's instructions don't match the x402 exact layout (extra instructions such as a Memo, a second transfer, compute-budget values out of bounds). | Rebuild the payment to [the documented layout](/docs/x1id/platform#the-payment-transaction). | | `402` | `REFERENCE_MISSING` | No issued, unexpired reference in the `TransferChecked`, or it is writable, a signer, repeated, or also in another instruction. | Append `accepts[].extra.reference` to the `TransferChecked` as one read-only, non-signer account. | | `402` | `REFERENCE_MISMATCH` | The reference was issued for a different request (method, path, query, body or endpoint), or the payment carries more than one reference. | Retry the exact request the challenge was issued for, byte for byte. | | `402` | `REFERENCE_REUSED` | A different payment carries a reference that already paid for a successful call. | Request a new challenge; each reference redeems once. | | `402` | `REFERENCE_EXPIRED` | The reference's 600-second redeem window has passed. | Request a new challenge and pay its reference. | | `402` | `MULTISIG_AUTHORITY_UNSUPPORTED` | The transfer authority is a token-program multisig account. | Pay from a single-signer wallet. | | `402` | `PAYMENT_UNVERIFIABLE` | The payment could not be checked (for example a malformed transaction, or, on a deployment without a payment store, an RPC answer with no block time to bound the payment's age). | Check the signature you sent; pay again if the transaction is not a valid payment. | | `402` | `VERIFIER_UNAVAILABLE` | A verification dependency (RPC lookup, payment store) is temporarily down. | Retryable. Your payment was not consumed; retry the same request shortly. | | `402` | `PAYMENT_INVALID` | The payment does not satisfy the requirements, for a reason not covered above. `message` names it. | Read `message`, fix the payment, and pay again. | | `409` | `PAYMENT_ALREADY_USED` / `REFERENCE_REUSED` | The payment was valid, but a concurrent request redeemed it first. | Final for that payment. Request a new challenge. | | `503` | `METERING_UNAVAILABLE` | The payment store failed while recording your call, so the response is withheld. | Retryable. Your payment was not consumed; retry the identical request. | | `400` | `PAYMENT_HEADER_CONFLICT` | Both `PAYMENT-SIGNATURE` and `X-PAYMENT` are present and differ. | Send one header (`PAYMENT-SIGNATURE` is the v2 name). | | `429` | `RATE_LIMITED` | Too many unauthenticated payment requests from your address. | Back off, or use an `X-API-Key`. | | `502` | `X402_UNAVAILABLE` | Pay-per-call is not available on this deployment right now. | Use an `X-API-Key`, or retry later. | | `402` | `SETTLEMENT_FAILED` | USDC-via-CDP rail only: the facilitator refused to settle a payment it had verified. | Pay again against a fresh challenge. | | `502` | `X402_SETTLE_TIMEOUT` / `X402_FACILITATOR_ERROR` | USDC-via-CDP rail only: the facilitator timed out or errored while settling. | Retry shortly. | The USDC-via-CDP codes only apply where that rail is on; `GET /v1/health` lists the live `rails` (api.x1id.io currently runs `x1-native` only). A call whose handler fails (for example a `404` for an unregistered name) returns that error untouched and does not consume your payment, so you can retry with the same payment. ## On-chain program errors The errors above come from the **read** surface (resolution). When you **build and send** a transaction — register, trade, make an offer, run an auction, manage a lease or recovery — the on-chain program can reject it with a custom error code. Wallets surface these as `custom program error: 0x…` (hex) or `custom(…)` (decimal). The common ones, with copy a UI can show: | Code | Name | What it means | What to tell the user | | --------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `0x1781` (6017) | `HandleIsTokenized` | The name is a capability NFT. Offers, auctions and on-chain recovery (`place_offer`, `start_auction`, `place_bid`, `set_recovery`, `initiate_recovery`, `cancel_recovery`) are not available on a tokenized name. | "This name trades only as its NFT — sell it with a fixed-price listing or by sending the NFT; offers, auctions and recovery don't apply." | | `0x93` (147) | pNFT frozen (listed) | The capability NFT is frozen because the name is **listed**. A plain send of a listed NFT fails with this. | "Cancel the listing before sending this name." | | `0x0` | account already in use | A second offer from the same wallet on the same name — your first offer's PDA still exists. | "You already have an open offer on this name — cancel it first." | | `0x1798` (6040) | `PriceChanged` | The listing or offer amount changed between when you loaded it and when you signed. | "The price changed — review the new amount and try again." | | `0x1792` (6034) | `BidTooLow` | A bid below the reserve, or not at least 5% over the current high bid. | "Bid at least the reserve, or 5% above the current high bid." | | `0x1799` (6041) | `SellerCannotBid` | The seller tried to bid in their own auction. | "You can't bid in your own auction." | | `0x178f` (6031) | `AuctionNotEnded` | Settling an auction that is still running. | "This auction hasn't ended yet." | | `0x1790` (6032) | `AuctionEnded` | Bidding on an auction that has already ended. | "This auction has ended." | | `0x1791` (6033) | `AuctionHasBids` | Cancelling an auction that already has bids, or moving the name out from under one: `transfer`, `release_handle`, `initiate_recovery` and `complete_recovery` all refuse while the name's auction carries a bid. | "This name has a live auction with bids, so it can't be cancelled, moved or recovered until the auction settles." | | `0x1793` (6035) | `BadDuration` | An auction duration outside the allowed range (600 s to 2,592,000 s / 30 days). | "Pick a duration between 10 minutes and 30 days." | | `0x17ab` (6059) | `VoucherNotExpired` | Refunding a gift before its expiry. | "This gift hasn't expired yet — a refund is only possible after it expires." | | `0x17dd` (6109) | `VoucherClaimNotRecipient` | Claiming a recipient-bound gift through the name-only `claim_voucher` from a wallet other than the bound recipient. Relayers use `claim_voucher_tokenized`, which stays open to any signer. | "Only the person this gift was sent to can claim it this way." | | `0x17df` (6111) | `AuctionNotStale` | `close_stale_auction` on an auction whose seller still owns the untokenized name. | "This auction is still live; only the seller can cancel it." | | `0x17e0` (6112) | `VoucherExpiryTooFar` | Creating a gift whose expiry is more than 366 days away. | "Pick a gift expiry within a year." | | `0x17e1` (6113) | `UnreceivableCreatorPayout` | Creating a TLD whose creator payout is a sysvar or an executable account, which can never receive XNT. | "Use an ordinary wallet address for the creator payout." | | `0x17e2` (6114) | `NotTreasury` | `set_reserved_claim_approval` signed by a key other than the treasury multisig. | "Only the treasury multisig can approve a reserved-name claim." | | `0x17e3` (6115) | `ReservedClaimNotApproved` | A reserved-name claim grant for a domain the treasury multisig has not approved for that name (or approved before the name's current reservation). | "This name isn't approved for claiming by that domain." | | `0x17e4` (6116) | `NamespacePricingMismatch` | `register_under_namespace_v2` got a pricing account that isn't the TLD's canonical `["ns_pricing", label]` PDA, isn't a valid price table, or is missing, including when a left-out optional placeholder shifts it into another slot. Also returned when the TLD is marked as priced but its price table account is absent. | "Something went wrong building this purchase. Refresh and try again." | | `0x17e5` (6117) | `NamespacePricingRequiresV2` | The original `register_under_namespace` on a TLD whose creator has set its own prices. Those names can only be registered with `register_under_namespace_v2`. | "This TLD sets its own prices. Update the app, or buy it somewhere that supports creator pricing." | | `0x17e6` (6118) | `PriceExceedsMax` | `register_under_namespace_v2` where the current price is above the `max_price_lamports` the buyer approved, for example because the creator raised prices after the quote. Nothing is charged except the network fee. | "The price went up since you got your quote. Review the new price and try again." | | `0x17e7` (6119) | `NamespaceTierBelowFloor` | `set_namespace_pricing` with a price below the 1 XNT protocol floor (including `0`). | "Each price must be at least 1 XNT." | | `0x17e8` (6120) | `NamespaceTierAboveMax` | `set_namespace_pricing` with a price above the 100,000,000 XNT protocol ceiling. | "Each price must be at most 100,000,000 XNT." | | `custom(6101)` | lease first payment too small | The first lease-to-own payment is below the one-year minimum (0.025 XNT at divisor 4). | "The first lease payment must cover at least one year." | | `0xbc4` (3012) | delegate PDA missing | A record-delegate action when no delegate is set for the name (Anchor `AccountNotInitialized`). | "No record delegate is set for this name." | | — | `reclaim_expired_lease` on a permanent name | The name was registered permanently (or already paid off), so there's no lease to reclaim. | "This name is permanent — there's nothing to reclaim." | These are the on-chain program's own codes. The exact hex/decimal pairs are stable for a given program version; branch on them where you can, but always keep a generic "the transaction was rejected" fallback for a code you don't recognise. The anti-snipe extension is **`end = max(end, now + 300s)`** — a late bid pushes the end to 5 minutes from that bid, it does not simply add 5 minutes each time. ## General guidance * **Never render a bare address.** Every successful result — from the SDK or the REST API — carries the namespace that produced it. Show it in any confirmation UI. * **Treat `ambiguous` as blocking, not a fallback chain.** Don't retry the same input against "just the handle" or "just the domain" — that reintroduces exactly the wrong-recipient risk the error exists to prevent. * **`not-found`, `no-record-for-chain` and "no primary" are not retryable.** They're correct, final answers about the current state of the registry. * **Transport errors (`rpc-error`, HTTP `502`) are the only retryable class on the read surface.** Back off and retry; everything else is a stable answer. For x402, `PAYMENT_NOT_FINALIZED`, `VERIFIER_UNAVAILABLE` and `METERING_UNAVAILABLE` are the retryable codes; after `REDEEM_SIGNATURE_REQUIRED` or `REDEEM_SIGNATURE_INVALID`, resend the same payment with a valid `payerSig`. ## Next --- # Events & webhooks URL: /docs/x1id/events > The X1ID identity-event stream — a stable, typed envelope over every on-chain identity change (register, transfer, primary, attest, market, and more) delivered to your endpoint as an HMAC-signed webhook, with the verification recipe. # Events & webhooks Mainnet Every on-chain identity change — a handle registered, transferred, a primary set, an attestation added, a sale — becomes a stable, typed **event**. Register a webhook and X1ID delivers each matching event to your endpoint as a signed `POST`, with retries and a dead-letter record. One stream powers identity-change alerts and Gate eligibility hooks, and a sibling stream, [Pay webhooks](/docs/x1id/pay-webhooks), covers payments and invoices, so you don't poll. **Payments and invoices: self-serve today.** To get a webhook when a wallet or an `@name` you control is paid, or when one of its invoices is created, paid, cancelled or closed, register it yourself with your API key and a wallet signature. See [Pay webhooks](/docs/x1id/pay-webhooks). Those deliveries use the `x1id.pay-events/v1` payload and the same signature scheme as below. **Identity events: provisioned on request.** Self-serve registration for the identity stream on this page (registrations, transfers, records, market, …) isn't public yet. Email `support@x1id.io` with your endpoint URL and the event types you want, and you'll get back a signing secret. The envelope and verification recipe below are the stable delivery contract, so you can build your receiver against them today. ## The event envelope Every event is a small, **stable** JSON object (`x1id.events/v1`). The on-chain instruction name and raw indexer shape are implementation details that churn; you get a stable `{ category, type }`, a typed `data` projection of the fields you most want, and — always — the untouched row in `raw` as an escape hatch. ```jsonc { "schema": "x1id.events/v1", "id": 148123, // monotonic; dedupe on this "type": "market.sold", // stable, namespaced "category": "market", // coarse filtering axis "rawEventType": "buy_handle", // the on-chain instruction "handle": { "name": "alice", "account": "" }, // null for config/sentinel events "owner": "", "chain": { "signature": "", "ixIndex": 0, "slot": 183500000, "blockTime": "2026-10-02T00:00:00.000Z" }, "data": { "priceLamports": "10000000000", "feeLamports": "250000000", "buyer": "…", "seller": "…" }, "raw": { /* the indexer detail, verbatim */ }, "emittedAt": "2026-10-02T12:00:00.000Z" } ``` Lamport amounts are decimal **strings**. A value whose shape isn't certain stays only in `raw` — nothing is reshaped or guessed. ## Event types Events are grouped into categories; each category has a set of normalized `type`s. | Category | Example types | | -------------- | ------------------------------------------------------------------------------------------------------------ | | `registration` | `identity.registered` | | `ownership` | `identity.transferred`, `identity.released`, `identity.recovered`, `identity.nft_minted` | | `records` | `record.created/updated/closed/verified/cleared`, `text_record.*`, `record.delegate_set/revoked` | | `primary` | `primary.set`, `primary.cleared` | | `attestation` | `attestation.created`, `attestation.revoked` | | `market` | `market.listed`, `market.sold`, `market.offer_placed`, `market.auction_started/settled`, `market.bid_placed` | | `lock` | `lock.locked`, `lock.unlock_initiated`, `lock.unlocked` | | `subname` | `subname.created/revoked`, `subname.record_*` | | `namespace` | `namespace.created`, `namespace.status_changed`, `namespace.override_*` | | `voucher` | `voucher.created`, `voucher.refunded` | | `commitment` | `commitment.created`, `commitment.cancelled` | | `lease` | `lease.renewed` | | `admin` | `admin.config_changed` (the specific op is in `data.action`) | | `other` | `unknown` — any instruction a build predates is still delivered, never dropped | ### Filtering A subscription's filter is an array of tokens; an event matches if the array is empty (**all events**) or any token matches: | Token | Matches | | ------------- | --------------------------------------------------------- | | `*` | everything | | `market` | every event in that category | | `market.*` | normalized types under that prefix | | `market.sold` | that exact type | | `buy_handle` | events whose `rawEventType` matches (the raw instruction) | ## Verifying a delivery Each delivery is a `POST` of the JSON body with these headers: ``` Content-Type: application/json X1id-Signature: t=,v1= X1id-Event-Id: X1id-Event-Type: X1id-Delivery-Id: X1id-Subscription-Id: ``` The signature is **HMAC-SHA256 over `` `${t}.${rawBody}` ``** using your subscription secret (`whsec_…`, shown once at creation). This is the Stripe scheme — the timestamp `t` foils replay. To verify: (1) read the **raw** request body bytes (don't re-serialize the parsed JSON); (2) parse `t` and `v1` from `X1id-Signature`; (3) reject if `|now − t|` exceeds your tolerance (300 s recommended); (4) recompute the HMAC and compare in **constant time**. ```js title="verify.js" import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, rawBody, header, toleranceSecs = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSecs) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && timingSafeEqual(a, b); } ``` ## Delivery, retries & ordering * A delivery succeeds **only** on a `2xx`. A `3xx` redirect is a failure — a webhook endpoint must not redirect. * On failure it retries with **full-jitter exponential backoff**, up to a configured number of attempts (6 by default), then **dead-letters** the delivery while keeping the full signed payload for inspection or replay. * Dedupe on the event **`id`** (and the `X1id-Event-Id` header): delivery is at-least-once, so the same event can arrive more than once, but `id` is monotonic and stable. * Respond fast (`2xx`) and process asynchronously — a slow handler just invites a retry. ## Next --- # Wallet integration URL: /docs/x1id/examples > Add @handle and X1NS support to a wallet's send flow end to end — what to show for each outcome resolve() can return, and the non-negotiable rule that a name is never just an address. # Add @handle support to a send flow This walks through wiring `@x1id/resolve` into a wallet's recipient field. The live reference integration is the **X1ID app** itself — [app.x1id.io](https://app.x1id.io) uses exactly this SDK for its own resolution and reverse lookup. It covers the part that's easy to get wrong: what to show the user for each thing `resolve()` can do, so a mistyped or ambiguous name never turns into a transfer to the wrong recipient. **`@handle` resolution is live on X1 mainnet.** The examples below point `rpcUrl` at X1 mainnet; the same registry at the same program ID is also on X1 testnet (`https://rpc.testnet.x1.xyz`) for development. X1NS `.x1` / `.xnt` / `.xen` domain resolution — a separate, older system — exists on mainnet only. This page targets **`@x1id/resolve@0.21.0`**. ## The setup Most wallets already have a send screen with a recipient input. The only new piece: before that input is treated as an address, check whether it *looks like a name*, and if so, resolve it before rendering a recipient. ```ts import { createResolver, WasmResolver, ResolveError, looksLikeName } from '@x1id/resolve'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const wasmPath = require.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm'); const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath)); // testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz' const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); ``` (For a browser build, swap the WASM-loading block for the `fetch`-based one in the [SDK reference](/docs/x1id/sdk#loading-the-wasm-module).) ## Step 1 — decide whether to resolve at all A recipient field gets two kinds of input: raw addresses (pasted or scanned) and names. Don't run a raw address through `resolve()` — it'll just throw `unrecognized`. Use `looksLikeName()` as the branch point: ```ts function onRecipientInputChange(value: string) { if (looksLikeName(value)) { resolveRecipient(value); // → Step 2 } else { // Treat as a raw address: run your existing validation/checksum path setRecipient(validateRawAddress(value)); } } ``` ## Step 2 — resolve and shape the result This is the core of the integration. `resolve()` either returns a `Resolved` or throws a `ResolveError` with a `code` your UI can switch on — there's no bare-string error to match against. ```ts /** What the recipient field renders. The address is never shown alone. */ interface Recipient { address: string; display: string; // "@jack" or "jack.x1" — the user must see which one they're paying verified: boolean; // false → show a warning before the user can send } type ResolveOutcome = { recipient: Recipient } | { hint: string }; async function resolveRecipient(input: string): Promise { try { const r = await x1id.resolve(input); return { recipient: { address: r.address, display: r.namespace === 'handle' ? `@${r.name}` : r.name, verified: r.verification === 'verified', }, }; } catch (e) { if (e instanceof ResolveError) { switch (e.code) { case 'not-found': return { hint: 'No name found' }; case 'ambiguous': return { hint: 'Type either @jack or jack.x1 — not both' }; case 'invalid-handle': return { hint: 'Handles allow letters, digits and hyphens only' }; case 'invalid-domain': return { hint: "That doesn't look like a valid domain" }; case 'no-record-for-chain': return { hint: 'This name has no address on the selected chain' }; case 'rpc-error': return { hint: 'Network issue — try again' }; default: return { hint: 'Could not resolve that name' }; } } throw e; // an unexpected, non-ResolveError failure — don't swallow it } } ``` ## Step 3 — the outcomes ```ts for (const input of ['@nike', 'jack.x1', '@jack.x1', 'not a name']) { console.log(input, '→', await resolveRecipient(input)); } // "@nike" → { recipient: { address: "E2cELM…", display: "@nike", verified: true } } ← held by the treasury: Step 4 refuses it // "jack.x1" → { recipient: { address: "9pXw…", display: "jack.x1", verified: false } } (mainnet RPC) // "@jack.x1" → { hint: "Type either @jack or jack.x1 — not both" } // "not a name" → never reaches resolveRecipient — looksLikeName() returns false first ``` * **Resolved, verified** — a handle resolved and control of the address was proved (the owner holds the registry account, or — for a tokenized handle — holds its NFT in their ATA). Show the recipient, then run the [pre-send checks](#step-4--check-before-you-sign) before enabling send. `verified` says who **controls** the address, not who the name **represents**: `@nike` above is `verified` because the X1ID treasury holds it as a reserved name. * **Resolved, unverified** — an X1NS domain resolved to its owner, or a tokenized handle whose NFT sits outside the holder's ATA. Show the recipient, but surface the distinction — don't hide it behind the same confident UI as a verified result. * **Not found / invalid** — `not-found`, `invalid-handle`, `invalid-domain`: nothing to render. Show an inline hint, leave send disabled, don't clear what the user typed. * **Ambiguous** — never guess. **The one case that must never silently pick a side.** `@jack` and `jack.x1` can belong to different people. Input shaped like both — `@jack.x1` — throws `ambiguous` instead of the SDK guessing. Your UI must not guess either: don't fall back to "try the handle first, then the domain." Ask the user to disambiguate. ## Step 4 — check before you sign What a user sees while typing can come from cache, and some names resolve to an address you must not pay. Right before you ask the wallet to sign a send, run these checks against fresh chain state and **refuse the send** if any fails: 1. **Re-resolve uncached.** The SDK caches `resolve()` for 30 s by default and has no per-call bypass, so keep a second resolver with `cacheTtlMs: 0` (or call `clearCache()` first) for the final resolve. If the address differs from the one the user confirmed, show the new one and ask again. 2. **Refuse held (reserved) names.** Brands, tickers, well-known words and a curated set of short names are registered to the **X1ID treasury** (`E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6`) until they reach an owner through the marketplace. They resolve normally, to the treasury, and neither the SDK nor `/v1/resolve` flags them. Check the free REST [`/quote/{name}`](/docs/x1id/api#price-quote): `status: "held"` means treasury-held. Also refuse any resolution whose address is the treasury. 3. **Refuse lapsed leases.** The SDK and `/v1/resolve` are lease-unaware: a [lease-to-own](/docs/x1id/faq#lease-to-own) handle past its paid-through date still resolves to the current holder, and can be reclaimed by someone else after its grace period. The free REST [`/resolve/{name}`](/docs/x1id/api) adds `lease: { expires_at, lapsed }` for a leased handle; refuse when `lapsed` is `true`. ```ts const TREASURY = 'E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6'; // Config.treasury (immutable) const fresh = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm, cacheTtlMs: 0 }); type PreSend = { ok: true; address: string } | { ok: false; reason: string }; async function checkBeforeSend(input: string, chain: 'X1' | 'SOL' | 'ETH' | 'BTC' = 'X1'): Promise { const r = await fresh.resolve(input, { chain }); // uncached; throws ResolveError as in Step 2 if (r.address === TREASURY) return { ok: false, reason: 'This name is held by X1ID and has no owner yet.' }; // The REST checks take top-level @handles only. Scoped names (name.tld) and // subnames (pay.alice) are resolved by the SDK above; /quote rejects them. const topLevelHandle = r.namespace === 'handle' && !r.name.includes('.'); if (topLevelHandle) { const base = 'https://api.x1id.io'; const name = encodeURIComponent(r.name); const [quote, rest] = await Promise.all([fetch(`${base}/quote/${name}`), fetch(`${base}/resolve/${name}?fresh=1`)]); // fresh: skip the ~20 s answer cache // Fail closed: if a check can't be read (429, 5xx), don't send. if (!quote.ok || !rest.ok) return { ok: false, reason: 'Could not verify this name right now. Try again.' }; const q = await quote.json(); const res = await rest.json(); if (q.status === 'held') return { ok: false, reason: 'This name is reserved and held by X1ID.' }; if (res.lease?.lapsed === true) return { ok: false, reason: "This name's lease has lapsed." }; } return { ok: true, address: r.address }; } ``` The REST checks cover top-level `@handles` only: `/quote` rejects a dotted name, so for scoped names (`name.tld`) and subnames use the SDK (the uncached resolve plus the treasury check above) and don't refuse them just for being dotted. An unregistered name of any kind throws `not-found` from the SDK, so it never reaches a send. The REST API is rate-limited per IP (100 requests/s, burst 200; see [Rate limits](/docs/x1id/api#rate-limits)), so a backend that runs these checks for many users should expect `429` or `503` under load, wait the `Retry-After` seconds, and keep failing closed. ### Edge cases | Case | SDK `resolve()` | REST `/resolve` | `/v1/resolve` | Do | | ------------------------------ | --------------------------------- | ------------------------------ | ------------------------- | ---------------------------------- | | Unregistered | throws `not-found` | `404` | `404` | show "No name found" | | Held / reserved (treasury) | resolves to the treasury, no flag | same | same | `/quote` `status: "held"` → refuse | | Leased, past paid-through date | resolves, no lease signal | resolves, `lease.lapsed: true` | resolves, no lease signal | refuse when `lapsed` | | Bare `jack` (no `@`) | throws (needs `@`) | treated as a handle | treated as a handle | require the `@` in your UI | | `@jack.x1` | throws `ambiguous` | `409` | `400 AMBIGUOUS` | ask the user which one | | X1NS name on testnet | not found | — | — | X1NS is mainnet-only | ### Trust fields | Field | Where | What it means | What it does **not** mean | | ---------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `namespace` | SDK, REST `/resolve`, `/v1/resolve` | which naming system answered (`handle`, `x1`, `xnt`, `xen`, or a customer TLD) | — always show it before a send | | `verification: "verified"` | SDK | control of the address is proved: it owns the registry account, or holds the handle's NFT in its ATA | that the name belongs to the person or brand it reads as (held names are `verified` to the treasury) | | `verification: "unverified"` | SDK | ownership not proved for that chain; X1NS results are always `unverified` | — show a warning | | `source` | `/v1/resolve` | the SDK's `verification`, renamed: `on-chain-verified` / `on-chain-unverified` | same caveats as above | | `verification: "owner"` | REST `/resolve` | a constant label on every result | not a trust grade | | `verifications[]` | REST `/resolve`, `/v1/resolve`, `/v1/verify` | "verified by x1id" badges: off-chain reviews signed by X1ID's attestor key, only while live for the current owner; REST forces `verified: false` (with `holder_mismatch` and a `caveat`) when a tokenized handle's NFT has moved | not a trustless proof, and not a reason to skip any pre-send check | | `lease` | REST `/resolve` only | `{ expires_at, lapsed }` for a leased handle; absent for permanent handles | grace period isn't modelled; `lapsed` is the paid-through fact | | `status` | REST `/quote` | `available`, `held` (treasury-held), or `taken` | `taken` doesn't tell you who owns it — resolve for that | ## Step 5 — the non-negotiable rule Regardless of which outcome fires, one rule holds across the whole integration: **never silently fill a recipient address into the send form.** Always show the resolved `namespace` next to the address before the user can confirm — `@jack` and `jack.x1` reading as the same "recipient" in your UI is exactly the ambiguity the SDK refuses to resolve on your behalf. ```ts function recipientConfirmation(recipient: Recipient) { return { heading: `Sending to ${recipient.display}`, // always the name, never just the address subheading: recipient.address, warning: recipient.verified ? null : "This address hasn't been verified for this chain.", sendEnabled: true, }; } ``` ## Step 6 — multi-chain sends If your wallet sends on more than one chain, pass `chain` through and handle `no-record-for-chain` as its own outcome rather than falling back to an X1 address on the wrong chain: ```ts async function resolveForChain(input: string, chain: 'X1' | 'SOL' | 'ETH' | 'BTC') { try { return { recipient: await x1id.resolve(input, { chain }) }; } catch (e) { if (e instanceof ResolveError && e.code === 'no-record-for-chain') { return { hint: `@${input.replace(/^@/, '')} has no address on ${chain} yet` }; } throw e; } } ``` For X1/SOL the SDK returns the handle's authority; for ETH/BTC it reads the handle's per-chain [records](/docs/x1id/records), with the staleness rule enforced. A handle with no live record for the requested chain throws `no-record-for-chain` — never a wrong or coerced address. ## Step 7 — show `@name` next to an address (reverse) The other half of a naming system: anywhere your wallet prints an address — an activity feed, a contact list, the "from" line on an incoming transfer — you can print the `@handle` its owner declared as primary instead. ```ts async function labelFor(address: string): Promise { const name = await x1id.reverse(address); // "nike" | "9pXw….x1" | null if (name === null) return shorten(address); // no primary set — show the address if (name.includes('.')) return shorten(address); // X1NS fallback carries no label; keep the address return `@${name}`; // on-chain primary handle } ``` Two things to keep straight: * A primary is **set by the owner and validated at read time**, not kept in sync by the program. If the handle is transferred, released, or its NFT sold, `reverse()` simply starts returning `null` for the old address — it never returns a name that address no longer controls. * A reverse label is a **display hint, not a recipient.** If a user wants to *send* to `@nike`, run it through `resolve()` — don't reuse an address you previously reverse-labelled. ## Full reference The live reference for everything above is the X1ID app at [app.x1id.io](https://app.x1id.io) — its recipient handling, handle pages and "primary for …" badges are built on this SDK. For the method-by-method API this example builds on, see the [SDK reference](/docs/x1id/sdk); for a copy-paste resolver singleton and a \~30-line React ``, the package [README on npm](https://www.npmjs.com/package/@x1id/resolve). ## Next --- # Gate URL: /docs/x1id/gate > Gating-as-a-service — protect an endpoint, a Discord, a mint, or an airdrop with one call that answers "does this @handle (or wallet) hold a verified on-chain attestation matching my policy?" Define reusable policies and let agents pay per decision. # Gate Mainnet **Gate** answers one question over HTTP: *does this subject hold a verified on-chain attestation that satisfies my policy?* Point it at an `@handle` or a wallet, hand it a policy, and get back a decision with the attestations behind it. You reuse X1ID's verification oracle as your allowlist instead of running your own. This is the **hosted** version of the SDK's gating primitive. If you'd rather read chain yourself (or gate **on-chain** from another program via a CPI), see [Attestation gating](/docs/x1id/gating) — same policy shapes, same staleness rules, no key needed. ## Check a subject Body: exactly one of `handle` / `wallet`, plus a `policy`. A wallet subject is gated on its **primary** `@handle`. ```bash title="POST /v1/gate/check" curl -X POST https://api.x1id.io/v1/gate/check \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "handle": "@jack", "policy": { "kind": 4 } }' ``` ```json { "passed": true, "subject": { "handle": "jack", "wallet": null }, "policy": { "kind": 4 }, "attestations": [ { "platform": "github", "kind": 4, "attested_at": 1733270400 } ] } ``` | field | meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `passed` | the decision. | | `subject` | who was evaluated. For a wallet subject, `handle` is its resolved primary (or `null`). | | `policy` | the policy that ran. | | `attestations` | the live attestations that count — the *which* behind the decision. | | `reason` | present only when `passed` is false for a structural reason: `no-primary-handle` (the wallet has no primary) or `handle-not-registered`. Lets you tell "no identity" from "identity, but doesn't match." | A definitive "does not pass" — an unregistered handle, a wallet with no primary, or no matching attestation — is a real `200 { "passed": false }` decision (and is metered). Only malformed input (`400`) and infrastructure failure (`502`) are non-decisions. ## Policies A policy is what the subject must prove. Three inline shapes, mirroring the SDK's `Gate` type exactly: | Policy | Passes when the handle holds… | | --------------------- | ----------------------------------------------------------------- | | `"any-verified"` | at least one non-stale attestation of **any** kind. | | `{ "kind": 4 }` | a non-stale attestation of **exactly** that kind. | | `{ "anyOf": [2, 4] }` | a non-stale attestation of **any** listed kind (X **or** GitHub). | Attestation kinds: `0` dns · `1` social (legacy) · `2` x · `3` discord · `4` github · `5` telegram. A policy with an out-of-range kind is rejected at definition time (`400 BAD_GATE`), never silently never-matching. ### Named policies Define a policy once and reference it by id — handy when the same door is checked from many places. Policy management is **keyed only** (a policy is scoped to your API key). ```bash title="POST /v1/gate/policies" curl -X POST https://api.x1id.io/v1/gate/policies \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "name": "github-verified", "gate": { "kind": 4 } }' # → 201 { "id": "gp_…", "name": "github-verified", "gate": { "kind": 4 }, "created_at": "…" } ``` Then pass the `gp_…` id as the `policy` in a check: ```bash curl -X POST https://api.x1id.io/v1/gate/check \ -H "X-API-Key: $X1ID_API_KEY" -H "content-type: application/json" \ -d '{ "wallet": "H8Fs…X1HiA", "policy": "gp_…" }' ``` `GET /v1/gate/policies/:id` reads one back (owner-scoped). A `gp_…` string is always treated as a stored-policy reference; any other value is an inline policy. ## Metering A gate **decision** is metered exactly like a resolve: with a key it counts against your [rate limit](/docs/x1id/platform#tiers-and-rate-limits); with no key, each decision can be paid for per call via [x402](/docs/x1id/platform#pay-per-call-with-x402). Defining and reading named policies always requires a key. ## What a pass means — and what it doesn't **Trust in the gate = trust in the attestor**, exactly like the blue-tick. * **`passed: true`** means the handle carries a live, attestor-signed attestation matching your policy. The attestor is X1ID's verification oracle. * It is **"verified by x1id"**, not trustless proof and not legal personhood. The attestor key is admin-rotatable. Render the result as exactly that. * **Staleness is enforced for you.** An attestation only counts for the *current* owner — one left from a previous owner (before a transfer, sale, or re-register) never counts. * **Ownership changes**, so re-check per session. Pair a gate check with [Connect](/docs/x1id/connect) to first prove the visitor controls the handle, then gate on what it's verified. ## Next --- # Attestation gating URL: /docs/x1id/gating > Gate your app, Discord, mint, or airdrop by whether an @handle holds a verified on-chain attestation — reuse X1ID's blue-tick oracle as your allowlist instead of running your own verification. # Attestation gating Gate access to your app, Discord, mint, or airdrop by whether an `@handle` holds a **verified on-chain attestation** — any signal, or a specific platform like "verified GitHub." You **reuse X1ID's blue-tick oracle as your allowlist** instead of running your own verification. Everything is read from chain: an `Attestation` is an attestor-signed account, and the staleness rule (it only counts for the *current* owner — `attested_at >= handle.registered_at`) is enforced for you. There are two ways in: an **off-chain** check (your server or app reads chain and decides) and an **on-chain** CPI (another program verifies the same thing inside its own transaction). This page targets **`@x1id/resolve@0.21.0`**. The program is deployed on **X1 mainnet** at `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` (`https://rpc.mainnet.x1.xyz`; testnet works the same with `https://rpc.testnet.x1.xyz`). ## Off-chain gate (SDK) Your server or app reads the handle's attestations and decides. Two functions cover it — `handlePassesGate` (fetch + check in one call) and `attestationsPassGate` (a pure, synchronous check when you already hold the attestations). ```ts import { handlePassesGate, attestationsPassGate, GateKind, type Gate, } from '@x1id/resolve'; // Define the gate: const anyVerified: Gate = 'any-verified'; // any blue-tick const githubOnly: Gate = { kind: GateKind.github }; // one specific platform const xOrGithub: Gate = { anyOf: [GateKind.x, GateKind.github] }; // One-call fetch + check. handleAccount / registeredAt / recordsClearedAt come // from the handle you resolved (name → deriveHandleAccount → decode the Handle): const { passed, attestations } = await handlePassesGate( rpc, programId, handleAccount, registeredAt, recordsClearedAt, githubOnly, ); if (!passed) throw new Error('Not allowed — verify your GitHub on X1ID first.'); // Already have the attestations? The check is pure + synchronous: attestationsPassGate(attestations, xOrGithub); // boolean ``` ### The `Gate` shape A `Gate` is what a handle must prove to pass: | Form | Passes when the handle holds… | | ------------------ | --------------------------------------------------------------------- | | `'any-verified'` | at least one non-stale attestation of **any** kind | | `{ kind }` | a non-stale attestation of **exactly** that kind | | `{ anyOf: [...] }` | a non-stale attestation of **any** listed kind (e.g. X **or** GitHub) | `GateKind` names the platform kinds so you gate without magic numbers: ```ts GateKind.dns // 0 GateKind.social // 1 (legacy generic) GateKind.x // 2 GateKind.discord // 3 GateKind.github // 4 GateKind.telegram // 5 ``` `handlePassesGate` returns both the pass/fail **and** the attestations, so a caller can also render *which* signals matched. Stale attestations never count — the staleness rule is baked into `HandleAttestation.stale` and this module always ignores stale ones. ### The recommended pattern Resolve the visitor's connected wallet → its primary `@handle` ([reverse resolution](/docs/x1id/sdk#reverse)) → gate it. Combine with a control-proof challenge (`createControlChallenge` / `verifyControlProof`) to first prove the wallet controls the handle, then gate on its attestations — a complete "verified-handle-only" door with no backend of your own. ## On-chain gate (CPI): `assert_attestation_gate` The SDK path above gates *off-chain*. To gate *on-chain* — another program CPI-verifying that the caller controls a verified handle within the same transaction — invoke `assert_attestation_gate`. It returns `Ok(())` when the gate passes and errors (`GateNotSatisfied`) otherwise, so **a CPI that errors fails the caller's transaction: that is the gate.** * **Program:** `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` * **Discriminator:** `sha256("global:assert_attestation_gate")[..8]` = `[101, 166, 24, 233, 23, 22, 25, 204]` **Argument** — `kind: Option` (borsh): `0x00` = *any* kind passes; `0x01 ` requires that specific kind. Codes: `0` dns · `1` social (legacy) · `2` x · `3` discord · `4` github · `5` telegram (the `Attestation::KIND_*` constants). **Instruction data** — `disc(8) ++ option(1 or 2)`: ``` require GitHub = [101,166,24,233,23,22,25,204, 1, 4] any verified = [101,166,24,233,23,22,25,204, 0] ``` **Accounts, in order:** | # | Account | Signer | Writable | Derivation | | - | ------------- | :----: | :------: | --------------------------------------------------------------------------------------------------- | | 0 | `caller` | ✅ | — | the entity proving control (also fee payer); allocates nothing, so not `mut` | | 1 | `handle` | — | — | `["handle", name_bytes]` — the handle being asserted | | 2 | `attestation` | — | — | `["attestation", handle_pubkey, [kind]]` — seeds re-derive from the attestation's own stored `kind` | For a **tokenized** handle (the owner holds the capability pNFT), append the owner's pNFT token account as `remaining_accounts[0]` — the ATA proof that `caller` holds the NFT. Omit it for an untokenized handle (the handler reads `handle.owner` directly). This is the same authority check the record-editing instructions use. **What the handler enforces:** (1) `caller` currently controls the handle (untokenized owner, or pNFT holder); (2) the attestation's `kind` matches the requested `kind` (when `Some`); (3) the attestation is bound to this handle; and (4) the epoch-staleness rule — `attestation.attested_at >= handle.registered_at`, so an attestation from a previous owner (before a transfer/sale/re-register) does not count. ## What it proves — and what it doesn't **Trust in the gate = trust in the attestor**, exactly like the blue-tick. * **Proves:** the handle currently holds a non-stale, attestor-signed attestation of the required kind. The attestor is X1ID's verification oracle (the same one behind the profile blue-tick). * **Does not prove:** anything the attestor didn't verify. A verification is an off-chain review (a DNS lookup, an OAuth completion, a human check) that X1ID's **admin-rotatable** attestor key signed off on — not a trustless proof. Render it as "verified by x1id", never as more. * **Ownership can change:** re-check per session — a handle sold after being verified carries no attestation for its new owner (the staleness rule handles this automatically). ## Next --- # Launch your TLD URL: /docs/x1id/launch-your-tld > Create your own X1NS top-level domain (like .pepe) permissionlessly on the X1ID registry. Pay a one-time creation fee, become the TLD creator, and earn 75% of every name registered under it — enforced on-chain. The Namespace account, the payout controls, and an honest account of what's live. # Launch your TLD Mainnet Anyone can create their own **X1NS top-level domain** — a `.yourbrand` — directly on the X1ID registry program. Pay a one-time creation fee, and you become the TLD's **creator**: every name registered under it (`alice.yourbrand`) routes **75% of the price to you** and 25% to the X1ID treasury, enforced on-chain. Names under your TLD are first-class handle accounts — they resolve, they index, they appear in the marketplace. These are **on-chain instructions** on the registry program (`8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`), built into X1ID's own program — not a fork of SNS, ENS, or anyone else. You call them the same way you call [registration](/docs/x1id/register): assemble the instruction, sign, and send. **What's enforced on-chain today.** The 75/25 split is enforced on **every primary registration** under a TLD, and a permissionless [referrer cut](#referrers-earn-a-cut) composes on top of it. On **resale** through the X1ID marketplace, the creator also earns a fixed share of the royalty: see [Resale royalty](#resale-royalty). **Registrations and marketplace actions may be paused.** The registry has an emergency lockdown, used for example during a security audit. While it's on, the program rejects new registrations, NFT mints, reserved-name and gift claims, TLD creation, and new listings, offers and auctions (`ProtocolLockedDown` / `MarketPaused`). Resolution, transfers, `set_primary` and records keep working, as do exits such as cancelling a listing, completing an existing sale, and lease renewal. Check the banner on [app.x1id.io](https://app.x1id.io), or read the flag from the `Config` account with the SDK's `readPauseState`. For a TLD, that means `create_namespace`, `register_under_namespace` and `register_under_namespace_v2` are rejected while the lockdown is on. Setting your TLD's prices (`set_namespace_pricing`) is not blocked by the lockdown, because it moves no value. ## Create a TLD `create_namespace(label)` is **permissionless** — no admin approval. It: 1. Charges the one-time **creation fee**, currently **1,000 XNT** (admin-configurable; read `Config.namespace_creation_fee_lamports`), from the payer to the treasury. 2. Refuses any label on the [**reserved denylist**](#reserved-tlds) — the ecosystem roots and a seeded set of brand/trademark labels are protected. 3. Initializes the `["namespace", label]` account, records **you** (the payer) as the `creator`, sets your `creator_payout` wallet, sets the creator share to the default **75%** (7500 bps) with that same value frozen as the floor, and sets the registration mode to [**Open**](#open-vs-invite-only) (anyone may register under it). A label is canonical ASCII (`a-z`, `0-9`, `-`), 1–16 characters. The creation fee is currently **1,000 XNT** (admin-configurable; read `Config.namespace_creation_fee_lamports`). It's a tunable config value rather than a hardcoded number, so read it from the on-chain `Config` account (or check the app) right before you create. Creating a TLD does **not** register any names under it; that's a separate step per name. ## The Namespace account Each TLD is one on-chain `["namespace", label]` account: | Field | Meaning | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `label` | the TLD, e.g. `pepe`. | | `status` | `Active`, `Paused`, or `Retired`. New names can be registered only while `Active`; existing `name.tld` handles keep resolving regardless. | | `created_at` | creation timestamp. | | `creator` | the wallet that created the TLD. The only key that can rotate the payout, set the [price table](#set-your-own-prices), or raise the share. Never changes. | | `creator_payout` | the lamport destination for the creator's share of every registration. | | `creator_share_bps` | the creator's cut in basis points (default `7500` = 75%). | | `creation_share_floor_bps` | the floor the share can never drop below — the split frozen in at creation, protecting everyone who registered under the advertised terms. | | `registration_mode` | `Open` (the default — anyone may register) or `InviteOnly` (only the creator may). Flipped anytime by the creator. See [below](#open-vs-invite-only). | ## Register a name under a TLD Two instructions create `name.tld` as a first-class handle at the PDA `["handle", label, name]` (ordinary `["handle", name]` handles are untouched): * **`register_under_namespace_v2(name, handle_type, max_price_lamports)`** works on every TLD. If the creator has [set prices](#set-your-own-prices), it charges the creator's price for the name's length; otherwise it charges the **global tier table** price, the same length-based pricing as a bare handle. It refuses to charge more than `max_price_lamports`, so pass the price you quoted to the buyer. * **`register_under_namespace(name, handle_type)`** is the original instruction. It still works, unchanged, on a TLD that has never set prices, and charges the global tier table price. On a TLD whose creator has set prices it fails with `NamespacePricingRequiresV2`, so nobody can buy at the global price by using it. Whichever instruction you use, the price is split: * **`creator_share_bps`** (75% by default) → the TLD's `creator_payout`. * The **exact remainder** → the treasury. The split is value-conserving: `creator_cut + treasury_cut == price`, floor-rounded on the creator side with treasury taking the remainder, so no lamport is created or lost. ## Set your own prices By default, a name under your TLD costs the same as a bare handle of the same length, from the global tier table. As the creator you can replace that with your own price table using **`set_namespace_pricing(label, tier_lamports)`**. * **Who can call it.** Only the TLD's `creator` wallet signs. X1ID's admin and the treasury can't set or change your prices; it's the same trust model as your payout and registration-mode settings. * **What you set.** `tier_lamports` is ten prices in lamports, one per name length: entries 0 through 8 price 1- to 9-character names, and entry 9 prices every name of 10 or more characters. You always send all ten. The prices don't have to go up or down with length, so a premium TLD can charge more for short names and a community TLD can price everything the same. * **Floor and ceiling.** Every entry must be at least **1 XNT** (`NAMESPACE_TIER_FLOOR_LAMPORTS` = 1,000,000,000 lamports) and at most **100,000,000 XNT** (`NAMESPACE_TIER_MAX_LAMPORTS`). An entry below the floor fails the whole call with `NamespaceTierBelowFloor`, and one above the ceiling fails with `NamespaceTierAboveMax`. Both limits are constants in the program, so no admin setting can change them. The floor makes sure the treasury's share of every sale still covers the rent of the accounts each registration creates (about 0.026 XNT), even with your share at the 80% cap and a referrer taking their full cut. * **Flat prices.** Your table is used as is. Unlike the global table, there's no launch-time or volume price ramp. * **Changing prices.** Call `set_namespace_pricing` again with a new table at any time, including while the TLD is paused. A new table affects future registrations only; names already registered are never touched. * **Cost to you.** The first call creates the TLD's pricing account at the PDA `["ns_pricing", label]` and grows your `Namespace` account by one byte that marks the TLD as priced. You pay the rent for both. Accounts, in order: `creator` (signer, writable), `namespace` (`["namespace", label]`, writable), `namespace_pricing` (`["ns_pricing", label]`, writable), and the system program. **Setting prices is one-way.** There is no instruction to delete a price table or return to the global tier table. You can set a table that matches whatever prices you want, but once your TLD is priced, buyers must use `register_under_namespace_v2`: the original `register_under_namespace` fails with `NamespacePricingRequiresV2` for your TLD. Make sure the apps selling your names support v2 before you set prices. ### Registering with v2 `register_under_namespace_v2` takes the same accounts as `register_under_namespace`, in the same order, with two differences: * The three optional accounts after `sysvar_instructions` (the referrer's integrator account, the integrator allowlist, and the minter authorization) must always be present. Pass the registry program ID in any slot you don't use; that is how Anchor marks an optional account as absent. * The TLD's `["ns_pricing", label]` PDA comes last, as the first remaining account (position 24). Pass it even if the creator has never set prices: an address that holds no account means "use the global tier table". The program checks the pricing account before any XNT moves. If it isn't the canonical PDA for this TLD, isn't a valid price table, or is missing (including when it slides into an optional slot because a placeholder was left out), the call fails with `NamespacePricingMismatch`. `max_price_lamports` protects the buyer. If the creator raises prices, or the global price ramps up, between your quote and the transaction landing, the call fails with `PriceExceedsMax` instead of charging more. The buyer loses only the network fee. The split is the same on both instructions and on both price sources: the creator's share, any referrer's cut from the treasury's portion, and the treasury's remainder, all checked on-chain. ## Resale royalty Names under a TLD are always tokenized, so a resale through the X1ID marketplace (`buy_handle` on a fixed-price listing) withholds the 2.5% royalty from the sale price, and for a `name.tld` that royalty is split on-chain: * **75%** of the royalty → the TLD's `creator_payout`. * The **exact remainder** (25%) → the treasury. This secondary split is a fixed protocol constant (`SCOPED_ROYALTY_CREATOR_BPS = 7500`). It is independent of `creator_share_bps`, which governs primary registrations only, so raising your primary share does not change it. The buyer's transaction must include your current `creator_payout` account; the program checks it against the Namespace record, so nobody can redirect your cut. The NFT's metadata also declares the same 75/25 creator split, which marketplaces that honor Metaplex royalties read; outside the X1ID program, whether that royalty is paid depends on the marketplace. ## Open vs. invite-only Every TLD carries a `registration_mode`, and the **creator alone** flips it — trustlessly, with no admin or treasury signature — via `set_namespace_registration_mode(mode)`: * **`Open`** (the default, set at creation) — **permissionless**: anyone may buy `name.tld` and assign ownership to any wallet they choose, exactly like a bare `register`. * **`InviteOnly`** — only the namespace **creator** may register names, assigning each new handle's owner to an invitee pubkey they pass in. The model for a curated or members-only community. Fees and the 75/25 split are **identical** in both modes — invite-only changes *who* may call `register_under_namespace` or `register_under_namespace_v2`, not the price or the payout. Flipping the mode affects only future registrations; names already registered are untouched. (In `Open` mode a registration by anyone other than the creator is allowed; in `InviteOnly` mode anyone but the creator is rejected with `NamespaceInviteOnly`.) ## Referrers earn a cut Registrations under a TLD compose with X1ID's **permissionless referrer** (integrator) rev-share. Anyone can self-register as a referrer — `register_integrator` needs no allowlist or admin approval — and then earn a share of registrations they drive, up to a hard cap of **20%** (`MAX_INTEGRATOR_RATE_BPS`). On a namespaced (`name.tld`) sale, the referrer's cut comes **entirely out of the treasury's 25%** — the creator's 75% is never touched: * **Creator** → `creator_share_bps` (75% by default) of the price. **Untouched by any referral.** * **Referrer** → up to 20% **of the treasury's 25% portion** (not of the whole price). * **Treasury** → the exact remainder. The whole thing stays value-conserving: `creator + referrer + treasury == price`, checked on-chain. A sale with no referrer is byte-identical to the plain 75/25 split. This is the same referrer system the consumer [invite flow](/docs/x1id/referrals) credits — here it's the on-chain, revenue-sharing form that composes onto TLD registrations. The 20% cap is of the treasury's portion, so a creator's advertised 75% is mathematically guaranteed regardless of who referred the buyer. ## Payout controls (creator-owned, trustless) Two instructions let a creator manage their TLD's economics without anyone's permission: ## Reserved TLDs Permissionless creation is gated by an on-chain **denylist** so nobody can grab a sensitive root. Each reserved label is its own `["reserved_namespace", label]` account, and `create_namespace` refuses any label for which that account **exists** — the same "shadow account" test the registry uses to stop a voucher shadowing a live handle. The denylist is **seeded at launch** with: * the **ecosystem roots** — `x1`, `xnt`, `xen` — and neighboring-chain roots like `sol` and `eth`, so a TLD can't impersonate core infrastructure; * a curated block of **brand and trademark labels** (the same classes the handle registry's [reserved-names policy](/docs/x1id/register) protects), so high-value and impersonation-prone names are held rather than sold to the first buyer. It is **admin-managed and extensible** over time — the seed set is a starting point, not a fixed ceiling: * **`add_reserved_namespace(label)` / `remove_reserved_namespace(label)`** — add or lift a denylist entry. * **`create_namespace_admin(label)`** — a fee-free sibling of `create_namespace` that **bypasses** the denylist, so X1ID itself can stand up the reserved core roots (`x1` / `xnt` / `xen`) as real, operational TLDs. Try to create a reserved TLD and the call reverts with `NamespaceReserved` — check availability (in the app or by deriving the `["reserved_namespace", label]` PDA) before you build a launch around a label. ## Admin controls Beyond the denylist, one instruction is an admin-only safety rail: * **`set_namespace_status(status)`** — the kill switch. Flipping a squatted or abusive TLD to `Paused`/`Retired` stops new registrations under it (existing `name.tld` handles keep resolving). Everything else about a TLD — the payout wallet, the share (upward), the [registration mode](#open-vs-invite-only), and the [price table](#set-your-own-prices) — is **creator-owned and trustless**, needing no admin involvement at all. The kill switch doesn't touch your prices either. ## How names under a TLD resolve `alice.pepe` resolves like any X1NS name. Point the [SDK](/docs/x1id/sdk) or the [public Resolve API](/docs/x1id/api) at it, or go through the [Universal resolver](/docs/x1id/universal-resolver). As always, the response names which namespace answered — `@alice`, `alice.x1`, and `alice.pepe` are distinct identities and are never conflated. ## Next --- # MCP server URL: /docs/x1id/mcp > The x1id MCP server for AI agents, live at https://mcp.x1id.io/mcp against X1 mainnet — all ten tools it exposes, the agent-verification ladder, and the local stdio connector. # MCP server The x1id MCP server exposes `@handle` resolution and records to AI agents over [MCP](https://modelcontextprotocol.io) — Streamable HTTP transport, stateless, **no auth for read tools**, **no wallet or signer key material at all**. It's a convenience surface, not a source of truth: the two resolution tools relay [`api.x1id.io`](/docs/x1id/api) byte-for-byte, every other read goes straight to X1 through [`@x1id/resolve`](https://www.npmjs.com/package/@x1id/resolve), and the `prepare_*` tools return an unsigned transaction or instruction for **your own wallet** to sign — this server never holds a key and never signs or broadcasts anything. **Live at `https://mcp.x1id.io/mcp`** (Streamable HTTP, stateless) against X1 **mainnet**. Read tools need no auth and are rate-limited per IP. Its source is not published, so use the hosted endpoint rather than self-hosting. ## Tools **Ten tools** on one `McpServer`, mapped to x1id's real API surface — the eight resolution / discovery / builder tools below, plus the two x402 payment tools called out beneath the table: | Tool | Reads | Notes | | ---------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `resolve_name` | `api.x1id.io` | Byte-for-byte relay of `GET /resolve/:name`. | | `reverse_lookup` | `api.x1id.io` | Byte-for-byte relay of `GET /reverse/:address`. | | `check_availability` | X1 RPC | Whether the handle's PDA exists yet. | | `get_records` | X1 RPC | A handle's live (non-stale) cross-chain address records, with per-record verification status. | | `get_agent_manifest` | X1 RPC + the manifest URL + a live HTTP probe | Whether a handle is an AI agent, its manifest, and how far that's verified — see [Agent verification](#agent-verification) below. | | `search_agent_handles` | an optional query API + X1 RPC | Prefix/substring search filtered to agent handles. Reports `not-available` if no query API is configured. | | `prepare_register` | X1 RPC | Builds an **unsigned** `register` instruction. Price is computed on-chain at the landing slot — simulate before showing one. | | `prepare_set_record` | X1 RPC | Builds an **unsigned** instruction to create or update one text record. | Two more tools exist for x402 payments (`prepare_x402_payment`, `verify_x402_payment`) — see [Getting paid](/docs/x1id/get-paid#agents-can-pay-too-x402) for the payment flow they support. Every `prepare_*` tool is read-only from this server's own perspective: it builds a transaction, but never signs or sends one. ### x402 payment tools | Tool | Takes | Returns | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `prepare_x402_payment` | `payTo`, `asset`, `amount`, `buyer`, and the seller's `reference` (required; for api.x1id.io, `accepts[].extra.reference`) | An **unsigned** payment transaction (`[SetComputeUnitLimit, SetComputeUnitPrice, TransferChecked + reference]`, sized by simulation) and `redeemMessage`. | | `verify_x402_payment` | the transaction `signature`, `payTo`, `asset`, `amount`, `buyer`, `reference` (required) | Whether that finalized transaction pays what was required, plus `single_use: "seller-must-store-signature"`. | The buyer signs and sends the transaction from their own wallet. For api.x1id.io they also sign `redeemMessage` with the same wallet and send it as `payload.payerSig` next to the transaction signature; the API refuses a redemption without it. `verify_x402_payment` is stateless and returns `ok: true` for the same signature on every call inside the window, so a seller using it must record each accepted signature and refuse it the second time, and issue a unique reference per invoice. ## Agent verification `get_agent_manifest` reports one of three levels, and only the level it actually checked — never one it assumed: | Level | Meaning | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **L0 — declared** | Self-asserted: the handle carries an `agent.manifest` record. | | **L1 — attested** | Backed by a live on-chain Attestation the manifest actually references. | | **L2 — pay-to confirmed** | A live HTTP `402` probe against the agent's own payment endpoint matched a chain-verified payment address on the handle's records. | L2 is the one thing the SDK's own agent helpers deliberately don't do — forcing a network request to an arbitrary third-party host from a library call — so it lives here, server-side, instead. ## Local stdio connector Separately from the hosted HTTP server above, the repo also ships a small **stdio** connector (`services/mcp-x1id`) — the shape a local agent runtime (Claude Desktop/CLI and similar) wires up over stdin/stdout rather than HTTP. It's a thin resolution + agent-discovery relay over the keyed [`/v1` platform](/docs/x1id/platform), not the full tool set above. It exposes **five** tools: | Tool | Does | | ------------------------- | ------------------------------------------------------------------------------------------------ | | `resolve_handle` | Resolve an `@handle` or X1NS name (optionally for a specific chain). | | `reverse_resolve` | A wallet address → its primary `@handle`. | | `verify_handle` | A handle's on-chain verification / attestation facts. | | `get_agent_manifest` | Discover an agent from its `@handle` — its manifest and MCP / x402 endpoints. | | `validate_agent_manifest` | Authoring aid: check a candidate manifest against a handle's derived identity before hosting it. | Transport is **stdio**, and every call relays `https://api.x1id.io/v1`. Set `X1ID_API_KEY` to call it keyed; **without a key** the `/v1` platform answers with an x402 `402` challenge, which this connector surfaces but **cannot pay** — it holds no signer or key material of any kind. For paid, agent-to-agent flows (building and settling an x402 payment) use the hosted server's `prepare_x402_payment` / `verify_x402_payment` tools instead. ## Using it today Point any MCP client at `https://mcp.x1id.io/mcp`; `GET https://mcp.x1id.io/health` returns `{"ok":true}` when it's up. Without MCP, an agent gets the same `@handle` resolution the server relays, straight from the public [`@x1id/resolve`](https://www.npmjs.com/package/@x1id/resolve) SDK or the [REST API](/docs/x1id/api). ```bash title="terminal" npm install @x1id/resolve # …or resolve over HTTP with no dependency at all: curl https://api.x1id.io/resolve/yourname ``` When the server source is public, you'll run it with the environment below. | Variable | Required | Default | | -------------------- | -------- | ------------------------------------------------------ | | `X1_RPC_URL` | yes | — | | `X1ID_PROGRAM_ID` | yes | — | | `X1ID_API_URL` | no | `https://api.x1id.io` | | `X1ID_QUERY_API_URL` | no | unset — `search_agent_handles` reports `not-available` | | `MCP_BIND` | no | `0.0.0.0:8696` | See the [Contracts](/docs/x1id/contracts) page for the program ID and RPC endpoint to fill in above. --- # Pay webhooks URL: /docs/x1id/pay-webhooks > Get an HMAC-signed HTTPS POST when a wallet or an @name you control is paid, or when one of its X1ID invoices is created, paid, cancelled or closed. Self-serve registration with a wallet signature, at-least-once delivery, and the on-chain verification recipe. # Pay webhooks Mainnet Get an HTTPS `POST` when a wallet or an `@name` you control is paid, or when one of its [invoices](/docs/x1id/checkout) is created, paid, cancelled or closed. Registration is **self-serve**: you prove control of the wallet or name with a wallet signature, and the API hands you a signing secret. Base URL: `https://api.x1id.io/v1/pay/webhooks`. Every call needs an X1ID developer API key in `X-API-Key` (see [Developer platform](/docs/x1id/platform)). **A webhook is a notification, never authority. The chain is the source of truth.** Every event restates a fact the X1 chain already proves, and events are produced only from transactions at **`finalized`** commitment, so nothing reported can be rolled back by a fork. But a webhook can be delayed, retried, duplicated, or (if your secret leaks) forged. Always [verify the signature](#verifying-the-signature), dedupe on the event `id`, and [confirm on chain](#verify-on-chain-before-releasing-value) before releasing anything of value. ## Quick start You need an X1ID developer API key and the wallet that controls what you subscribe to. ### Ask for a challenge Say what you want to watch, where to send it, and which wallet will sign. `scope` is an `@name` or a wallet address. ```bash curl -s https://api.x1id.io/v1/pay/webhooks/challenge \ -H "X-API-Key: $X1ID_KEY" -H "content-type: application/json" \ -d '{ "scope": "@acme", "wallet": "", "url": "https://shop.example/x1id/webhook", "event_types": ["invoice.paid", "payment.received"], "description": "shop checkout" }' # → { "nonce": "…", "message": "x1id-pay-webhook:v1\n…", "expires_at": "…" } ``` ### Sign `message` with that wallet Use the wallet's `signMessage`. Raw bytes and the Solana off-chain-message envelope are both accepted. Signing moves no funds: the message names your API key id, the scope, the URL, the event types, a single-use nonce and a 10-minute expiry. ### Register ```bash curl -s https://api.x1id.io/v1/pay/webhooks \ -H "X-API-Key: $X1ID_KEY" -H "content-type: application/json" \ -d '{ "nonce": "", "signature": "" }' # → 201 { "id": "whsub_…", "secret": "whsec_…", "scope": {…}, "status": "active", … } ``` Store `secret` now. It is **shown once**; you can [rotate it](#endpoints) any time. ### Who may register what | Scope | The signing wallet must be… | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | a wallet address | that wallet | | `@name` (top-level) | the name's **current on-chain owner** (or NFT holder), **or** its **payment address** (a live, on-chain-verified X1 address record, the same rule every X1ID pay surface uses) | Control is checked from chain state at registration time. For an `@name`, both wallets are recorded as the subscription's **proven wallets**, and you only receive the name's events that were paid to one of them. If the name changes hands or its payment address moves, events paid to the new wallet are **not** delivered to you: the subscription is paused with `paused_reason: "scope_changed: …"`. Register again with a fresh proof. Dotted names (subnames, `name.x1`, customer TLDs) are not supported as a scope yet. Subscribe to the paying wallet's address instead. ## Endpoints All endpoints require `X-API-Key` and count against that key's normal rate limit. A webhook belongs to the account that registered it. | Method & path | Body / query | Returns | | ------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------- | | `POST /v1/pay/webhooks/challenge` | `{scope, wallet?, url, event_types?, description?}` | `{nonce, message, expires_at}` | | `POST /v1/pay/webhooks` | `{nonce, signature}` | `201` webhook + `secret` (once) | | `GET /v1/pay/webhooks` | | `{webhooks: [...]}` | | `GET /v1/pay/webhooks/{id}` | | the webhook | | `GET /v1/pay/webhooks/{id}/deliveries` | `?limit=1..200` (default 50) | `{deliveries: [...]}`, the delivery log | | `POST /v1/pay/webhooks/{id}/rotate-secret` | | `{id, secret}` (once); used from the next attempt, including retries | | `POST /v1/pay/webhooks/{id}/resume` | | re-activates a webhook after a dead-letter auto-pause | | `DELETE /v1/pay/webhooks/{id}` | | `204` | **`url`** must be `https`, on port 443, with no credentials, and its host must resolve **only** to public addresses. Private, loopback, link-local, cloud-metadata, CGNAT and other reserved ranges are refused at registration and again on every delivery (after DNS resolution, with the connection pinned to the vetted address). Redirects are never followed: a `3xx` is a failed delivery. **`event_types`** accepts any of the [types below](#event-types), `invoice.*`, `payment.*`, `invoice`, `payment`, or `*`. Empty means all. Unknown values are rejected. ### Errors Errors are `{code, message}`: | Code | Meaning | | ----------------------------------------------------------------------- | ------------------------------------------------------ | | `INVALID_SCOPE`, `INVALID_WALLET`, `INVALID_URL`, `INVALID_EVENT_TYPES` | A field failed validation | | `WALLET_MISMATCH` | For a wallet scope, `wallet` must be that same wallet | | `CHALLENGE_INVALID` | Nonce unknown, already used, or expired | | `SIGNATURE_INVALID` | The signature doesn't verify over `message` | | `NOT_CONTROLLER`, `CONTROL_UNPROVEN` | The wallet doesn't (provably) control the scope | | `NOT_FOUND` | The `@name` isn't registered | | `URL_REJECTED` | The URL resolves to a non-public address | | `LIMIT_REACHED` | The account already has the maximum number of webhooks | | `WEBHOOK_NOT_FOUND` | No such webhook on this account | | `NOT_RESUMABLE` | The webhook isn't auto-paused | | `RATE_LIMITED`, `API_KEY_REQUIRED` | Key / rate-limit errors, as on the rest of `/v1` | | `PAY_WEBHOOKS_DISABLED` | The feature is switched off on this deployment | | `RPC_ERROR` | Chain read failed; retry | ## Event types | Type | When | | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `payment.received` | A direct transfer (System or SPL, Solana Pay style) arrived at a watched X1ID payment address | | `invoice.paid` | An X1ID invoice was paid through the registry program (`pay_invoice`) | | `invoice.created` | An invoice was created with this payee | | `invoice.cancelled` | An invoice was cancelled | | `invoice.closed` | An invoice account was closed (`invoice.closedStatus`: `0` open/expired, `1` paid, `2` cancelled) | | `payment.binding_verified` | A payment whose `@name` binding couldn't be proven when first seen is now proven on chain (carries the full payment) | Each payment produces **exactly one** of `payment.received` / `invoice.paid`, so subscribing to both never double-counts. An `@name` subscription only receives events whose binding to that name is **verified**; a payment first seen as `unverifiable` reaches it later as `payment.binding_verified`, so subscribe to that type too if you watch a name. ## Payload Every delivery body uses the `x1id.pay-events/v1` schema: ```jsonc { "schema": "x1id.pay-events/v1", "id": "pev_4b2f…", // stable: dedupe on this (= X1id-Event-Id) "type": "invoice.paid", "notice": "Notification only. The chain is the source of truth: …", "chain": { "signature": "5Kt…", // the transaction — verify it on chain "ixIndex": 2, // top-level instruction index "innerIndex": -1, // -1 = top level, else the CPI position "slot": 183500000, "blockTime": "2026-10-17T12:00:00.000Z", // null if the RPC had none "commitment": "finalized", "finalized": true }, "payment": { // null for invoice.created / cancelled / closed "kind": "invoice", // "invoice" | "transfer" "payer": "7xKX…", "recipient": "E2cE…", // the wallet paid (SPL: the token account's owner) "recipientTokenAccount": "9aBc…", // SPL only, else null "asset": "spl", // "native" (XNT) | "spl" "mint": "B69c…", // null = native XNT "amount": "40000000", // what the payer paid, base units, decimal STRING "fee": "100000", // protocol fee (0 for a transfer); null if unknown "net": "39900000", // what the recipient received; null if unknown "invoice": "4jCP…", // invoice address, or null "reference": "4jCP…", // first Solana Pay reference, or null "references": ["4jCP…"], "memo": "order #1042", // PUBLIC and unauthenticated — display only "binding": { "status": "verified", // verified | none | unbound | unverifiable "handles": [ { "name": "acme", "tld": null, "account": "H8Fs…", "basis": "invoice", "checkedSlot": 183500000 } ] } }, "invoice": { // invoice.* events; for invoice.paid: {address, payee} "address": "4jCP…", "payee": "E2cE…", "actor": "7xKX…", // who signed the instruction "mint": null, "amount": "40000000", "expiresAt": "1830000000", // unix seconds, string "bound": true, // created bound to an @name "handleAccount": "H8Fs…", "createIndexed": true // false: created before the indexed range — read the chain }, "truncated": true // only present if the body was trimmed to fit 64 KiB } ``` Amounts are base-unit integers as **strings**, never floats. `binding.status` says how the payment ties to an `@name`: | Status | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `verified` | Proven from chain state at the payment's slot. `basis` is `owner`, `holder`, `verified-record`, or `invoice` (the program itself checked it) | | `none` | No `@name` pays to that wallet | | `unbound` | An invoice created without a name | | `unverifiable` | Could not be proven (yet); may follow as `payment.binding_verified` | `memo` is public and unauthenticated: anyone can write anything in it. Display it, but never use it to decide anything. Match payments to orders by **invoice address** (or reference), not memo. ### Headers ``` Content-Type: application/json X1id-Signature: t=,v1= X1id-Event-Id: pev_… (same as body.id) X1id-Event-Type: invoice.paid X1id-Delivery-Id: X1id-Subscription-Id: whsub_… ``` ## Verifying the signature `v1 = HMAC_SHA256(secret, ".")`, hex-encoded. This is the same scheme as the [identity event stream](/docs/x1id/events#verifying-a-delivery). 1. Read the **raw** request bytes. Never re-serialize parsed JSON. 2. Parse `t` and `v1` from `X1id-Signature`. 3. Reject if `t` is outside your tolerance window (300 s recommended) to stop replays. 4. Recompute the HMAC and compare in **constant time**. ```js title="webhook.js" import express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyX1idWebhook(secret, rawBody, header, toleranceSecs = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSecs) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && timingSafeEqual(a, b); } const app = express(); // Keep the raw body: don't let a JSON parser run first. app.post("/x1id/webhook", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); if (!verifyX1idWebhook(process.env.X1ID_WEBHOOK_SECRET, raw, req.get("X1id-Signature") ?? "")) { return res.sendStatus(400); } const ev = JSON.parse(raw); if (alreadyProcessed(ev.id)) return res.sendStatus(200); // at-least-once: dedupe queue(ev); // do the work async res.sendStatus(200); // answer fast (< 10 s) }); ``` ```python title="webhook.py" import hashlib, hmac, json, os, time from flask import Flask, abort, request SECRET = os.environ["X1ID_WEBHOOK_SECRET"] app = Flask(__name__) def verify_x1id_webhook(secret: str, raw_body: bytes, header: str, tolerance_secs: int = 300) -> bool: parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance_secs: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) @app.post("/x1id/webhook") def x1id_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify_x1id_webhook(SECRET, raw, request.headers.get("X1id-Signature", "")): abort(400) ev = json.loads(raw) # dedupe on ev["id"], then queue the work return "", 200 ``` During a secret rotation, deliveries switch to the new secret on their next attempt. If you can't update atomically, accept either secret for a short overlap. ## Verify on chain before releasing value For low-value, automated flows (a receipt email, a dashboard tick) the signed webhook alone is fine. For goods, credit or withdrawals, confirm the fact on chain first: * **Direct transfer** (`payment.received`): fetch `chain.signature` with `getTransaction(signature, { commitment: "finalized" })`, check it succeeded (`meta.err === null`), and check that the recipient's balance change (native lamports, or the token balance of `recipientTokenAccount` for `mint`) matches `net`. * **Invoice** (`invoice.paid`): read the **Invoice account** itself and check status `Paid`, `paid_by`, amount and mint. The SDK's [`verifyInvoicePaid`](/docs/x1id/checkout#4-verify-the-payment-on-your-server) does exactly this in one call. The X1ID SDK and `query.x1id.io/pay/invoice/
` are conveniences built on the same data; the account and the transaction are the authority. ## Delivery semantics * **At least once.** Dedupe on `id` (`X1id-Event-Id`). The same event may arrive more than once, for example a retry after your `2xx` was lost. * **Success is any `2xx` within 10 s.** Anything else (`3xx`, `4xx`, `5xx`, timeout, connection or TLS failure) is retried. Respond quickly and do slow work asynchronously. * **Retries:** exponential backoff with full jitter. The n-th retry waits a random time up to `min(6 h, 10 s × 2^(n-1))`. After **6 attempts** the delivery is **dead-lettered**: kept in the delivery log with its last status code or error class. * **Auto-pause:** after 50 consecutive dead-lettered deliveries the webhook is paused (`paused_reason: "auto-paused: …"`). Fix your endpoint and `POST /v1/pay/webhooks/{id}/resume`. Events that occur while a webhook is paused are not queued for it. * **Ordering is not guaranteed** (retries reorder). Order by `chain.slot`, then `ixIndex`, then `innerIndex`, and read current state from chain when order matters. * **Latency:** events come from finalized transactions (roughly 15–20 s after `confirmed` on X1), plus a short polling interval. * **Delivery log:** `GET /v1/pay/webhooks/{id}/deliveries` shows, per event, the status (`pending` / `delivered` / `dead`), attempts, last HTTP status or a fixed error class (`timeout`, `connect_failed`, `dns_failed`, `blocked_destination`, …) and response time. Response bodies are never read or stored. ## Limits | Limit | Value | | ------------------------------------------ | ------------------------------------------------------------------------------------ | | Webhooks per account | 25 | | Deliveries per webhook | 60 per minute (excess deliveries wait; they are not failures) | | Response timeout | 10 s | | Attempts before dead-letter | 6 | | Consecutive dead letters before auto-pause | 50 | | Body size | 64 KiB; if trimmed, `memo` and `references` are dropped and `truncated: true` is set | | Challenge lifetime | 10 minutes, single use | ## Next --- # Developer platform URL: /docs/x1id/platform > The keyed /v1 API at api.x1id.io — Connect sign-in, Gate, Agents, the Universal Resolver, and events, all behind one X-API-Key. How to get a key, the rate-limit tiers, and the pay-per-call x402 alternative. # Developer platform Mainnet The X1ID **developer platform** is a hosted JSON API under `https://api.x1id.io/v1`. It packages the things you'd otherwise build yourself on top of the [`@x1id/resolve`](/docs/x1id/sdk) SDK — sign-in, access gating, agent discovery, cross-namespace resolution — behind one key. **Two surfaces, one host.** The [Resolve API](/docs/x1id/api) (`api.x1id.io/resolve/…`) is **public and keyless** — resolution is a read anyone can do. Everything on this page lives under the **`/v1`** prefix and is **keyed**: a key identifies which app is calling, so the platform can rate-limit, meter, and (for Connect) bind a session to your app. ## Base URL and authentication ``` https://api.x1id.io/v1 ``` Send your key in the **`X-API-Key`** header on every `/v1` request: ```bash title="terminal" curl https://api.x1id.io/v1/resolve/x1 \ -H "X-API-Key: $X1ID_API_KEY" ``` **API keys are server-only.** Never put an X1ID API key in browser code, a mobile bundle, or anything a user can download. Call `/v1` from your own backend and attach `X-API-Key` there. For browser sign-in, proxy the `/v1/auth/*` calls through your backend, as shown in [Sign in with X1ID](/docs/x1id/connect#keep-your-key-on-the-server). Every non-2xx response is a uniform `{ code, message }` body, so you can branch on `code` instead of parsing prose: ```json { "code": "API_KEY_REQUIRED", "message": "provide an X-API-Key header. Request a key at https://developers.x1id.io (or contact support@x1id.io)." } ``` `API_KEY_REQUIRED` is what a **keyless** call to a metered endpoint gets **where x402 is not enabled**. Where [x402](#pay-per-call-with-x402) *is* enabled, that same keyless call instead returns **HTTP `402 Payment Required`** (describing what to pay), not `API_KEY_REQUIRED` — the pay-per-call path, no key needed. **The one exception to `{ code, message }`:** the `402` challenge body is an x402 **payment-requirements** object — `{ x402Version, accepts: [ … ] }`, where each `accepts[]` entry carries the `scheme`, the CAIP-2 `network` (on X1 mainnet, `solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT`), `asset`, `payTo`, `amount` (smallest units, as a decimal string), and `maxTimeoutSeconds`; the X1-native entry also carries `extra` (with `X-Payer`: `feePayer`, the request-bound `reference` and `requestHash`). A USDC-via-CDP entry, where offered, uses the x402 v1 field name `maxAmountRequired` instead of `amount`. It is the x402 wire format, not an error envelope — detect a `402` by its status, then read `accepts[]`, not a `code`. ## Get a key Keys are self-serve from the **Developer Portal** at [developers.x1id.io](https://developers.x1id.io). The portal signs you in with [X1ID Connect](/docs/x1id/connect) — your wallet is the identity, no password — and then lets you create, list, and revoke keys for your account, and watch your usage. **A key's secret is shown once, at creation.** Store it somewhere safe the moment it's revealed; the portal keeps only a hash and can't show it again. Lost a secret? Revoke the key and create a new one. The portal is backed by four endpoints, all authenticated by your Connect **bearer session** (not an `X-API-Key`): | Endpoint | Does | | --------------------- | ------------------------------------------------------ | | `POST /v1/keys` | Create a key — the one-time secret is in the response. | | `GET /v1/keys` | List your keys, each with its tier and recent usage. | | `DELETE /v1/keys/:id` | Revoke one of your keys. | | `GET /v1/usage` | Your request counts, tier, and current limits. | ## Tiers and rate limits Every key carries a tier. Both windows are **per key**, shared across every `/v1` endpoint the key calls. | Tier | Burst | Daily | For | | ----------- | ----------- | ------------- | ------------------------------------------------------------------------------- | | **free** | 300 / min | 15,000 / day | Evaluating and building. Self-serve. | | **partner** | 1,000 / min | 100,000 / day | Production integrations. Issued after an agreement — contact `support@x1id.io`. | Each response carries your live budget in headers: ``` X-RateLimit-Limit 300 X-RateLimit-Remaining 299 X-RateLimit-Daily-Limit 15000 X-RateLimit-Daily-Remaining 14999 ``` Over the limit returns `429 { "code": "RATE_LIMITED", … }`. A keyed request that's over its window is **not** silently charged per call and does **not** fall through to x402: it gets the `429` until the window resets. x402 is a separate path for requests that send **no** key (see below), not an overflow for a key that hit its limit. To get more headroom, raise your tier. ## Pay-per-call with x402 You don't strictly need a key. Where it's enabled, the metered read endpoints also accept an **[x402](/docs/x1id/get-paid#agents-can-pay-too-x402)** payment: call with no key, get a `402 Payment Required` describing what to pay, pay on-chain, sign the challenge's `redeemMessage` with the wallet that paid, and retry with a `PAYMENT-SIGNATURE` header carrying both signatures (the v1 name `X-PAYMENT` is still accepted). On success the response carries a `PAYMENT-RESPONSE` header (and the v1 `X-PAYMENT-RESPONSE`) confirming settlement. Send `X-Payer` with your wallet address on both requests: it is **required** for the X1-native rail (see [below](#bind-your-payer-with-x-payer-on-the-first-request)). The order a request is tried is fixed: a valid `X-API-Key` is served (or rate-limited with `429`); a key that's present but invalid gets `401 INVALID_KEY`; only a request with **no** key reaches x402. The [Connect](/docs/x1id/connect) `/v1/auth/*` endpoints and Gate policy management are keyed-only and not x402-metered. This is the no-signup, no-contract path — ideal for autonomous agents. The X1-native rail is self-sponsored XNT on X1 (the buyer pays their own gas); a USDC-via-CDP rail can also be offered. Default prices are a fraction of a cent per call (resolve/reverse cheaper than verify/gate) and are tunable per deployment. **Breaking (2026-10-11): redemption now needs the payer's signature.** api.x1id.io refuses a payment header without `payload.payerSig` (`REDEEM_SIGNATURE_REQUIRED`), so a client that only sends `payload.signature` stops working. Add the [redeem signature](#sign-the-redemption-with-the-paying-wallet) step, and upgrade to `@x1id/resolve` **0.22.0** for `x402RedeemMessage`. The request-bound reference is the only payment shape: a payment transaction with any other instruction, including a Memo, is refused. x402 is only live on a deployment that has a pay-to address configured. Check `GET /v1/health` — its `x402` field tells you whether pay-per-call is accepted, and `rails` lists which payment rails are on. ### x402 headers (v2 names) api.x1id.io speaks the x402 **v2** HTTP header names and still accepts the v1 ones: | Direction | v2 header (preferred) | v1 header (still accepted) | Contents | | ----------------------------- | ------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Request: your payment | `PAYMENT-SIGNATURE` | `X-PAYMENT` | base64 JSON payment payload. On the X1-native rail: `{ x402Version, scheme: "exact", network, payload: { signature, payerSig } }` (the v2 `accepted: { scheme, network }` form works too) | | Response: the `402` challenge | `PAYMENT-REQUIRED` | (body only) | base64 of the same `{ x402Version, accepts: [...] }` object that is also the `402` body | | Response: settled `200` | `PAYMENT-RESPONSE` | `X-PAYMENT-RESPONSE` | base64 settlement receipt (both headers carry the same value) | | Request: who is paying | `X-Payer` (or `?payer=`) | | your wallet's base58 address. See below | Send **one** payment header. If `PAYMENT-SIGNATURE` and `X-PAYMENT` are both present and differ, the request is rejected. ### Bind your payer with X-Payer on the first request **`X-Payer` is required.** Send `X-Payer: ` (or `?payer=`) on the request that gets the `402`. Only then does the X1-native challenge carry a **request-bound reference** (`extra.reference`). Without it the `402` has `"code": "PAYER_REQUIRED"` and no reference. With `X-Payer`, the X1-native `accepts[]` entry's `extra` holds: * `feePayer`: your wallet. You pay your own fee; there is no sponsor. * `reference`: a base58 address you add to your payment (see below). It binds the payment to **this exact request** (method, path, query and body), to the challenge, and to your wallet. * `requestHash`: hex SHA-256 of the canonical request the reference was issued for. Optional to check. * `redeemMessage`: the text your wallet signs to redeem the payment (see [below](#sign-the-redemption-with-the-paying-wallet)). It is `x402RedeemMessage(reference)`. A live challenge, requested with `X-Payer`: ```json { "x402Version": 2, "accepts": [{ "scheme": "exact", "network": "solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT", "amount": "100000", "asset": "So11111111111111111111111111111111111111112", "payTo": "E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6", "maxTimeoutSeconds": 60, "extra": { "feePayer": "H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA", "reference": "S7k9Y5brdu1MBfV8WykKzu8KBkFi97ri5JUp5H4cTTC", "requestHash": "e96b43f9ae0ea38a788a748548527d328a67cb16271f64a99eb97e630f4a82bc", "redeemMessage": "x1id-x402-redeem:v1|api.x1id.io/dev-api\nreference:S7k9Y5brdu1MBfV8WykKzu8KBkFi97ri5JUp5H4cTTC" } }] } ``` Redemption is bound to the wallet you named: only a payment whose `TransferChecked` authority **is** that wallet, which that wallet **signs**, and whose redemption carries that wallet's signature over `redeemMessage`, can redeem it. A later `X-Payer` cannot re-point it. A malformed `X-Payer` (not a base58 address) is **silently ignored**, as if you had not sent it, so double-check the value if you see `PAYER_REQUIRED`. **Retry the identical request.** A payment for `GET /v1/resolve/alice` cannot be redeemed on `/v1/resolve/bob`, and a `POST /v1/gate/check` payment only works with the same body bytes. Path case matters: `@Alice` and `@alice` are different requests. ### Always set a compute-unit limit **X1 charges the transaction fee by the compute-unit limit you request, not the compute units you use**: about 10 lamports per requested CU plus a small base fee. A transaction with no `SetComputeUnitLimit` defaults to **200,000 CU per instruction, at least 0.002 XNT**, which is many times the price of the call. Always set the limit explicitly, and size it to what your transaction actually consumes. What a payment really costs, measured on a live X1 mainnet payment (2026-10-11, wrapped-native asset): | Payment transaction | CU consumed | Limit requested | Fee (lamports) | | --------------------------------------------------------------- | ----------- | --------------- | -------------- | | CU limit + CU price + `TransferChecked` **+ reference account** | **6,719** | 7,719 | **80,191** | The reference account adds about 180 CU, which keeps the network fee below the 0.0001 XNT resolve price. **Simulate, then size the limit to what was consumed plus about 1,000 CU of headroom** (about 7,700 for the reference shape). Do not guess low: **a limit below what the transaction consumes makes it fail on chain, and you are still charged the fee.** Keep it at or under the server's cap of **40,000** CU. ### The payment transaction The X1-native rail verifies a **fixed shape**: 1. `ComputeBudget::SetComputeUnitLimit`: sized from simulation. It must be first, and anything above 40,000 is rejected (`compute-limit-too-high`). 2. `ComputeBudget::SetComputeUnitPrice`: a small price (x1id's builders use 1 micro-lamport/CU; the cap is 5,000,000). 3. **One** SPL `TransferChecked` of at least the quoted `amount` of the quoted `asset` to `payTo`'s associated token account, signed by your wallet, with **`extra.reference` appended as one extra account, read-only and not a signer**. SPL Token ignores extra accounts on a single-owner transfer. Nobody signs with the reference; it is a hash, not a key. **Explorers may mislabel the reference.** Because the `TransferChecked` carries one account more than a plain transfer, explorers and `jsonParsed` RPC output can decode it as a multisig transfer: the reference shows up as a multisig "signer" and your wallet as `multisigAuthority`. Neither is what happens on-chain. The reference is a read-only, non-signer account (the standard Solana Pay reference pattern), and the transfer authority is your ordinary wallet, approved by its normal transaction signature. A mainnet payment in this shape, for comparison: [`2UUMMij6…9UdRo`](https://explorer.fortiblox.com/tx/2UUMMij6tX5RGHqgjnLJoo2Y6PoCRpnq3Cqaf6fUs3Eu8HpQHPhsH7GuYMZZrZ8JtvJSsf8gNxcG9SEXEXz9UdRo). **Allowed instructions.** Only ComputeBudget and SPL Token instructions are allowed (a Memo instruction gets `PAYMENT_LAYOUT_INVALID`), exactly one transfer may pay `payTo`, the reference must appear exactly once and only in the transfer, and Address Lookup Tables are refused. A multisig token authority is not supported. The amount is checked twice: the `TransferChecked` amount, and the balance actually credited to `payTo`'s token account, which must also cover the price. A Token-2022 mint whose transfer fee leaves `payTo` short is refused, and so is a Token-2022 payment whose credited amount the RPC can't report. **Wrap native XNT in a separate, earlier transaction.** The default `asset` is the wrapped-native mint (`So11111111111111111111111111111111111111112`). It has **9 decimals**, like XNT. If your wrapped balance is short, top it up first (create the ATA if needed, transfer XNT into it, `SyncNative`) and wait for it to confirm. Those instructions cannot go in the payment transaction: instruction 0 must be the compute-unit limit, and the wrap has to happen before the transfer. ```ts import { ComputeBudgetProgram, Connection, PublicKey, TransactionMessage, VersionedTransaction, } from "@solana/web3.js"; import { createTransferCheckedInstruction, getAssociatedTokenAddressSync } from "@solana/spl-token"; import { encodeBase58, parseX402Response, x402RedeemMessage } from "@x1id/resolve"; // 0.22.0+ const API = "https://api.x1id.io/v1/resolve/alice"; const connection = new Connection("https://rpc.mainnet.x1.xyz"); const payer = wallet.publicKey; // your wallet adapter / keypair // 1. Ask for the challenge. X-Payer is required to get a reference. const challenge = await fetch(API, { headers: { "X-Payer": payer.toBase58() } }); const parsed = parseX402Response(await challenge.json()); // status 402 const X1_MAINNET = "solana:4SvBP3omtvcCVWdxq1zBY5cDp4wndjsT"; // match exactly: a CDP rail is also "solana:..." const req = parsed?.accepts.find((a) => a.network === X1_MAINNET); if (!req?.extra.reference) throw new Error("no reference: send X-Payer"); // PAYER_REQUIRED const mint = new PublicKey(req.asset); // 2. Build the payment: [CU limit, CU price, TransferChecked + reference]. // (Wrap XNT -> wSOL in an EARLIER transaction if your balance is short.) const transfer = createTransferCheckedInstruction( getAssociatedTokenAddressSync(mint, payer), mint, getAssociatedTokenAddressSync(mint, new PublicKey(req.payTo), true), // payTo may be a PDA payer, BigInt(req.amount), 9, // wrapped-native XNT has 9 decimals (read the mint for any other asset) ); transfer.keys.push({ pubkey: new PublicKey(req.extra.reference), isSigner: false, isWritable: false }); const build = (units: number) => [ ComputeBudgetProgram.setComputeUnitLimit({ units }), ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), transfer, ]; const { blockhash } = await connection.getLatestBlockhash("finalized"); const compile = (units: number) => new VersionedTransaction( new TransactionMessage({ payerKey: payer, recentBlockhash: blockhash, instructions: build(units) }).compileToV0Message(), ); // Simulate at the 40,000 cap, then request what it used + ~1,000 headroom // (~7,700). Too low = failed tx, fee still charged. const sim = await connection.simulateTransaction(compile(40_000), { sigVerify: false, replaceRecentBlockhash: true }); if (sim.value.err) throw new Error(`payment would fail: ${JSON.stringify(sim.value.err)}`); const tx = compile(Math.min(40_000, sim.value.unitsConsumed! + 1_000)); const signature = await connection.sendTransaction(await wallet.signTransaction(tx)); await connection.confirmTransaction(signature, "finalized"); // 3. Sign the redemption with the SAME wallet that paid. The 402 carries the // message verbatim; the SDK rebuilds it so you can check it first. const redeemMessage = x402RedeemMessage(req.extra.reference); if (req.extra.redeemMessage !== redeemMessage) throw new Error("unexpected redeemMessage"); const payerSig = encodeBase58(await wallet.signMessage(new TextEncoder().encode(redeemMessage))); // 4. Claim: the IDENTICAL request, both signatures in PAYMENT-SIGNATURE. // ({ reference: req.extra.reference, payerSig } instead of // { signature, payerSig } also works: the server finds the payment by its reference.) const payment = btoa(JSON.stringify({ x402Version: 2, scheme: "exact", network: req.network, payload: { signature, payerSig }, })); const res = await fetch(API, { headers: { "X-Payer": payer.toBase58(), "PAYMENT-SIGNATURE": payment } }); // 200 + PAYMENT-RESPONSE header on success ``` The server only accepts a **finalized** payment, so wait for finality before you claim. A `402` with `PAYMENT_NOT_FINALIZED` means retry the same payment header shortly. With `{ reference }`, the server checks every finalized transaction that lists your reference and skips any that only mention it without being a valid payment, so another transaction naming your reference can't block you; if none is valid yet you get the same retryable `PAYMENT_NOT_FINALIZED`. ### Sign the redemption with the paying wallet Your payment is public once it lands, signature and all. So the claim proves the caller is the payer: `payload.payerSig` is the base58 ed25519 signature, by the wallet bound with `X-Payer`, over `extra.redeemMessage`: ```text redeemMessage = "x1id-x402-redeem:v1|api.x1id.io/dev-api" "\n" "reference:" reference ``` * Sign it with your wallet's `signMessage`. A raw signature over the UTF-8 bytes and one over a wallet's Solana off-chain message envelope (SIMD-0048) are both accepted. * `@x1id/resolve` 0.22.0 exports `x402RedeemMessage(reference)` and the domain string `X402_REDEEM_DOMAIN`, so you can rebuild the message and compare it with the one in the `402`. * The reference is single-use and already bound to the request and the payer, so a `payerSig` can't be reused on another challenge. * A missing `payerSig` gets `REDEEM_SIGNATURE_REQUIRED`, and one that doesn't verify gets `REDEEM_SIGNATURE_INVALID`. **Neither consumes the payment:** sign and resend the same request with the same payment. ### How the reference is derived You do not need to compute it, since the `402` gives it to you. To check it, or to confirm you are paying for the request you will send, the `@x1id/resolve` SDK exports `canonicalX402Request`, `x402RequestHash` and `deriveX402Reference`: ```text canonical = METHOD "\n" path "\n" query "\n" hex(SHA-256(body)) requestHash = hex(SHA-256(canonical)) reference = base58(SHA-256("x1id-x402-ref:v1|api.x1id.io/dev-api" "\n" canonical "\n" nonce "\n" payer)) ``` * **METHOD** is uppercased. Scheme, host and fragment are left out. * **path**: each `/`-separated segment is percent-decoded, then re-encoded per RFC 3986 (letters, digits and `- . _ ~` stay literal; every other byte becomes `%XX`, uppercase). Trailing slashes are kept and case is preserved. * **query**: split on `&`, `+` means a space, each key and value decoded and re-encoded, pairs sorted by key then value, duplicates kept, a bare `k` becomes `k=`. * **body**: the exact bytes sent. GET, HEAD and empty bodies use SHA-256 of the empty string (`e3b0c442…7852b855`). * **nonce** is the challenge's server-side nonce. It is not sent to you: the server computes the reference and puts it in `extra.reference`. Worked example: | | | | ----------- | --------------------------------------------------------------------------------------------------------------- | | Request | `GET https://api.x1id.io/v1/resolve/@Alice?fresh=1&chain=x1` | | canonical | `GET\n/v1/resolve/%40Alice\nchain=x1&fresh=1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | | requestHash | `9a408b21042932cda138cd88c8421e27cc23b699ab6780084dfa22269edfb6ce` | | nonce | `00112233445566778899aabbccddeeff` | | payer | `H8FsKYhMyCN9brknY7tL4JXRbKZEjVh7XgrVmWcX1HiA` | | reference | `GxfSof9wYBDnnLUXR3ZqePRUoNN2etHiJYkkaUShVseQ` | ### x402 error codes A rejected payment gets another `402` carrying a fresh challenge, with the rejection's code in both `error` and `code` and an explanation in `message`, so you can branch on `error` and pay again from the same response: | `code` | Meaning | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `PAYER_REQUIRED` | The challenge was requested without `X-Payer`, so it has no reference. | | `PAYMENT_PAYLOAD_INVALID` | The payment header is not base64 JSON of an x402 payment payload with `payload.signature` or `payload.reference`. | | `REDEEM_SIGNATURE_REQUIRED` | No `payload.payerSig`. Sign `extra.redeemMessage` with the paying wallet and resend; the payment was not consumed. | | `REDEEM_SIGNATURE_INVALID` | `payload.payerSig` is not the paying wallet's signature over `extra.redeemMessage`. The payment was not consumed. | | `PAYMENT_NOT_FINALIZED` | The payment is not finalized yet. Retry the identical request with the same payment header shortly. | | `PAYMENT_ALREADY_USED` | This payment already bought a successful response. A replay of the same signature gets this code. Pay again against a fresh challenge. | | `REFERENCE_MISSING` | No issued reference in the `TransferChecked`, or it is writable, a signer, repeated, or also in another instruction. | | `REFERENCE_MISMATCH` | The request changed (method, path, query, body or endpoint), or the payment carries two references. | | `REFERENCE_REUSED` | A different payment carries a reference that has already paid for a successful call. Request a new challenge. | | `REFERENCE_EXPIRED` | Past the 600-second redeem window. Request a new challenge. | | `MULTISIG_AUTHORITY_UNSUPPORTED` | The transfer authority is a multisig. Pay from a single-signer wallet. | | `PAYMENT_LAYOUT_INVALID` | The transaction has an instruction outside the fixed shape (for example a Memo), a second transfer, or compute-budget values out of bounds. | The [error reference](/docs/x1id/errors#x402-payments) lists every code the gateway returns, with its HTTP status and what to do. A call whose handler fails (for example a `404`) does not consume your payment, so you can retry the same request with the same payment. ## Health Never gated. Reports liveness, whether x402 is enabled, and which RPC host and program the deployment reads. ```bash curl https://api.x1id.io/v1/health # → { "status": "ok", "service": "x1id-dev-api", "x402": true, # "rails": ["x1-native"], "rpc": "rpc.mainnet.x1.xyz", # "program": "8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P" } ``` ## The platform endpoints --- # Address records URL: /docs/x1id/records > Read a handle's multi-chain payment records with the staleness rule enforced, and prove control of a non-SVM (ETH / BTC / SVM) address so its record shows as verified. # Address records A `@handle` can carry a payment address for several chains — X1, Solana, Ethereum, Bitcoin. Each lives in its own on-chain `Record` account. This page covers **reading** those records (with the staleness rule enforced for you) and **verifying** a non-SVM address so its record shows as proven. This page targets **`@x1id/resolve@0.21.0`**. The registry is on **X1 mainnet** at `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` (`https://rpc.mainnet.x1.xyz`). ## Reading records The simplest read is `records()` on a resolver — it takes a handle and applies the staleness rule: ```ts const all = await x1id.records('@nike'); // readonly HandleRecord[] ``` For a lower-level read (no resolver, just an RPC), use `fetchRecords`. It demands the handle's `registeredAt` and `recordsClearedAt` — the staleness rule needs them, and there is no variant without them — plus an optional `holderEpoch` for tokenized names (see [the staleness rule](#the-staleness-rule)): ```ts import { fetchRecords, liveRecords, valueToAddress } from '@x1id/resolve'; // handleAccount = the ["handle", name] PDA; registeredAt / recordsClearedAt / holderEpoch from the Handle account const records = await fetchRecords(rpc, programId, handleAccount, registeredAt, recordsClearedAt, holderEpoch); for (const r of liveRecords(records)) { console.log(r.chain, valueToAddress(r.chain, r.value)); } ``` A `HandleRecord` carries: | Field | Meaning | | ----------- | ------------------------------------------------------------------------------------- | | `account` | the Record account's address (base58) | | `handle` | the Handle account it hangs off (base58) | | `coinType` | SLIP-44 coin type as stored | | `chain` | which `Chain` that maps to (`'X1' \| 'SOL' \| 'ETH' \| 'BTC'`), or `null` if unknown | | `value` | raw bytes as stored on-chain | | `address` | human-readable address — see `valueToAddress` | | `verified` | `Record.verified` **gated by staleness**: forced `false` whenever the record is stale | | `updatedAt` | `Record.updated_at`, unix seconds | | `stale` | true when the record belongs to a previous owner — see below | `fetchRecords` returns **every** record, stale ones flagged, so an owner surface can show what a previous owner left behind. Anything that resolves, displays to a visitor, or counts must take `liveRecords(...)` first. `valueToAddress` renders stored bytes per chain: X1/SOL as base58 pubkeys, ETH as `0x`-hex, BTC as the canonical address string; unknown coin types render as hex so a value is never silently hidden. ## The staleness rule A record that predates the current registration belongs to a **previous, unrelated owner** of the same name. It must never be resolved as this name's address, never counted, and only ever offered to the current owner as something to remove. A `Handle`'s address is `["handle", name]` — a pure function of the name — so release + re-register lands the new registration at the *same* pubkey, and `Record` PDAs (`["record", handle_pubkey, coin_type]`) hang off that pubkey. A record a previous owner created (possibly `verified: true` for *their* address) is therefore physically attached to the new owner's name with no action by anyone. The program cannot prevent this; the mandatory read-side rule is enforced structurally here. A record is `stale` when **any** of: * `updated_at < handle.registered_at` — predates the current registration; **or** * `updated_at < handle.records_cleared_at` — the owner ran `clear_records`, bulk-invalidating records without a transfer; **or** * `updated_at < handle.holder_epoch` — the name is a capability NFT and changed hands through a **native marketplace sale** (`buy_handle`), which bumps `holder_epoch` instead of `registered_at` (the registration itself does not move on a tokenized sale). `fetchRecords` takes `holderEpoch` for this; pass it for tokenized names. This applies to **payment resolution**, not just badges — which is why `fetchRecords` requires the epoch anchors and there is no code path that returns an unjudged record. **Plain NFT transfers are not covered.** `holder_epoch` is bumped only by a native marketplace sale. If a capability NFT is moved by a **plain Metaplex transfer** (wallet-to-wallet, or a sale on a generic NFT marketplace), nothing on the `Handle` moves — not `registered_at`, not `holder_epoch` — so the previous holder's records stay **live** and keep resolving as the new holder's payment address. A new holder should review and `clear_records` on any name they acquired by plain transfer; the app surfaces this as a prompt. Closing this on-chain (stamping the holder on every transfer) is tracked as a future change. ## Verifying a non-SVM address record To make a record show as `verified: true`, the address's owner must prove control. On-chain, the program itself recovers the address from the signature — this SDK never derives or checks an address client-side, it just builds the challenge and packages the wallet's signature. The flow has three steps: create the (unverified) record, have the wallet sign a challenge, then submit the proof. ### Ethereum (secp256k1 / EIP-191) ```ts import { buildRecordChallenge, buildCreateRecordIx, buildVerifyRecordEthIx, splitEthSignature, } from '@x1id/resolve'; // 1. create the (unverified) record const createIx = buildCreateRecordIx({ programId, payer, owner, handle, record, coinType: 60, // ETH, per SLIP-44 value: ethAddressBytes, // 20 raw bytes }); // 2. build the challenge and have the wallet sign it with personal_sign const challenge = buildRecordChallenge('alice', 60, registeredAt, 'someNonce123'); const sig65 = await wallet.signMessage(challenge); // 65 raw bytes: r || s || v // 3. verify const { signature, recoveryId } = splitEthSignature(sig65); const verifyIx = buildVerifyRecordEthIx({ programId, owner, handle, record, nonce: 'someNonce123', signature, recoveryId, }); ``` `verify_record_eth` hashes the challenge with EIP-191's `"\x19Ethereum Signed Message:\n"` framing — the exact framing every wallet's `personal_sign` produces. There is no EIP-712 variant on-chain. `splitEthSignature` normalizes `v`'s two conventions (27/28 or 0/1). ### Bitcoin (BIP-137) ```ts import { buildVerifyRecordBtcIx, splitBtcSignature } from '@x1id/resolve'; const sig65 = await wallet.signmessage(challenge); // header(1) || r(32) || s(32) const { header, signature } = splitBtcSignature(sig65); const verifyIx = buildVerifyRecordBtcIx({ programId, owner, handle, record, nonce: 'someNonce123', header, signature, }); ``` `verify_record_btc` derives the address from the recovered pubkey (never trusts a client-supplied one) and accepts only **compressed P2PKH** (header 31–34) and **bech32/P2WPKH** (39–42), mainnet only — uncompressed P2PKH and P2SH-segwit are refused rather than guessed. `splitBtcSignature` passes the header byte through unchanged; most current wallets produce one of the two supported ranges. ### Another SVM address No challenge needed — the claimed address simply **co-signs** the transaction (X1 and Solana share ed25519, so the signature *is* the proof): ```ts import { buildVerifyRecordSvmIx } from '@x1id/resolve'; const verifyIx = buildVerifyRecordSvmIx({ programId, owner, handle, record, claimed, // the address being claimed — MUST co-sign this transaction }); ``` ### About the challenge `buildRecordChallenge(handle, coinType, registeredAt, nonce)` builds the exact bytes a wallet must sign — `x1-handles:v1::::`, byte-identical to the program's Rust `challenge()`. Unlike the off-chain control-proof flow, the nonce does **not** need to be server-issued — the on-chain transaction landing *is* the single-use event, so any ASCII-alphanumeric nonce (1–64 chars) you pick is fine. Pass `handle` already canonical (run it through `normalizeHandle` first if it might have a leading `@` or mixed case). ## Next --- # Register a handle URL: /docs/x1id/register > Every on-chain registration path in the SDK — permanent, lease-to-own, front-run-safe commit-reveal, and claiming a gifted name — as unsigned instruction builders you sign and relay yourself. # Register a handle `@x1id/resolve` ships instruction builders for **every registration path**. Each returns an unsigned `BuiltInstruction` — `{ programId, keys, data }` — that you assemble into a transaction, **sign, and relay yourself**. The SDK never holds keys, and every discriminator and account order matches the deployed program. This page targets **`@x1id/resolve@0.21.0`**. The registry is on **X1 mainnet** at `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` (`https://rpc.mainnet.x1.xyz`; testnet works the same with `https://rpc.testnet.x1.xyz`). **Registrations and marketplace actions may be paused.** The registry has an emergency lockdown, used for example during a security audit. While it's on, the program rejects new registrations, NFT mints, reserved-name and gift claims, TLD creation, and new listings, offers and auctions (`ProtocolLockedDown` / `MarketPaused`). Resolution, transfers, `set_primary` and records keep working, as do exits such as cancelling a listing, completing an existing sale, and lease renewal. Check the banner on [app.x1id.io](https://app.x1id.io), or read the flag from the `Config` account with the SDK's `readPauseState`. ## What ships, and what doesn't Registration is in the SDK. Only **`transfer`** and **`set_primary`** are not yet exposed as builders — run those from [app.x1id.io](https://app.x1id.io) for now. | Flow | Builder(s) | | ------------------------------ | --------------------------------------------------------------------------------------- | | Permanent (one-time) | `buildRegisterIx` (+ `fetchConfigTreasury`) | | Lease-to-own | `buildRegisterLeasedIx`, `buildRenewLeaseIx`, `buildReclaimExpiredLeaseIx` | | Commit–reveal (front-run-safe) | `commitmentHash`, `buildCommitIx`, `buildRegisterRevealedIx`, `buildCancelCommitmentIx` | | Claim a gifted name | `buildClaimVoucherIx`, `buildClaimVoucherTokenizedIx` | | Reserved name (admin) | `buildRegisterReservedIx` | **Price is computed on-chain.** `register`, `register_leased` and `register_revealed` all compute their price at execution time (a time ramp over the on-chain tier table that rises over time) — there is **no client-supplied price or slippage parameter**; the payer pays whatever the program charges as of the landing slot. These builders do not know or assert the price. To show the payer a figure first, **simulate the transaction**, or read the live `/quote/:name` [REST endpoint](/docs/x1id/api#price-quote). ### Deriving PDAs The builders do **not** derive PDAs for you (a second on-curve implementation is how wrong addresses get derived silently). Derive them with your runtime's canonical `findProgramAddress`, or the [`WasmResolver`](/docs/x1id/sdk#wasmresolver) helpers. The seeds you need: * `["config"]` — the singleton config account * `["handle", name_bytes]` — the handle being claimed (`name` is the canonical form, from `parseName(input).canonical`) ## Permanent registration The default: pay the one-time ramp price and claim an unregistered `["handle", name]` PDA. ```ts import { buildRegisterIx, fetchConfigTreasury, HandleType } from '@x1id/resolve'; const treasury = await fetchConfigTreasury(rpc, configAccount); // Config.treasury, read fresh const ix = buildRegisterIx({ programId, payer, // signer — pays the fee and the Handle account's rent owner, // need NOT sign — register on someone else's behalf (gifting, onboarding) config: configAccount, // the ["config"] PDA treasury, // Config.treasury — admin-rotatable, don't cache across a session handle, // the ["handle", name] PDA — must be unregistered name: 'payday-bot', // canonical form handleType: HandleType.Agent, // optional revenue-share pair (#7397) — supply BOTH or NEITHER: // integrator, integratorAllowlist, }); // assemble, sign with `payer`, relay. ``` `HandleType` is descriptive only (it gates nothing): `Human` (0), `Merchant` (1), `Org` (2), `Agent` (3). **The NFT-included default.** The product's standard consumer registration folds a tradeable capability **pNFT** into the single one-time price (see [Fees](/docs/x1id/pricing)). That path is the program's `register_tokenized` instruction, which app.x1id.io uses; in `@x1id/resolve@0.21.0` it does **not** yet have a standalone builder. `buildRegisterIx` builds the base `register` (a name-only handle owned by `owner`), which can be minted into its NFT later. The one tokenizing path with a builder today is the **gift** claim, [`buildClaimVoucherTokenizedIx`](#claim-a-gifted-name), which registers *and* mints the pNFT in one instruction. ## Lease-to-own A sibling of `register` for term leases — the permanent path is untouched and stays the default. Pay a fraction up front and build equity toward the permanent price; `payment` is in **lamports**, capped on-chain at the permanent price (offering the full price settles the handle permanently in one instruction). ```ts import { buildRegisterLeasedIx, buildRenewLeaseIx, buildReclaimExpiredLeaseIx, fetchConfigTreasury, HandleType } from '@x1id/resolve'; const treasury = await fetchConfigTreasury(rpc, configAccount); // 1. Claim under a lease, paying the first installment now: const leaseIx = buildRegisterLeasedIx({ programId, payer, owner, config: configAccount, treasury, handle, name: 'studio', handleType: HandleType.Org, payment: 25_000_000_000n, // lamports toward the lease; >= permanent price settles it outright }); // 2. Pay more toward a lease — PERMISSIONLESS (anyone may keep someone else's lease alive): const renewIx = buildRenewLeaseIx({ programId, payer, config: configAccount, treasury, handle, payment: 10_000_000_000n, }); // 3. Take over a leased handle whose grace period has ENDED, under a fresh lease: const reclaimIx = buildReclaimExpiredLeaseIx({ programId, payer, owner, config: configAccount, treasury, handle, handleType: HandleType.Org, payment: 25_000_000_000n, }); ``` The program enforces `now >= expires_at + grace` before a reclaim succeeds. `renew_lease` and `reclaim_expired_lease` are both permissionless by design. ## Commit–reveal (front-run-safe) The anti-front-running path: stake out a hash of your intended name, wait `Config.min_commitment_age_secs`, then reveal. The name never touches the chain until reveal. The result is a **permanent** registration, identical to `register`. ```ts import { commitmentHash, buildCommitIx, buildRegisterRevealedIx, buildCancelCommitmentIx, fetchConfigTreasury, HandleType, } from '@x1id/resolve'; // 1. Generate a secret and persist it BEFORE committing — a lost secret strands the commitment. const secret = crypto.getRandomValues(new Uint8Array(32)); const hash = await commitmentHash({ name: 'moonshot', owner, handleType: HandleType.Human, secret, programId, }); // derive ["commitment", hash] with your runtime's findProgramAddress → `commitment` // 2. Commit — only the hash goes on chain: const commitIx = buildCommitIx({ programId, payer, commitment, commitmentHash: hash }); // 3. After min_commitment_age_secs (and before max), reveal: const treasury = await fetchConfigTreasury(rpc, configAccount); const revealIx = buildRegisterRevealedIx({ programId, payer, owner, config: configAccount, treasury, handle, commitment, name: 'moonshot', handleType: HandleType.Human, secret, // optional integrator pair, same contract as register's }); // Escape hatch — reclaim an unconsumed commitment's rent at any age (payer-only): const cancelIx = buildCancelCommitmentIx({ programId, payer, commitment }); ``` The commitment hash is `sha256(name ‖ owner ‖ handle_type ‖ secret ‖ program_id)` — the program id in the preimage is domain separation, so a commitment can't replay against a different deployment. The reveal's `owner` **must** match the one the commitment hashed, or it fails closed (`CommitmentMismatch`). The reveal is valid only inside the window: at least `min_commitment_age_secs` after the commit (`CommitmentTooNew`) and strictly before `max_commitment_age_secs` (`CommitmentTooOld`). Use `fetchCommitment(rpc, commitmentAccount)` to read a commitment's `payer` / `committedAt`. **Persist the secret the moment you generate it, before `commit` is even sent.** Without it the hash can't be recomputed, so the commitment can only ever be cancelled (which at least needs its address, discoverable via `getProgramAccounts` on the payer). ## Claim a gifted name A [gift](/docs/x1id/features#gift-a-handle) is a prepaid voucher: the giver escrows the mint price into `["voucher", name]`, and the recipient claims it later, paying only the transaction fee and the handle's rent. **`buildClaimVoucherTokenizedIx` is the default path** the live gift flow uses — it registers the handle *and* mints its tradeable capability pNFT in one instruction. ```ts import { buildClaimVoucherTokenizedIx } from '@x1id/resolve'; const ix = buildClaimVoucherTokenizedIx({ programId, claimer, config: configAccount, treasury, voucher, voucherPayer, // the original giver — receives the voucher's rent on close owner, // who the pNFT + ownership go to (bound recipient, or claimer for an open voucher) handle, name: 'gift1', mint, ownerTokenAccount, metadata, masterEdition, tokenRecord, collectionMint, collectionMetadata, collectionMasterEdition, collectionAuthority, // integrator — required iff the voucher carries one }); ``` **Prepend a compute-budget instruction.** The register + 3 Metaplex CPIs exceed the 200k default compute budget — add a `ComputeBudget.setComputeUnitLimit` (the app uses \~400k) in the **same** transaction, or the claim fails on-chain. A name-only variant, `buildClaimVoucherIx`, claims without minting the pNFT; on a recipient-bound gift only the bound recipient can sign it (`VoucherClaimNotRecipient`), so a relayer claiming on someone's behalf uses the tokenized path. A gift's expiry can be at most 366 days out (`VoucherExpiryTooFar`). To *create* a gift, use `buildCreateVoucherIx`; to refund an unclaimed one after expiry, `buildRefundVoucherIx` — see the [SDK README](https://www.npmjs.com/package/@x1id/resolve). ## Reserved names (admin only) `buildRegisterReservedIx` builds `register_reserved` — the **admin-only**, free registration of a reserved name **into the treasury** (on success the handle's owner is set on-chain to `Config.treasury`, not to an arbitrary target). The `admin` account must equal `Config.admin` and must sign; there is no price and no treasury-payment account. Reserved and premium names reach the public through the [marketplace](/docs/x1id/features#buy-and-sell-handles), not ordinary registration — this builder exists for the operator who seeds that inventory, not for an end user claiming a name. A treasury-held reserved name can also be released to the verified holder of a matching domain. That path needs the **treasury multisig's** on-chain approval: `open_reserved_claim_approval` (permissionless) creates an empty approval account for the name, and `set_reserved_claim_approval`, signed only by the treasury (`NotTreasury` otherwise), stamps the sha256 of the one approved domain. The attestor can grant the name only to a holder of that approved domain (`ReservedClaimNotApproved` otherwise), so neither the admin nor the attestor alone can decide who receives a reserved name. ## Next --- # SDK reference URL: /docs/x1id/sdk > The @x1id/resolve TypeScript SDK — resolve @handles and X1NS names to addresses, reverse-resolve an address to its primary @handle, and read multi-chain records, all straight from chain. # SDK reference `@x1id/resolve` resolves **`@handles`** and **X1NS** names (`.x1` / `.xnt` / `.xen`) to addresses on X1, an address back to its primary `@handle`, and a handle's per-chain payment records. It reads accounts directly off chain over RPC — it never calls a hosted resolution API, so it works against any X1 RPC and keeps working if x1id.io itself goes away. This page targets **`@x1id/resolve@0.22.0`** — the current npm `latest`. **`@handle` resolution is live on X1 mainnet.** The handle registry program is deployed on X1 **mainnet** (`https://rpc.mainnet.x1.xyz`), and the same program at the same ID is also on X1 testnet (`https://rpc.testnet.x1.xyz`) for development. X1NS domain resolution (`.x1` / `.xnt` / `.xen`) is a separate, pre-existing system that exists on **X1 mainnet only** — against a testnet RPC an X1NS name is not found and `reverse()`'s X1NS fallback returns nothing. Point `rpcUrl` at the chain that holds the names you want; on an RPC where the registry is not deployed a handle is an honest `not-found`, never a fabricated address. ## Install ```bash npm install @x1id/resolve@0.22.0 ``` `@solana/web3.js` is an **optional** peer dependency — you only need it if you pass web3.js types around; the SDK talks to RPC directly and holds no keys. ## Loading the WASM module Account derivation needs a real ed25519 on-curve check, which the SDK runs in a small Rust→WASM module rather than reimplementing in TypeScript — a hand-rolled JS version is the kind of thing that is subtly wrong for months, and this is code that loses money when it is wrong. `wasm` is therefore a **required** field on `ResolverConfig`; there is deliberately no JavaScript fallback. The package ships the module at the exported subpath `@x1id/resolve/wasm/x1_resolve_wasm.wasm`. ```ts import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { WasmResolver, createResolver } from '@x1id/resolve'; const require = createRequire(import.meta.url); const wasmPath = require.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm'); const wasm = await WasmResolver.fromBytes(readFileSync(wasmPath)); // testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz' const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); ``` Node ≥ 20.6 can use `import.meta.resolve` instead of `createRequire`: ```ts import { fileURLToPath } from 'node:url'; const wasmPath = fileURLToPath(import.meta.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm')); ``` ```ts // Vite / Rollup / webpack 5 — the `?url` suffix hands you the asset URL import wasmUrl from '@x1id/resolve/wasm/x1_resolve_wasm.wasm?url'; import { WasmResolver, createResolver } from '@x1id/resolve'; const wasm = await WasmResolver.fromBytes(await (await fetch(wasmUrl)).arrayBuffer()); // testnet works the same: rpcUrl: 'https://rpc.testnet.x1.xyz' const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); ``` Or copy `node_modules/@x1id/resolve/wasm/x1_resolve_wasm.wasm` into your static directory and `fetch('/x1_resolve_wasm.wasm')` it — that is what [x1id.io](https://x1id.io) does. `WasmResolver.fromBytes()` calls `WebAssembly.instantiate(bytes, {})` on the bytes you hand it — the SDK never fetches the module itself. Load the copy that ships in the npm package you installed (or self-host that file); don't point at an unpinned CDN URL, which can serve a module from a different version than your JS. A resolver instance is single-threaded; create one per process (or per page) and reuse it. ### Browser requirements * **CSP.** Compiling WebAssembly needs `'wasm-unsafe-eval'` in `script-src` (no `'unsafe-eval'` needed). `connect-src` must allow your X1 RPC (`https://rpc.mainnet.x1.xyz`), plus `https://api.x1id.io` if you call the REST API from the page. Server-side use needs no CSP change. * **Serve the `.wasm` file as `application/wasm`.** The SDK instantiates from bytes, so the MIME type isn't checked by `fromBytes`, but serve it correctly anyway so CDNs and proxies don't mangle or block it. ### Next.js recipe Written against Next.js 14/15 conventions; **not yet tested on Next.js 16**. **Server (route handlers, server components) — recommended for a payment flow.** Use the Node runtime (the Edge runtime is untested), read the module from the package, and keep `@x1id/resolve` out of the server bundle so the `.wasm` file ships with the deployment: ```ts title="lib/x1id.server.ts" import 'server-only'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { WasmResolver, createResolver, type Resolver } from '@x1id/resolve'; let resolver: Promise | undefined; export function getResolver(): Promise { resolver ??= (async () => { const require = createRequire(import.meta.url); const bytes = await readFile(require.resolve('@x1id/resolve/wasm/x1_resolve_wasm.wasm')); const wasm = await WasmResolver.fromBytes(bytes); return createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); })(); return resolver; } ``` ```js title="next.config.mjs" export default { output: 'standalone', serverExternalPackages: ['@x1id/resolve'], outputFileTracingIncludes: { '/**': ['./node_modules/@x1id/resolve/wasm/*.wasm'], }, }; ``` This is the Next 15 form. On Next 14 both keys live under `experimental`: `experimental.serverComponentsExternalPackages` and `experimental.outputFileTracingIncludes`. **Browser (client components).** Copy the module into `public/` at install time, so it is served from your own origin at the version in your lockfile: ```json title="package.json" { "scripts": { "postinstall": "cp node_modules/@x1id/resolve/wasm/x1_resolve_wasm.wasm public/x1_resolve_wasm.wasm" } } ``` ```ts title="lib/x1id.client.ts" 'use client'; import { WasmResolver, createResolver } from '@x1id/resolve'; export async function loadResolver() { const bytes = await (await fetch('/x1_resolve_wasm.wasm')).arrayBuffer(); const wasm = await WasmResolver.fromBytes(bytes); return createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', wasm }); } ``` ```js title="next.config.mjs (headers)" export default { async headers() { return [ { source: '/x1_resolve_wasm.wasm', headers: [{ key: 'Content-Type', value: 'application/wasm' }] }, { source: '/:path*', headers: [ { key: 'Content-Security-Policy', // One string. Merge these directives into your existing policy: // Next.js's own scripts may need a nonce or more sources. value: "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://rpc.mainnet.x1.xyz https://api.x1id.io", }, ], }, ]; }, }; ``` ## createResolver Create a resolver instance. Derivation and RPC reads happen lazily, on each call — nothing is fetched at construction time. ```ts import { createResolver, WasmResolver } from '@x1id/resolve'; const wasm = await WasmResolver.fromBytes(/* module bytes, see above */); const x1id = createResolver({ rpcUrl: 'https://rpc.mainnet.x1.xyz', // testnet works the same with rpc.testnet.x1.xyz wasm, cacheTtlMs: 30_000, // optional — this is the default }); ``` **Parameters (`ResolverConfig`):** | Field | Type | Required | Description | | ----------------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rpcUrl` | `string` | Yes | Any X1 RPC endpoint. Use `https://rpc.mainnet.x1.xyz` for `@handles` (`https://rpc.testnet.x1.xyz` for the testnet registry). | | `wasm` | `WasmResolver` | Yes | The loaded WASM module, from `WasmResolver.fromBytes()`. No JS fallback exists. | | `fetchImpl` | `typeof fetch` | No | Custom transport for tests or proxies. Defaults to the global `fetch`. | | `cacheTtlMs` | `number` | No | Resolution cache TTL in milliseconds. Default `30_000`. Set `0` to disable caching (`resolve()` only). | | `handleProgramId` | `string` | No | `@handle` registry program ID. Defaults to the canonical X1 deployment, `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`; override to point at a different deployment (e.g. a local test validator). | **Returns:** `Resolver` — an object exposing `resolve`, `reverse`, `records`, and `clearCache`. ## resolve Resolve a name to an address. `resolve` classifies its input **by shape** — a leading `@` means a handle, a recognized TLD suffix (`.x1` / `.xnt` / `.xen`) means an X1NS domain — and never falls back from one namespace to the other. Input shaped like both throws `ambiguous` rather than silently picking one. ```ts resolve(input: string, opts?: { chain?: 'X1' | 'SOL' | 'ETH' | 'BTC' }): Promise ``` **Returns `Resolved`:** | Field | Type | Description | | ---------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | `string` | Exactly what was passed in, for display. | | `name` | `string` | Canonical form of the name. | | `namespace` | `Namespace` | Which naming system answered. The union is `'handle' \| 'x1' \| 'xnt' \| 'xen' \| (string & {})` — the open string arm is a **customer-TLD label** (e.g. `'shib'` for `alices.shib`), so a UI must treat it as an arbitrary label, not an exhaustive enum. **Always render this before letting a user send** — see [the one rule](/docs/x1id/examples). | | `address` | `string` | The resolved address: base58 for X1/SOL, `0x`-hex for ETH, bech32 for BTC. | | `chain` | `'X1' \| 'SOL' \| 'ETH' \| 'BTC'` | Chain the address belongs to. | | `verification` | `'verified' \| 'unverified'` | Whether ownership of `address` was proved for this chain — see [Verification](#verification). | | `verifications?` | `HandleVerification[]` | Optional per-platform "verified by \[platform]" signals. The SDK's own on-chain `resolve()` does **not** populate this — read [attestations directly](/docs/x1id/verification). | ```ts const res = await x1id.resolve('@nike'); // { // input: '@nike', name: 'nike', namespace: 'handle', // address: 'E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6', // chain: 'X1', verification: 'verified' // } ``` **`@nike` is a held (reserved) name — don't send to it.** The address above is the X1ID treasury (`E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6`), which holds reserved and premium names until they reach an owner through the marketplace. `verification: 'verified'` only means the treasury controls the account; it says nothing about the brand. The SDK carries **no** held flag. Before a payment, check the REST [`/quote/{name}`](/docs/x1id/api#price-quote) endpoint and refuse the send when `status` is `"held"`, and refuse any resolution whose address is the treasury. See [the pre-send checks](/docs/x1id/examples#step-4--check-before-you-sign). ### Tokenized handles A handle can be minted into a capability **NFT**, after which *holding the NFT* is the authority and the registry's `owner` field stops moving with the name. `resolve()` handles this for you: for a tokenized handle it returns whoever currently holds the NFT **in their associated token account**, never the stale `owner` field. A handle whose NFT has been **burned** (mint supply 0) resolves to no one — `ResolveError { code: 'not-found', reason: 'nft-burned' }`, never the stale `owner`. ### The ambiguous case **This is the one behavior to get right.** `@jack` and `jack.x1` can be registered to **different people**. There is no fallback chain in this SDK from one namespace to the other — a fallback is exactly how those two identities get silently conflated, and the failure mode is a transfer that succeeds, to the wrong person. Input shaped like both (`@jack.x1`) throws `ambiguous` instead. Never catch it and pick one — ask the user which they meant. ## reverse Look up the primary name for an address. ```ts reverse(address: string): Promise ``` Checked in this order, and returned as a bare string (the handle without the `@`) or `null`: 1. **On-chain `@handle` primary.** Returned only when all three trust rules hold: the handle it names is a live registry account; the pointer was set no earlier than the handle's current `registered_at` (so a pointer left from before a release + re-register doesn't count); and the address still holds authority (the `owner` field if untokenized, or an associated token account holding exactly 1 of the handle's NFT if tokenized). 2. **X1NS primary domain, as a fallback.** Only when there is no valid handle primary. The X1NS record stores the domain *account*, not its label, so the result is `"."` — not `"jack.x1"`. 3. **`null`** otherwise. "No primary name" is a normal state, not an error — don't treat it as a failure. ```ts const name = await x1id.reverse('E2cELMtby3YYYbDdxamuGKWCyte3kwLywv19pEk2iUq6'); // null — reverse() returns the wallet's PRIMARY name (set via set_primary), // or null when none is set. resolve() and reverse() are NOT strict inverses: // this is the X1ID treasury, which holds @nike (and other reserved names) // but has set no primary, so it reverses to null. // A wallet that HAS set a primary returns the bare handle, e.g. "nike". // Telling the two shapes apart: a handle result never contains a dot. const display = name === null ? null : name.includes('.') ? name : `@${name}`; ``` A pointer that fails any rule is treated as absent (falls through to X1NS, then `null`), never as an error. Only a malformed *address* throws (`ResolveError { code: 'unrecognized' }`). ## records Every per-chain payment record of a `@handle`, with the [staleness rule](/docs/x1id/records#the-staleness-rule) already applied: records left behind by a previous registration of the same name — or by a previous holder when a capability NFT changed hands in a **native marketplace sale** — come back flagged `stale` with `verified` forced false. (A **plain** NFT transfer stamps nothing on chain and is not covered — see the caveat under [the staleness rule](/docs/x1id/records#the-staleness-rule).) ```ts records(input: string): Promise ``` ```ts const all = await x1id.records('@nike'); // HandleRecord[]: { account, handle, coinType, chain, value, address, // verified, updatedAt, stale } ([] when none are set) import { liveRecords } from '@x1id/resolve'; const live = liveRecords(all); // the ones that belong to the current owner ``` `@handle` inputs only — X1NS domains keep their addresses elsewhere. Throws `not-found` for an unregistered handle, `unrecognized` for a non-handle input. See [Address records](/docs/x1id/records) for reading and writing the full record family. ## clearCache ```ts x1id.clearCache(); ``` `resolve` results are cached per `(namespace, name, chain)` for `cacheTtlMs` (default 30 s); `reverse` and `records` are not cached. Call `clearCache()` after a registration or transfer you know just happened on chain, so a stale result doesn't linger. **Re-resolve, uncached, right before signing a payment.** A name can change hands inside the cache window. `resolve()` has no per-call cache bypass, so for the final resolve before you sign a send either call `clearCache()` first or keep a second resolver created with `cacheTtlMs: 0` for that one call. A cached result is fine for display while the user types; cache a positive result for at most 30 s, and a not-found or a reverse label for at most 30–60 s. **The SDK is lease-unaware.** A [lease-to-own](/docs/x1id/faq#lease-to-own) handle past its paid-through date still resolves, with no lease signal on `Resolved` (the hosted `/v1/resolve` is the same). Before sending funds, read the free REST [`/resolve/{name}`](/docs/x1id/api), which adds `lease: { expires_at, lapsed }` for a leased handle, and refuse when `lapsed` is `true`. ## Multi-chain resolution A name can carry addresses for more than one chain. Request the one you want with `opts.chain`: ```ts await x1id.resolve('@jack', { chain: 'X1' }); // default — the handle's authority await x1id.resolve('@jack', { chain: 'SOL' }); // X1 shares Solana's ed25519 key format await x1id.resolve('@jack', { chain: 'ETH' }); // the handle's ETH record, if one is set await x1id.resolve('@jack', { chain: 'BTC' }); // the handle's BTC record, if one is set ``` For **X1/SOL** the SDK returns the handle's current authority. For **ETH/BTC** it reads the handle's per-chain `Record` accounts; a handle without a live record for the requested chain throws `ResolveError { code: 'no-record-for-chain' }` — an explicit "no record", never a wrong address on the wrong chain. Every record is judged against the [universal staleness rule](/docs/x1id/records#the-staleness-rule) structurally. ## Verification | `verification` | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `verified` | Control of the address was proved: the address owns the `@handle` registry account, or — for a tokenized handle — holds its NFT **in its associated token account** (the only place the registry recognises as the name's authority). | | `unverified` | Ownership was not proved for that chain (X1NS), or — for a tokenized handle — the NFT is held outside the holder's ATA (an auxiliary account or escrow): that wallet controls the token but cannot act for the name until it is back in its ATA. **Show a warning.** | This is per-chain control. The separate **"verified by \[platform]"** blue-tick signal (verified X, GitHub, domain…) is covered in [Reading verified identity](/docs/x1id/verification). ## Beyond resolution: the full helper surface `@x1id/resolve` is not only a resolver — the same package ships the instruction builders, sign-in, gating, and cross-namespace helpers that app.x1id.io and the developer platform are built on. Every builder is transport-neutral (`{ programId, keys, data }`, no `@solana/web3.js` import) and holds no keys; you sign and relay. The groups below are the ones the pre-mainnet surface adds on top of `resolve` / `reverse` / `records`. ### Namespaces and scoped names A launched customer TLD (`.shib`) is an on-chain `Namespace` account at `["namespace", tld]`. A name registered under it — `alices.shib` — is an always-tokenized `Handle` at the **two-seed** PDA `["handle", tld, name]`, a *different* account from the bare `@alices` at `["handle", name]`, so the two can have different owners. The `scoped` helpers are the SDK last-mile for these (feature #8484/#8485): ```ts import { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver } from '@x1id/resolve'; // Shape-only classifier (no RPC): is this a `name.tld` scoped candidate? const cand = scopedTldCandidate('alices.shib'); // → { sub: 'alices', tld: 'shib' } | null // Is a fetched `Namespace` account live? (byte-reader; caller checks program ownership) const active = namespaceIsActive(namespaceAccountData); // → boolean // The one PDA derivation scoped resolution needs — `["handle", tld, name]` — // injected (web3.js) because this package never hand-rolls curve math: const deriver = await makeScopedHandleDeriver(); const handlePda = await deriver.scopedHandleKey('shib', 'alices', programId); ``` `createResolver().resolve()` answers scoped names directly — `resolve('alices.shib')` returns `namespace: 'shib'` once that TLD is a launched, `Active` namespace; the owner/tokenization/verification read reuses the bare-handle authority path. The low-level `WasmResolver` also exposes `deriveNamespaceAccount(label, programId)` for `["namespace", label]`. ### Subnames (and subnames under a scoped handle) A **subname** is a label under a parent handle — `pay` under `alice` → `pay.alice`. Subnames branch one further level, so `pay.alice` can carry its own `deep.pay.alice` **sub-subname** (see [What you can do](/docs/x1id/features#subnames)). A subname has no owner of its own: the parent's current authority controls it. Because a scoped name is itself a `Handle`, a subname can also hang under one, giving `pay.alices.shib`. Subname resolution enforces a **two-layer** staleness rule (parent-vs-subname, then record-vs-subname), so always read through `resolveSubnameRecords`: ```ts import { buildCreateSubnameIx, buildRevokeSubnameIx, buildCreateSubnameRecordIx, buildUpdateSubnameRecordIx, buildCloseSubnameRecordIx, fetchSubnames, resolveSubnameRecords, subnameIsLive, decodeSubname, } from '@x1id/resolve'; // Create `pay.alice` (rent-only, parent-authority-gated): const ix = buildCreateSubnameIx({ programId, payer, owner, parent, subname, label: 'pay' }); // Resolve a subname's LIVE records — the ONLY safe entry point (both layers enforced): const live = await resolveSubnameRecords(rpc, programId, subnameAccount, subname, parentRegisteredAt); ``` PDA derivation (`["subname", parentHandlePda, label]`, and records at `["record", subnamePda, coinType]`) is done with your runtime, exactly as the module docs show — this package does not hand-roll `find_program_address`. ### Sign in (Connect) The `signin` helpers are the client side of [X1ID Connect](/docs/x1id/connect) — a domain-bound challenge, a wallet signature, verification, and the address→`@handle` identity, in one call: ```ts import { signInWithX1ID, createSignInChallenge, verifySignIn, resolveIdentity } from '@x1id/resolve'; const identity = await signInWithX1ID({ rpcUrl, wasm, // IdentityConfig: how to resolve the handle address, // the connected wallet (base58) signMessage, // wallet.signMessage: (bytes) => Promise<64-byte sig> domain: 'yourapp.com', requireHandle: true, // default — "Sign in with X1ID" implies one; false allows handle-less }); // → SignInIdentity: { address, handle, verified…, challenge, nonce, issuedAt, expiresAt } ``` `createSignInChallenge` / `parseSignInChallenge` / `verifySignIn` are the split primitives if you want the backend to verify a challenge your frontend had signed; `resolveIdentity(cfg, address)` is the identity read on its own. This is a server-less proof your own backend verifies; it doesn't use the hosted API. **Against the hosted Connect API**, use `signInWithWallet` (SIWS, then a never-sent memo transaction, then v2 `signMessage`, falling back automatically) or `signInWithSolana` (SIWS only, allowlisted domains only). They sign the server's audience-bound strings verbatim and return a Bearer session. The memo-transaction path is what makes Backpack, the X1 wallet, and Ledger work. ```ts import { signInWithWallet } from '@x1id/resolve'; const { method, ...session } = await signInWithWallet({ wallet, // Wallet Standard wallet apiBase: `${window.location.origin}/api/x1id`, // your backend proxy, which adds X-API-Key apiKey: 'server-held', // placeholder; the real key never reaches the browser rpcUrl: 'https://rpc.mainnet.x1.xyz', }); ``` **0.21.0 is required for hosted sign-in.** The API now accepts only audience-bound strings, and `signinWithTx` / `buildVerifyTxChallenge` from SDK 0.20.x and earlier throw on the bound memo. See [Sign in with X1ID](/docs/x1id/connect#with-the-sdk) for the full flow, the SIWS domain allowlist, and the backend proxy. ### Gate on verified identity The `gate` helpers test a handle's on-chain attestations against a policy — read it yourself over RPC, or call the hosted [Gate endpoint](/docs/x1id/gate): ```ts import { attestationsPassGate, handlePassesGate, GateKind, checkGate, requireGate } from '@x1id/resolve'; // A Gate is "any-verified" | { kind } | { anyOf: number[] }, with named kinds: const gate = { anyOf: [GateKind.x, GateKind.github] }; // dns,social,x,discord,github,telegram // Fetch + test in one RPC call: const { passed, attestations } = await handlePassesGate(rpc, programId, handleAccount, registeredAt, recordsClearedAt, gate); // …or the hosted client (keyed or x402). Server-side only: never put the key in browser code. const res = await checkGate({ apiKey: process.env.X1ID_API_KEY!, baseUrl }, { handle: '@jack' }, 'any-verified'); ``` Stale attestations never count toward a gate. ### Pay per call (x402) Without an API key, the [`/v1` platform](/docs/x1id/platform#pay-per-call-with-x402) answers `402` and is paid per call. The SDK holds no keys and builds no transactions, but it parses the challenge and builds the message your wallet signs to redeem the payment: ```ts import { encodeBase58, parseX402Response, x402RedeemMessage, X1_MAINNET_NETWORK } from '@x1id/resolve'; const res = await fetch(url, { headers: { 'X-Payer': wallet.publicKey.toBase58() } }); const req = parseX402Response(await res.json())?.accepts.find((a) => a.network === X1_MAINNET_NETWORK); // …pay req.amount of req.asset to req.payTo with req.extra.reference on the TransferChecked → `signature` const message = x402RedeemMessage(req!.extra.reference!); // === req.extra.redeemMessage const payerSig = encodeBase58(await wallet.signMessage(new TextEncoder().encode(message))); const payment = btoa(JSON.stringify({ x402Version: 2, scheme: 'exact', network: req!.network, payload: { signature, payerSig } })); await fetch(url, { headers: { 'X-Payer': wallet.publicKey.toBase58(), 'PAYMENT-SIGNATURE': payment } }); ``` **Breaking in 0.22.0:** api.x1id.io requires `payload.payerSig` on every redemption (`REDEEM_SIGNATURE_REQUIRED` without it), and `buildX402Requirements` no longer takes a memo; the request-bound reference is the only payment shape. `X402_REDEEM_DOMAIN` is the message's domain prefix, and `canonicalX402Request` / `x402RequestHash` / `deriveX402Reference` let you check a reference. The full client, with the payment transaction, is on [Pay-per-call with x402](/docs/x1id/platform#pay-per-call-with-x402). ### Universal resolution (.sol / .eth) `createUniversalResolver` wraps the native resolver with optional SNS (`.sol`) and ENS (`.eth`) adapters behind one `resolve`, mirroring the hosted [universal resolver](/docs/x1id/universal-resolver). Each external namespace is gated off unless you supply its RPC/config: ```ts import { createUniversalResolver } from '@x1id/resolve'; const uni = createUniversalResolver({ native: x1id, // the createResolver() instance sns: { rpcUrl, deriver }, // omit to gate .sol off ens: { rpcUrl: ETH_RPC_URL }, // omit to gate .eth off }); await uni.resolve('vitalik.eth'); // throws `not-configured` if that namespace is disabled uni.detect('jack.sol'); // classify by shape → namespace | null uni.status(); // which namespaces are live vs config-gated ``` ## Should I even try to resolve this? Not every recipient-field input is a name — most of the time it's a pasted address. Use `looksLikeName()` to decide whether to attempt resolution at all, so a pasted base58 address never surfaces a spurious "invalid handle" error: ```ts import { looksLikeName } from '@x1id/resolve'; if (looksLikeName(value)) { // "@jack", "jack.x1", "jack.xnt", "jack.xen" — attempt resolution resolveAndShowRecipient(value); } else { // Treat as a raw address instead — validate/checksum it directly validateRawAddress(value); } ``` `looksLikeName()` is a pure, synchronous shape check — no RPC, never throws. It returns `true` for anything starting with `@`, or ending in a recognized TLD, regardless of whether that name is actually registered. ## Errors `resolve`, `reverse` and `records` throw `ResolveError`, never a bare string — so a UI can branch on `.code` instead of parsing a message: ```ts import { ResolveError } from '@x1id/resolve'; try { const res = await x1id.resolve(userInput); } catch (e) { if (e instanceof ResolveError) { console.log(e.code); // e.g. "not-found", "ambiguous", "invalid-handle" console.log(e.input); // the original input that caused the error } } ``` See the [Error reference](/docs/x1id/errors) for the full list of `ResolveErrorCode` values and what a UI should do about each. ## WasmResolver The loaded module also exposes the low-level derivation primitives, if you need to derive accounts yourself (for instance to build a write instruction — see [Register a handle](/docs/x1id/register)): ```ts class WasmResolver { static fromBytes(bytes: BufferSource): Promise; normalizeHandle(raw: string): string | null; deriveHandleAccount(canonical: string, programId: Uint8Array): Uint8Array | null; // ["handle", name] deriveHandlePrimaryAccount(owner: Uint8Array, programId: Uint8Array): Uint8Array | null; // ["primary", owner] deriveX1nsAccount(label: string, tld: 'x1' | 'xnt' | 'xen'): Uint8Array | null; deriveNamespaceAccount(label: string, programId: Uint8Array): Uint8Array | null; // ["namespace", label] derivePrimaryAccount(owner: Uint8Array): Uint8Array | null; // X1NS primary-domain record deriveAssociatedTokenAccount(owner: Uint8Array, mint: Uint8Array): Uint8Array | null; } ``` Also exported: `parseName`, `looksLikeName`, `normalizeHandle`, `namespaceLabel`, `encodeBase58`, `decodeBase58`, `decodeBase58_32`, `CHAIN_COIN_TYPE`, and the types `Resolved`, `Namespace`, `Chain`, `Verification`, `ResolveErrorCode`. ## Next --- # Universal resolver URL: /docs/x1id/universal-resolver > Resolve X1ID @handles, X1NS domains, Solana .sol names, and Ethereum .eth names through one endpoint. A disabled namespace returns an honest NOT_CONFIGURED — it never fabricates an address. # Universal resolver Mainnet The **universal resolver** resolves names across naming systems through one call: X1ID `@handles` and X1NS domains natively, Solana `.sol` names (SNS), and Ethereum `.eth` names (ENS). Each foreign namespace reads its **own** chain through an adapter, so a `.sol` or `.eth` result is real on-chain data — never a guess. ## Resolve any name Keyed or [x402-metered](/docs/x1id/platform#pay-per-call-with-x402), priced like a resolve. The optional `?chain=` picks which chain's address you want (`X1` default, or `SOL` / `ETH` / `BTC`). ```bash title="GET /v1/resolve-universal/:name" curl "https://api.x1id.io/v1/resolve-universal/jack.sol?chain=SOL" \ -H "X-API-Key: $X1ID_API_KEY" ``` ```json { "input": "jack.sol", "name": "jack.sol", "namespace": "sol", "address": "7Np1jK…pQ4mZ", "chain": "SOL", "source": "sns", "verified": true } ``` | field | meaning | | ----------- | ----------------------------------------------------------------------------------------- | | `namespace` | which naming system the name belongs to: `handle`, `x1` / `xnt` / `xen`, `sol`, or `eth`. | | `source` | the system that answered: `x1id`, `sns`, or `ens`. | | `verified` | whether ownership of `address` was proved on-chain. | An unknown name is `400`; a name with no address for the requested chain is `404 NO_RECORD_FOR_CHAIN`; an RPC failure is `502`. A name in a namespace that isn't enabled on this deployment is `501 NOT_CONFIGURED` — see below. ## Which namespaces are live Ungated. Lists each namespace and whether its adapter is configured on this deployment — check it before you rely on `.sol` or `.eth`. ```bash curl https://api.x1id.io/v1/namespaces ``` ```json { "namespaces": [ { "namespace": "handle", "source": "x1id", "configured": true }, { "namespace": "x1", "source": "x1id", "configured": true }, { "namespace": "xnt", "source": "x1id", "configured": true }, { "namespace": "xen", "source": "x1id", "configured": true }, { "namespace": "sol", "source": "sns", "configured": true }, { "namespace": "eth", "source": "ens", "configured": true } ] } ``` **`.sol` and `.eth` are config-gated, by design.** The native X1ID / X1NS path is always on. SNS (`.sol`) reads a Solana RPC and ENS (`.eth`) reads an Ethereum RPC — each is enabled only when that deployment has the corresponding RPC configured. **ENS in particular needs an Ethereum RPC configured** to answer at all. When an adapter is off it returns a typed `NOT_CONFIGURED` and never fabricates an address or fails the whole resolver. Always consult `GET /v1/namespaces` rather than assuming. ## When to use it Use the universal resolver when your app accepts names from more than one ecosystem in one field — a cross-chain wallet, a payments box, a directory. If you only need X1ID `@handles` and X1NS, the [public Resolve API](/docs/x1id/api) (keyless) or the [SDK](/docs/x1id/sdk) (reads chain directly, no hosted dependency) are simpler. **Never conflate the namespaces.** `@jack`, `jack.x1`, `jack.sol`, and `jack.eth` can belong to **different people**. The resolver classifies strictly by shape and tells you which `namespace`/`source` answered — render that before letting a user send, and never fall back from one namespace to another. ## Next --- # Verified validators URL: /docs/x1id/validators > Link an X1 validator's vote account to a handle and earn the gold shield — a trustless, attestor-free verification proven on-chain by the vote account's withdraw authority. Co-sign it, or submit a detached offline signature. How both instructions work, the challenge the withdraw key signs, and the Verify-your-validator flow. # Verified validators Mainnet A handle can prove it belongs to the operator of an X1 **validator** and earn a **gold shield** — a verification in a class of its own, above the social [blue tick](/docs/x1id/verification). The blue tick is an off-chain review that X1ID's attestor key signed off on. The validator shield is **trustless**: no attestor, no human review, no key to trust. Control is proven **on-chain**, by the only key that can prove it — the vote account's `authorized_withdrawer`. These are **on-chain instructions** on the registry program (`8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P`), built into X1ID's own program. The attestation they mint is `kind 6` (`KIND_VALIDATOR`) at the usual `["attestation", handle, kind]` PDA. ## Why it needs no attestor Social accounts can't be proven on chain — an X or GitHub handle has no on-chain key, so someone trusted (the X1ID attestor) has to do the off-chain check and sign the result. A validator is different: its vote account **is** an on-chain account, and it names an `authorized_withdrawer` — the key with authority over the validator's stake rewards. If you can sign as that key, you demonstrably control the validator. The program checks that signature against the authority it reads **straight from the vote account**, so the proof is self-contained. That's why the validator kind sits deliberately **one past** the attestor-mintable range: `create_attestation` (the attestor's instruction) refuses any `kind >= KIND_COUNT`, and `KIND_VALIDATOR` **is** `KIND_COUNT` (6). The trusted attestor key **cannot** mint a validator attestation even if it wanted to — only the two cryptographic instructions below can. Both instructions require **two authorities to agree**: * The **handle's current authority** (its owner, or the NFT holder if the handle is tokenized) authorizes linking this validator to the handle. * The **vote account's withdraw authority** proves control of the validator. What stored on the minted attestation: | Field | Holds | | --------------- | -------------------------------------------------------------------------------------------------- | | `kind` | `6` (`KIND_VALIDATOR`). | | `evidence_hash` | the validator's **vote account pubkey** (32 bytes). | | `attestor` | the **withdraw authority** that proved control — a historical fact, **not** the x1id attestor key. | | `attested_at` | when the link was (last) stamped; subject to the usual staleness rule. | ## Two ways to prove it Pick the path that fits how the withdraw key is held. ### `attest_validator` (co-sign) Two signers on one transaction: the handle's current authority and the vote account's withdraw authority. The program: 1. Confirms the supplied `vote_account` is a **real vote account** — owned by the Vote program, with a recognized version tag — rejecting anything masquerading as one. 2. Reads `authorized_withdrawer` from its fixed offset in the vote account data. 3. Requires the **co-signing** `withdraw_authority` to equal that key (the trustless proof). 4. Requires the handle's current authority to sign (owner, or NFT holder if tokenized). 5. Mints `Attestation { kind: KIND_VALIDATOR }`, storing the vote account in `evidence_hash` and the withdraw authority in `attestor`. ### `attest_validator_signed` (detached signature) When the withdraw key can't co-sign the transaction — it's cold, air-gapped, or on a different machine from the handle owner — the withdraw authority signs a **challenge offline** instead, and the handle owner submits the transaction **alone**, carrying that signature in an **Ed25519 precompile instruction**. The client places the Ed25519 precompile instruction **immediately before** `attest_validator_signed` in the same transaction. The program: 1. Validates the vote account and reads the withdraw authority exactly as above. 2. Requires the handle's current authority to sign (and be the sole transaction signer). 3. **Rebuilds the exact challenge** (below) from on-chain state. 4. Introspects the instruction sysvar: the instruction **directly before** this one must be the real Ed25519 program, verifying a single signature over that exact challenge, by that exact withdraw authority. Anything else fails with `BadValidatorProof`. Same result as the co-sign path, same trustless guarantee — only the real withdraw key can produce a signature over the challenge. #### The challenge The withdraw authority signs these **96 bytes**, in order: | Segment | Bytes | Value | | --------------- | ----- | --------------------------------------------------------------- | | Domain prefix | 24 | the ASCII string `x1id:validator-attest:v1` | | Handle PDA | 32 | the `["handle", name]` account address | | Vote account | 32 | the validator's vote account pubkey | | `registered_at` | 8 | the handle's current `registered_at`, as an `i64` little-endian | Binding the challenge to the handle's **current** `registered_at` is what makes a prior owner's signature unusable after a transfer or re-registration: the epoch in the signed bytes no longer matches, so the proof is rejected — the same discipline the non-SVM record proofs use. The withdraw authority's signature carries **no expiry**. It stays valid for that exact `(handle, vote account, registered_at)` tuple until the handle is re-registered or the withdraw key is rotated. This is intended: a validator attestation is a self-evident cryptographic proof, not an attestor endorsement, so the operator can always re-establish it. Re-submitting only re-stamps the same link and is owner-gated, so it is harmless. ## The gold shield A live validator attestation surfaces as a **gold shield** next to the handle — distinct from the social blue tick, and a higher class of verification because it is trustless. Where both are present, **gold wins**: a validator-verified name shows the gold shield as its single badge, with the full detail (the linked vote account, any social platforms) on the handle's own page. Reading it mirrors the blue tick exactly. A validator attestation is `kind 6` in a handle's attestations, with its vote account in `evidence_hash`. It is subject to the **same staleness rule** as every other attestation: a stale one (predating the current `registered_at`, or a `clear_records`) never lights the shield. See [Verified identity](/docs/x1id/verification#the-staleness-rule) for `fetchAttestations` and how staleness is judged from chain. ```ts import { fetchAttestations, liveAttestations } from '@x1id/resolve'; const atts = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt); // The validator attestation is kind 6; its evidence_hash is the vote account. const validator = liveAttestations(atts).find((a) => a.kind === 6); if (validator) { // Show the gold shield. validator.evidenceHash is the vote account pubkey. } ``` ## Verify your validator In the app, linking a validator to your handle follows the detached-signature path — the withdraw key never has to touch the browser. ### Enter your vote account On your handle's settings, choose to verify a validator and paste your validator's **vote account** address. The app reads its `authorized_withdrawer` from chain and shows you the exact challenge to sign. ### Sign the challenge offline Sign the shown challenge with your **withdraw authority key**, wherever it lives — your CLI, a hardware wallet, an air-gapped machine. The key never leaves that environment. ### Paste the signature Paste the resulting signature back into the app. It assembles the transaction: the Ed25519 precompile instruction carrying your signature, immediately followed by `attest_validator_signed`. ### Submit You (the handle owner) sign and send the transaction. On success, the gold shield appears on your handle — proven on-chain, no attestor involved. **Rotated your withdraw authority?** Re-run the flow. The attestation overwrites in place with the new signer, so the stored proof always reflects the current `authorized_withdrawer`. ## Next --- # Verified identity URL: /docs/x1id/verification > Read a handle's blue-tick — whether it has proved control of an X, Discord, GitHub, Telegram account or a domain — from the resolve result or straight from chain, with the staleness rule enforced. # Verified identity Beyond the per-chain `verification` a `resolve()` returns, a handle can carry **per-platform verifications** — the blue-tick signal that a handle proved control of an X, Discord, GitHub, or Telegram account, or a domain. This page is how you read that signal. This page targets **`@x1id/resolve@0.21.0`**. The registry is on **X1 mainnet** at `8JgnNWi24bq9uzfnT9XmkWxvaWMVgoEs9bu8QsHhLe1P` (`https://rpc.mainnet.x1.xyz`). ## Two ways to read it A verification can reach you two ways, and the helpers tolerate either: 1. **From a `Resolved`'s `verifications[]`** — when the producing resolver supplies it (the tools/api REST `/resolve` response carries this field). The SDK's own on-chain `resolve()` does **not** populate it. 2. **Straight from chain** — read the `Attestation` accounts yourself with `fetchAttestations`. This applies the staleness rule structurally and needs no REST layer. ## From a resolve result If your `Resolved` carries `verifications[]`, three absence-tolerant helpers read it: ```ts import { isVerified, verifiedPlatforms, isVerifiedOn } from '@x1id/resolve'; // `resolved` is a Resolved carrying the resolver's verifications[], e.g. // verifications: [ // { platform: 'x', value: '@michelle', verified: true, attested_at: 1730000000 }, // { platform: 'github', value: 'michelle', verified: true, attested_at: 1730000100 }, // { platform: 'domain', value: 'michelle.com', verified: true, attested_at: 1730000200 }, // ] isVerified(resolved); // true — show the blue tick (any live signal) verifiedPlatforms(resolved); // ['x', 'github', 'domain'] isVerifiedOn(resolved, 'github'); // true isVerifiedOn(resolved, 'telegram'); // false ``` All three are **absence-tolerant**: an older resolver, the SDK's own on-chain `resolve()`, or a handle with no live verifications all yield `false` / `[]`, never a throw. Never index into `verifications` blindly — go through these helpers. **Rendering a badge.** Light the tick when `isVerified(resolved)` is true; on hover/focus, list `verifiedPlatforms(resolved)` and pull each platform's account from the paired `value` (`@michelle` for `x`, `michelle.com` for `domain`). Platform slugs are `x` / `discord` / `github` / `telegram`, plus legacy `domain` (DNS) and `social` (pre-per-platform, no attribution). ## Straight from chain To compute the same signal without a REST layer, read the `Attestation` accounts directly. Every function demands the handle's `registeredAt` and `recordsClearedAt` so the staleness rule is applied structurally — there is no way to fetch an attestation without them. ```ts import { fetchAttestations, liveAttestations, isHandleVerified, platformSlug, textRecordKeyFor, } from '@x1id/resolve'; // handleAccount / registeredAt / recordsClearedAt come from the Handle you // resolved (name → deriveHandleAccount → decode the Handle account). const atts = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt); isHandleVerified(atts); // the blue-tick condition, from chain for (const a of liveAttestations(atts)) { platformSlug(a.kind); // 'x' | 'discord' | 'github' | 'telegram' | null (legacy) textRecordKeyFor(a.kind); // 'com.x' | ... | 'website' | null — where the paired value lives } ``` * **`fetchAttestations`** returns *every* attestation of a handle, stale ones flagged, so an owner surface can show what a previous registration left behind. Anything that renders a badge should take `isHandleVerified` / `liveAttestations`. * **`isHandleVerified`** implements the rule below: true when at least one non-stale attestation of any kind exists. * **`platformSlug` / `textRecordKeyFor`** mirror the on-chain `Attestation::platform_slug` and the shared contract table exactly, so joining a live attestation to its paired text record uses the same mapping the program, resolver and app badge use. ## Attestation kinds | Kind | Constant | Slug | Paired text record | | ---- | --------------------------- | ----------------- | ----------------------------------- | | `0` | `ATTESTATION_KIND_DNS` | `dns` (legacy) | `website` | | `1` | `ATTESTATION_KIND_SOCIAL` | `social` (legacy) | — | | `2` | `ATTESTATION_KIND_X` | `x` | `com.x` | | `3` | `ATTESTATION_KIND_DISCORD` | `discord` | `com.discord` | | `4` | `ATTESTATION_KIND_GITHUB` | `github` | `com.github` | | `5` | `ATTESTATION_KIND_TELEGRAM` | `telegram` | `org.telegram` | | `6` | `KIND_VALIDATOR` | `validator` | — (vote account in `evidence_hash`) | The two legacy kinds (`dns`, `social`) are kept valid forever so existing accounts are never orphaned; `platformSlug` returns `null` for them because neither names a single modern platform. A `HandleAttestation` also exposes `account`, `handle`, `kind`, `kindName`, `evidenceHash`, `attestedAt`, `attestor`, and `stale`. **Kind `6` is the validator shield, not a blue-tick platform.** Unlike kinds `0`–`5`, it is minted trustlessly on-chain (no attestor) and surfaces as a distinct **gold shield**, so render it separately rather than as a social-verification row. Its `evidence_hash` holds the validator's vote account. See [Verified validators](/docs/x1id/validators) for both instructions, the challenge format, and the Verify-your-validator flow. ### Verified = one live signal of any kind A handle is verified when **at least one non-stale attestation of any kind** exists — a single strong signal is enough (requiring two would reject Nike proving control of nike.com). `kind` records which signal proved it. ## The staleness rule An attestation only vouches for the **current** owner when it is live. A stale attestation belongs to a previous, unrelated owner of the same name and must never be rendered as verifying the current one. A `Handle`'s address is `["handle", name]` — a pure function of the name — so release + re-register lands the new registration at the *same* pubkey, and the previous owner's attestation is physically attached to the new owner's name with no action by anyone. Attestations do not expire on a timer; they are invalidated by **ownership epoch**. An attestation is `stale` (and so excluded by `liveAttestations` / `isHandleVerified`) when **either**: * `attested_at < handle.registered_at` — predates the current registration; **or** * `attested_at < handle.records_cleared_at` — the owner ran `clear_records`, bulk-invalidating records and attestations without a transfer. One documented blind spot, shared with `Handle.owner` and `Primary`: a bearer-NFT marketplace trade bumps no epoch, so an attestation keeps reading live until the attestor re-reviews or revokes. Re-check verification per session for anything high-stakes. ## What a verification proves — honestly A verification is an **off-chain review** (a DNS lookup, an OAuth completion, a human check) that X1ID's **attestor key** signed off on at `attested_at`. It is **not** a trustless proof: that key is admin-rotatable, so the trust anchor is "whoever the registry admin currently designates." Render it as **"verified by x1id"**, never as more. (The trustless upgrade for the domain signal is DNSSEC/zkTLS, on the roadmap.) ## Next