Documentation Index
Fetch the complete documentation index at: https://humanos.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
You can embed the Humanos Link flow inside an <iframe> on your page and receive KYC results and credential decisions directly in the parent window via an encrypted postMessage - no server-to-server webhook required for the real-time channel.
This guide covers the client-side integration only. You still need to create a request via the API to obtain a sessionId before embedding the iframe.
How It Works
- Your page generates an ECDH P-256 key pair using the Web Crypto API.
- The public key and your origin are passed as query parameters when loading the iframe.
- The backend validates your origin against the allowlist configured in the dashboard.
- After the user completes the flow, the iframe sends an encrypted
postMessage to your page.
- Your page decrypts the payload using its private key to obtain the KYC result.
The customer’s private key never leaves the browser. The backend generates a new ephemeral key pair for every payload, ensuring forward secrecy.
Prerequisites
- HTTPS - Both your page and the iframe must be served over HTTPS. The Web Crypto API is not available in insecure contexts.
- Allowed origins - Your embedding origin must be registered in the Humanos Dashboard under Settings → Notifications. Up to 10 HTTPS origins can be configured.
- A valid session - You need a
sessionId from the request generation API before embedding.
Step 1: Generate an ECDH Key Pair
Generate a P-256 key pair using the Web Crypto API. Mark the private key as non-extractable so it cannot be read by other scripts on the page.
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
false, // private key non-extractable
["deriveKey", "deriveBits"],
);
// Export the public key as base64-encoded SPKI DER
const spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const pubKeyB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
Store the keyPair.privateKey reference - you will need it later to decrypt the payload.
Step 2: Build and Load the Iframe
Construct the iframe URL by appending pubKey and postMessageOrigin as query parameters to the Link URL.
const sessionId = "YOUR_SESSION_ID";
const iframeSrc =
`https://link.humanos.app/link/${sessionId}` +
`?pubKey=${encodeURIComponent(pubKeyB64)}` +
`&postMessageOrigin=${encodeURIComponent(window.location.origin)}`;
Then set it as the src of your iframe element:
<iframe
id="humanos-frame"
src=""
style="width: 100%; height: 600px; border: none;"
allow="camera"
></iframe>
document.getElementById("humanos-frame").src = iframeSrc;
The allow="camera" attribute is required if the flow includes identity
verification (KYC), as it needs camera access for the selfie and document scan
steps.
Step 3: Listen for the Encrypted PostMessage
Register a message event listener on the window. Always verify the event.origin matches the expected Humanos origin before processing.
window.addEventListener("message", async (event) => {
// Only accept messages from Humanos
if (event.origin !== "https://link.humanos.app") return;
// Only process encrypted payloads
if (!event.data?.encrypted) return;
const { ephemeralPublicKey, iv, ciphertext } = event.data;
// Decrypt the payload (see Step 4)
const payload = await decryptPayload(
ephemeralPublicKey,
iv,
ciphertext,
keyPair.privateKey,
);
console.log("KYC result:", payload);
});
Step 4: Decrypt the Payload
The payload is encrypted using ECDH P-256 + HKDF-SHA256 + AES-256-GCM. The decryption process derives a shared secret from the backend’s ephemeral public key and your private key, then uses HKDF to produce the AES key.
const HKDF_INFO = new TextEncoder().encode("humanos-postmessage-v1");
async function decryptPayload(ephemeralPublicKey, iv, ciphertext, privateKey) {
// 1. Import the backend's ephemeral public key
const epkBytes = Uint8Array.from(atob(ephemeralPublicKey), (c) =>
c.charCodeAt(0),
);
const epk = await crypto.subtle.importKey(
"spki",
epkBytes,
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
// 2. Derive shared secret via ECDH
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: epk },
privateKey,
256,
);
// 3. Derive AES-256-GCM key via HKDF-SHA256
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedBits,
"HKDF",
false,
["deriveKey"],
);
const aesKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info: HKDF_INFO,
},
hkdfKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
// 4. Decrypt (ciphertext includes the 16-byte GCM auth tag)
const ctBytes = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
const ivBytes = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivBytes, tagLength: 128 },
aesKey,
ctBytes,
);
return JSON.parse(new TextDecoder().decode(plaintext));
}
Payload Structure
Once decrypted, the payload contains the KYC result and credential decisions:
{
"version": "1",
"eventType": "kyc_complete",
"requestId": "68c42ec3e47c9a7f9241e0ba",
"sessionId": "abc123",
"timestamp": "2026-03-17T14:30:00.000Z",
"identity": {
"fullName": "Jane Doe",
"birth": "1990-05-15",
"docId": "AB1234567",
"documentType": "PASSPORT",
"countryAlpha3": "USA",
"veriffRiskScore": 0.12
},
"credentials": [
{
"id": "cred_001",
"name": "Identity Verification",
"resourceType": "identity",
"status": "accepted",
"decisionDate": "2026-03-17T14:30:00.000Z"
}
]
}
| Field | Type | Description |
|---|
version | string | Payload format version (currently "1") |
eventType | string | Event type (currently "kyc_complete") |
requestId | string | The request identifier |
sessionId | string | The session identifier used to load the iframe |
timestamp | string | ISO 8601 timestamp of the event |
identity | object | null | Identity data from KYC verification, or null if not applicable |
credentials | array | List of credential decisions |
Identity Object
| Field | Type | Description |
|---|
fullName | string | Full name as extracted from the document |
birth | string | Date of birth |
docId | string | Document number |
documentType | string | null | Type of document used (e.g., PASSPORT, ID_CARD) |
countryAlpha3 | string | ISO 3166-1 alpha-3 country code |
veriffRiskScore | number | null | Risk score from identity verification provider |
Credential Object
| Field | Type | Description |
|---|
id | string | Credential identifier |
name | string | Display name of the credential |
resourceType | string | Type of resource (identity, signature, consent, form, mandate) |
status | string | Decision status (accepted, rejected) |
decisionDate | string | ISO 8601 timestamp of the decision |
The encrypted message sent via postMessage has the following structure:
{
"version": "1",
"encrypted": true,
"scheme": "ECDH-P256-AES-256-GCM",
"ephemeralPublicKey": "<base64, SPKI DER>",
"iv": "<base64, 12-byte nonce>",
"ciphertext": "<base64, AES-GCM ciphertext + 16-byte auth tag>"
}
Complete Example
Below is a self-contained example that generates a key pair, loads the iframe, listens for the encrypted message, and decrypts the payload.
<!doctype html>
<html>
<body>
<iframe
id="humanos-frame"
style="width: 100%; height: 600px; border: none"
allow="camera"
></iframe>
<pre id="result">Waiting for KYC result...</pre>
<script>
const SESSION_ID = "YOUR_SESSION_ID";
const HUMANOS_ORIGIN = "https://link.humanos.app";
const HKDF_INFO = new TextEncoder().encode("humanos-postmessage-v1");
let privateKey;
async function init() {
// 1. Generate key pair
const keyPair = await crypto.subtle.generateKey(
{ name: "ECDH", namedCurve: "P-256" },
false,
["deriveKey", "deriveBits"],
);
privateKey = keyPair.privateKey;
// 2. Export public key
const spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const pubKeyB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
// 3. Load iframe
const src =
`${HUMANOS_ORIGIN}/link/${SESSION_ID}` +
`?pubKey=${encodeURIComponent(pubKeyB64)}` +
`&postMessageOrigin=${encodeURIComponent(window.location.origin)}`;
document.getElementById("humanos-frame").src = src;
}
// 4. Listen for encrypted message
window.addEventListener("message", async (event) => {
if (event.origin !== HUMANOS_ORIGIN) return;
if (!event.data?.encrypted) return;
const { ephemeralPublicKey, iv, ciphertext } = event.data;
// Import ephemeral public key
const epkBytes = Uint8Array.from(atob(ephemeralPublicKey), (c) =>
c.charCodeAt(0),
);
const epk = await crypto.subtle.importKey(
"spki",
epkBytes,
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
// Derive shared secret
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: epk },
privateKey,
256,
);
// HKDF → AES key
const hkdfKey = await crypto.subtle.importKey(
"raw",
sharedBits,
"HKDF",
false,
["deriveKey"],
);
const aesKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new Uint8Array(0),
info: HKDF_INFO,
},
hkdfKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
// Decrypt
const ctBytes = Uint8Array.from(atob(ciphertext), (c) =>
c.charCodeAt(0),
);
const ivBytes = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivBytes, tagLength: 128 },
aesKey,
ctBytes,
);
const payload = JSON.parse(new TextDecoder().decode(plaintext));
document.getElementById("result").textContent = JSON.stringify(
payload,
null,
2,
);
});
init();
</script>
</body>
</html>
Security Considerations
- Origin validation - The backend validates
postMessageOrigin against your configured allowlist. If the origin is not registered, no encrypted payload will be generated.
- Non-extractable private key - The private key is generated with
extractable: false, preventing any script from reading the raw key material.
- Ephemeral keys - The backend generates a fresh ephemeral key pair for each payload, so compromising one message does not compromise others.
- Authenticated encryption - AES-256-GCM provides both confidentiality and integrity. Tampered ciphertext will fail decryption.
Dashboard Configuration
To enable iframe postMessage notifications:
- Navigate to Humanos Dashboard → Settings → Notifications.
- Toggle Embed notifications on.
- Add your allowed origins (HTTPS only, up to 10).
When disabled, the iframe will not send any postMessage events regardless of the query parameters provided.