Developer documentation

isHuman + lemma.id for developers

Everything you need to require one verified human per account: the browser SDK, site-private PPIDs, offline backend verification, and three levels of proof, lemma.id, presence proof, and human proof.

/

What lemma.id gives your site

lemma.id is the continuous identity object your users hold. Your site gets a stable, site-private PPID bound to your user record, not a legal identity, email, document number, or ID that works anywhere else.

One integration, three levels of proof:

  • lemma.id (requiredAssurance: 'passkey'), same returning lemma.id / PPID continuity. Low friction; not Sybil-resistant alone.
  • Presence proof, fresh passkey when the presentation is signed, so the holder is at the device. Delivery is still a signed presentation.
  • Human proof (requiredAssurance: 'ishuman'), one verified human per account. Use for free-trial abuse, ticketing, rewards, voting, recovery after a ban, anywhere the attacker's cheapest move is a new account.
Why this exists

Login proves control of an account. Bot detection flags suspicious behavior. Neither can tell you the person you banned yesterday just signed up again. lemma.id gives your backend a durable handle on the person, so a new email, SIM, device, or OAuth account doesn't reset the slate.

5-minute quickstart

  1. Set siteId to your site's canonical hostname (for example app.example.com). It must match the hostname users see in the browser on your domain.
  2. Add the verifier SDK to your page (see SDK integration below).
  3. Gate a sensitive action with await verifier.verifyForBackend({ autoProvision: true }) for signup and account creation (T2), or verify({ autoProvision: true }) for client-only UX gates. The first visit may open a Lemma-hosted popup for passkey wallet unlock, passkey proof issuance, or isHuman IDV step-up depending on requiredAssurance. Fail closed when verification fails (see human and assurance below).
  4. Store ppid on your user or account record. It is an opaque, site-private identifier, not a government ID or email.
  5. Optional API key: create one in the API key manager when you need server-side PPID blocks. An API key is not required for the basic human check.
  6. On confirmed abuse: call POST /api/ishuman/site-block with your API key (see Revocation). Do not rely on the abuser's browser to enforce the ban.
No webhooks to configure

Relying sites do not register webhook URLs with lemma.id. Verification completes in the browser (SDK + Lemma-hosted popup). Your backend learns the outcome when the client sends you the ppid or a stamped audit event. There is no lemma.id callback.

Using an AI coding agent?

Point your agent at the machine-oriented integration guide or llms.txt. It covers guardrails, trust tiers, code patterns, and anti-patterns for integrating isHuman into your platform.

When to register your site

Most integrations need only the browser SDK. Dashboard registration is required only when you call server-side abuse APIs.

GoalRegistration required?What to do
Gate actions with verify()NoSet siteId to the hostname users see in the browser (for example app.example.com).
Block or revoke a PPID server-sideYesRegister your site domain in the API key manager and use the returned X-API-Key.

When you register, site_domain must match your SDK siteId after normalization: lowercase, no scheme or path, no port. Strip www. if that is how users reach your app. Internal site_... identifiers are for API keys and database ownership; they are not your SDK siteId.

End-to-end recipe: gate signup (recommended)

Verify locally on your backend with a signed presentation. Do not trust a bare ppid from the browser.

HTML + JavaScript
<script src="https://lemma.id/sdk/proof-verifier.js"></script> <script> const verifier = new ProofVerifier({ siteId: 'app.example.com' }); document.getElementById('signup-form').addEventListener('submit', async (e) => { e.preventDefault(); const { ok, presentation } = await verifier.verifyForBackend({ autoProvision: true, requiredAssurance: 'passkey', }); if (!ok) { alert('Verification required'); return; } const email = document.getElementById('email').value; await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, presentation }), }); }); </script>

On your server, verify presentation with @lemma/ishuman-verify or lemma_ishuman_verify.py before creating the account. Bind the user to result.ppid from the verified server result, not the parallel client ppid field.

Backend trust tiers

Choose how much you trust the client when creating accounts or granting access. Higher tiers resist forged requests.

TierPatternWhen to uselemma.id contact
T1, Client ppid verify() → POST { ppid } to your API Low-risk UX gates only (waitlists, soft limits), never account creation None after verify
T2, Signed presentation or stamp verifyForBackend() → backend verify, or stamp({ includeCredential: true }) → backend verifyStamp Signup, account creation, audit logs, moderate-trust actions (recommended default) Revocation snapshot refresh only
T2+, Action-bound stamp stampAction(...) envelope with action_assertion + action_signature Checkout, withdrawals, posting, presale claims, fraud-sensitive server mutations Revocation snapshot refresh only
T3, Fresh session proof Full presentation with credential + session assertion, verified locally or through /api/ishuman/verify-presentation High-trust financial actions needing live session proof Per-request (optional)
Anti-patterns

