XityDevelopers

API reference

A plain HTTP JSON API with Ed25519 challenge-response authentication Use the HTTP API or the JavaScript client Wallet signatures authorize chain changes, and Xity Connect handles application consent

Account IDs and asset workflows

Resolve account names and short Xity IDs before signing canonical wallet transactions, creator access needs identity verification and operator approval before the first launch, each asset still requires a wallet signature

Quickstart

Five minutes to a working session

Everything happens client-side except the HTTP calls Generate an Ed25519 keypair, register the public key, sign a challenge, and call the API with the session token

Generate an Ed25519 keypair
The private key is sealed client-side and never sent unsealed The address is derived from the public key: xity1 + sha256d(public_key)[:20] hex
Register the public key
POST /auth/signup with the PEM This creates the wallet, the identity record, and a WALLET_BIND transaction on the chain
Request a challenge and sign it
POST /auth/challenge?address=xity1…, then sign the canonical JSON {"address":…,"challenge":…} (exact byte rules below)
Open a session
POST /auth/signin returns a bearer token Attach Authorization: Bearer <token> to every authenticated call
Call the API
Read /identity, store values in the vault, request verifications You are a first-class identity on the chain
Node.js (18+) , complete working example
import { webcrypto } from 'node:crypto'; const ed = await import('@noble/ed25519'); // npm i @noble/ed25519 const crypto = webcrypto; const BASE = 'https://api.xityconnect.org'; const sha256 = (b) => crypto.subtle.digest('SHA-256', b); const hex = (b) => Buffer.from(b).toString('hex'); // 1 Keypair (client-generated , the private key is never sent unsealed) const priv = ed.utils.randomSecretKey(); const pub = await ed.getPublicKeyAsync(priv); const digest = await sha256(await sha256(pub)); const address = 'xity1' + hex(digest.slice(0, 20)); // 2 Register (public key as SPKI PEM) const derPrefix = Uint8Array.of(0x30,0x2a,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x03,0x21,0x00); const der = Uint8Array.of(...derPrefix, ...pub); const pem = '-----BEGIN PUBLIC KEY-----\n' + Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n') + '\n-----END PUBLIC KEY-----'; await fetch(BASE + '/auth/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wallet_method: 'register', public_key_pem: pem }) }); // 3 Challenge + canonical-JSON signature const ch = await (await fetch(BASE + '/auth/challenge?address=' + address, { method: 'POST' })).json(); const payload = JSON.stringify({ address, challenge: ch.challenge }); // sorted keys const sig = hex(await ed.signAsync(new TextEncoder().encode(payload), priv)); // 4 Sign in const si = await (await fetch(BASE + '/auth/signin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address, challenge: ch.challenge, signature: sig, device_info: {} }) })).json(); // 5 Authenticated call const ident = await fetch(BASE + '/identity', { headers: { Authorization: 'Bearer ' + si.session_token } }).then(r => r.json()); console.log(ident.identity);
Python , the same flow
import json, hashlib, urllib.request from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization BASE = 'https://api.xityconnect.org' def post(path, body=None, token=None): req = urllib.request.Request(BASE + path, method='POST') req.add_header('Content-Type', 'application/json') if token: req.add_header('Authorization', 'Bearer ' + token) data = json.dumps(body).encode() if body is not None else None with urllib.request.urlopen(req, data) as r: return json.loads(r.read()) # 1 Keypair + address sk = Ed25519PrivateKey.generate() pub = sk.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw) pem = sk.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode() address = 'xity1' + hashlib.sha256(hashlib.sha256(pub).digest()).hexdigest()[:40] # 2 Register post('/auth/signup', {'wallet_method': 'register', 'public_key_pem': pem}) # 3 Challenge + canonical-JSON signature ch = post('/auth/challenge?address=' + address) payload = json.dumps({'address': address, 'challenge': ch['challenge']}, sort_keys=True, separators=(',', ':')) sig = sk.sign(payload.encode()).hex() # 4 Sign in si = post('/auth/signin', {'address': address, 'challenge': ch['challenge'], 'signature': sig, 'device_info': {}}) # 5 Authenticated call req = urllib.request.Request(BASE + '/identity', headers={'Authorization': 'Bearer ' + si['session_token']}) print(json.load(urllib.request.urlopen(req)))
Authentication

How authentication works

