Bisotun Docs
On this page

Bisotun developer guide#

How to add "Log in with Bisotun Wallet" (or any request for verified data) to a website. The user scans a QR code or taps a button, Bisotun Wallet shows exactly what your site asks for, and after the user agrees your back end receives the verified data.

Contents: Concepts · Getting access · Architecture · Front end · Back end · Node.js example · Python example · Session requests · Session results · Session pointer and deep links · JSON-LD identifiers · Error codes · Security checklist

1. Concepts#

Term Meaning
Card (credential) Signed set of attributes in the user's wallet, e.g. bisotun.main.email
Attribute One value on a card, identified as <scheme>.<issuer>.<card>.<attribute>, e.g. bisotun.main.email.address
Requestor Your back end. It has a name and a key registered at the session server, and permissions for specific attributes
Session One disclosure (or issuance) exchange between your requestor, the session server and the wallet
Session server https://cert.bisotun.app
Session pointer Small JSON object that tells the wallet where the session is ({"u": ..., "bisotun": ...}), shown as QR code or link
Requestor token Secret handle of the session for your back end (to read the result). Never give it to the browser

Attributes available today:

Attribute Content
bisotun.main.email.address Email address, verified by the Bisotun email issuer
bisotun.main.app.pseudonym Random account ID of the wallet. It is the same towards every website, so only use it when linkability is acceptable

For a login, bisotun.main.email.address is the usual choice: it is verified and the user recognises it.

Current status (2026-09-29): Bisotun's outgoing email is still in test mode, so users cannot obtain the email card yet. Until that changes, the only attribute every wallet has is bisotun.main.app.pseudonym (passwordless login, with the linkability caveat above). A request can accept either one, see the "Email or account ID" example in section 8. Try both at the demo: https://cert.bisotun.app/demo/.

2. Getting access#

Ask the Bisotun operator for a requestor registration. You receive:

Generate an RSA key pair and send only requestor.pk.pem:

openssl genrsa -out requestor.sk.pem 2048
openssl rsa -in requestor.sk.pem -pubout -out requestor.pk.pem

3. Architecture#

 Browser                        Your back end                 Session server            Bisotun Wallet
   |  POST /api/bisotun/start       |                              |                          |
   |------------------------------->|  POST /session (signed JWT)  |                          |
   |                                |----------------------------->|                          |
   |                                |<-----------------------------|                          |
   |                                | {sessionPtr, token, frontendRequest}                    |
   |<-------------------------------|                              |                          |
   | {sessionPtr, frontendRequest,  |                              |                          |
   |  token: <your opaque id>}      |                              |                          |
   |  QR code / "open wallet" button|                              |                          |
   |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - >|<------------------------>|
   |  status updates (/api/session/{token}/frontend/...)           |   user approves          |
   |  GET /api/bisotun/result/<id>  |                              |                          |
   |------------------------------->|  result (poll or callback)   |                          |
   |                                |<-----------------------------|                          |
   |<-------------------------------|  verified attributes         |                          |

The session request is always created and signed on your server. The browser only receives the session pointer and the frontend request, never the requestor token or your key, and it cannot change what is requested.

Two ways for your back end to reach the session server:

Your back end runs Start session Get the result
On the Bisotun server, in the Docker network bisotun-net POST http://bisotun-server:8088/session GET http://bisotun-server:8088/session/{token}/result (or /result-jwt)
Anywhere else POST https://cert.bisotun.app/session (signed JWT, Content-Type: text/plain) Set callbackUrl in the request; the server POSTs the result as a signed JWT to that HTTPS URL when the session ends

Only POST /session with a signed JWT is public on cert.bisotun.app; the result endpoints are internal unless the operator opens them for you.

4. Front end (@bisotun/frontend)#

@bisotun/frontend renders the QR code, the "Open Bisotun Wallet" button on phones and the pairing code, follows the session and hands you the result. It speaks Persian (default), Arabic and English and mirrors its layout for right-to-left languages.

Install#

The package is not on a public registry. Bisotun hosts the built bundle:

https://wallet.bisotun.app/sdk/bisotun.js        UMD build, global `bisotun`, CSS included
https://wallet.bisotun.app/sdk/fonts/*.woff2     "Bisotun Sans" web fonts (loaded relative to the script)

