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.
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
- Set
siteIdto your site's canonical hostname (for exampleapp.example.com). It must match the hostname users see in the browser on your domain. - Add the verifier SDK to your page (see SDK integration below).
- Gate a sensitive action with
await verifier.verifyForBackend({ autoProvision: true })for signup and account creation (T2), orverify({ 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 onrequiredAssurance. Fail closed when verification fails (seehumanandassurancebelow). - Store
ppidon your user or account record. It is an opaque, site-private identifier, not a government ID or email. - 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.
- On confirmed abuse: call
POST /api/ishuman/site-blockwith your API key (see Revocation). Do not rely on the abuser's browser to enforce the ban.
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.
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.
| Goal | Registration required? | What to do |
|---|---|---|
Gate actions with verify() | No | Set siteId to the hostname users see in the browser (for example app.example.com). |
| Block or revoke a PPID server-side | Yes | Register 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.
<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.
| Tier | Pattern | When to use | lemma.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) |
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:
- Passkey wallet, create an empty lemma.id (provisional person root, no IDV yet).
- Passkey proof on two sites, derive distinct PPIDs with
assurance: passkeyviaverifyForBackend({ requiredAssurance: 'passkey' }). - Demo relying sites, stamp actions and verify server-side with
verifyStamp(see ticketing demo and trials demo). - Site-local block, block the ticketing PPID; trials remains valid with lemma.id continuity.
- 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)
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.
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.
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 }),
});
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.
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) });
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.
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):
- Verify presentation + convergence artifact (
verify_with_policy). - If
legacy_ppidis present, look up the provisional account bylegacy_ppid. - If found: merge rows to canonical
ppid, carry forward site blocks/doubts from the legacy PPID, then archive the provisional account. - If not found: create a new account for canonical
ppid. - 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).
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:
- Cryptographic presentation or action-stamp verification
- Canonical PPID extraction from verified result
- Convergence verification (if
ppid_convergence.v1present) - Site-policy lookup for canonical and legacy PPIDs
- Business logic
Stable fail-closed reasons: site_blocked, doubt_required, site_policy_unavailable, site_policy_not_configured.
Troubleshooting
siteIdhostname mismatch, staging and production subdomains derive different PPIDs. Use the exact hostname users see; do not mixwww.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_domainmust match SDKsiteId. A block issued forexample.comdoes not affect PPIDs derived forapp.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 hostname | Derives a per-site PPID bound to that hostname |
Store ppid on your user record | Does 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 setup | IDV 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.
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.
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.
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.
| Step | Where | What happens |
|---|---|---|
| 1 | Your page | You call verifyForBackend({ autoProvision: true, requiredAssurance }) before a protected action. Use passkey for continuity; ishuman when one verified human per account is required. |
| 2 | Lemma popup | If 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. |
| 3 | lemma.id | Issues 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. |
| 4 | Your page | SDK validates signature, expiry, and global revocation locally, then returns { ok, presentation, ppid, assurance } (via verifyForBackend) or { human, ppid, assurance, reason } (via verify). |
| 5 | Your backend | Verify 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.
<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
| Option | Type | Default | Notes |
|---|---|---|---|
siteId | string | window.location.hostname | Canonical hostname for your site. The derived PPID is bound to this value. |
lemmaOrigin | string | https://lemma.id | Override only for non-production testing. |
autoProvision | boolean | false | When true on the constructor, every verify() may open the popup if no proof exists. Prefer passing { autoProvision: true } on entry-point calls only. |
debug | boolean | false | Enables SDK console logging. |
isBlockedLocally | function | null | Optional callback returning a boolean or { blocked, doubt_required } from your backend policy endpoint. |
Key methods
| Method | Returns | Notes |
|---|---|---|
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 verifyForBackend | Deliberate 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 assurancehuman 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:
expireduses 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; withautoProvision: truethe 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_requiredrequires an explicitverifyFreshForBackend()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.
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
| Method | Returns | Notes |
|---|---|---|
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 }
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
| Field | Type | Meaning |
|---|---|---|
verified | boolean | Was a valid human proof present? |
ppid | string | null | Site-scoped pseudonymous identifier. |
reason | string | The verify() reason code. |
siteId | string | Your siteId hostname binding. |
verifiedAt | number | Unix ms when the stamp was produced. |
expiresAt | number | null | Credential expiry (unix seconds). |
credentialId | string | null | Underlying credential id. |
credential | object | null | Present with { includeCredential: true }. Recommended for durable audit evidence. |
proof | object | null | Present with { includeProof: true }. Adds session assertion; not ideal for long-term logs. |
Copy-paste
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.
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);
}
# 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).
| Method | Path | Purpose | Auth |
|---|---|---|---|
POST | /api/ishuman/start-verification | Create 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-proof | Derive site-bound credential from master proof. | Wallet assertion |
POST | /api/ishuman/verify-presentation | Optional server-side re-verify (prefer local verifyStamp()). | None |
POST | /api/ishuman/site-block | Immediate site-level PPID block. | X-API-Key |
POST | /api/ishuman/site-unblock | Remove site-scoped block. | X-API-Key |
POST | /api/ishuman/site-doubt | Require deliberate fresh IDV without creating a ban. | X-API-Key |
POST | /api/ishuman/site-doubt-clear | Explicitly clear a site doubt. | X-API-Key |
GET | /api/ishuman/site-doubts | List active doubts for your site. | X-API-Key |
GET | /api/ishuman/check | Return separate blocked and doubt_required decisions for a PPID. | X-API-Key (server-only) |
GET | /api/revocation/bloom-filter | Signed global revocation Bloom snapshot (backend verifiers cache ~15 min). | None |
GET | /api/ishuman/site-blocks | List active blocks for your site. | X-API-Key |
GET | /api/ishuman/stats | Public 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 -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.
| Stack | Client | Server |
|---|---|---|
| React / Next.js | Load SDK in a client component or useEffect; call verifyForBackend before submit | Route handler verifies presentation |
| Vue / Nuxt | Client-only SDK integration | Server middleware or API route |
| Rails | Stimulus or vanilla JS for SDK | Controller action + lemma_ishuman_verify |
| Django | Template script or JS bundle | View + VerificationContext |
| PHP | Script tag + fetch to your API | Python 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 assiteIdin the SDK.
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.