Do not trust X-Credential-ID, email headers, or a body ppid without a stamped credential or presentation verification. Anyone can forge those fields.

Assurance workflow demo

The live demo at /demo walks through the one-PPID assurance model when LEMMA_ONE_PPID_ASSURANCE_MODEL and LEMMA_PASSKEY_ASSURANCE_ENABLED are enabled:

  1. Passkey wallet, create an empty lemma.id (provisional person root, no IDV yet).
  2. Passkey proof on two sites, derive distinct PPIDs with assurance: passkey via verifyForBackend({ requiredAssurance: 'passkey' }).
  3. Demo relying sites, stamp actions and verify server-side with verifyStamp (see ticketing demo and trials demo).
  4. Site-local block, block the ticketing PPID; trials remains valid with lemma.id continuity.
  5. isHuman step-up, site escalates to IDV; re-verify with requiredAssurance: 'ishuman' and note the same PPID comes back with stronger assurance.

See also the agent integration guide assurance table and demo recording checklist.

Signup recipes by trust tier

T1: client ppid only (low-risk gates, never account creation)

Do not use T1 for signup

T1 sends an unverified ppid from the browser. A determined attacker can replay a captured value. Reserve T1 for low-risk UX gates (waitlists, soft rate limits) where you accept client-side trust. For account creation, use T2 below.