Load it directly (CORS *, cached for 5 minutes), or copy bisotun.js and fonts/ to your own site, for example under /assets/bisotun/, if you want to control updates. ESM / CommonJS builds for bundlers (index.mjs, index.cjs, type definitions) are available from the Bisotun operator.

Embed#

<section id="bisotun-form"></section>
<script src="https://wallet.bisotun.app/sdk/bisotun.js"></script>   <!-- or your own copy -->
<script>
  const login = bisotun.newWeb({
    element: '#bisotun-form',
    language: 'fa',                    // 'fa' (default), 'ar' or 'en'
    session: {
      url: '/api/bisotun',             // YOUR back end, not the session server
      start: {
        url: (o) => `${o.url}/start`,
        method: 'POST',
        credentials: 'same-origin',
      },
      result: {
        url: (o, { sessionToken }) => `${o.url}/result/${encodeURIComponent(sessionToken)}`,
        credentials: 'same-origin',
      },
    },
  });

  login.start()
    .then((result) => {
      // result = JSON returned by YOUR /result endpoint
      window.location.assign('/account');
    })
    .catch((endState) => {
      // 'Aborted', 'Cancelled', 'TimedOut', 'Error' or 'BrowserNotSupported'
      console.warn('Login did not complete:', endState);
    });
</script>

Theming#

Colours are CSS custom properties on the widget root (--bisotun-primary, --bisotun-surface, --bisotun-ink, ...). Add the class bisotun-theme-dark (always dark) or bisotun-theme-auto (follows the system) to the widget root or an ancestor for the dark palette. Main class names: bisotun-form, bisotun-header, bisotun-content, bisotun-qr-code, bisotun-button, bisotun-popup.

5. Back end#

Your back end needs three endpoints (names are free):

  1. POST /api/bisotun/start: build the session request, sign it, POST it to the session server, store the requestor token server-side under a new random id, return { sessionPtr, frontendRequest, token: <random id> }.
  2. GET /api/bisotun/result/<id>: look up the requestor token, fetch (or read the stored callback) result, check it (section 9), create the user's login session, return what the page needs.
  3. Only for external back ends: POST /api/bisotun/callback: receive the result JWT from the session server, verify it, store it under the requestor token.

Signed session request (JWT)#

Header {"alg": "RS256", "typ": "JWT"} (or HS256 with an HMAC key). Claims:

Claim Value
iss your requestor name
iat current Unix time (the server rejects requests older than 300 s; keep your clock in sync)
sub verification_request (disclosure), issue_request (issuance)
sprequest / iprequest { "request": <session request>, "callbackUrl"?: "https://...", "timeout"?: seconds, "validity"?: seconds }

timeout is how long the server waits for the wallet to connect; validity is the lifetime of the signed result JWT. Post the compact JWT as the body with Content-Type: text/plain.

The response of POST /session:

{
  "sessionPtr": { "u": "https://cert.bisotun.app/api/session/Ab12Cd34Ef56Gh78Ij90", "bisotun": "disclosing" },
  "token": "KzxuWK3i4tEkJpUVEJhv",
  "frontendRequest": {
    "authorization": "qGrMmL8UZwZ88Sq8gobV",
    "pairingHint": false,
    "minProtocolVersion": "1.0",
    "maxProtocolVersion": "1.1"
  }
}

token is the requestor token. Keep it on the server.

6. Node.js example#

Node 18+ and Express. External back end with RS256 and result callbacks; no JWT library needed.

// server.mjs — npm install express
import crypto from 'node:crypto';
import fs from 'node:fs';
import express from 'express';

const SESSION_SERVER = 'https://cert.bisotun.app';
const REQUESTOR = 'shop-example';                              // your requestor name
const REQUESTOR_KEY = fs.readFileSync('requestor.sk.pem');       // RSA private key
const SERVER_PUBKEY = fs.readFileSync('bisotun-server.pk.pem');  // result signing key
const PUBLIC_URL = 'https://shop.example';                       // your site

const b64url = (buf) => Buffer.from(buf).toString('base64url');

function signJwt(claims) {
  const input = `${b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))}.${b64url(JSON.stringify(claims))}`;
  const sig = crypto.sign('RSA-SHA256', Buffer.from(input), REQUESTOR_KEY);
  return `${input}.${b64url(sig)}`;
  // HS256 instead: crypto.createHmac('sha256', Buffer.from(hmacKeyBase64, 'base64')).update(input).digest()
}

