Xodian · API v1
Xodian Docs
The complete Xodian manual: quickstart, concepts, API reference, widget, token verification, webhooks, sandbox and end-user guide.
Overview
Xodian lets your app confirm that a user is one real person on a genuine phone, without learning who they are. No selfies, no ID uploads, no personal data.
The person confirms in the omni app. Your backend receives a signed id_token (ES256 JWT) that says:
- human: this is a real person on a genuine, unmodified phone
- sub: a private ID for this person that only your app sees, stable across visits
- level: how strong the evidence is (1 = device, 2 = established)
- uniqueness: whether this person is
newto you,returning, orcontested
What you get
One stable ID per real person, per app. Fifty wallets on one phone resolve to one ID, and a new phone keeps the ID the wallet already had. An Android reinstall with a fresh wallet is the case we cannot tie back, and it arrives labelled new_device - ask for level 2 where that matters.
What you never get
Name, face, documents, phone number, device model, balance or activity. The wallet address only if you ask and the person agrees.
Good fits
Airdrops and reward campaigns (one claim per person), game accounts and trials, community votes, and a cheap bot filter in front of full KYC.
Not a fit
Xodian is not identity verification. It does not tell you a person's name, age or country, and it does not satisfy KYC/AML obligations on its own.
Quickstart (10 minutes)
You need a server that can keep a secret, and a web page or app to show the button. The sandbox is free and needs no sign-up.
Get a test key
From the sandbox section of the home page, or by API. The secret is shown once; keep it on your server.
curl -X POST https://xodian.xode.net/v1/test/clients \ -H "Content-Type: application/json" \ -d '{"name":"Acme Airdrop","domain":"acme.example"}' # → {"clientId":"rpt_3f9a…","clientSecret":"osk_rpt_3f9a…_…", # "test":true,"baseUrl":"https://xodian.xode.net","dailyQuota":500}Open a session from your server
referenceis your own ID for this attempt (for example a claim ID). It comes back in the token asnonce.curl -X POST https://xodian.xode.net/v1/sessions \ -H "Authorization: Bearer $XODIAN_SECRET" \ -H "Content-Type: application/json" \ -d '{"scope":["human"],"minLevel":1,"reference":"signup-42"}'{ "sessionId": "BQuIzVWtfYrpFA4_N4ZgzpLW", "pollToken": "CI_8JleZxedaAoDLo7Wg46vB", "expiresAt": 1791251375, "url": "https://xodian.xode.net/s/BQuIzVWtfYrpFA4_N4ZgzpLW", "deeplink": "xode://id?s=BQuIzVWtfYrpFA4_N4ZgzpLW", "scope": ["human"], "minLevel": 1, "test": true }Send the whole object to the browser. It contains no secret.
Show the button
The widget draws the QR on desktop and a "Continue in omni" button on phones, polls the status, and calls you back.
<script src="https://xodian.xode.net/sdk.js"></script> <div id="verify"></div> <script> Xodian.button("#verify", () => fetch("/api/xodian/start", { method: "POST" }).then(r => r.json()), { onApproved: (s) => fetch("/api/xodian/finish?session=" + s.sessionId) }); </script>Answer it in the sandbox
Test sessions show a Simulate panel inside the widget (approve, deny, reject, expire), or call the simulator API.
Fetch and verify the token on your server
Read the session with your secret, then verify the
idTokenagainst the JWKS. See Verifying the token.
rp_… instead of rpt_…). Reject tokens that carry "test": true in production.Core concepts
Session
One verification attempt. Your server creates it; the person answers it in omni within 5 minutes (expiresAt). Every session ends in exactly one final state:
| status | meaning | what to do |
|---|---|---|
pending | Waiting for the person | Keep the QR or button visible |
approved | Verified at or above minLevel | Fetch the token on your server |
rejected | The person tried but did not qualify; see reason | Show a neutral message; omni tells the person privately how to qualify |
denied | The person declined | Let them try again later |
expired | Nobody answered in time | Create a new session |
Assurance levels
| level | requires | use for |
|---|---|---|
| 1 · device | Wallet bound to a hardware key (StrongBox, TEE or Secure Enclave); passed Google Play Integrity or Apple App Attest and recently re-checked. Rejects emulators, rooted devices and modified builds. | Sign-ups, trials, bot filtering |
| 2 · established | Level 1, plus an account at least 14 days old, 7 days of attested activity (days on which a heartbeat arrived carrying a hardware-key signature, not merely a wallet signature), and no device change in the last 30 days. | Airdrops, payouts, votes |
Set minLevel per session. Below it you receive rejected and nothing else: no level, no signals.
Which level is one-person-one-account?
Level 1 answers "a real phone, now". It is the right gate for bot filtering, trials and sign-ups.
Level 2 is the one to ask for when uniqueness is what you are paying for - airdrops, payouts, votes. On Android the hardware key is destroyed with the app's data, so a reinstall plus a fresh wallet presents as a phone we have not seen; we report that as new_device rather than hide it. Level 2 closes it: the attested days needed for a second wallet on one phone force a device change, and any device change inside 30 days fails level 2. iOS keeps its key in the keychain and so returns as returning or contested.
Pairwise subject (sub)
Each app gets a different sub for the same person, so partners cannot join their data to track someone. Within your app it is stable: the same person gets the same sub every time, including after they move their wallet to a new phone. Several wallets on one phone get the same sub. Use sub as your key for "one per person".
Uniqueness
| value | meaning |
|---|---|
new | First time this person verifies with your app |
new_device | new, and both the hardware key and the account are only hours old - the shape an Android reinstall with a fresh wallet makes. Hold these for level 2 if one-person-one-account is what you need. |
returning | Seen before; same sub as last time |
contested | The wallet moved onto a phone that had already verified for someone else in your app. You decide: accept, hold for review, or refuse. |
Scopes
human is always included. Add wallet to ask for the person's XODE wallet address; it is shared only if the person turns it on in omni, and your key must be enabled for it.
Reference and nonce
Whatever you pass as reference (up to 200 characters) is returned as the token's nonce. Check it, so a token issued for one attempt cannot be replayed for another.
API reference
Base URL https://xodian.xode.net. JSON in, JSON out. Server calls authenticate with your client secret:
Authorization: Bearer osk_<clientId>_<random>
| method | path | auth | purpose |
|---|---|---|---|
| POST | /v1/sessions | secret | Open a session |
| GET | /v1/sessions/{id} | secret | Status, and the token once approved |
| GET | /v1/sessions/{id}/status?t= | poll token | Browser-safe status (never the token) |
| GET | /v1/.well-known/jwks.json | public | Signing keys |
| GET | /v1/.well-known/configuration | public | Discovery document |
| POST | /v1/test/clients | none | Create a sandbox key |
| POST | /v1/test/sessions/{id}/simulate | test session | Answer a test session |
POST /v1/sessions
| field | type | default | |
|---|---|---|---|
scope | string[] | ["human"] | human, optionally wallet |
minLevel | 1 | 2 | 1 | Lowest level you accept (up to your key's maximum) |
reference | string | "" | Your correlation ID, returned as nonce |
{
"sessionId": "BQuIzVWtfYrpFA4_N4ZgzpLW",
"pollToken": "CI_8JleZxedaAoDLo7Wg46vB",
"expiresAt": 1791251375,
"url": "https://xodian.xode.net/s/BQuIzVWtfYrpFA4_N4ZgzpLW",
"deeplink": "xode://id?s=BQuIzVWtfYrpFA4_N4ZgzpLW",
"scope": ["human"],
"minLevel": 1,
"test": true
}url is what the QR encodes; deeplink opens omni directly on the same phone. Pass sessionId, pollToken, url, deeplink and expiresAt to the browser.
GET /v1/sessions/{id}
Server only. Returns the status; when approved it also returns idToken, sub and level. Only the client that created the session can read it.
curl https://xodian.xode.net/v1/sessions/BQuIzVWtfYrpFA4_N4ZgzpLW \
-H "Authorization: Bearer $XODIAN_SECRET"
# approved →
{
"sessionId": "BQuIzVWtfYrpFA4_N4ZgzpLW",
"status": "approved",
"expiresAt": 1791251375,
"idToken": "eyJhbGciOiJFUzI1NiIs…",
"sub": "oid_9f3c1a8be41d…",
"level": 1
}
# rejected →
{ "sessionId": "…", "status": "rejected", "reason": "insufficient_level", "expiresAt": … }GET /v1/sessions/{id}/status
For the browser. The session-scoped pollToken reveals only the state and, for final states, the reason. The widget calls this for you every 1 to 3 seconds.
GET https://xodian.xode.net/v1/sessions/{sessionId}/status?t={pollToken}
{ "sessionId": "…", "status": "pending", "expiresAt": 1791251375 }Discovery and keys
GET https://xodian.xode.net/v1/.well-known/configuration
{
"service": "Xodian",
"issuer": "https://xodian.xode.net",
"jwks_uri": "https://xodian.xode.net/v1/.well-known/jwks.json",
"sessions_endpoint": "https://xodian.xode.net/v1/sessions",
"scopes_supported": ["human", "wallet"],
"levels": { "1": "device", "2": "established" },
"id_token_signing_alg_values_supported": ["ES256"],
"session_ttl_seconds": 300
}Keys are ES256 (P-256). When we rotate, the previous key stays in the JWKS for a while, so tokens signed just before a rotation still verify. Cache the JWKS, and refetch when you see an unknown kid.
Errors
| HTTP | when |
|---|---|
| 400 | Unknown scope, minLevel out of range, malformed body |
| 401 | Missing, malformed or invalid client secret |
| 403 | Your key is not enabled for the wallet scope |
| 404 | No such session, or it belongs to another client (also: wrong poll token) |
| 409 | The session is no longer open |
| 429 | Daily verification quota reached, or too many test keys today |
Errors are JSON: {"detail": "…"}, where detail is a message or an object with code and message.
Web widget (sdk.js)
One small script with no dependencies. It works in any page and any framework.
<script src="https://xodian.xode.net/sdk.js"></script>
Xodian.button(target, getSession, options)
Renders a "Verify with Xodian" button into target (a selector or element). On click it awaits getSession(), which must return the session object from your backend, then opens the modal.
<script src="https://xodian.xode.net/sdk.js"></script>
<div id="verify"></div>
<script>
Xodian.button("#verify",
() => fetch("/api/xodian/start", { method: "POST" }).then(r => r.json()),
{ onApproved: (s) => fetch("/api/xodian/finish?session=" + s.sessionId) });
</script>Xodian.open(session, options)
Opens the modal for a session you already have. Returns { close() }.
// Your own button, your own flow:
const session = await fetch("/api/xodian/start", { method: "POST" }).then(r => r.json());
const modal = Xodian.open(session, {
onApproved: (s) => finish(s.sessionId), // fetch the token on YOUR server
onRejected: (s) => explain(s.reason),
onDenied: () => {},
onExpired: () => {},
onClose: () => {}, // closed by the user
});
// modal.close() closes it from code.Options
| option | |
|---|---|
onApproved(s) | Approved. s is the public status; fetch the token on your server. |
onRejected(s) | Did not qualify; s.reason says why. |
onDenied(s) · onExpired(s) | Declined, or timed out. |
onClose() | The person closed the modal. |
onError(e) | button only: getSession() failed. |
label | button only: the button text. |
text | Your own wording or language for the modal (see below). |
autoCloseMs | How long the success state stays (default 1800). |
persona | Sandbox only: the simulated person's name. |
baseUrl | Override the API host (normally taken from the script URL). |
Wording and languages
The modal is English by default. Pass text to translate it or match your voice. Keys: title, subtitle, step1, step1m (mobile), step2, step3, openApp, noApp, secured, okTitle, okBody, deniedTitle, deniedBody, rejTitle, rejBody, expTitle, expBody, close. Missing keys fall back to English.
Xodian.open(session, {
text: {
title: "사람 인증",
subtitle: "실제 사람인지 확인해요. 이름·번호·지갑은 공유되지 않아요.",
step1: "omni 앱으로 스캔", step2: "공유 내용을 확인하고 승인", step3: "완료",
close: "닫기",
},
});Native apps
// iOS / Android / Flutter: no QR needed on the same phone.
// Open the deeplink from the session your backend created:
openURL(session.deeplink) // xode://id?s=…
// omni asks the person, then returns them. Your app polls your backend,
// which reads GET /v1/sessions/{id} with the secret./s/<id>) follows the phone's language: English, Korean, Japanese or Chinese.Xodian Connect (wallet)
Connect is Xodian as a web3 gateway: your site connects to a person's XODE wallet and asks for signatures, with no browser extension. The recovery phrase never leaves the omni app, and every request is shown there before anything is signed.
The shape of it: your backend opens a session, the person approves in omni (a QR on desktop, a deep link on the phone), your page receives a connect token once, and from then on the page asks for signatures with that token.
Opening a session
A live client opens the session from its backend, with its client secret, and hands the browser only sessionId and pollToken:
POST https://xodian.xode.net/v1/connect/sessions
Authorization: Bearer osk_<clientId>_<secret>
Origin: https://your-domain.example
Content-Type: application/json
{"clientId":"rp_…","scope":["human","wallet"],"minLevel":1}
Origin is not a credential
An Origin header constrains browsers, not HTTP clients. Without a secret, anyone could send your domain with your (public) clientId, open a session in your name, and receive the connect token when someone approved. So a live client presents the secret and gets 403 secret_required without it. The Origin rule still applies on top: the secret says who, the Origin says from where.
Sandbox keys keep the browser-only path. A self-serve rpt_ key may open a session straight from the page, over your registered domain or http://localhost, because there it cannot pay: a real phone refuses a sandbox session, so it can never reach a real wallet. Such a session shows as unverified on the consent screen, and so does any session a live client opens without its secret.
The SDK
<script src="https://xodian.xode.net/connect.js"></script>
XodianConnect.init({ clientId: 'rp_…', chainId: 3417 });
const { address, evmAddress, sub, level } = await XodianConnect.connect();
XodianConnect.disconnect();
The connection (token, address, pairwise sub, level) is kept in the page's own localStorage and lasts 30 days. A 401 from a signing call means it was revoked or expired: the SDK clears it and reconnects.
Discovery works two ways. For EVM libraries the provider announces itself per EIP-6963 as XODE omni (rdns: net.xode.omni), so wagmi, RainbowKit and viem list it without anyone fighting over window.ethereum — pass init({ injectEthereum: true }) only if you want that slot filled and it is free. For polkadot-js apps the wallet is at window.injectedWeb3.xodian, with enable(), accounts.get() and a signer.
Provider methods
| method | answer |
|---|---|
eth_requestAccounts · eth_accounts | The wallet's 0x address, derived from the same recovery phrase as its XODE account |
eth_chainId · net_version | The chain the page was told about. Defaults to XODE's own EVM layer (3417); init({ chainId }) sets yours |
wallet_switchEthereumChain | Accepted for any chain. A connection is not bound to one: a message signature is chain-independent and a transaction carries its own chainId |
personal_sign | EIP-191 signature from the 0x account. Needs omni 1.2.1 or newer on the phone |
| anything else | 4200 unsupported method — including eth_sendTransaction, which is not offered yet |
Errors worth handling
| code | meaning |
|---|---|
4001 | The person refused, or closed the modal. Show a cancellation, not a failure |
4100 | This connection has no 0x address — it was made by an older omni build. Reconnect |
4200 | Method not supported |
-32602 | Invalid parameters (a wrong address, a malformed chain id) |
What omni refuses, and why
The signing screen is the security boundary, so it declines whatever it cannot explain to the person. Expect these, and treat them as correct:
- A message that is one of XODE's own reserved strings — anything beginning
xode-. Those authenticate a wallet to XODE itself, so a site must not be able to ask for one. - A call whose pallet and call index are not ones omni can check from the raw bytes, judged before any decoder is consulted.
- A batch wrapper. Reading what is nested inside one needs the full chain metadata, which the phone does not carry, so it is refused rather than approved on trust.
- Any balance transfer that is not one of the two it re-derives from the bytes itself, which is how a "transfer everything" call is kept out.
- Text containing bidi overrides or zero-width characters — rendered as hex with a warning, because such a line reads as something other than what it says.
- A long message, until the person opens it. Over roughly 400 characters or 8 lines it is collapsed and Approve stays disabled, so a challenge hidden below the fold cannot be signed by a tap. Keep sign-in messages short.
Receipts
Every signing decision is recorded in the Sentinel ledger and anchored on XODE, the same as a verification. A refusal carries the wallet's signature, so a receipt that says the person refused means the person; a request closed without one is recorded as a cancellation instead.
Limits
| Connection | 30 days, revocable by the person in omni at any time |
| Sign request | 5 minutes to answer; 30 open per connection per 5 minutes |
| Message size | 1 KB for a plain message, 8 KB for an EIP-191 one |
Verifying the token
Always verify on your server. Never trust a status the browser reports.
Claims
{
"iss": "https://xodian.xode.net",
"aud": "rp_acme",
"sub": "oid_9f3c1a8be41d02c7aa50e07b",
"iat": 1791251090, "exp": 1791251690,
"jti": "BQuIzVWtfYrpFA4_N4ZgzpLW",
"nonce": "signup-42",
"human": true,
"level": 2, "level_name": "established",
"uniqueness": "new",
"verified_at": 1791251090,
"receipt": "1791251090-a1b2c3d4",
"wallet": "XqF4…" // only with the wallet scope AND the person's consent
}| claim | |
|---|---|
iss | Always https://xodian.xode.net |
aud | Your client ID |
sub | The person's pairwise ID for your app |
exp | 10 minutes after issue: fetch and verify right away |
jti | The session ID; store it to make each token single-use |
nonce | Your reference |
level, level_name | 1 device · 2 established |
uniqueness | new / new_device / returning / contested |
receipt | Handle of the on-chain-anchored decision record (live keys) |
wallet | Only with the wallet scope and the person's consent |
test | true on sandbox tokens; absent on live ones |
Checklist
- Signature is ES256 and verifies against the JWKS
issishttps://xodian.xode.netandaudis your client IDexpis in the futurenonceequals thereferenceyou created the session withjtihas not been used before- In production,
testis absent levelis at least what this action needs
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://xodian.xode.net/v1/.well-known/jwks.json"));
export async function finish(sessionId, expectedReference) {
const s = await fetch(`https://xodian.xode.net/v1/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${process.env.XODIAN_SECRET}` },
}).then(r => r.json());
if (s.status !== "approved") return { ok: false, reason: s.reason || s.status };
const { payload } = await jwtVerify(s.idToken, JWKS, {
issuer: "https://xodian.xode.net",
audience: process.env.XODIAN_CLIENT_ID,
algorithms: ["ES256"],
});
if (payload.nonce !== expectedReference) throw new Error("token is for another session");
if (payload.test && process.env.NODE_ENV === "production") throw new Error("sandbox token");
await db.usedTokens.insertUnique(payload.jti); // one use per token
return { ok: true, person: payload.sub, level: payload.level,
contested: payload.uniqueness === "contested" };
}import os, requests, jwt # pip install "pyjwt[crypto]" requests
JWKS = jwt.PyJWKClient("https://xodian.xode.net/v1/.well-known/jwks.json")
def finish(session_id, expected_reference):
s = requests.get(f"https://xodian.xode.net/v1/sessions/{session_id}",
headers={"Authorization": f"Bearer {os.environ['XODIAN_SECRET']}"},
timeout=10).json()
if s["status"] != "approved":
return None
key = JWKS.get_signing_key_from_jwt(s["idToken"]).key
claims = jwt.decode(s["idToken"], key, algorithms=["ES256"],
audience=os.environ["XODIAN_CLIENT_ID"],
issuer="https://xodian.xode.net")
assert claims["nonce"] == expected_reference
assert not claims.get("test"), "sandbox token"
return claims # claims["sub"] is your key for this personWebhooks
Instead of polling, live keys can receive a POST when a session reaches a final state. Webhooks are set up with your live key (not available on self-serve test keys).
| event |
|---|
session.approved (includes idToken) · session.rejected · session.denied |
grant.revoked: the person removed your app in omni (includes sub). Apply your own data policy for that person. |
POST https://your-app.example/xodian/webhook
Content-Type: application/json
User-Agent: xodian-webhook/1
X-Omni-Signature: t=1791251090,v1=5f1c…e9
{"event":"session.approved","sessionId":"…","status":"approved",
"expiresAt":1791251375,"idToken":"eyJhbGciOiJFUzI1NiIs…"}
# when the person removes your app in omni:
{"event":"grant.revoked","sub":"oid_9f3c1a8be41d02c7aa50e07b"}Verify the signature
The header is t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(webhook secret, t + "." + raw body). Reject old timestamps to stop replays.
import crypto from "node:crypto";
// Use the RAW request body, exactly as received.
function verifyXodian(rawBody, header, secret /* whsec_… */) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
const ok = parts.v1 && crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1.padEnd(expected.length)));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh;
}Delivery
Respond with any 2xx within 8 seconds. Otherwise we try again, up to 4 attempts in total, with growing gaps. Deliveries can repeat, so handle them idempotently by sessionId, and still verify the idToken as usual.
Sandbox
Test keys (rpt_…) are free and self-serve. Everything matches production except:
- Sessions are answered with the simulator, from the widget's Simulate panel or by API
- Tokens carry
"test": trueand asubthat starts withoid_test_ - No webhooks and no on-chain receipts
- 500 sessions per day per key; up to 5 new keys per network per day
Simulator
curl -X POST https://xodian.xode.net/v1/test/sessions/$SESSION_ID/simulate \
-H "Content-Type: application/json" \
-d '{"outcome":"approve","level":2,"persona":"alice","shareWallet":true}'
# outcome: approve | deny | reject | expire
# level: 1 | 2 (approve)
# persona: any name; same persona → same sub
# uniqueness: new | returning | contested (optional, forces the value)
# reason: insufficient_level | device_unverified | no_device | integrity_stale (reject)The same persona always maps to the same sub, so you can test "one claim per person" and returning users. Approving below the session's minLevel produces a rejection, just as in production.
Rejection reasons
A rejected session carries a reason. Show your users a neutral message; omni explains the details to them privately.
| reason | meaning | what the person can do |
|---|---|---|
insufficient_level | Verified, but below your minLevel | Keep using omni on this phone; level 2 comes with time |
device_unverified | The phone or app did not pass Play Integrity / App Attest | Use an unmodified phone and the store version of omni |
integrity_stale | The device check is too old | Open omni so it re-checks, then try again |
no_device | No hardware key is bound to this wallet | Open omni and finish device setup |
device_mismatch, device_proof_failed | The phone answering is not the phone the wallet is bound to | Answer from the phone where the wallet lives |
user_denied | The person declined (status denied) | Nothing; they can try again |
Treat unknown reasons as a generic rejection. New codes may be added.
On-chain receipts
Every live decision, approve or deny, is written to the Sentinel ledger and anchored on the XODE chain every hour. The token's receipt claim is the handle for that record. You, or an auditor, can later prove that Xodian really made that decision at that moment and that nobody changed it afterwards.
GET https://xodian.xode.net/sentinel/receipt/1791251090-a1b2c3d4
GET https://xodian.xode.net/sentinel/anchors
GET https://xodian.xode.net/sentinel/verify.py # independent verifier, standard library onlyThe ledger is public, so it never holds personal data: the subject is a hash, not the sub or the wallet.
Security checklist
- The client secret lives only on your server. Never in a page, an app bundle or a repository
- Verify every token yourself: signature,
iss,aud,exp,nonce, single-usejti - Key one-per-person benefits on
sub, not on wallet or email - Decide what
contestedmeans for you before launch (accept, review or refuse) - Use level 2 wherever money or votes are at stake
- Refuse
testtokens in production - Store only what you need: often
suband the date is enough
Limits and keys
test key rpt_ | live key rp_ | |
|---|---|---|
| How to get one | Self-serve | Ask us; early access for partners building on XODE |
| Sessions per day | 500 | Agreed per partner (default 10,000) |
| Levels | 1–2 | Up to the level agreed |
| Wallet scope | Yes | When enabled |
| Webhooks, receipts, verified-partner badge | — | Yes |
A session lives 5 minutes; a token 10 minutes. Live keys can be rotated on request. The old secret stops working the moment the new one is issued, so deploy the new one right away.
For your users
You can link your users to this section. What they see:
Install omni
Free on Google Play and the App Store. omni is the XODE app that runs a light node on the phone.
Scan or tap
On a computer, scan the QR with the omni app. On the phone itself, tap "Continue in omni".
Check what is shared, then confirm
omni shows which app is asking and exactly what it will receive. Confirming signs with the wallet key and the phone's hardware key.
Done
The app learns that you are a real person, plus a private ID that only it sees. Never your name, number, balance or activity.
Managing apps
In omni, go to Settings → Xodian to see the apps you have verified with, your current level, and to remove an app. The app is notified when you remove it, but deleting data it already holds is up to that app; ask it directly.
"I was rejected"
omni shows the reason and how to fix it. Usually: use the store version of omni on an unmodified phone, open omni so it re-checks the device, or, for apps that ask for level 2, keep using omni on the same phone for a couple of weeks.
FAQ
Is this KYC?
No. Xodian proves one real person on a genuine phone and never learns who they are. Many teams use it in front of KYC, so they only pay for full checks on people who pass.
Can one person still get several IDs?
One per genuine phone. Level 2 makes that expensive, because each phone has to be kept active for weeks. What Xodian removes is the cheap version: many wallets, emulators and modified apps on one device.
What happens when someone changes phone?
They keep the same sub for your app, because the wallet carries it across. A wallet that moves onto a phone already used by someone else comes back contested.
Do you support iPhone and Android?
Yes: Google Play Integrity on Android, Apple App Attest on iOS. Your token never says which platform.
Can I use it without a backend?
No. The token is only released to a server that holds the secret. That is what makes the result trustworthy.
I integrated on app.xode.net/id. Do I need to change anything?
The old paths still work. New integrations should use https://xodian.xode.net, and token verification should expect iss = https://xodian.xode.net.
Changelog
- 2026-10-06 Moved to
xodian.xode.netwith short paths (/v1/…,/sdk.js,/s/<id>); issuer is nowhttps://xodian.xode.net. Hand-off page in 4 languages. - 2026-10-05 v1: sessions, levels 1–2, pairwise IDs, uniqueness, widget, sandbox and simulator, webhooks, on-chain receipts.
Questions or a live key?
Last updated 2026-10-07 · API v1