Client (low-risk gates only)
const result = await verifier.verify({ autoProvision: true }); if (!result.human) return; await fetch('/api/waitlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, ppid: result.ppid }), });

T2: recommended signup (signed presentation)

Send a signed presentation from verifyForBackend(), then verify cryptographically on your backend before creating the account. Bind the user to result.ppid from the verified server result, not the parallel client ppid field.

Client
const { ok, presentation } = await verifier.verifyForBackend({ autoProvision: true, requiredAssurance: 'ishuman', // or 'passkey' for low-friction signup }); if (!ok) return; await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, presentation }), });
Python backend
from lemma_ishuman_verify import VerificationContext ctx = VerificationContext(site_id="app.example.com", required_assurance="ishuman") @app.post("/api/signup") def signup(): body = request.get_json() or {} result = ctx.verify(body["presentation"]) if not result.ok: return {"error": result.reason}, 403 # create account bound to result.ppid ...

See also examples/relying_site_offline_verify.py and static/js/lemma-ishuman-verify.mjs for full verify examples.

T2+: action-bound stamps (stampAction)

For fraud-sensitive mutations (checkout, withdrawals, presale code claims), use stampAction() to bind the verified PPID to a specific action, HTTP method, path, and server nonce. Your backend verifies with verify_action_stamp() / verifyActionStamp() plus a nonce replay store.

Client
const challenge = await fetch('/api/checkout/challenge', { method: 'POST' }).then(r => r.json()); const event = await verifier.stampAction( { cartId, amountCents, currency }, { action: 'checkout', method: 'POST', path: '/api/checkout', nonce: challenge.server_nonce, requiredAssurance: 'passkey', }, ); await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(event) });
Python backend
from lemma_ishuman_verify import VerificationContext, InMemoryNonceStore ctx = VerificationContext(site_id="app.example.com", required_assurance="passkey") nonce_store = InMemoryNonceStore() @app.post("/api/checkout") def checkout(): body = request.get_json() or {} result = ctx.verify_action_stamp( body, action="checkout", method=request.method, path=request.path, body=body, nonce_store=nonce_store, ) if not result.ok: return {"error": result.reason}, 403 return process_checkout(result.ppid, body)

Fresh passkey for sensitive actions

When a passkey-tier user must prove present biometric/PIN control for a specific mutation, pass requireFreshPasskey: true. This does not change assurance tier, it adds a server-attested fresh-passkey ceremony bound to your server nonce.

Client, fresh passkey claim
const event = await verifier.stampAction(payload, { action: 'claim_presale_code', method: 'POST', path: '/api/presale/claim-code', nonce: challenge.server_nonce, requireFreshPasskey: true, requiredAssurance: 'passkey', });

Live reference: tickets presale demo. Verification is local-first: backend verifiers cache GET /api/revocation/bloom-filter (~15 min), not per request.

PPID convergence (provisional → known person)

When a user already verified on wallet A creates a new provisional wallet B and completes IDV, lemma.id rebinds B to the known person. The site PPID may change. lemma.id issues a signed ppid_convergence.v1 artifact on the next derive-site-proof for that site.

Merge recipe (transactional):

  1. Verify presentation + convergence artifact (verify_with_policy).
  2. If legacy_ppid is present, look up the provisional account by legacy_ppid.
  3. If found: merge rows to canonical ppid, carry forward site blocks/doubts from the legacy PPID, then archive the provisional account.
  4. If not found: create a new account for canonical ppid.
  5. Reject if convergence is invalid, wrong-site, expired, or tampered, fail closed.

Ordinary first IDV on the same wallet preserves PPID and emits no convergence artifact. Requires LEMMA_PPID_CONVERGENCE_ENABLED=1 (with one-PPID model).

Site policy and isBlockedLocally

Site blocks are not in the global Bloom filter. Mirror blocks on your backend and expose them to the SDK through isBlockedLocally, call your own policy endpoint, never lemma.id directly from the browser (API keys are server-only).

SDK constructor
const verifier = new ProofVerifier({ siteId: 'app.example.com', isBlockedLocally: async (ppid) => { const res = await fetch('/api/policy/check?ppid=' + encodeURIComponent(ppid)); const data = await res.json(); return { blocked: !!data.blocked, doubt_required: !!data.doubt_required }; }, });

Your policy endpoint should call GET /api/ishuman/check server-side with your site API key, or mirror block state locally after POST /api/ishuman/site-block. When verify() returns doubt_required, invoke verifyFreshForBackend(). A successful fresh IDV clears only the matching doubt when it derives the same site PPID.

Backend enforcement order

On every protected endpoint:

  1. Cryptographic presentation or action-stamp verification
  2. Canonical PPID extraction from verified result
  3. Convergence verification (if ppid_convergence.v1 present)
  4. Site-policy lookup for canonical and legacy PPIDs
  5. Business logic

Stable fail-closed reasons: site_blocked, doubt_required, site_policy_unavailable, site_policy_not_configured.

Troubleshooting

  • siteId hostname mismatch, staging and production subdomains derive different PPIDs. Use the exact hostname users see; do not mix www. and bare domain unless that is intentional.
  • Persistent no_credential, pass { autoProvision: true } on entry-point calls (signup, post, checkout). Without it, the SDK will not open the Lemma popup for first-time users.
  • Blocks not applying, the API key's registered site_domain must match SDK siteId. A block issued for example.com does not affect PPIDs derived for app.example.com.
  • idv_cancelled, the user closed the popup or the browser blocked it. Prompt them to allow popups and try again.
  • revocation_data_untrusted, local revocation snapshot could not be validated (clock skew, stale cache). Retry after a moment; check system clock if it persists.

What you configure vs what lemma.id runs

You (relying site)lemma.id (platform)
Embed proof-verifier.js and call verify()Hosts the wallet + IDV popup and issues signed credentials
Set siteId to your hostnameDerives a per-site PPID bound to that hostname
Store ppid on your user recordDoes not receive your app's business data or audit logs
API key, only for site-block, site-unblock, etc.Operates upstream IDV (Didit by default) and platform webhooks internally
No webhook URL, Didit account, or upstream IDV setupIDV issuer webhooks terminate at lemma.id, not at your servers

What are lemma.id and human proof?

lemma.id is the continuous identity object, user-held credentials, site-private PPIDs, and signed presentations. Human proof is the IDV-backed level: one verified human per account, without your site learning who that human legally is. Presence proof is the fresh-passkey ceremony at presentation signing. lemma.id continuity handles return visits; human proof handles Sybil resistance.

Three levels of proof

lemma.id (passkey) can issue site credentials without IDV when LEMMA_PASSKEY_ASSURANCE_ENABLED and the one-PPID model are enabled, continuity and low-friction signup. Presence proof is the fresh-passkey ceremony when a presentation is signed. Human proof (ishuman) always requires live IDV (liveness + document-to-selfie matching). A bot or stolen ID image cannot get a human proof because a live human must match a genuine government document at issuance.

Core model

When a human proof is required, the credential is rooted in a verified-person root derived from the IDV result. A master credential is issued for lemma.id; on your site, a Lemma-hosted popup derives a site-specific credential (a per-site PPID) on first request and caches it for later checks. Because the binding is to the person root rather than to an email, device, or OAuth account, resetting those cheap identifiers does not mint a fresh, clean identity.

What your servers store: no KYC fields

The live identity check runs at the IDV issuer, and verify() returns only { human, ppid, reason, timeMs }. Your backend never sees the government ID, selfie, legal name, or date of birth. You get the human signal without the PII that makes identity verification a compliance and breach liability. Store the ppid on your user record and treat it as an opaque per-site identifier, nothing more.

Integration flow (your site)

This is the path a relying-site developer implements. Platform-side IDV webhooks are handled by lemma.id and are not part of your integration. With lemma.id continuity (passkey) enabled, first-time users can get a lemma.id and site proof without IDV; a human proof is a deliberate step-up when your policy requires Sybil resistance.

StepWhereWhat happens
1Your pageYou call verifyForBackend({ autoProvision: true, requiredAssurance }) before a protected action. Use passkey for continuity; ishuman when one verified human per account is required.
2Lemma popupIf no proof exists: lemma.id path unlocks with passkey and issues continuity credentials (no IDV). Human-proof path runs live IDV at Didit after unlock.
3lemma.idIssues master + site-bound credential for your siteId. Step-up from lemma.id continuity to human proof on the same lemma.id returns the same PPID with a stronger proof level.
4Your pageSDK validates signature, expiry, and global revocation locally, then returns { ok, presentation, ppid, assurance } (via verifyForBackend) or { human, ppid, assurance, reason } (via verify).
5Your backendVerify presentation or action stamp cryptographically on your server. Apply site-policy checks (blocks/doubts), site blocks are not in the global Bloom filter. No lemma.id webhook is required.

SDK integration

Add the hosted verifier SDK and gate actions with verify(). Use autoProvision: true on entry points so first-time users can complete IDV in a popup.

HTML + JavaScript
<script src="https://lemma.id/sdk/proof-verifier.js"></script> <script> const verifier = new ProofVerifier({ siteId: 'app.example.com' }); async function requireHuman() { const result = await verifier.verify({ autoProvision: true }); if (!result.human) { throw new Error(result.reason || 'not_verified'); } return result.ppid; // store on your user record } </script>

Constructor options

OptionTypeDefaultNotes
siteIdstringwindow.location.hostnameCanonical hostname for your site. The derived PPID is bound to this value.
lemmaOriginstringhttps://lemma.idOverride only for non-production testing.
autoProvisionbooleanfalseWhen true on the constructor, every verify() may open the popup if no proof exists. Prefer passing { autoProvision: true } on entry-point calls only.
debugbooleanfalseEnables SDK console logging.
isBlockedLocallyfunctionnullOptional callback returning a boolean or { blocked, doubt_required } from your backend policy endpoint.

Key methods

MethodReturnsNotes
verify(opts?){ human, ppid, assurance, reason, timeMs }Primary check; may open Lemma popup on first visit with autoProvision: true.
verifyForBackend(opts?){ ok, presentation, ppid, assurance, reason, timeMs }Recommended for signup. Returns a backend-safe presentation bundle.
verifyFreshForBackend(opts?)Same as verifyForBackendDeliberate fresh IDV when your site policy returns doubt_required.
stampAction(payload, opts?)Promise<Object>Action-bound proof for T2+ server mutations. Includes action_assertion + action_signature.
getPPID(opts?)Promise<string | null>Cached PPID after initial verify; no popup by default.
getVerification(opts?)Promise<Stamp>Compact verification stamp for logging.
stamp(payload, opts?)Promise<Object>Attach audit evidence to your events (includeCredential: true recommended).

stampAction options: action, method, path, nonce, requireFreshPasskey, requiredAssurance.

verify() result

verify() returns { human: boolean, ppid: string | null, assurance: string | null, reason: string, timeMs: number, error: string | null }.

human vs assurance

human is the legacy/general success gate: true when the requested assurance tier passed (including passkey or ishuman). For policy decisions, also inspect assurance, because a passkey success does not mean IDV-backed humanness. Require requiredAssurance: 'ishuman' on signup when one account must map to one verified human.

Common reason values (grouped):

  • Success: valid, vc_valid, session_valid
  • Needs popup / first visit: no_credential, site_proof_required, wallet_locked, no_ishuman_credential
  • Renewal: expired uses ordinary 30-day site-proof renewal.
  • Hard fail: invalid_signature, cryptographic verification failed (tampered or corrupted credential). Fail closed; do not treat as recoverable.
  • Recoverable with autoProvision: revoked, credential is in the global Bloom filter; with autoProvision: true the SDK may open a popup for fresh issuance (unless your site policy blocks the PPID).
  • Site policy: site_blocked, doubt_required, enforce via your backend; site blocks never auto-clear through IDV.
  • User action: idv_cancelled, not_ishuman
  • Infrastructure: revocation_data_untrusted
  • Fresh challenge: doubt_required requires an explicit verifyFreshForBackend() call after your site clears the doubt path.

After the first successful verification, repeat calls on the same tab typically validate from cache with no network call (local Ed25519 + revocation bloom).

Attach the verification to your own logs

Once a user is verified, associate their site-scoped PPID with the actions you record: a comment, a checkout, a login, a moderation decision. The SDK provides helpers to stamp events. This data lives entirely in your systems; lemma.id stores none of it.

The pattern

Call verify({ autoProvision: true }) once at an entry point. After that, getPPID(), getVerification(), and stamp() read the cached session with no popup, so they're safe to use inline anywhere in your app.

Audit helpers

MethodReturnsNotes
getPPID(opts?)Promise<string | null>Verified PPID, or null. Does not open a popup unless autoProvision: true.
getVerification(opts?)Promise<Stamp>Compact verification stamp for logging.
stamp(payload, opts?)Promise<Object>Copies payload and merges a lemma field. Your object is not mutated.

Options: { key?: string (default 'lemma'), includeCredential?: boolean, includeProof?: boolean, autoProvision?: boolean }.

Which evidence should I store?

For audit logs, use { includeCredential: true }, which stores the bare verifiable credential. It is compact, offline-verifiable, and durable until expiry. Use { includeProof: true } only when you also need the signed session assertion (replay resistance); it ages out and is less suitable for long-lived logs. Both re-verify on your backend without calling lemma.id per check.

Stamp shape

FieldTypeMeaning
verifiedbooleanWas a valid human proof present?
ppidstring | nullSite-scoped pseudonymous identifier.
reasonstringThe verify() reason code.
siteIdstringYour siteId hostname binding.
verifiedAtnumberUnix ms when the stamp was produced.
expiresAtnumber | nullCredential expiry (unix seconds).
credentialIdstring | nullUnderlying credential id.
credentialobject | nullPresent with { includeCredential: true }. Recommended for durable audit evidence.
proofobject | nullPresent with { includeProof: true }. Adds session assertion; not ideal for long-term logs.

Copy-paste

JavaScript
const ih = new ProofVerifier({ siteId: 'app.example.com' }); // 1) Verify once at an entry point (may open a popup on first visit). await ih.verify({ autoProvision: true }); // 2) Stamp actions and POST to YOUR backend. async function logAction(action, extra = {}) { const event = await ih.stamp({ action, ...extra, at: Date.now() }, { includeCredential: true }); await fetch('/my/api/audit-log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event), }); } await logAction('post_comment', { commentId: 123 }); const ppid = await ih.getPPID();

Re-verify a stored stamp on your backend

If you captured a stamp with { includeCredential: true }, confirm on your own infrastructure that a logged action came from a verified human. verifyStamp() checks the credential, revocation status, and that the stamp's ppid matches. lemma.id is contacted only to refresh the signed revocation snapshot (cached ~15 min), not on every audit row.

For old log rows, pass { durable: true } so an aged session assertion is treated as informational while the credential check still applies.

Node.js / Deno / Workers
import { createVerifier } from "https://lemma.id/sdk/lemma-ishuman-verify.mjs"; const verifier = createVerifier({ siteId: "app.example.com" }); const check = await verifier.verifyStamp(row.lemma); if (!check.ok) { flagSuspiciousLogRow(row, check.reason); }
Python
# curl -O https://lemma.id/sdk/lemma_ishuman_verify.py from lemma_ishuman_verify import VerificationContext ctx = VerificationContext(site_id="app.example.com") check = ctx.verify_stamp(row["lemma"]) if not check.ok: flag_suspicious_log_row(row, check.reason)

API reference

Most integrations use only the browser SDK. These HTTP endpoints support moderation, optional server checks, and the Lemma-hosted wallet popup (which calls some of them on your users' behalf).

MethodPathPurposeAuth
POST/api/ishuman/start-verificationCreate a Didit IDV session.Wallet assertion
GET/api/ishuman/verification-status/<session_id>Poll status after IDV (used by the Lemma popup; not required if you use the SDK only).None (unguessable session id)
POST/api/ishuman/derive-site-proofDerive site-bound credential from master proof.Wallet assertion
POST/api/ishuman/verify-presentationOptional server-side re-verify (prefer local verifyStamp()).None
POST/api/ishuman/site-blockImmediate site-level PPID block.X-API-Key
POST/api/ishuman/site-unblockRemove site-scoped block.X-API-Key
POST/api/ishuman/site-doubtRequire deliberate fresh IDV without creating a ban.X-API-Key
POST/api/ishuman/site-doubt-clearExplicitly clear a site doubt.X-API-Key
GET/api/ishuman/site-doubtsList active doubts for your site.X-API-Key
GET/api/ishuman/checkReturn separate blocked and doubt_required decisions for a PPID.X-API-Key (server-only)
GET/api/revocation/bloom-filterSigned global revocation Bloom snapshot (backend verifiers cache ~15 min).None
GET/api/ishuman/site-blocksList active blocks for your site.X-API-Key
GET/api/ishuman/statsPublic network statistics.None

Wallet assertion proves control of the user's wallet signing key (Ed25519 challenge/response). It is used by the Lemma wallet popup, not by typical relying-site server code. X-API-Key is your site API key from the API key manager.

Revocation and abuse controls

Do not rely on the abuser's browser or wallet to enforce a ban.

Tier 0: Immediate site deny (your app)

Deny the current session or action (403, sign-out, etc.) while your site policy propagates.

Tier 1: Site-bound PPID block (canonical abuse path)

Call POST /api/ishuman/site-block with your site API key. lemma.id writes a SiteBlock row scoped to your site. Site blocks are not published to the global Bloom filter, they are enforced through your backend site-policy layer.

Mirror block state on your server and expose it to the SDK via isBlockedLocally, or query GET /api/ishuman/check server-side (never expose your API key to the browser). A site block persists through fresh IDV, recovery, and renewal until your authenticated POST /api/ishuman/site-unblock call removes it. Use POST /api/ishuman/site-doubt when you want a temporary fresh-IDV challenge instead of a ban.

curl, site-block
curl -X POST https://lemma.id/api/ishuman/site-block \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_SITE_API_KEY" \ -d '{"ppid": "did:lemma:ppid_...", "reason": "Terms violation, automated activity"}'

Global Bloom revocation (network-wide)

Network-wide credential revocation is reflected in GET /api/revocation/bloom-filter. The SDK checks this locally (~15 min cache). A revoked reason means the credential is in the global Bloom; with autoProvision: true the user may recover via fresh issuance unless your site policy blocks them. This is separate from site-local blocks.

Network-wide enumeration revocation endpoints are retired (HTTP 410). Use site-block for relying-site abuse control.

Framework notes

Adapt the same crypto contract; do not change verification semantics per framework.

StackClientServer
React / Next.jsLoad SDK in a client component or useEffect; call verifyForBackend before submitRoute handler verifies presentation
Vue / NuxtClient-only SDK integrationServer middleware or API route
RailsStimulus or vanilla JS for SDKController action + lemma_ishuman_verify
DjangoTemplate script or JS bundleView + VerificationContext
PHPScript tag + fetch to your APIPython helper or port verify logic

For SSR frameworks, keep ProofVerifier in client components only, it uses window, popups, and browser crypto.

For a side-by-side comparison of what your site, lemma.id, Auth0, and direct KYC each store, see the Trust & Personal Data Minimization page.

Privacy model (PPID by site)

  • Each site's PPID is derived from the verified-person root and the normalized site hostname. Binding to the person root, rather than a wallet secret, email, or device, is what makes a ban survive credential rotation on your site.
  • Derived credentials are site-specific and pairwise-unlinkable; one site's PPID cannot be correlated with another site's PPID by the relying sites.
  • Relying sites receive only a human verdict and their own site-private PPID. They never receive the user's name, ID document, date of birth, or other IDV PII.
  • Internal site identifiers (site_..., used for API keys and database ownership) are separate from the hostname used as siteId in the SDK.
What lemma.id can and can't see

lemma.id holds the verified-person root that links credentials for re-verification and enforcement. After approved issuance (and on declined, expired, or abandoned sessions), lemma.id requests upstream deletion of raw IDV session data at Didit: document images, liveness captures, and selfies. Your site never receives that material. Routine verification on your site runs locally in the browser; lemma.id does not observe which pages users visit or when they pass verify() on your domain.