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:
- your requestor name (the JWT
issclaim); - either an HMAC key (base64, algorithm HS256) or registration of your RSA public key (you keep the private key; algorithm RS256, recommended);
- the list of attributes you may request (
disclose_perms); - the session server's result signing public key (PEM), to verify signed results;
- the address of the hosted browser bundle (section 4).
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>
newWeb(options)renders insideelement;newPopup(options)shows the same form in an overlay. Both return{ start, abort }. An instance can be started once; create a new one to start again.- With a bundler:
import { newWeb, newPopup } from '@bisotun/frontend';(point your package manager at the delivered package directory). - By default the start response is mapped as
sessionPtr<-r.sessionPtr,sessionToken<-r.token,frontendRequest<-r.frontendRequest. Always returnfrontendRequest: without it the widget falls back to the old frontend protocol (no pairing, weaker protection against QR code relaying). - The widget talks directly to
https://cert.bisotun.app/api/session/...for status updates. If your site has a Content Security Policy, allow it:connect-src 'self' https://cert.bisotun.appandfont-src 'self'. direction: 'rtl' | 'ltr'overrides the text direction;translationsoverrides single strings.
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):
POST /api/bisotun/start: build the session request, sign it,POSTit to the session server, store the requestor token server-side under a new random id, return{ sessionPtr, frontendRequest, token: <random id> }.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.- 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:
statusisDONE;proofStatusisVALID;disclosed[i][j].statusisPRESENTfor every attribute you need;- for signed results: signature valid under the session server key,
issisbisotun-server,expin the future.
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.
10. Session pointer and deep links#
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#
- Build and sign session requests only on your server; never ship your key or the requestor token to the browser.
- Check
status,proofStatusand every attributestatusbefore trusting a result; verify signatures of result JWTs. - Use each result once, then delete the mapping.
- Use HTTPS for
callbackUrl. - Keep your server clock in sync (requests older than 5 minutes are refused).
- Ask for the fewest attributes that work. Users see exactly what you request.
- Store your requestor key like a password; tell the operator immediately if it may have leaked.
- Be accurate towards your users: the hosted session server
(
cert.bisotun.app) verifies the proof for you and therefore sees the disclosed values briefly (held in memory for 5 minutes, not logged). Bisotun keeps no record of what users share, but do not tell users that nobody besides you sees the data. - Your site is shown in the wallet by its host name with the notice that it is not known to Bisotun; there is no registry of verified requestor names yet.
Support: support@bisotun.app. Security reports: security@bisotun.app.