function verifyServerJwt(jwt) {
  const [h, p, s] = jwt.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'RS256') throw new Error('unexpected alg');
  const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), SERVER_PUBKEY, Buffer.from(s, 'base64url'));
  if (!ok) throw new Error('bad signature');
  const claims = JSON.parse(Buffer.from(p, 'base64url'));
  if (claims.iss !== 'bisotun-server') throw new Error('unexpected issuer');
  if (claims.exp && claims.exp < Date.now() / 1000) throw new Error('expired');
  return claims;
}

const pending = new Map();   // opaque id -> { token, result }
const byToken = new Map();   // requestor token -> opaque id

const app = express();

app.post('/api/bisotun/start', async (req, res) => {
  const jwt = signJwt({
    iss: REQUESTOR,
    iat: Math.floor(Date.now() / 1000),
    sub: 'verification_request',
    sprequest: {
      callbackUrl: `${PUBLIC_URL}/api/bisotun/callback`,
      request: {
        '@context': 'https://wallet.bisotun.app/ld/request/disclosure/v2',
        disclose: [[['bisotun.main.email.address']]],
      },
    },
  });
  const r = await fetch(`${SESSION_SERVER}/session`, {
    method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: jwt,
  });
  if (!r.ok) return res.status(502).json({ error: 'session start failed' });
  const { sessionPtr, token, frontendRequest } = await r.json();
  const id = crypto.randomBytes(16).toString('hex');
  pending.set(id, { token, result: null });
  byToken.set(token, id);
  res.json({ sessionPtr, frontendRequest, token: id });
});