There are no passwords A client holds an Ed25519 private key and proves possession by signing a server-issued challenge Sessions are bearer tokens; every privileged operation is proven by a signature

  • Challenges are single-use and expire in 300 seconds
  • Signing in from a suspicious context raises a risk score; CRITICAL blocks the attempt, HIGH notifies the owner
  • /auth/signout-all revokes every session for the caller
  • Recovery-critical operations require an additional step-up signature: a fresh signature over { address, purpose, timestamp }, valid ±300 seconds and single-use
  • Server-side key generation is refused permanently (410) Keys are born on the client
Signing

Canonical JSON , byte-exact rules

Signatures are over canonical JSON, byte-identical to Python's json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=True)Get one byte wrong and the signature fails

  • Keys sorted alphabetically, recursively
  • No whitespace: {&quot;a&quot;:1,&quot;b&quot;:2} , no spaces after : or ,
  • Non-ASCII escaped as \uXXXX; astral-plane characters as surrogate pairs
  • Integers without a decimal point; null for None; NaN/Infinity are rejected
Auth signature payload (the exact bytes)
{"address":"xity1…","challenge":"…"}
Conventions

Conventions

  • Consent page (send users here): https://xityconnect.com/authorize
  • API base (token exchange, userinfo, and the rest of this reference): production https://api.xityconnect.org; development http://localhost:8086
  • All bodies and responses are JSON (Content-Type: application/json)
  • Errors are FastAPI-shaped: { "detail": "human readable reason" } with a 4xx/5xx status Unexpected server errors return an opaque reference , quote it when contacting the operator
  • Browser origins must be allowlisted server-side (XITY_CORS_ORIGINS); the API never allows * with credentials
  • Addresses are xity1 + 40 hex chars, derived as sha256d(public_key)[:20]
  • Durable state is stored server-side; your key is sealed in the browser, and the platform only holds the PIN-sealed backup
Reference

Endpoint reference

Authentication

POST/auth/signup

Register an account from a client-generated public key Creates the wallet, the identity record, and a WALLET_BIND transaction

