Xodian문서 홈
Sandbox →

Xodian · API v1

Xodian 문서

Xodian 전체 매뉴얼: 빠른 시작, 핵심 개념, API 레퍼런스, 위젯, 토큰 검증, 웹훅, 샌드박스, 사용자 안내.

개요

Xodian은 사용자가 정품 휴대폰을 쓰는 실제 사람 한 명인지 앱이 확인할 수 있게 해 줘요. 그 사람이 누구인지는 알지 못한 채로요. 셀카도, 신분증 업로드도, 개인정보도 필요 없어요.

확인은 사용자가 omni 앱에서 해요. 여러분의 서버는 서명된 id_token(ES256 JWT)을 받고, 여기에는 다음이 담겨요:

  • human: 변조되지 않은 정품 휴대폰을 쓰는 실제 사람이에요
  • sub: 여러분의 앱만 볼 수 있는 이 사람의 비공개 ID. 방문할 때마다 같아요
  • level: 증거의 강도 (1 = 기기, 2 = 정착)
  • uniqueness: 이 사람이 여러분에게 처음(new)인지, 재방문(returning)인지, 충돌(contested)인지

받는 것

앱마다 실제 사람 한 명당 안정적인 ID 하나. 한 폰의 지갑 50개는 하나의 ID로 모이고, 폰을 바꿔도 그 지갑이 가졌던 ID가 유지됩니다. 재설치 와 새 지갑이 함께인 경우만 되짚을 수 없고, 그때는 new_device라고 표시해 보냅니다 — 중요한 곳에서는 레벨 2를 요구하세요.

절대 받지 않는 것

이름, 얼굴, 서류, 전화번호, 기기 모델, 잔액, 활동 내역. 지갑 주소는 요청하고 본인이 동의할 때만 받아요.

잘 맞는 곳

에어드랍·보상 캠페인(1인 1회), 게임 계정과 체험판, 커뮤니티 투표, 그리고 정식 KYC 앞단의 저렴한 봇 필터.

맞지 않는 곳

Xodian은 신원 확인이 아니에요. 이름·나이·국가를 알려주지 않고, 그 자체로 KYC/AML 의무를 충족하지 않아요.

빠른 시작 (10분)

비밀값을 보관할 서버와, 버튼을 보여줄 웹페이지나 앱이 필요해요. 샌드박스는 무료이고 가입도 필요 없어요.

  1. 테스트 키 받기

    홈페이지의 샌드박스 섹션이나 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}
  2. 서버에서 세션 열기

    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
    }

    이 객체를 통째로 브라우저에 넘기세요. 비밀값은 들어 있지 않아요.

  3. 버튼 보여주기

    위젯이 데스크톱에서는 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>
  4. 샌드박스에서 응답하기

    테스트 세션에는 위젯 안에 시뮬레이션 패널(승인·거절·불합격·만료)이 나와요. 시뮬레이터 API를 써도 돼요.

  5. 서버에서 토큰 받아 검증하기

    비밀키로 세션을 조회한 뒤 idToken을 JWKS로 검증하세요. 토큰 검증을 참고하세요.

라이브 전환은 코드는 그대로 두고 라이브 키(rpt_… 대신 rp_…)만 바꾸면 돼요. 운영 환경에서는 "test": true가 붙은 토큰을 거부하세요.

핵심 개념

세션

한 번의 인증 시도예요. 여러분의 서버가 만들고, 사용자는 5분 안에(expiresAt) omni에서 응답해요. 모든 세션은 정확히 하나의 최종 상태로 끝나요:

status의미할 일
pending사용자 응답 대기 중QR이나 버튼을 계속 보여주기
approvedminLevel 이상으로 인증됨서버에서 토큰 받기
rejected시도했지만 조건 미달. reason 참고중립적인 메시지 표시. 자격 갖추는 방법은 omni가 본인에게만 안내
denied사용자가 거절함나중에 다시 시도할 수 있게 하기
expired시간 안에 아무도 응답하지 않음새 세션 만들기

보증 수준

level조건용도
1 · device(기기)지갑이 하드웨어 키(StrongBox, TEE, Secure Enclave)에 묶여 있고, Google Play Integrity 또는 Apple App Attest를 통과했으며 최근 재확인됨. 에뮬레이터·루팅 기기·변조 앱은 거부.가입, 체험, 봇 필터
2 · established(정착)레벨 1 + 계정 14일 이상,7일간의 증명된 활동(지갑 서명만이 아니라 하드웨어 키 서명을 담은 하트비트가 도착한 날), 최근 30일 기기 변경 없음.에어드랍, 지급, 투표