// The session server posts the signed result here when the session ends.
app.post('/api/bisotun/callback', express.text({ type: '*/*' }), (req, res) => {
  try {
    const claims = verifyServerJwt(req.body.trim());
    const id = byToken.get(claims.token);
    if (id) pending.get(id).result = claims;
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});

app.get('/api/bisotun/result/:id', async (req, res) => {
  const entry = pending.get(req.params.id);
  if (!entry) return res.status(404).end();
  for (let i = 0; i < 20 && !entry.result; i++) await new Promise((r) => setTimeout(r, 250));
  const result = entry.result;
  if (!result) return res.status(504).json({ error: 'no result yet' });
  pending.delete(req.params.id);
  byToken.delete(entry.token);
  if (result.status !== 'DONE' || result.proofStatus !== 'VALID') {
    return res.status(403).json({ error: 'not verified', status: result.status, proofStatus: result.proofStatus });
  }
  const email = result.disclosed[0][0].rawvalue;
  // ... find or create the user, start YOUR login session (cookie) here ...
  res.json({ email });
});

app.use(express.static('public'));   // index.html (loads bisotun.js, section 4)
app.listen(3000);

Back end on the Bisotun server itself (network bisotun-net): post to http://bisotun-server:8088/session, leave out callbackUrl, and in the result endpoint call GET http://bisotun-server:8088/session/${token}/result (plain JSON, section 9). Delete a session you no longer need with DELETE /session/${token}.

7. Python example#

Python 3.10+, pip install flask requests pyjwt[crypto]. Back end on the Bisotun server (internal requestor API, HMAC key), polling the result.

# app.py
import base64, secrets, time
import jwt, requests
from flask import Flask, jsonify, abort

SESSION_SERVER = "http://bisotun-server:8088"   # internal requestor API
REQUESTOR = "shop-example"
HMAC_KEY = base64.b64decode(open("requestor.hmac").read().strip())

app = Flask(__name__, static_folder="public", static_url_path="")
pending = {}   # opaque id -> requestor token


def signed_request(attributes):
    claims = {
        "iss": REQUESTOR,
        "iat": int(time.time()),
        "sub": "verification_request",
        "sprequest": {"request": {
            "@context": "https://wallet.bisotun.app/ld/request/disclosure/v2",
            "disclose": [[attributes]],
        }},
    }
    return jwt.encode(claims, HMAC_KEY, algorithm="HS256")
    # RS256: jwt.encode(claims, open("requestor.sk.pem").read(), algorithm="RS256")


@app.post("/api/bisotun/start")
def start():
    r = requests.post(f"{SESSION_SERVER}/session", data=signed_request(["bisotun.main.email.address"]),
                      headers={"Content-Type": "text/plain"}, timeout=10)
    r.raise_for_status()
    pkg = r.json()
    sid = secrets.token_hex(16)
    pending[sid] = pkg["token"]
    return jsonify(sessionPtr=pkg["sessionPtr"], frontendRequest=pkg.get("frontendRequest"), token=sid)


@app.get("/api/bisotun/result/<sid>")
def result(sid):
    token = pending.pop(sid, None) or abort(404)
    res = requests.get(f"{SESSION_SERVER}/session/{token}/result", timeout=10).json()
    if res.get("status") != "DONE" or res.get("proofStatus") != "VALID":
        return jsonify(error="not verified", status=res.get("status")), 403
    email = res["disclosed"][0][0]["rawvalue"]
    # ... find or create the user and start your own login session here ...
    return jsonify(email=email)

To verify a signed result instead (/result-jwt or callback body):

claims = jwt.decode(result_jwt, open("bisotun-server.pk.pem").read(), algorithms=["RS256"],
                    issuer="bisotun-server", options={"verify_aud": False})

8. Session requests#

Disclosure request (sub: verification_request, claim sprequest.request):

{
  "@context": "https://wallet.bisotun.app/ld/request/disclosure/v2",
  "disclose": [
    [ [ "bisotun.main.email.address" ] ]
  ],
  "labels": { "0": { "en": "Email address", "fa": "نشانی ایمیل", "ar": "البريد الإلكتروني" } }
}

disclose is a list (all required) of choices (the user picks one option), each option being a list of attributes that must come from the same card. Examples:

Want disclose
Email address [[["bisotun.main.email.address"]]]
Email and membership number [[["bisotun.main.email.address"]], [["bisotun.main.membership.memberid"]]]
Email or account ID [[["bisotun.main.email.address"], ["bisotun.main.app.pseudonym"]]]
A specific value [[[{"type": "bisotun.main.membership.level", "value": "gold"}]]]

Issuance request (sub: issue_request, claim iprequest.request; needs issue_perms):

{
  "@context": "https://wallet.bisotun.app/ld/request/issuance/v2",
  "credentials": [
    { "credential": "bisotun.main.membership", "validity": 1830297600,
      "attributes": { "memberid": "M-1001", "level": "gold" } }
  ]
}

validity (Unix time) is optional; default is six months. An issuance request may also contain a disclose part, which the user must satisfy before the card is issued.

Signature sessions are not offered by the Bisotun service.

The bisotun command line tool prints requests for testing:

bisotun request --disclose bisotun.main.email.address
bisotun request --auth-method hmac --name shop-example --key <base64 key> --disclose bisotun.main.email.address

9. Session results#

GET /session/{token}/result:

{
  "token": "KzxuWK3i4tEkJpUVEJhv",
  "status": "DONE",
  "type": "disclosing",
  "proofStatus": "VALID",
  "disclosed": [
    [
      {
        "rawvalue": "user@example.org",
        "value": { "": "user@example.org", "en": "user@example.org" },
        "id": "bisotun.main.email.address",
        "status": "PRESENT",
        "issuancetime": 1790640000
      }
    ]
  ]
}

Signed variants (GET /session/{token}/result-jwt, and the body posted to callbackUrl) carry the same fields as JWT claims, plus iss: bisotun-server, iat, exp and sub: disclosing_result (issuing_result for issuance).

Accept a login only if all hold:

status Meaning
INITIALIZED Waiting for the wallet
PAIRING Wallet connected, waiting for the pairing code in the browser
CONNECTED Wallet has the request, user is deciding
DONE Finished (check proofStatus)
CANCELLED User declined, or an error occurred (see error)
TIMEOUT No wallet connected in time
proofStatus Meaning
VALID Proof is valid
INVALID Proof is invalid
UNMATCHED_REQUEST Proof does not match the request
MISSING_ATTRIBUTES Not all requested attributes were disclosed
EXPIRED Card was expired
INVALID_TIMESTAMP Signature timestamp invalid (signature sessions only)

Results are kept 5 minutes after the session ends; a session may last at most 15 minutes once the wallet has connected.

The session pointer is what the wallet needs to find a session:

{ "u": "https://cert.bisotun.app/api/session/Ab12Cd34Ef56Gh78Ij90", "bisotun": "disclosing" }

bisotun is one of disclosing, issuing, signing, redirect, revoking. The frontend may add "continueOnSecondDevice": true for QR codes. Wallet-facing session URLs always have the form <server>/api/session/{clientToken}.

Ways to hand the pointer to Bisotun Wallet (<ptr> = URL-encoded pointer JSON):

Form Example Use
Universal link https://wallet.bisotun.app/-/session#<ptr> QR codes and links; opens the app when installed (verified App Link), otherwise a page that forwards to the app or to /download/
Custom scheme bisotun://qr/json/<ptr> Buttons on the same phone
Android intent intent://qr/json/<ptr>#Intent;package=app.bisotun.wallet;scheme=bisotun;S.browser_fallback_url=https%3A%2F%2Fwallet.bisotun.app%2Fdownload%2F;end Android browsers; falls back to the download page
Raw JSON {"u": "...", "bisotun": "disclosing"} Inside a QR code

@bisotun/frontend builds all of these for you. Android package names: app.bisotun.wallet (release) and app.bisotun.wallet.alpha (test builds).

11. JSON-LD identifiers#

@context Used for
https://wallet.bisotun.app/ld/request/disclosure/v2 Disclosure request
https://wallet.bisotun.app/ld/request/issuance/v2 Issuance request
https://wallet.bisotun.app/ld/request/signature/v2 Signature request (not offered)
https://wallet.bisotun.app/ld/request/revocation/v1 Revocation request
https://wallet.bisotun.app/ld/request/frontendoptions/v1 Frontend options (pairing)
https://wallet.bisotun.app/ld/request/client/v1 Request as delivered to the wallet
https://wallet.bisotun.app/ld/options/v1 Session options
https://wallet.bisotun.app/ld/signature/v2 Attribute-based signature

Protocol version headers: X-Bisotun-MinProtocolVersion, X-Bisotun-MaxProtocolVersion (used between wallet and server).

12. Error codes#

Errors from the session server are JSON:

{ "error": "UNAUTHORIZED", "status": 403, "description": "You are not authorized to issue or verify this attribute" }
error HTTP Typical cause
UNAUTHORIZED 403 Wrong key, unknown requestor name, or attribute not in your permissions
MALFORMED_VERIFIER_REQUEST 400 Disclosure request could not be parsed
MALFORMED_ISSUER_REQUEST 400 Issuance request could not be parsed
MALFORMED_SIGNATURE_REQUEST 400 Signature request could not be parsed
ATTRIBUTES_WRONG 400 Attributes do not belong to the card type, or are missing
CANNOT_ISSUE 500 Server has no private key for this card type
ISSUING_DISABLED 403 Server does not issue
INVALID_REQUEST 400 Invalid HTTP request (content type, body)
MALFORMED_INPUT 400 Body could not be parsed
SESSION_UNKNOWN 400 Unknown or expired session (results are kept 5 minutes)
UNEXPECTED_REQUEST 403 Request not allowed in the current session state
PAIRING_REQUIRED 403 Wallet must be paired first
PROTOCOL_VERSION 400 Protocol version negotiation failed
INVALID_PROOFS 400 Proofs from the wallet are invalid
ATTRIBUTES_MISSING 400 Not all requested attributes were present
ATTRIBUTES_EXPIRED 400 Disclosed attributes were expired
UNKNOWN_PUBLIC_KEY 403 Card was signed with a key the server does not know
KEYSHARE_PROOF_MISSING 403 Keyshare part of the proof is missing
ISSUANCE_FAILED 500 Cards could not be created
NEXT_SESSION 500 Chained session could not be started
REVOCATION, UNKNOWN_REVOCATION_KEY 500, 404 Revocation errors
UNSUPPORTED 501 Not supported by this server
INVALID_TOKEN 403 Unknown or invalid token
TOO_MANY_REQUESTS 429 Rate limit
EXCEPTION, INTERNAL_ERROR 500 Server problem; retry later, report if persistent

A request with a @context other than the ones above, or with a protocol other than bisotun, is rejected.

Front-end end states (rejection value of start()): Aborted (you called abort()), Cancelled (user declined in the wallet), TimedOut, Error, BrowserNotSupported.

13. Security checklist#

Support: support@bisotun.app. Security reports: security@bisotun.app.