XodianDocs Home
Sandbox →

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 new to you, returning, or contested

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.

  1. 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}
  2. Open a session from your server

    reference is your own ID for this attempt (for example a claim ID). It comes back in the token as nonce.

    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.

  3. 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>
  4. Answer it in the sandbox

    Test sessions show a Simulate panel inside the widget (approve, deny, reject, expire), or call the simulator API.

  5. Fetch and verify the token on your server

    Read the session with your secret, then verify the idToken against the JWKS. See Verifying the token.

Going live is the same code with a live key (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:

statusmeaningwhat to do
pendingWaiting for the personKeep the QR or button visible
approvedVerified at or above minLevelFetch the token on your server
rejectedThe person tried but did not qualify; see reasonShow a neutral message; omni tells the person privately how to qualify
deniedThe person declinedLet them try again later
expiredNobody answered in timeCreate a new session

Assurance levels

levelrequiresuse for
1 · deviceWallet 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 · establishedLevel 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

valuemeaning
newFirst time this person verifies with your app
new_devicenew, 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.
returningSeen before; same sub as last time
contestedThe 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>
methodpathauthpurpose
POST/v1/sessionssecretOpen a session
GET/v1/sessions/{id}secretStatus, and the token once approved
GET/v1/sessions/{id}/status?t=poll tokenBrowser-safe status (never the token)
GET/v1/.well-known/jwks.jsonpublicSigning keys
GET/v1/.well-known/configurationpublicDiscovery document
POST/v1/test/clientsnoneCreate a sandbox key
POST/v1/test/sessions/{id}/simulatetest sessionAnswer a test session

POST /v1/sessions

fieldtypedefault
scopestring[]["human"]human, optionally wallet
minLevel1 | 21Lowest level you accept (up to your key's maximum)
referencestring""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

HTTPwhen
400Unknown scope, minLevel out of range, malformed body
401Missing, malformed or invalid client secret
403Your key is not enabled for the wallet scope
404No such session, or it belongs to another client (also: wrong poll token)
409The session is no longer open
429Daily 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.
labelbutton only: the button text.
textYour own wording or language for the modal (see below).
autoCloseMsHow long the success state stays (default 1800).
personaSandbox only: the simulated person's name.
baseUrlOverride 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.
The page people land on after scanning the QR (/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

methodanswer
eth_requestAccounts · eth_accountsThe wallet's 0x address, derived from the same recovery phrase as its XODE account
eth_chainId · net_versionThe chain the page was told about. Defaults to XODE's own EVM layer (3417); init({ chainId }) sets yours
wallet_switchEthereumChainAccepted for any chain. A connection is not bound to one: a message signature is chain-independent and a transaction carries its own chainId
personal_signEIP-191 signature from the 0x account. Needs omni 1.2.1 or newer on the phone
anything else4200 unsupported method — including eth_sendTransaction, which is not offered yet

Errors worth handling

codemeaning
4001The person refused, or closed the modal. Show a cancellation, not a failure
4100This connection has no 0x address — it was made by an older omni build. Reconnect
4200Method not supported
-32602Invalid 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

Connection30 days, revocable by the person in omni at any time
Sign request5 minutes to answer; 30 open per connection per 5 minutes
Message size1 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
issAlways https://xodian.xode.net
audYour client ID
subThe person's pairwise ID for your app
exp10 minutes after issue: fetch and verify right away
jtiThe session ID; store it to make each token single-use
nonceYour reference
level, level_name1 device · 2 established
uniquenessnew / new_device / returning / contested
receiptHandle of the on-chain-anchored decision record (live keys)
walletOnly with the wallet scope and the person's consent
testtrue on sandbox tokens; absent on live ones

Checklist

  1. Signature is ES256 and verifies against the JWKS
  2. iss is https://xodian.xode.net and aud is your client ID
  3. exp is in the future
  4. nonce equals the reference you created the session with
  5. jti has not been used before
  6. In production, test is absent
  7. level is 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" };
}

Webhooks

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": true and a sub that starts with oid_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.

reasonmeaningwhat the person can do
insufficient_levelVerified, but below your minLevelKeep using omni on this phone; level 2 comes with time
device_unverifiedThe phone or app did not pass Play Integrity / App AttestUse an unmodified phone and the store version of omni
integrity_staleThe device check is too oldOpen omni so it re-checks, then try again
no_deviceNo hardware key is bound to this walletOpen omni and finish device setup
device_mismatch, device_proof_failedThe phone answering is not the phone the wallet is bound toAnswer from the phone where the wallet lives
user_deniedThe 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 only

The 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-use jti
  • Key one-per-person benefits on sub, not on wallet or email
  • Decide what contested means for you before launch (accept, review or refuse)
  • Use level 2 wherever money or votes are at stake
  • Refuse test tokens in production
  • Store only what you need: often sub and the date is enough

Limits and keys

test key rpt_live key rp_
How to get oneSelf-serveAsk us; early access for partners building on XODE
Sessions per day500Agreed per partner (default 10,000)
Levels1–2Up to the level agreed
Wallet scopeYesWhen 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:

  1. Install omni

    Free on Google Play and the App Store. omni is the XODE app that runs a light node on the phone.

  2. Scan or tap

    On a computer, scan the QR with the omni app. On the phone itself, tap "Continue in omni".

  3. 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.

  4. 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.net with short paths (/v1/…, /sdk.js, /s/<id>); issuer is now https://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
Talk to us on Telegram