minLevel은 세션마다 정해요. 그보다 낮으면 rejected만 받고, 레벨이나 다른 신호는 받지 않아요.

한 사람 한 계정은 어느 레벨인가요?

레벨 1은 "지금 진짜 폰"에 답합니다. 봇 차단·체험·가입에 맞는 기준입니다.

유일성에 비용을 지불하신다면 레벨 2를 요구하세요 — 에어드롭·지급·투표. 안드로이드는 앱 데이터와 함께 하드웨어 키가 삭제되므로, 재설치 후 새 지갑은 처음 보는 폰으로 나타납니다. 숨기지 않고 new_device로 알려드립니다. 레벨 2가 이를 막습니다: 한 폰에서 둘째 지갑의 증명된 활동일을 만들려면 기기 변경이 발생하고, 30일 내 기기 변경이 있으면 레벨 2를 통과하지 못합니다. iOS는 키체인에 키가 남아 returning 또는 contested로 돌아옵니다.

앱별 고유 ID (sub)

같은 사람이라도 앱마다 다른 sub를 받아서, 파트너끼리 데이터를 합쳐 사람을 추적할 수 없어요. 여러분 앱 안에서는 고정이에요. 같은 사람은 매번 같은 sub를 받고, 지갑을 새 폰으로 옮겨도 그대로예요. 한 폰의 여러 지갑은 같은 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

필드타입기본값
scopestring[]["human"]human, 필요하면 wallet
minLevel1 | 21허용할 최저 레벨(키의 최대 레벨까지)
referencestring""여러분의 상관 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일일 인증 한도 도달, 또는 오늘 테스트 키를 너무 많이 만듦

오류는 JSON {"detail": "…"} 형식이고, detail은 메시지이거나 code·message를 가진 객체예요.