Request body
{ "wallet_method": "register", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----" }
Response
{ "address": "xity1…", "public_key": "<pem>", "identity_id": "…" }
POST/auth/challenge?address=xity1…

Issue a one-time auth challenge

Response
{ "address": "xity1…", "challenge": "<random>", "expires_in": 300 }
POST/auth/signin

Verify the challenge signature and open a session

Request body
{ "address": "xity1…", "challenge": "<the challenge>", "signature": "<hex>", "device_info": {} }
Response
{ "authenticated": true, "session_token": "…", "expires_at": "…", "risk_level": "LOW" }
POST/auth/signout-all

Revoke every session for the caller

Auth: Bearer

Response
{ "signed_out": true, "sessions_revoked": 2 }

Identity & wallet

GET/identity

The caller's identity record (private evidence excluded)

Auth: Bearer

Response
{ "identity": { "identity_id": "…", "status": "ACTIVE", "verifications": { "email": 1730000000 } } }
GET/wallet

The caller's wallet: type, balance, bound identity

Auth: Bearer

Response
{ "address": "xity1…", "type": "ed25519", "balance": 0, "bound_identity": "…" }
GET/wallet/transactions?limit=50

The caller's transaction history (PII-scrubbed)

Auth: Bearer

Response
{ "transactions": [ { "tx_id": "…", "tx_type": "…", "timestamp": 0 } ] }
POST/verify/request

Issue a verification code for email/phone/device/wallet The code is relayed by the operator; it is NEVER returned by the API

Auth: Bearer

Request body
{ "method": "email", "verification_data": { "email": "you@example.com" } }
Response
{ "method": "email", "message": "Verification code issued" }
POST/verify/confirm?method=email&code=…

Confirm a code On success the method is timestamped on chain Codes expire in 10 minutes and work once

Auth: Bearer

Response
{ "verified": true, "method": "email" }

Vault

POST/vault/store

Store a value in your vault Sealed with a SHA-256 integrity hash, verified on every read Values are JSON objects up to 256 KiB; up to 64 keys per identity

Auth: Bearer

Request body
{ "key": "residency", "value": { "country": "SG" }, "encryption_key_hash": null }
Response
{ "stored": true, "key": "residency", "integrity_hash": "<sha256>" }
GET/vault/{key}

Read a vault value Owner-only; tampered values are refused Reserved keys (ceremony records) are server-written only

Auth: Bearer

Response
{ "key": "residency", "value": { "country": "SG" } }

Recovery

POST/recovery/start

Initiate recovery Starts the 24-hour clock and notifies the wallet owner Proofless by design , proofs come at completion

Request body
{ "address": "xity1…", "method": "BACKUP_CODE", "recovery_data": {} }
Response
{ "request_id": "<32-byte hex>", "status": "PENDING", "waiting_period_hours": 24 }
GET/recovery/status/{request_id}

Status of a request The linked address is never disclosed

Response
{ "request_id": "…", "method": "BACKUP_CODE", "status": "PENDING", "waiting_until": 1730000000 }
GET/recovery/pending

The caller's pending recovery requests

Auth: Bearer

Response
{ "pending": [ { "request_id": "…", "method": "GUARDIAN", "waiting_until": 0 } ] }
POST/recovery/cancel

Cancel a pending request Owner-only , requires the owner's session

Auth: Bearer

Request body
{ "request_id": "…" }
Response
{ "status": "CANCELLED" }
POST/recovery/enroll/guardians?guardians=xity1a&guardians=xity1b

Replace the enrolled guardian set Each guardian must be a registered wallet; minimum two

Auth: Bearer

Response
{ "enrolled": 2, "guardians": [ { "address": "xity1…", "enrolled_at": 0 } ] }
GET/recovery/guardians

List the caller's enrolled guardians

Auth: Bearer

Response
{ "guardians": [ … ] }
POST/recovery/backup-code

Provision (or replace) the caller's single-use backup code Returned exactly once

Auth: Bearer

Response
{ "provisioned": true, "backup_code": "<16 hex>", "note": "Store this code securely…" }
POST/recovery/device-token

Provision (or replace) a trusted-device recovery token Returned exactly once

Auth: Bearer

Response
{ "provisioned": true, "device_token": "<32 hex>", "note": "…" }
POST/recovery/complete

Complete a recovery after the waiting period with a per-method proof, rotating the wallet key

Request body
{ "request_id": "…", "proof_data": { "backup_code": "…" }, "new_public_key": "-----BEGIN PUBLIC KEY-----…" }
Response
{ "status": "COMPLETED" }

Profile, permissions & notifications

GET/profile

The caller's presentation profile

Auth: Bearer

Response
{ "address": "xity1…", "display_name": "" }
PUT/profile

Update display name / preferences / privacy settings

Auth: Bearer

Request body
{ "display_name": "Alex Tan" }
Response
{ "updated": true }
GET/permissions

List the caller's grants in both directions: made by them and held by them

Auth: Bearer

Response
{ "granted": [ { "id": "…", "scope": "identity:read", "level": "read", "status": "ACTIVE" } ], "received": [ … ] }
POST/permissions/grant

Delegate scoped access Scopes/levels are a closed allowlist; admin and wildcards are rejected

Auth: Bearer

Request body
{ "grantee_address": "xity1…", "scope": "identity:read", "level": "read", "expires_in_hours": 24 }
Response
{ "permission_id": "…" }
GET/notifications?unread_only=false

The caller's notifications

Auth: Bearer

Response
{ "notifications": [ { "id": "…", "event_type": "RECOVERY_INITIATED", "severity": "critical" } ] }

Chain

GET/chain/info

Chain the platform anchors to

Response
{ "height": 0, "chain_id": "xity-platform-1", "total_supply": 0, "validator_count": 1 }
GET/blocks/{height}

A sealed block: full header plus transaction ids/types , no payloads, no signatures Everything a verifying node needs

Response
{ "header": { … }, "transactions": [ { "tx_id": "…", "tx_type": "…" } ] }
GET/chain/spec

The public chain-spec pin: everything a new node needs to join and independently verify the ledger

Response
{ "chain_id": "…", "genesis_hash": "…", "validators": [ … ] }

Operator (admin key)

GET/operator/biometric/status

Whether the server-side biometric verifier is loaded, with the pinned model checksums

Auth: X-Xity-Admin-Key

Response
{ "available": true, "pipeline": "biometric-verifier/1.1", "model_checksums": { … } }
POST/operator/biometric/verify?session_id=…

Run the server-side face match + liveness over a completed ceremony's hash-bound evidence Re-verifies every frame hash, attaches the report, and grants the biometric level on-chain when the decision is VERIFIED Fails closed: 503 when the pipeline is unavailable, REVIEW/REJECT on any anomaly

Auth: X-Xity-Admin-Key

Response
{ "decision": "VERIFIED", "reasons": [], "report": { "match": { "median_cosine": 0.97 }, "pad": { "mean_real": 0.91 } }, "biometric_level_granted": true }
Integrate

Sign in with XityConnect

Let users sign in to your app with their Xity identity , the same authorization-code + PKCE flow as Google, in three steps Create the app yourself in the developer console: you get a client_id, choose public (PKCE) or confidential (secret), and register your redirect URIs No node to run, no admin key

Create an app →

Redirect to the consent screen
Generate a random code_verifier, send SHA256(verifier) (base64url) as the code_challenge, with state for CSRF protection
Receive the authorization code
Your redirect URI receives ?code=…&state=… Verify the state matches, exchange within 60 seconds
Exchange for a scoped token
POST /oauth/token with the code + verifier The token lasts 24 hours and sees only the approved scope
TypeScript , the complete sign-in
// PKCE helper , base64url, no padding (RFC 7636) Send the challenge // exactly as generated const b64url = (b: Uint8Array) => btoa(String.fromCharCode(...b)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // 1 Redirect const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = b64url(new Uint8Array(await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(verifier)))); const authUrl = 'https://xityconnect.com/authorize' + '?client_id=YOUR_CLIENT_ID' + '&redirect_uri=' + encodeURIComponent('https://yourapp.com/callback') + '&scope=identity:read' + '&state=' + yourCsrfState + '&code_challenge=' + challenge; location.href = authUrl; // 2 On /callback, your SERVER exchanges the code (the API does not // allow arbitrary browser origins; keep the verifier server-side too) const token = await fetch('https://api.xityconnect.org/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', code: new URLSearchParams(location.search).get('code'), client_id: 'YOUR_CLIENT_ID', redirect_uri: 'https://yourapp.com/callback', code_verifier: verifier }) }).then(r => r.json()); // 3 Read the identity (scope: identity:read) const me = await fetch('https://api.xityconnect.org/oauth/userinfo', { headers: { Authorization: 'Bearer ' + token.access_token } }).then(r => r.json()); // Read the permitted identity fields from me

Request only the scopes your application needs and explain each request before consent

  • identity:read reads the approved identity fields
  • records:read reads application records the wallet can access
  • records:write prepares record changes that still require a wallet signature
  • spaces:write prepares signed Space NFT operations
  • coins:transfer prepares coin transfers that still require a wallet signature

Tokens expire after 24 hours and users can revoke access from Security

Advanced

The verification ceremony

Enrolling an identity card runs a four-message protocol that binds real capture bytes to wallet-signed manifests The server never sees a private key; the operator verifier runs on hardware the applicant never controls

POST /verify/ceremony/start
Opens a session with a nonce and a 10-minute TTL
POST /verify/ceremony/{id}/capture
One signed manifest per pose (centre, left, right) plus the document photograph Each manifest carries a SHA-256 per frame and a chained hash head; the server recomputes the chain and checks the sequence equals the pose index
POST /verify/ceremony/{id}/evidence
The real frame bytes The server recomputes every frame's SHA-256 and requires an exact match with the signed hashes , a subset never yields a complete record
POST /verify/ceremony/{id}/decide
Hard-gate decision engine Possible verdicts: EVIDENCE_ACCEPTED, RETRY_CAPTURE, REVIEW_REQUIRED, SECURITY_REJECTED, EXPIRED

The operator then runs POST /operator/biometric/verify(admin key): face detection, quality gates, ArcFace matching of the live poses against the document photo, and a presentation-attack ensemble A frame must contain exactly one readable face , a second person makes that frame unusable rather than silently choosing the largest box , and a verification without presentation-attack scores cannot return VERIFIED A VERIFIED decision grants the on-chain biometric level; anything anomalous returns REVIEW or REJECT , the pipeline can fail downward, never upward

Operations

Errors & rate limits

Rate limits are per client IP and per address, applied on the sensitive endpoints A 429 means back off Everything else is FastAPI-shaped JSON

EndpointLimitWindow
/auth/signup5hour
/auth/challenge205 min
/auth/signin105 min
/verify/request510 min
/recovery/start3hour
/recovery/complete3hour
/verify/ceremony/*3010 min

Success is idempotent where it matters: re-submitting byte-identical signed manifests is accepted silently; any different re-submission is rejected

Glossary

Glossary

Identity
An on-chain record bound to one wallet address
Vault
Storage protected by access controls and integrity checks Encrypt sensitive content on your device before sending it
Verification
A timestamped, on-chain assertion about an identity (email, phone, device, wallet , and the operator-granted biometric level)
Ceremony
The enrollment protocol binding real capture bytes to wallet-signed manifests
Guardian
A registered wallet trusted to co-approve recovery
Step-up signature
A fresh single-use Ed25519 signature over a purpose-bound payload, required for security-critical operations
Evidence
The real frame bytes of a ceremony, provably identical to the signed hashes