Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/docs-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ jobs:
with:
node-version: 22
cache: npm
cache-dependency-path: |
package-lock.json
examples/universal-usernames-adapter/package-lock.json

- name: Install dependencies
run: npm ci

# Also installs and tests the universal usernames adapter template
# (check:adapter), which has its own lockfile.
- name: Validate documentation
run: npm run check
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ node_modules/
.mintlify/
dist/
coverage/
.env
.env.*
!.env.example
68 changes: 68 additions & 0 deletions api-reference/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,74 @@ x-auth-token: YOUR_API_KEY
</Card>
</CardGroup>

## Universal usernames API

These routes connect a `.eth` name to a [username endpoint](/developer-guide/universal-usernames/endpoint-reference) your brand hosts. They use the same base URLs as the Offchain Manager API, but they don't accept API keys. Only the current exact owner of the parent name can call them, and the wallet signs every request.

### Authenticate as the owner

Both kinds of credential go in the `x-auth-token` header as `siwe:<base64(message)>.<base64(signature)>`, where the message is an [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) sign-in message, the signature is the wallet's hex signature string, and both are base64-encoded as UTF-8 text. The message `domain` is the API host, such as `offchain-manager.namespace.ninja`, or a Namespace App domain.

- Reads, such as getting the connection or validating an endpoint, take an unexpired sign-in session signed by the owner.
- Each change takes its own operation signature: a fresh message with a nonce from [`GET /api/v1/auth/nonce`](/api-reference/offchain-manager/universal-usernames/get-nonce), the statement `Authorize <action> for Universal Usernames on <parentName>`, an expiry at most 10 minutes after it was issued, and exactly one resource, `urn:namespace:uun:<parentNode>:<action>:<expectedRevision>`.

The actions are `save`, `activate`, `rotate-credential`, `pause`, `resume`, and `disconnect`. `chainId` is `1` on the production API and `11155111` on staging. A nonce expires 60 seconds after you request it and works once.

```ts
const message = createSiweMessage({
domain: 'app.namespace.ninja',
uri: 'https://app.namespace.ninja',
address: owner.address,
statement: `Authorize pause for Universal Usernames on ${parentName}`,
version: '1',
chainId: 1,
nonce, // from GET /api/v1/auth/nonce
issuedAt,
expirationTime: new Date(issuedAt.getTime() + 5 * 60 * 1000),
resources: [`urn:namespace:uun:${namehash(parentName)}:pause:${revision}`],
});
const signature = await owner.signMessage({ message });
const base64 = (text: string) => Buffer.from(text, 'utf8').toString('base64');
const token = `siwe:${base64(message)}.${base64(signature)}`;
```

The [Offchain Manager SDK](/developer-guide/sdks/offchain-manager/universal-usernames) builds and signs these messages for you.

<CardGroup cols={2}>
<Card
title="Get a username connection"
icon="link"
href="/api-reference/offchain-manager/universal-usernames/get-connection"
arrow="true"
>
Read the status, revisions, and last validation for a parent name.
</Card>
<Card
title="Validate an endpoint"
icon="list-check"
href="/api-reference/offchain-manager/universal-usernames/validate-endpoint"
arrow="true"
>
Dry-run your endpoint for one username without saving anything.
</Card>
<Card
title="Save a username connection"
icon="floppy-disk"
href="/api-reference/offchain-manager/universal-usernames/save-connection"
arrow="true"
>
Store endpoint settings as a new revision and validate them.
</Card>
<Card
title="Activate a username connection"
icon="power-off"
href="/api-reference/offchain-manager/universal-usernames/activate-connection"
arrow="true"
>
Verify a live lookup and start serving usernames.
</Card>
</CardGroup>

## Mint Manager API

