Developer documentation
lemma.id proof of humanity
Integrate verified-human credentials with site-private PPIDs, browser-local verification, and server-side abuse controls.
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.verify({ autoProvision: true }). The first visit may open a Lemma-hosted popup (wallet unlock + live IDV). Fail closed whenhumanis false. - 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 blocks or revocation requests. 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 — not via a lemma.id callback.
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
A minimal signup flow with no webhook handler on your backend:
<script src="https://lemma.id/sdk/ishuman-verifier.js"></script>
<script>
const verifier = new IsHumanVerifier({ siteId: 'app.example.com' });
document.getElementById('signup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = await verifier.verify({ autoProvision: true });
if (!result.human) {
alert('Verification required: ' + (result.reason || 'not verified'));
return;
}
const email = document.getElementById('email').value;
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, ppid: result.ppid }),
});
});
</script>
On your server, store ppid on the new account record. Optionally stamp the first login for audit:
const event = await verifier.stamp(
{ action: 'signup_complete', email },
{ includeCredential: true }
);
await fetch('/api/audit-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
});
lemma.id never calls your servers when IDV completes. The client sends ppid (or a stamped event) to your API after verify() succeeds.
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 ishuman-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, network-revoke, 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 is lemma.id proof of humanity?
lemma.id proof of humanity is an identity-rooted enforcement layer for websites and applications. A user verifies once through a trusted IDV issuer (Didit by default), then receives an Ed25519-signed credential that can be reused on your site while preserving privacy via a site-private PPID.
Credentials are issued only after a live identity check — liveness detection plus document-to-selfie matching. A bot, AI agent, or stolen ID image cannot obtain a credential, because a live human had to match a genuine government document at issuance.
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 — not to a wallet, email, or device — resetting any of those does not mint a fresh, clean identity.
The live identity check runs at the IDV issuer. verify() returns only { human, ppid, reason, timeMs }. Your backend never receives the government ID, selfie, legal name, or date of birth — so you get an enforcement-grade human signal without running a KYC stack or holding the PII that makes identity verification a compliance and breach liability. Store the ppid on your user record; treat it as an opaque per-site identifier, not as sensitive identity data.
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.
| Step | Where | What happens |
|---|---|---|
| 1 | Your page | You call verify({ autoProvision: true }) before a protected action (signup, post, checkout, etc.). |
| 2 | Lemma popup | If the user has no proof yet, a popup at /wallet/ishuman-idv unlocks the wallet (passkey) and runs live IDV at Didit. |
| 3 | lemma.id | After approved IDV, lemma.id issues a master verified-human credential and derives a site-bound credential for your siteId. |
| 4 | Your page | The SDK validates signature, expiry, and revocation locally, then returns { human: true, ppid: "did:lemma:ppid_..." }. |
| 5 | Your backend | Accept the ppid (or a stamped event with includeCredential: true) from your client. Optionally re-verify cryptographically on your server — no lemma.id webhook 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/ishuman-verifier.js"></script>
<script>
const verifier = new IsHumanVerifier({ 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 (ppid) => boolean for site-level blocks without a network round-trip. |
verify() result
verify() returns { human: boolean, ppid: string | null, reason: string, timeMs: number, error: string | null }.
Common reason values (grouped):
- Success:
valid,vc_valid,session_valid - Needs popup / first visit:
no_credential,site_proof_required,wallet_locked,no_ishuman_credential - Failure:
expired,revoked,invalid_signature,site_blocked,idv_cancelled,not_ishuman,revocation_data_untrusted
After the first successful verification, repeat calls on the same tab typically validate from cache with no network call (local Ed25519 + revocation bloom).
Attach verification to your own logs
Once a user is verified, associate their site-scoped PPID with actions you record — a comment, checkout, login, 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 — safe to use inline anywhere in your app.
Helper methods
| 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 } — it 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 verifier = new IsHumanVerifier({ siteId: 'app.example.com' });
// 1) Verify once at an entry point (may open a popup on first visit).
await verifier.verify({ autoProvision: true });
// 2) Stamp actions and POST to YOUR backend.
async function logAction(action, extra = {}) {
const event = await verifier.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 verifier.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 + wallet_secret (popup path) |
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/network-revoke | Submit network-wide revocation request (reviewed). | X-API-Key |
GET | /api/ishuman/check | Check block / revocation status for a PPID. | 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
Revocation is tiered. 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 lemma.id propagates canonical revocation.
Tier 1: Site-bound PPID revocation (immediate, canonical)
Call POST /api/ishuman/site-block with your site API key. lemma.id writes a SiteBlock row and a canonical RevocationList entry, then publishes a Bloom snapshot so verifiers reject that PPID on your site across devices.
You can also revoke IAM users via POST /api/developer/sites/<site_id>/users/<ppid>/revoke, which triggers the same canonical PPID path.
With the verifier SDK, use isBlockedLocally(ppid) or call GET /api/ishuman/check server-side.
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"}'
Tier 2: Network revocation (reviewed)
Request network action via POST /api/ishuman/network-revoke with your site API key. A tier 1 site block applies immediately while the request is reviewed. lemma.id operators approve network-wide wallet revocation internally — POST /api/ishuman/approve-revocation is not a customer-facing endpoint.
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 — not 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 — never 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 network revocation. 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.