웹 위젯 (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() 실패.
labelbutton 전용: 버튼 문구.
text모달 문구를 직접 지정하거나 번역(아래 참고).
autoCloseMs성공 화면 유지 시간(기본 1800).
persona샌드박스 전용: 시뮬레이션할 사람 이름.
baseUrlAPI 주소 지정(보통 스크립트 주소에서 자동으로 정해짐).

문구와 언어

모달은 기본이 영어예요. 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.
QR을 찍고 들어오는 페이지(/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가 지우고 다시 연결합니다.

탐색 경로는 둘입니다. 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_sign0x 계정의 EIP-191 서명. 폰에 omni 1.2.1 이상이 필요합니다
그 외4200 미지원 메서드 — 아직 제공하지 않는 eth_sendTransaction도 포함입니다

다뤄야 할 에러

코드의미
4001사용자가 거절했거나 창을 닫았습니다. 실패가 아니라 취소로 보여주세요
4100이 연결에 0x 주소가 없습니다 — 구버전 omni로 만든 연결입니다. 다시 연결하세요
4200지원하지 않는 메서드
-32602잘못된 파라미터(틀린 주소, 형식이 어긋난 체인 id)

omni가 거절하는 것, 그리고 이유

서명 화면이 보안 경계입니다. 그래서 사용자에게 설명할 수 없는 것은 거절합니다. 아래는 예상되는 동작이고, 정상으로 취급해주세요.

  • XODE 자체 예약 문자열인 메시지 — xode-로 시작하는 모든 것. 이들은 지갑을 XODE 자신에게 인증하는 문자열이라, 사이트가 요청할 수 있어서는 안 됩니다.
  • omni가 원시 바이트에서 확인할 수 없는 팔레트·호출 인덱스의 호출. 디코더에 묻기 전에 판정합니다.
  • 배치 래퍼. 안에 무엇이 들었는지 읽으려면 전체 체인 메타데이터가 필요하고 폰은 그것을 들고 있지 않으므로, 신뢰로 승인하지 않고 거절합니다.
  • omni가 바이트에서 직접 재구성하는 두 전송이 아닌 잔액 전송은 모두. "전액 보내기" 류를 막는 방식입니다.
  • 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_name1 기기 · 2 정착
uniquenessnew / new_device / returning / contestednew · returning · contested
receipt온체인에 앵커된 판정 기록의 핸들(라이브 키)
walletwallet 스코프 + 본인 동의가 있을 때만
test샌드박스 토큰이면 true, 라이브 토큰에는 없음

체크리스트

  1. 서명이 ES256이고 JWKS로 검증됨
  2. iss가 https://xodian.xode.net, aud가 여러분의 클라이언트 ID
  3. exp가 아직 지나지 않음
  4. nonce가 세션을 만들 때 보낸 reference와 같음
  5. jti가 처음 쓰는 값
  6. 운영 환경에서는 test가 없음
  7. 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" };
}

웹훅

폴링 대신, 라이브 키는 세션이 최종 상태가 되면 POST로 알림을 받을 수 있어요. 웹훅은 라이브 키 발급 시 함께 설정해요(셀프 테스트 키에는 없음).

이벤트
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=<유닉스 초>,v1=<hex>이고, v1 = HMAC-SHA256(웹훅 비밀키, 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_로 시작해요
  • 웹훅과 온체인 영수증이 없어요
  • 키당 하루 500세션, 네트워크당 하루 새 키 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 원장에 기록되고, 매시간 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나 지갑 주소가 아니에요.

보안 체크리스트

  • 클라이언트 비밀키는 서버에만 두세요. 웹페이지, 앱 번들, 저장소에 넣지 마세요
  • 모든 토큰을 직접 검증하세요: 서명, iss, aud, exp, nonce, 1회용 jti
  • 1인 1회 혜택은 지갑이나 이메일이 아니라 sub 기준으로
  • 출시 전에 contested를 어떻게 처리할지 정하세요(수락·검토·거부)
  • 돈이나 투표가 걸린 곳은 레벨 2
  • 운영 환경에서는 test 토큰 거부
  • 필요한 것만 저장하세요. 대부분 sub와 날짜면 충분해요

한도와 키

테스트 키 rpt_라이브 키 rp_
받는 방법직접 발급문의. XODE 기반 파트너 얼리 액세스
하루 세션 수500파트너별 협의(기본 10,000)
레벨1–2협의한 레벨까지
wallet 스코프가능활성화 시
웹훅, 영수증, 인증 파트너 배지—가능

세션은 5분, 토큰은 10분 유효해요. 라이브 키는 요청하면 교체할 수 있어요. 새 키가 발급되는 즉시 이전 키는 막히니 바로 적용하세요.

사용자 안내

사용자에게 이 섹션 링크를 보내도 돼요. 사용자가 보게 되는 흐름:

  1. omni 설치

    Google Play와 App Store에서 무료예요. omni는 휴대폰에서 라이트노드를 돌리는 XODE 앱이에요.

  2. 스캔하거나 탭하기

    컴퓨터에서는 omni 앱으로 QR을 찍어요. 휴대폰에서는 "omni에서 계속하기"를 눌러요.

  3. 공유 내용 확인 후 승인

    omni가 어떤 앱이 요청했는지, 정확히 무엇을 받는지 보여줘요. 승인하면 지갑 키와 휴대폰 하드웨어 키로 서명해요.

  4. 완료

    앱은 실제 사람이라는 사실과, 그 앱만 볼 수 있는 비공개 ID만 알게 돼요. 이름·번호·잔액·활동 내역은 절대 공유되지 않아요.

연결한 앱 관리

omni의 설정 → Xodian에서 인증한 앱 목록과 현재 레벨을 보고, 앱을 해제할 수 있어요. 해제하면 앱에 알림이 가지만, 앱이 이미 가진 데이터를 지우는 건 그 앱의 몫이니 필요하면 직접 요청하세요.

"불합격이 나왔어요"

omni가 이유와 해결 방법을 알려줘요. 보통은 변조되지 않은 폰에서 스토어 버전 omni를 쓰거나, omni를 열어 기기를 재확인하거나, 레벨 2를 요구하는 앱이라면 같은 폰에서 2주 정도 omni를 계속 쓰면 돼요.

자주 묻는 질문

KYC인가요?

아니에요. Xodian은 정품 폰을 쓰는 실제 사람 한 명이라는 것만 증명하고, 누구인지는 알지 못해요. 많은 팀이 KYC 앞단에 두어, 통과한 사람에게만 정식 확인 비용을 써요.

한 사람이 여러 ID를 받을 수 있나요?

정품 폰 한 대당 하나예요. 레벨 2는 폰마다 몇 주씩 계속 써야 해서 비용이 커져요. Xodian이 없애는 건 값싼 방식, 즉 한 기기에 여러 지갑·에뮬레이터·변조 앱이에요.

폰을 바꾸면 어떻게 되나요?

지갑이 옮겨가면서 여러분 앱에서의 sub도 그대로 유지돼요. 이미 다른 사람이 쓴 폰으로 지갑이 옮겨오면 contested로 와요.

아이폰과 안드로이드 모두 되나요?

네. 안드로이드는 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, 고유성, 위젯, 샌드박스·시뮬레이터, 웹훅, 온체인 영수증.

궁금한 점이나 라이브 키가 필요하면

최종 수정 2026-10-07 · API v1
텔레그램으로 문의하기