Xodian · API v1
Xodian ドキュメント
Xodian の完全マニュアル:クイックスタート、基本概念、APIリファレンス、ウィジェット、トークン検証、Webhook、サンドボックス、利用者向けガイド。
概要
Xodian を使うと、アプリはユーザーが正規の端末を使う実在の人物ひとりであることを確認できます。その人が誰なのかは知らないまま。自撮りも、本人確認書類のアップロードも、個人情報も不要です。
確認はユーザーが omni アプリで行います。あなたのバックエンドは署名付きの id_token(ES256 JWT)を受け取り、そこには次の情報が入っています:
- human:改造されていない正規の端末を使う実在の人物である
- sub:あなたのアプリだけが見られる、この人の非公開ID。何度訪れても同じ
- level:証拠の強さ(1 = デバイス、2 = 定着)
- uniqueness:この人があなたにとって初めて(
new)か、再訪(returning)か、競合(contested)か
受け取るもの
アプリごとに実在する人物1人につき安定したID1つ。1台のウォレット50個は1つのIDにまとまり、端末を変えてもそのウォレットが持っていたIDは維持されます。再インストールかつ新しいウォレットの場合だけ結び付けられず、その時は new_device と明示してお送りします — 重要な場面ではレベル2を要求してください。
決して受け取らないもの
名前、顔、書類、電話番号、端末機種、残高、利用履歴。ウォレットアドレスは、要求し本人が同意した場合のみです。
向いている用途
エアドロップや報酬キャンペーン(1人1回)、ゲームアカウントや体験版、コミュニティ投票、そして本格的なKYCの前段に置く安価なボット対策。
向いていない用途
Xodian は本人確認ではありません。名前・年齢・国籍は分からず、それだけで KYC/AML の義務を満たすこともありません。
クイックスタート(10分)
秘密鍵を保管できるサーバーと、ボタンを表示するWebページまたはアプリが必要です。サンドボックスは無料で、登録も不要です。
テストキーを取得
トップページのサンドボックス、またはAPIから取得します。シークレットは一度しか表示されないので、サーバーに保管してください。
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}サーバーからセッションを開く
referenceはこの試行に対するあなた自身のIDです(例:受け取りID)。トークンの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 }このオブジェクトをそのままブラウザに渡してください。シークレットは含まれていません。
ボタンを表示
ウィジェットはデスクトップではQRを、スマホでは「omniで続ける」ボタンを表示し、状態を確認して結果を知らせます。
<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>サンドボックスで応答
テストセッションではウィジェット内にシミュレーションパネル(承認・拒否・不合格・期限切れ)が出ます。シミュレーターAPIも使えます。
サーバーでトークンを取得・検証
シークレットでセッションを読み、
idTokenを JWKS で検証します。トークンの検証を参照してください。
rpt_… ではなく rp_…)に替えるだけです。本番では "test": true の付いたトークンを拒否してください。基本概念
セッション
1回の確認の試みです。あなたのサーバーが作成し、ユーザーは5分以内(expiresAt)に omni で応答します。すべてのセッションは必ずひとつの最終状態で終わります:
| status | 意味 | すること |
|---|---|---|
pending | ユーザーの応答待ち | QRやボタンを表示したままにする |
approved | minLevel 以上で確認済み | サーバーでトークンを取得 |
rejected | 試したが条件を満たさなかった。reason を参照 | 中立的なメッセージを表示。条件の満たし方は omni が本人だけに案内 |
denied | ユーザーが拒否した | 後で再試行できるようにする |
expired | 時間内に応答がなかった | 新しいセッションを作る |
保証レベル
| level | 条件 | 用途 |
|---|---|---|
| 1 · device(デバイス) | ウォレットがハードウェアキー(StrongBox、TEE、Secure Enclave)に紐づき、Google Play Integrity または Apple App Attest に合格し、最近再確認されている。エミュレーター・root化端末・改造アプリは拒否。 | 登録、体験版、ボット対策 |
| 2 · established(定着) | レベル1 + アカウント作成から14日以上、7日間の検証済みアクティビティ(ウォレット署名だけでなく、ハードウェアキー署名を伴うハートビートが届いた日)、直近30日間の端末変更なし。 | エアドロップ、支払い、投票 |
minLevel はセッションごとに指定します。それを下回ると rejected だけが返り、レベルなどの情報は返りません。
一人一アカウントはどのレベルですか?
レベル1は「今、本物の端末」に答えます。ボット対策・トライアル・新規登録に適した基準です。
一意性に対価を払うならレベル2を要求してください — エアドロップ・支払い・投票。Androidではアプリのデータと共にハードウェアキーが削除されるため、再インストール後の新しいウォレットは未知の端末として現れます。隠さず new_device としてお知らせします。レベル2がこれを閉じます: 1台で2つ目のウォレットの検証済み日数を作るには端末変更が必要で、30日以内の端末変更はレベル2を満たしません。iOSはキーチェーンに鍵が残るため returning または contested として戻ります。
アプリ別ID(sub)
同じ人でもアプリごとに異なる sub になるため、パートナー同士がデータを突き合わせて人を追跡することはできません。あなたのアプリ内では固定で、同じ人には毎回同じ sub が返り、ウォレットを新しい端末に移しても変わりません。1台の端末の複数ウォレットは同じ sub になります。「1人1回」の判定キーには sub を使ってください。
一意性(uniqueness)
| 値 | 意味 |
|---|---|
new | この人があなたのアプリで初めて確認した |
returning | 以前に確認済み。前回と同じ sub |
contested | ウォレットが、あなたのアプリで既に別の人として確認された端末に移ってきた。受け入れ・保留して確認・拒否のいずれにするか決めてください。 |
スコープ
human は常に含まれます。ユーザーの XODE ウォレットアドレスが必要なら wallet を追加します。ユーザーが omni で共有をオンにした場合のみ渡され、あなたのキーでこの権限が有効になっている必要があります。
reference と nonce
reference に渡した値(最大200文字)はトークンの nonce として返ります。必ず照合し、ある試行用のトークンが別の試行に使い回されないようにしてください。
APIリファレンス
ベースURLは https://xodian.xode.net、リクエストもレスポンスもJSONです。サーバーからの呼び出しはクライアントシークレットで認証します:
Authorization: Bearer osk_<clientId>_<random>
| メソッド | パス | 認証 | 用途 |
|---|---|---|---|
| POST | /v1/sessions | シークレット | セッションを開く |
| GET | /v1/sessions/{id} | シークレット | 状態、承認後はトークンも |
| GET | /v1/sessions/{id}/status?t= | ポーリングトークン | ブラウザ向けの状態(トークンは返さない) |
| GET | /v1/.well-known/jwks.json | 公開 | 署名鍵 |
| GET | /v1/.well-known/configuration | 公開 | ディスカバリー文書 |
| POST | /v1/test/clients | なし | サンドボックスキーの作成 |
| POST | /v1/test/sessions/{id}/simulate | テストセッション | テストセッションに応答 |
POST /v1/sessions
| フィールド | 型 | 既定値 | |
|---|---|---|---|
scope | string[] | ["human"] | human、必要なら wallet |
minLevel | 1 | 2 | 1 | 受け入れる最低レベル(キーの上限まで) |
reference | string | "" | あなたの相関ID。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 はQRに入るアドレス、deeplink は同じ端末で omni を直接開きます。ブラウザには sessionId、pollToken、url、deeplink、expiresAt を渡してください。
GET /v1/sessions/{id}
サーバー専用です。状態を返し、approved のときは idToken、sub、level も返します。セッションを作ったクライアントだけが読めます。
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
ブラウザ向けです。セッション専用の pollToken で分かるのは状態と(最終状態なら)理由だけです。ウィジェットが1〜3秒ごとに代わりに呼び出します。
GET https://xodian.xode.net/v1/sessions/{sessionId}/status?t={pollToken}
{ "sessionId": "…", "status": "pending", "expiresAt": 1791251375 }ディスカバリーと鍵
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
}鍵は ES256(P-256)です。鍵を更新しても以前の鍵はしばらく JWKS に残るため、更新直前に署名されたトークンも検証できます。JWKS はキャッシュし、未知の kid が来たら取り直してください。
エラー
| HTTP | 発生条件 |
|---|---|
| 400 | 不明なスコープ、範囲外の minLevel、不正なリクエスト本文 |
| 401 | シークレットがない・形式が違う・無効 |
| 403 | キーで wallet スコープが有効になっていない |
| 404 | セッションが存在しないか、別クライアントのもの(ポーリングトークンの誤りも404) |
| 409 | セッションは既に終了している |
| 429 | 1日の確認上限に到達、または本日のテストキー作成数の超過 |
エラーは JSON {"detail": "…"} で、detail はメッセージ、または code と message を持つオブジェクトです。
Webウィジェット(sdk.js)
依存関係のない小さなスクリプトひとつです。どのページ・どのフレームワークでも動きます。
<script src="https://xodian.xode.net/sdk.js"></script>
Xodian.button(target, getSession, options)
target(セレクターまたは要素)に「Verify with Xodian」ボタンを描画します。クリックすると getSession() を待ちます。この関数はあなたのバックエンドから取得したセッションオブジェクトを返す必要があります。その後モーダルを開きます。
<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)
手元にあるセッションでモーダルを開きます。{ 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.オプション
| オプション | |
|---|---|
onApproved(s) | 承認。s は公開の状態値です。トークンはサーバーで取得してください。 |
onRejected(s) | 条件未達。理由は s.reason。 |
onDenied(s) · onExpired(s) | 拒否、または期限切れ。 |
onClose() | ユーザーがモーダルを閉じた。 |
onError(e) | button のみ:getSession() が失敗。 |
label | button のみ:ボタンの文言。 |
text | モーダルの文言の変更・翻訳(下記参照)。 |
autoCloseMs | 成功表示の時間(既定 1800)。 |
persona | サンドボックスのみ:シミュレーションする人の名前。 |
baseUrl | APIホストの指定(通常はスクリプトのURLから自動)。 |
文言と言語
モーダルの既定は英語です。text を渡すと翻訳したり、サービスの口調に合わせたりできます。キー:title、subtitle、step1、step1m(モバイル)、step2、step3、openApp、noApp、secured、okTitle、okBody、deniedTitle、deniedBody、rejTitle、rejBody、expTitle、expBody、close。指定のないキーは英語になります。
Xodian.open(session, {
text: {
title: "사람 인증",
subtitle: "실제 사람인지 확인해요. 이름·번호·지갑은 공유되지 않아요.",
step1: "omni 앱으로 스캔", step2: "공유 내용을 확인하고 승인", step3: "완료",
close: "닫기",
},
});ネイティブアプリ
// 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>)は端末の言語(英語・韓国語・日本語・中国語)に合わせて表示されます。Xodian Connect(ウォレット)
Connect は web3 ゲートウェイとしての Xodian です。ブラウザ拡張なしで、あなたのサイトが利用者の XODE ウォレットに接続して署名を求めます。リカバリーフレーズは omni アプリから出ず、すべてのリクエストは署名前にそのアプリで利用者に表示されます。
流れはこうです。あなたのバックエンドがセッションを開き、利用者が omni で承認し(デスクトップは QR、スマートフォンはディープリンク)、あなたのページが connect トークンを一度だけ受け取り、以降はそのトークンで署名を求めます。
セッションを開く
ライブクライアントはバックエンドからクライアントシークレットと共にセッションを開き、ブラウザには sessionId と 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 は資格情報ではありません
Origin ヘッダーはブラウザを縛りますが、HTTP クライアントは縛りません。シークレットがなければ、誰でもあなたのドメインと(公開された)clientId を送ってあなたの名前でセッションを開き、誰かが承認した瞬間に connect トークンを受け取れます。そのためライブクライアントはシークレットを提示し、無ければ 403 secret_required を受けます。Origin の規則はその上でそのまま適用されます — シークレットは誰かを、Origin はどこからかを示します。
サンドボックスキーはブラウザのみの経路を保ちます。セルフサービスの rpt_ キーは、登録ドメインまたは http://localhost からページで直接セッションを開けます。そこでは対価がないからです — 実機はサンドボックスセッションを拒否するため、実際のウォレットには届きません。そのようなセッションは同意画面で未検証と表示され、ライブクライアントがシークレットなしで開いたセッションも同じです。
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();
接続情報(トークン・アドレス・ペアワイズ sub・レベル)はページ自身の localStorage に保存され、30 日間有効です。署名呼び出しで 401 が返るのは失効または期限切れで、SDK が消去して再接続します。
発見経路は 2 つあります。EVM ライブラリには EIP-6963 で XODE omni(rdns: net.xode.omni)として自らを通知します。そのため wagmi・RainbowKit・viem の一覧に、window.ethereum の枠を奪い合わずに現れます — その枠を埋めたく、かつ空いている場合のみ init({ injectEthereum: true }) を使ってください。polkadot-js アプリには window.injectedWeb3.xodian として公開され、enable()・accounts.get()・signer を提供します。
プロバイダのメソッド
| メソッド | 応答 |
|---|---|
eth_requestAccounts · eth_accounts | ウォレットの 0x アドレス。XODE アカウントと同じリカバリーフレーズから導出されます |
eth_chainId · net_version | ページに伝えられたチェーン。既定は XODE 自身の EVM レイヤー(3417)で、init({ chainId }) で指定します |
wallet_switchEthereumChain | どのチェーンでも受け付けます。接続はチェーンに縛られません — メッセージ署名はチェーンに依存せず、トランザクションは自身の chainId を持ちます |
personal_sign | 0x アカウントによる EIP-191 署名。端末に omni 1.2.1 以降が必要です |
| その他 | 4200 未対応メソッド — まだ提供していない eth_sendTransaction も含みます |
扱うべきエラー
| コード | 意味 |
|---|---|
4001 | 利用者が拒否した、またはモーダルを閉じた。失敗ではなくキャンセルとして表示してください |
4100 | この接続に 0x アドレスがない — 旧い omni で作られた接続です。再接続してください |
4200 | 未対応のメソッド |
-32602 | 不正なパラメータ(誤ったアドレス、形式の崩れたチェーン id) |
omni が拒否するもの、その理由
署名画面がセキュリティ境界です。だから利用者に説明できないものは拒否します。以下は想定される動作であり、正しい挙動として扱ってください。
- XODE 自身の予約文字列であるメッセージ —
xode-で始まるすべて。これらはウォレットを XODE 自身に認証させる文字列であり、サイトが要求できてはなりません。 - omni が生のバイト列から確認できないパレット・コール番号のコール。デコーダに尋ねる前に判定します。
- バッチのラッパー。中に何が入っているかを読むにはチェーンのメタデータ全体が必要で、端末はそれを持たないため、信頼で承認せず拒否します。
- omni がバイト列から自ら再構成する 2 つの送金以外の残高送金すべて。「全額送金」の類を排除する方法です。
- bidi オーバーライドやゼロ幅文字を含むテキスト — 警告付きで hex 表示されます。そうした行は書かれている内容と違って読めるためです。
- 長いメッセージは利用者が開くまで。おおよそ 400 文字または 8 行を超えると折りたたまれ承認ボタンが無効になるため、画面の下に隠した要求をタップ一回で署名させられません。ログインメッセージは短く保ってください。
レシート
すべての署名の判断は Sentinel 台帳に記録され XODE にアンカーされます(検証と同じです)。拒否にはウォレットの署名が載るため、利用者が拒否したというレシートは実際に利用者を意味します。署名なしに閉じられたリクエストはキャンセルとして記録されます。
上限
| 接続 | 30 日。利用者は omni からいつでも失効できます |
| 署名リクエスト | 応答は 5 分以内、接続ごとに 5 分で 30 件まで |
| メッセージサイズ | 通常のメッセージ 1 KB、EIP-191 メッセージ 8 KB |
トークンの検証
検証は必ずサーバーで行ってください。ブラウザが伝える状態は信用しないでください。
クレーム
{
"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
}| クレーム | ||
|---|---|---|
iss | 常に https://xodian.xode.net | |
aud | あなたのクライアントID | |
sub | あなたのアプリ向けのこの人のID | |
exp | 発行から10分。すぐに取得して検証してください | |
jti | セッションID。保存してトークンを1回限りにしてください | |
nonce | あなたの reference | |
level、level_name | 1 デバイス · 2 定着 | |
uniqueness | new / new_device / returning / contested | new · returning · contested |
receipt | オンチェーンにアンカーされた判定記録のハンドル(本番キー) | |
wallet | wallet スコープ+本人の同意がある場合のみ | |
test | サンドボックスのトークンは true、本番では付かない |
チェックリスト
- 署名が ES256 で、JWKS で検証できる
issがhttps://xodian.xode.net、audがあなたのクライアントIDexpを過ぎていないnonceがセッション作成時のreferenceと一致jtiが未使用- 本番では
testが付いていない levelがその操作に必要な水準以上
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 personWebhook
ポーリングの代わりに、本番キーではセッションが最終状態になったときに POST で通知を受け取れます。Webhook は本番キーの発行時に設定します(セルフサービスのテストキーにはありません)。
| イベント |
|---|
session.approved(idToken を含む)· session.rejected · session.denied |
grant.revoked:ユーザーが omni であなたのアプリを解除した(sub を含む)。その人のデータはあなたのポリシーに従って扱ってください。 |
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"}署名の検証
ヘッダーは t=<UNIX秒>,v1=<hex> で、v1 = HMAC-SHA256(Webhookシークレット, t + "." + 生の本文) です。リプレイを防ぐため、古いタイムスタンプは拒否してください。
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;
}配信
8秒以内に 2xx を返してください。そうでなければ間隔を広げながら合計4回まで試行します。同じ通知が重複することがあるので sessionId で冪等に処理し、idToken は通常どおり検証してください。
サンドボックス
テストキー(rpt_…)は無料で、自分で作成できます。本番と同じですが、次の点が異なります:
- セッションへの応答はウィジェットのシミュレーションパネルか、APIのシミュレーターで行う
- トークンに
"test": trueが付き、subはoid_test_で始まる - Webhook とオンチェーンレシートはない
- キーごとに1日500セッション、ネットワークごとに1日5個まで新規キー
シミュレーター
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)同じ persona は常に同じ sub になるので、「1人1回」や再訪ユーザーをテストできます。セッションの minLevel より低いレベルで承認すると、本番と同じく不合格になります。
不合格の理由
不合格のセッションには reason が付きます。ユーザーには中立的なメッセージを表示してください。詳細は omni が本人にだけ説明します。
| reason | 意味 | ユーザーにできること |
|---|---|---|
insufficient_level | 確認はできたが minLevel 未満 | この端末で omni を使い続ける。レベル2は時間とともに到達 |
device_unverified | 端末またはアプリが Play Integrity / App Attest に不合格 | 改造していない端末とストア版の omni を使う |
integrity_stale | 端末の確認が古い | omni を開いて再確認してから再試行 |
no_device | このウォレットに紐づくハードウェアキーがない | omni を開いて端末設定を完了する |
device_mismatch、device_proof_failed | 応答した端末がウォレットの紐づく端末ではない | ウォレットのある端末から応答する |
user_denied | ユーザーが拒否(状態 denied) | 特になし。再試行可能 |
未知の理由は一般的な不合格として扱ってください。新しいコードが追加されることがあります。
オンチェーンレシート
本番のすべての判定(承認・拒否)は Sentinel 台帳に記録され、1時間ごとに XODE チェーンにアンカーされます。トークンの receipt がその記録のハンドルです。あなたや監査人は後から、Xodian がその時点で実際にその判定をし、その後誰も変更していないことを証明できます。
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台帳は公開されているため、個人情報は含みません。対象はハッシュ値で、sub やウォレットではありません。
セキュリティチェックリスト
- クライアントシークレットはサーバーだけに置く。Webページ、アプリのバンドル、リポジトリに入れない
- すべてのトークンを自分で検証する:署名、
iss、aud、exp、nonce、1回限りのjti - 1人1回の特典はウォレットやメールではなく
subで判定する - リリース前に
contestedの扱いを決める(受け入れ・確認・拒否) - お金や投票が関わる場面ではレベル2を使う
- 本番では
testトークンを拒否する - 必要なものだけを保存する。多くの場合
subと日付で十分
上限とキー
テストキー rpt_ | 本番キー rp_ | |
|---|---|---|
| 取得方法 | セルフサービス | お問い合わせ。XODE上で開発するパートナー向けアーリーアクセス |
| 1日のセッション数 | 500 | パートナーごとに合意(既定 10,000) |
| レベル | 1–2 | 合意したレベルまで |
| wallet スコープ | 可 | 有効化した場合 |
| Webhook、レシート、認証パートナーバッジ | — | 可 |
セッションの有効期間は5分、トークンは10分です。本番キーは依頼に応じて更新できます。新しいキーが発行された瞬間に古いキーは無効になるので、すぐに反映してください。
利用者の方へ
ユーザーにこのセクションへのリンクを送っても構いません。ユーザーが見る流れ:
omni をインストール
Google Play と App Store で無料です。omni はスマホでライトノードを動かす XODE のアプリです。
読み取るかタップ
パソコンでは omni アプリでQRを読み取ります。スマホでは「omniで続ける」をタップします。
共有内容を確認して承認
omni はどのアプリが求めているか、何を受け取るかを正確に表示します。承認するとウォレットキーと端末のハードウェアキーで署名します。
完了
アプリに伝わるのは、実在の人物であることと、そのアプリだけが見られる非公開IDだけ。名前・番号・残高・利用履歴は決して共有されません。
連携アプリの管理
omni の設定 → Xodianで、確認したアプリの一覧と現在のレベルを見たり、アプリを解除したりできます。解除するとアプリに通知されますが、アプリが既に持っているデータの削除はそのアプリ次第です。必要なら直接依頼してください。
「不合格になりました」
omni が理由と解決方法を表示します。多くの場合、改造していない端末でストア版の omni を使う、omni を開いて端末を再確認する、レベル2を求めるアプリなら同じ端末で2週間ほど omni を使い続ける、で解決します。
よくある質問
KYC ですか?
いいえ。Xodian は正規の端末を使う実在の人物ひとりであることだけを証明し、誰かは知りません。多くのチームが KYC の前段に置き、通過した人にだけ本格確認の費用をかけています。
ひとりで複数のIDを得られますか?
正規の端末1台につき1つです。レベル2は端末ごとに数週間使い続ける必要があるため、コストが大きくなります。Xodian がなくすのは安価な手口、つまり1台の端末での多数のウォレット・エミュレーター・改造アプリです。
端末を替えたらどうなりますか?
ウォレットと一緒に、あなたのアプリでの sub も引き継がれます。既に別の人が使った端末にウォレットが移ると contested になります。
iPhone と Android の両方に対応していますか?
はい。Android は Google Play Integrity、iOS は Apple App Attest を使います。トークンにはどちらのプラットフォームかは含まれません。
バックエンドなしで使えますか?
いいえ。トークンはシークレットを持つサーバーにだけ渡されます。だからこそ結果を信頼できます。
app.xode.net/id で組み込みました。変更が必要ですか?
従来のパスも引き続き動作します。新しい組み込みは https://xodian.xode.net を使い、トークン検証では iss = https://xodian.xode.net を想定してください。
変更履歴
- 2026-10-06
xodian.xode.netに移転し、短いパス(/v1/…、/sdk.js、/s/<id>)を導入。発行者はhttps://xodian.xode.netに。QR案内ページを4言語に対応。 - 2026-10-05 v1:セッション、レベル1–2、アプリ別ID、一意性、ウィジェット、サンドボックスとシミュレーター、Webhook、オンチェーンレシート。
ご質問・本番キーのご相談は
最終更新 2026-10-07 · API v1