<CardGroup cols={2}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Activate a username connection"
description: "Verify a live lookup and start serving usernames from the endpoint."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/activate"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Disconnect a parent name"
description: "End the connection and wipe the stored credential."
openapi: "/openapi/offchain-openapi.json DELETE /api/v1/universal-usernames/{parentName}"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Get a username connection"
description: "Read the status, revisions and last validation for a parent name."
openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Get a nonce"
description: "Get a single-use nonce for an operation signature."
openapi: "/openapi/offchain-openapi.json GET /api/v1/auth/nonce"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Inspect a parent name"
description: "Check ownership, resolver, route and blockers before connecting."
openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}/inspection"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "List connection events"
description: "Read the audit log for a parent name."
openapi: "/openapi/offchain-openapi.json GET /api/v1/universal-usernames/{parentName}/events"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Pause a username connection"
description: "Stop serving usernames without disconnecting."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/pause"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Preview a username"
description: "Fetch the records any username would resolve to."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/preview"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Resume a username connection"
description: "Serve usernames again after a pause."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/resume"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Rotate the endpoint credential"
description: "Replace the bearer token Namespace sends to your endpoint."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/credential"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Save a username connection"
description: "Save endpoint settings as a new revision and validate them."
openapi: "/openapi/offchain-openapi.json PUT /api/v1/universal-usernames/{parentName}"
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Validate an endpoint"
description: "Dry-run an endpoint for one username without saving anything."
openapi: "/openapi/offchain-openapi.json POST /api/v1/universal-usernames/{parentName}/validate"
---
6 changes: 6 additions & 0 deletions changelog/offchain-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ icon: "cloud"
---


<Update label="Unreleased" description="v1.2.0">
- Added:
- **Universal usernames**: `getUsernameConnection`, `validateUsernameEndpoint`, `saveUsernameConnection`, `activateUsernameConnection`, `previewUsername`, `rotateUsernameCredential`, `pauseUsernameConnection`, `resumeUsernameConnection`, `disconnectUsernameConnection`, and `getUsernameConnectionEvents` connect a `.eth` name to a brand-hosted username endpoint. They authenticate with the parent owner's wallet, and every change takes one signature instead of an API key.
- **Typed errors** for these methods: `NotParentOwnerError`, `ConnectionRevisionConflictError`, and `EndpointValidationError`. Existing methods keep their current error behaviour.
</Update>

<Update label="May 1, 2026" description="v1.0.13">
- Added:
- **New Blockchain Support**: Added support for two Substrate L1 chains:
Expand Down
16 changes: 16 additions & 0 deletions changelog/universal-usernames-sdk.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: "Universal Usernames SDK"
description: "Changelog for the Universal Usernames SDK."
sidebarTitle: "Universal Usernames SDK"
icon: "globe"
---

<Update label="Unreleased" description="v0.1.0">
- Added:
- **Profile schema v1**: `USERNAME_PROFILE_SCHEMA_V1`, `PROFILE_LIMITS`, and the `UsernameProfileV1` type for the JSON your username endpoint returns.
- **Validator**: `validateUsernameProfile` and `parseUsernameProfile` report every issue with a stable code, a kind (`envelope`, `record`, or `transport`), and a JSON Pointer path. Duplicate keys are caught on the raw body.
- **Record resolution**: `resolveText`, `resolveAddress` with the ENSIP-19 default EVM rules, and `resolveContenthash` for `ipfs://`, `ipns://`, `bzz://`, and `ar://`.
- **Endpoint helpers**: `createUsernameHandler` serves profiles as a Fetch API handler with an optional constant-time bearer check, and `defineUsernameProfile` accepts chain names as address keys.
- **Validator CLI**: `universal-usernames validate <endpointUrl> --username <label> [--token <bearer>]` exits with `0` when the profile is valid.
- **Fixtures**: valid and invalid profiles with their expected issue codes, for your own tests.
</Update>
190 changes: 190 additions & 0 deletions developer-guide/guide/connect-universal-usernames.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
---
title: "Connect universal usernames"
description: "Resolve every username in your system as an ENS name."
sidebarTitle: "Connect universal usernames"
icon: "globe"
---

import ProfileExample from '/snippets/universal-usernames/profile-v1-example.mdx';

Connect `brand.eth` to an HTTPS endpoint you run, and every username that endpoint knows resolves as `username.brand.eth` in ENS-aware wallets. You build the endpoint, connect it in the Namespace App, point `brand.eth` at Namespace's HybridResolverV2, and activate. Your system stays the source of truth for usernames and records.

For the concepts first, read [Universal usernames](/overview/universal-usernames).

## Prerequisites

- A `.eth` name directly below `.eth`, such as `brand.eth`, that hasn't expired.
- The wallet that owns it. Only the exact owner can connect a name, and approved operators don't count:
- for a wrapped name, the NameWrapper owner;
- for an unwrapped name, the ENS registry owner, shown as the manager in the ENS app, which can differ from the registrant;
- for an ENSv2 name, the owner in the ENSv2 registry.
- A place to host an HTTPS endpoint with a public address.
- ETH for one or two transactions, unless `brand.eth` already uses HybridResolverV2.

<Note>
Universal usernames is rolling out to selected brands first. If the Namespace App doesn't show a **Usernames** tab for your wallet, [talk to us](/user-guide/custom-solutions).
</Note>

