Skip to main content
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

  1. Your page generates an ECDH P-256 key pair using the Web Crypto API.
  2. The public key and your origin are passed as query parameters when loading the iframe.
  3. The backend validates your origin against the allowlist configured in the dashboard.
  4. After the user completes the flow, the iframe sends an encrypted postMessage to your page.
  5. 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"
    }
  ]
}
FieldTypeDescription
versionstringPayload format version (currently "1")
eventTypestringEvent type (currently "kyc_complete")
requestIdstringThe request identifier
sessionIdstringThe session identifier used to load the iframe
timestampstringISO 8601 timestamp of the event
identityobject | nullIdentity data from KYC verification, or null if not applicable
credentialsarrayList of credential decisions

Identity Object

FieldTypeDescription
fullNamestringFull name as extracted from the document
birthstringDate of birth
docIdstringDocument number
documentTypestring | nullType of document used (e.g., PASSPORT, ID_CARD)
countryAlpha3stringISO 3166-1 alpha-3 country code
veriffRiskScorenumber | nullRisk score from identity verification provider

Credential Object

FieldTypeDescription
idstringCredential identifier
namestringDisplay name of the credential
resourceTypestringType of resource (identity, signature, consent, form, mandate)
statusstringDecision status (accepted, rejected)
decisionDatestringISO 8601 timestamp of the decision

Wire Format

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:
  1. Navigate to Humanos DashboardSettings → Notifications.
  2. Toggle Embed notifications on.
  3. 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.