## What you sign

| Step | What it takes |
| --- | --- |
| Save the endpoint | One wallet signature |
| Switch the resolver | One or two transactions, only if `brand.eth` isn't on HybridResolverV2 yet |
| Activate | One wallet signature |
| Replace the endpoint later | Two wallet signatures: save, then activate |
| Rotate the credential, pause, resume, or disconnect | One wallet signature each, no transaction |

Each signature authorizes one change to one parent at one revision, uses a single-use nonce, and expires within 10 minutes, so it can't be replayed.

<Steps>
<Step title="Choose the parent name">

Open the [offchain dashboard](https://app.namespace.ninja/offchain) in the Namespace App, go to the **Usernames** tab, and connect the wallet that owns `brand.eth`. Pick the name from the list, or type it in. The App checks ownership live, so a name you transferred recently shows its current owner.

</Step>

<Step title="Inspect the parent">

The App reads `brand.eth` onchain and shows its registry version, whether it's wrapped, its resolver and route, the records on the parent that will be copied, the number of stored offchain subnames, and any onchain subnames.

Some states block a connection:

| Blocker | Why | What to do |
| --- | --- | --- |
| `NOT_ETH_2LD` | The name isn't directly below `.eth`, for example `brand.app.eth` or a DNS name | Use a second-level `.eth` name |
| `EXPIRED` | The name has expired | Renew it |
| `CANNOT_SET_RESOLVER` | The name is wrapped with the `CANNOT_SET_RESOLVER` fuse burned | Use another name; the resolver can't change |
| `L2_ROUTE` | `brand.eth` already uses HybridResolverV2 to serve subnames from an L2 | Switching the route would break those subnames, so it's blocked |

If `brand.eth` has stored offchain subnames, the App asks you to acknowledge that they stop resolving while the parent is connected. Namespace records that acknowledgment in the connection's events, and blocks changes to stored subnames until you disconnect.

Onchain subnames such as an existing onchain `happy.brand.eth` keep resolving onchain and hide that username from your endpoint. The App lists them so you can decide what to do with each.

</Step>

<Step title="Build the endpoint">

Your endpoint answers `GET {endpoint URL}/{username}` with a JSON profile, or `404` when the username doesn't exist:

<ProfileExample />

Pick a starting point:

- Copy the [adapter template](/developer-guide/universal-usernames/adapter-template), a tested Hono endpoint, and replace its `lookupUser` stub.
- Use `createUsernameHandler` from the [Universal Usernames SDK](/developer-guide/sdks/universal-usernames) inside your existing service.
- Have your coding agent build it. Install the skill with `npx skills add thenamespace/skills -s universal-usernames`; it carries the contract, record formats, and adapters for Hono, Express, and Next.js.

The [endpoint reference](/developer-guide/universal-usernames/endpoint-reference) has the full contract. Deploy the endpoint and check it before you connect it:

```bash
npx @thenamespace/universal-usernames validate https://api.brand.example/ens/users --username happy
```

</Step>

<Step title="Connect and validate">

In the App, enter:

- The endpoint URL, such as `https://api.brand.example/ens/users`. Namespace appends `/{username}`.
- The authentication: none, or a bearer token your endpoint expects.
- A sample username that exists in your system, such as `happy`.

Select **Validate** for a dry run. Namespace calls your endpoint the same way lookups do, lists every issue with the JSON path it applies to, and shows a read-only preview of the records that passed. Nothing is stored yet. Fix any issues with the [troubleshooting codes](/developer-guide/universal-usernames/troubleshooting) and validate again.

When the report is clean, select **Save** and sign the message in your wallet. The connection becomes `validated`. Namespace stores the token encrypted and never shows it again.

</Step>

<Step title="Point the parent at HybridResolverV2">

Lookups reach your endpoint through Namespace's HybridResolverV2 and its offchain route, route `1`. If `brand.eth` already uses HybridResolverV2 on its default or offchain route, skip this step.

| Network | HybridResolverV2 address |
| --- | --- |
| Ethereum mainnet | `0x0A67542cbD8d71CB760d1EeB5a4b4a77ea35Ff9b` |
| Sepolia | `0x976a3E43b2a6706F52D9555931c68eb62D134ff7` |

HybridResolverV2 has no fallback to your current resolver. Records on `brand.eth` itself, such as its address, avatar, or contenthash, have to be copied to HybridResolverV2 before the switch, or `brand.eth` stops returning them. The App does this in order:

1. On HybridResolverV2, set the offchain route for `brand.eth` and copy every record the current resolver holds that the App can encode, in one `multicall`.
2. Set the resolver of `brand.eth` to HybridResolverV2.

| Parent | Transaction 1, on HybridResolverV2 | Transaction 2 |
| --- | --- | --- |
| Already on HybridResolverV2, default or offchain route | None | None |
| ENSv1, unwrapped | `multicall` with `setNameRoute(name, 1)` and the record copies | `setResolver` on the ENS registry |
| ENSv1, wrapped | Same as unwrapped | `setResolver` on the NameWrapper |
| ENSv2 | Same `multicall`, with `setNameRoute` first | `setResolver` on the `.eth` registry |

With a wallet that supports batched calls (EIP-5792), the App sends both as one batch. After the switch it shows the old and new value of every copied record. If a record can't be copied, the App stops and asks before dropping it. Your old resolver isn't changed.

Until you activate, the parent keeps serving any stored offchain subnames, and usernames from your endpoint resolve empty.

</Step>

<Step title="Activate and verify">

Select **Activate** and sign. Namespace checks that:

- your wallet still owns `brand.eth`;
- the resolver of `brand.eth` is HybridResolverV2;
- the route for `happy.brand.eth` is the offchain route;
- `happy.brand.eth` has no onchain owner;
- stored offchain subnames are acknowledged, if there are any.

The connection moves to `verifying` and the parent starts serving from your endpoint. Namespace then resolves the sample username through the ENS Universal Resolver with CCIP-Read and compares the answer with the validated preview. A match makes the connection `active`. A mismatch puts it back to `validated` with the errors, and you can fix them and activate again.

Check it yourself with any ENS client. viem follows CCIP-Read by default:

```ts
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { normalize } from 'viem/ens';

const client = createPublicClient({ chain: mainnet, transport: http() });
const name = normalize('happy.brand.eth');

const address = await client.getEnsAddress({ name });
const avatar = await client.getEnsText({ name, key: 'avatar' });
console.log({ address, avatar });
```

</Step>

<Step title="Manage the connection">

The **Usernames** tab shows the connection's health. From there you can:

- Preview any username to see the records it resolves to right now. Records are edited in your own system; Namespace only shows them.
- Replace the endpoint by saving the new URL and activating it. The current endpoint keeps serving until the new one passes, and a failed replacement leaves it serving.
- Rotate the credential. This is one step: Namespace validates the new token and switches to it in the same call, with no activation afterwards. Accept both tokens on your endpoint until the rotation finishes.
- Pause the connection. While it's paused, lookups under `brand.eth` return an error with no signature. Resuming checks ownership and the sample username again.
- Disconnect. This is final: Namespace wipes the credential, keeps a redacted history, and stored offchain subnames resolve again. The resolver and route stay as they are, so switch the resolver back yourself if you want to.
- Read the events. Every save, activation, rotation, pause, suspension, and disconnect is logged with the signer and revision.

If `brand.eth` changes owner or expires, Namespace suspends the connection within about a minute and wipes the credential. Lookups return an error from then on. The new owner has to connect from scratch.

</Step>
</Steps>

## Behaviours to plan for

These follow from how the deployed resolver and the gateway work. They aren't bugs, so design around them.

- A username that has an onchain owner keeps resolving onchain. HybridResolverV2 answers names with an onchain owner from its own records, even empty ones, so your endpoint never sees them.
- There's no fallback resolver. Copy the parent's records before switching, which the App does for you.
- Usernames are one label. `team.happy.brand.eth` resolves empty and never reaches your endpoint.
- Proofs last at most 60 seconds, and Namespace serves a change within 60 seconds. Wallets and apps can cache answers for longer.
- Changing the connection takes a wallet signature, never a transaction. Only the first resolver switch is onchain.
- Everything that resolves is public, even when your endpoint requires a bearer token.

## Troubleshooting

[Troubleshooting universal usernames](/developer-guide/universal-usernames/troubleshooting) explains every validation issue code and connection error. For general problems, see [Troubleshooting](/developer-guide/troubleshooting).

## Next steps

- Automate setup with the [owner API](/api-reference/offchain-manager/universal-usernames/get-connection) or the [Offchain Manager SDK](/developer-guide/sdks/offchain-manager/universal-usernames).
- Read the [endpoint reference](/developer-guide/universal-usernames/endpoint-reference) and [records](/developer-guide/universal-usernames/records) in full.
Loading
Loading