> ## Documentation Index
> Fetch the complete documentation index at: https://docs.humanos.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Iframe Integration

> Embed the Humanos Link flow in an iframe, receive encrypted real-time results via postMessage, and optionally skip the OTP for high-frequency approvals with trusted sessions

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 is required for the real-time channel.

<div className="hm-tip">
  This guide covers the **client-side** integration. The embeddable URL is created server-side when you [create a request](/essentials/quick-start): you pass your iframe public key in the request body and Humanos returns a per-subject `link` to embed.
</div>

## How It Works

1. Your page generates an ECDH P-256 key pair using the Web Crypto API. The **private key never leaves the browser**.
2. Your page sends the **public key** to your backend, which includes it in the `iframe.pubKey` field of the [create-request](/essentials/quick-start) call.
3. Humanos returns a `link` for each subject. You embed that `link` in an `<iframe>`.
4. When the iframe loads, the backend detects the parent window's origin and validates it against the allowlist configured in your dashboard. The origin is **never taken from a query parameter**; it is browser-detected and server-validated.
5. After the user completes the flow, the iframe sends an encrypted `postMessage` to your page. Your page decrypts it with the private key from step 1.

The backend generates a new ephemeral key pair for every payload, ensuring forward secrecy.

<div className="hm-tip">
  Unlike earlier versions of this integration, you do **not** append `pubKey` or `postMessageOrigin` to the iframe URL. The public key is registered when the request is created, and the origin is detected automatically. The old query-parameter flow is no longer used.
</div>

***

### Prerequisites

* **HTTPS**: Both your page and the iframe must be served over HTTPS. The Web Crypto API is unavailable in insecure contexts, and trusted-session cookies require a secure context.
* **Allowed origins**: Your embedding origin must be registered in the Humanos Dashboard; see [Dashboard Configuration](#dashboard-configuration). Up to 10 HTTPS origins can be configured, or `*` to allow all, which is not recommended; see the warning below.
* **iframe notifications enabled**: The embed/postMessage channel must be enabled for your organization, otherwise no encrypted payload is sent.

***

### 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.

```javascript theme={"dark"}
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. This is what you send to Humanos.
const spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
const pubKeyB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
```

Keep the `keyPair.privateKey` reference. You will need it later to decrypt the payload.

***

### Step 2: Create the Request with Your Public Key

Send `pubKeyB64` to your backend, which creates the request with an `iframe` object. The public key goes in `iframe.pubKey` as base64-encoded P-256 SPKI; a request that includes `iframe.pubKey` is put into **iframe mode**.

```jsonc theme={"dark"}
// POST https://api.humanos.tech/request  (server-side, from your backend)
{
  "users": [{ "contact": "user@example.com" }],
  "securityLevel": "HUMANOS_KYC",
  "iframe": {
    "pubKey": "<base64 SPKI DER public key from Step 1>",
    // Optional: skip the OTP for high-frequency approvals (see Trusted Sessions):
    "cookie": { "allow": true, "duration": 3600 }
  }
}
```

<div className="hm-tip">
  In iframe mode, `secondaryContact` is not supported, and the one-time code is sent lazily when the iframe first loads, not at request creation.
</div>

The response is a `GenerateRequestEntity` whose `subjects[]` each carry a `link`, the URL to embed:

```jsonc theme={"dark"}
{
  "id": "507f1f77bcf86cd799439011",
  "subjects": [
    {
      "contact": "user@example.com",
      "id": "did:web:humanos.tech:user:73ebefdd-...",
      "link": "https://app.humanos.tech/link/507f1f77bcf86cd799439011.Ab12Cd34Ef56Gh78"
    }
  ]
}
```

The `link` is present for iframe / deferred-OTP requests. Pass it back to your page to embed.

***

### Step 3: Embed the Link

Set the returned `link` as the `src` of your iframe. **Do not** add `pubKey` or `postMessageOrigin` query parameters; they are ignored.

```html theme={"dark"}
<iframe
  id="humanos-frame"
  src=""
  style="width: 100%; height: 600px; border: none;"
  allow="camera; publickey-credentials-get; publickey-credentials-create"
></iframe>
```

```javascript theme={"dark"}
// `link` is the subjects[].link value returned in Step 2.
document.getElementById("humanos-frame").src = link;
```

<div className="hm-tip">
  The `allow` attribute delegates the browser permissions the flow needs inside a cross-origin iframe:

  * `camera` is required if the flow includes identity verification, since KYC needs camera access for the selfie and document scan steps.
  * `publickey-credentials-get` lets a returning subject verify with a passkey (device biometrics) instead of typing the one-time code.
  * `publickey-credentials-create` lets a subject save a passkey after confirming the code, so later approvals can use biometrics.

  If one of these is missing, or the browser does not support delegating it, the Link app hides the corresponding option and falls back to the one-time code. Browsers ignore `allow` entries they do not recognize, so including all three is safe.
</div>

***

### Step 4: Listen for the Encrypted PostMessage

Register a `message` event listener on the window. Always verify that `event.origin` matches the origin of the `link` you embedded, which is the Humanos app origin such as `https://app.humanos.tech` or `https://app.humanos.id`.

```javascript theme={"dark"}
// Derive the expected origin from the link you embedded in Step 3.
const HUMANOS_ORIGIN = new URL(link).origin;

window.addEventListener("message", async (event) => {
  // Only accept messages from the Humanos app origin
  if (event.origin !== HUMANOS_ORIGIN) return;

  // Only process encrypted payloads
  if (!event.data?.encrypted) return;

  const { ephemeralPublicKey, iv, ciphertext } = event.data;

  const events = await decryptPayload(
    ephemeralPublicKey,
    iv,
    ciphertext,
    keyPair.privateKey,
  );

  console.log("Humanos events:", events); // an array of events, see Payload Structure
});
```

***

### Step 5: Decrypt the Payload

The payload is encrypted using **ECDH P-256 + HKDF-SHA256 + AES-256-GCM**. The decryption derives a shared secret from the backend's ephemeral public key and your private key, then uses HKDF to produce the AES key.

```javascript theme={"dark"}
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 (empty salt, fixed info string)
  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));
}
```

***

## Trusted Sessions: skip the OTP

For high-frequency approvals, where the same subject approves repeatedly for the same issuer, you can opt a request into a **trusted browser session** so the subject only completes the one-time code **once**. Subsequent approvals in the same browser skip the OTP automatically.

### Opting in

Set `iframe.cookie` when you create the request, as in Step 2:

```jsonc theme={"dark"}
"iframe": {
  "pubKey": "<base64 SPKI DER public key>",
  "cookie": {
    "allow": true,      // opt into trusted sessions (default false)
    "duration": 3600    // session lifetime in seconds; clamped to [3600, 86400]
  }
}
```

| Field      | Type      | Default | Notes                                                                                            |
| ---------- | --------- | ------- | ------------------------------------------------------------------------------------------------ |
| `allow`    | `boolean` | `false` | When `true`, a valid session lets the subject skip the OTP.                                      |
| `duration` | `integer` | `3600`  | Session lifetime in seconds. Only used when `allow` is `true`. Clamped to **1 hour – 24 hours**. |

### How it works

1. On the **first** approval, the subject completes the OTP as usual. On success, Humanos sets the **`HttpOnly`, `Secure`, `SameSite=None`** cookie `humanos_trusted_session` in the iframe, scoped to that **subject + issuer** and valid for `duration` seconds.
2. Because the iframe is third-party, the browser must grant it cookie access via the **[Storage Access API](https://developer.mozilla.org/docs/Web/API/Storage_Access_API)**. The Link app requests this on a user gesture; the user may see a one-time browser prompt.
3. On **subsequent** approvals for the same subject + issuer, embedding the `link` auto-authenticates the session and the OTP step is skipped. If the cookie is missing, expired, revoked, or storage access is denied, the flow falls back to the normal OTP.

<div className="hm-tip">
  The trusted-session cookie is `HttpOnly`, so it is never readable by your JavaScript, and it is never included in the postMessage payload. You only need to set `iframe.cookie.allow`; the Link app manages the cookie and the OTP-skip for you.
</div>

### Constraints

* Same browser, same subject, same issuer, within the configured `duration`.
* HTTPS only, since the cookie is `Secure`, and the embedding origin must still pass the allowlist check; trusted sessions do **not** bypass origin validation.
* If the browser blocks third-party storage and the user denies the Storage Access prompt, the subject completes the OTP normally.

***

## Payload Structure

The decrypted plaintext is a **JSON array of events**, not a single object. Each event is shaped like a Humanos webhook event. A single completion typically contains one `identity` event and/or one `credential` event per credential decided.

```json theme={"dark"}
[
  {
    "eventType": "identity",
    "api_version": "2026-07-06",
    "requestId": "507f1f77bcf86cd799439011",
    "issuerDid": "did:web:humanos.tech:org:550e8400-e29b-41d4-a716-446655440000",
    "user": {
      "contact": "user@example.com",
      "id": "did:web:humanos.tech:user:73ebefdd-..."
    },
    "identity": {
      "fullName": "Jane Doe",
      "gender": "F",
      "birth": "1990-05-15",
      "docId": "AB1234567",
      "fullDocId": "AB1234567",
      "countryAlpha3": "USA",
      "documentType": "PASSPORT"
    },
    "decision": {
      "success": true,
      "message": "Verified",
      "date": "2026-07-06T14:30:00.000Z"
    }
  },
  {
    "eventType": "credential",
    "api_version": "2026-07-06",
    "requestId": "507f1f77bcf86cd799439011",
    "issuerDid": "did:web:humanos.tech:org:550e8400-e29b-41d4-a716-446655440000",
    "user": {
      "contact": "user@example.com",
      "id": "did:web:humanos.tech:user:73ebefdd-..."
    },
    "credential": {
      "id": "urn:via:credential:550e8400-...",
      "name": "Terms of Service",
      "resourceType": "CONSENT",
      "status": "ACTIVE"
    },
    "decision": {
      "action": "accept",
      "date": "2026-07-06T14:30:00.000Z"
    }
  }
]
```

<div className="hm-tip">
  The exact field names depend on the **API version pinned to the API key that created the request**. The shapes here are for `2026-07-06`, the latest. A request created with an API key pinned to an older version receives the **same events transformed to that version's shape**. For example, `2026-05-17` flattens `decision.action`/`date` to top-level `action`/`decisionDate` and returns `user.did` instead of `user.id`. Pin your API version and test against it.
</div>

Every event shares these fields:

<ResponseField name="eventType" type="string" required>
  `identity` or `credential`.
</ResponseField>

<ResponseField name="api_version" type="string" required>
  API version the payload is formatted for, as `YYYY-MM-DD`.
</ResponseField>

<ResponseField name="requestId" type="string" required>
  Identifier of the request.
</ResponseField>

<ResponseField name="issuerDid" type="string" required>
  Your organization's DID.
</ResponseField>

<ResponseField name="user" type="object" required>
  The subject the event concerns.

  <Expandable title="user">
    <ResponseField name="contact" type="string" required>
      The subject's contact, an email or phone number.
    </ResponseField>

    <ResponseField name="id" type="string" required>
      Subject identifier: a DID, such as `did:web:…`.
    </ResponseField>
  </Expandable>
</ResponseField>

Then, keyed by `eventType`, each event adds its own fields:

<div className="sdk-tabs">
  <Tabs>
    <Tab title="identity">
      <ResponseField name="decision" type="object" required>
        The KYC identity-verification outcome.

        <Expandable title="decision">
          <ResponseField name="success" type="boolean" required>
            Whether verification succeeded.
          </ResponseField>

          <ResponseField name="date" type="string" required>
            ISO 8601 timestamp of the decision.
          </ResponseField>

          <ResponseField name="message" type="string">
            Human-readable detail, such as a rejection reason.
          </ResponseField>
        </Expandable>
      </ResponseField>

      <ResponseField name="identity" type="object">
        Data extracted from the verified document. Present on success; omitted when verification did not complete.

        <Expandable title="identity">
          <ResponseField name="fullName" type="string" required>Full name as printed on the document.</ResponseField>
          <ResponseField name="birth" type="string" required>Date of birth in ISO 8601 format.</ResponseField>
          <ResponseField name="docId" type="string" required>Document number.</ResponseField>
          <ResponseField name="fullDocId" type="string" required>Extended document number.</ResponseField>
          <ResponseField name="countryAlpha3" type="string" required>ISO 3166-1 alpha-3 country code.</ResponseField>
          <ResponseField name="gender" type="string | null">Gender as recorded on the document.</ResponseField>
          <ResponseField name="nationality" type="string">Nationality.</ResponseField>
          <ResponseField name="placeOfBirth" type="string">Place of birth.</ResponseField>
          <ResponseField name="placeOfIssue" type="string">Place the document was issued.</ResponseField>
          <ResponseField name="documentType" type="string">Document type, such as `PASSPORT` or `ID_CARD`.</ResponseField>
          <ResponseField name="issueDate" type="string">Document issue date.</ResponseField>
          <ResponseField name="expiresAt" type="string">Document expiry date.</ResponseField>
          <ResponseField name="addresses" type="string[]">Addresses on record.</ResponseField>
          <ResponseField name="height" type="number">Height.</ResponseField>
          <ResponseField name="weight" type="number">Weight.</ResponseField>
        </Expandable>
      </ResponseField>
    </Tab>

    <Tab title="credential">
      <ResponseField name="decision" type="object" required>
        The subject's decision. The accept/reject outcome lives here, not on `credential.status`.

        <Expandable title="decision">
          <ResponseField name="action" type="string" required>
            Either `accept` or `reject`.
          </ResponseField>

          <ResponseField name="date" type="string" required>
            ISO 8601 timestamp of when the decision was made.
          </ResponseField>
        </Expandable>
      </ResponseField>

      <ResponseField name="credential" type="object" required>
        The credential that was decided.

        <Expandable title="credential">
          <ResponseField name="id" type="string" required>URN of the credential.</ResponseField>
          <ResponseField name="resourceType" type="string" required>`DOCUMENT`, `CONSENT`, `FORM`, `JSON`, or `POLICY`.</ResponseField>
          <ResponseField name="status" type="string" required>`DRAFT`, `ACTIVE`, `REJECTED`, `CANCELED`, `REVOKED`, or `EXPIRED`.</ResponseField>
          <ResponseField name="name" type="string">Human-readable name of the credential.</ResponseField>
          <ResponseField name="description" type="string">Free-text description of the credential.</ResponseField>
          <ResponseField name="internalId" type="string">Your own identifier for the credential.</ResponseField>
          <ResponseField name="tags" type="string[]">Free-form labels attached to the credential.</ResponseField>
          <ResponseField name="w3cCredential" type="object" required>The signed W3C Verifiable Credential, using the VIA protocol. See its schema on any credential endpoint in the API reference.</ResponseField>
          <ResponseField name="decisions" type="object[]" required>Per-grantor decision records for the credential.</ResponseField>
        </Expandable>
      </ResponseField>
    </Tab>
  </Tabs>
</div>

***

## Wire Format

The encrypted message posted via `postMessage` is the outer envelope you receive on the `message` event. Decrypt its `ciphertext` to get the payload array above.

<ResponseField name="version" type="string" required>
  Envelope version, currently `1`.
</ResponseField>

<ResponseField name="encrypted" type="boolean" required>
  Always `true`; check it before processing the message.
</ResponseField>

<ResponseField name="scheme" type="string" required>
  Encryption scheme: `ECDH-P256-AES-256-GCM`.
</ResponseField>

<ResponseField name="ephemeralPublicKey" type="string" required>
  The backend's ephemeral public key for the ECDH exchange, as base64 SPKI DER.
</ResponseField>

<ResponseField name="iv" type="string" required>
  Initialization vector: a 12-byte nonce, base64-encoded.
</ResponseField>

<ResponseField name="ciphertext" type="string" required>
  AES-GCM ciphertext plus the 16-byte auth tag, base64-encoded.
</ResponseField>

***

## Complete Example

A self-contained page that generates a key pair, asks **your** backend to create the request, passing the public key, embeds the returned `link`, listens for the encrypted message, and decrypts the array of events. Replace `createHumanosRequest` with a call to your own backend endpoint, which holds your API key and calls `POST /request`.

```html theme={"dark"}
<!doctype html>
<html>
  <body>
    <iframe
      id="humanos-frame"
      style="width: 100%; height: 600px; border: none"
      allow="camera; publickey-credentials-get; publickey-credentials-create"
    ></iframe>
    <pre id="result">Waiting for result...</pre>

    <script>
      const HKDF_INFO = new TextEncoder().encode("humanos-postmessage-v1");
      let privateKey;
      let humanosOrigin;

      async function init() {
        // 1. Generate the ECDH key pair (private key stays in the browser)
        const keyPair = await crypto.subtle.generateKey(
          { name: "ECDH", namedCurve: "P-256" },
          false,
          ["deriveKey", "deriveBits"],
        );
        privateKey = keyPair.privateKey;

        // 2. Export the public key
        const spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
        const pubKeyB64 = btoa(String.fromCharCode(...new Uint8Array(spki)));

        // 3. Ask YOUR backend to create the request with iframe.pubKey.
        //    Your backend calls POST /request and returns the subject's `link`.
        const { link } = await createHumanosRequest(pubKeyB64);

        // 4. Embed the returned link and remember its origin for verification.
        humanosOrigin = new URL(link).origin;
        document.getElementById("humanos-frame").src = link;
      }

      // Your backend endpoint. Holds the API key, calls POST /request, returns { link }.
      async function createHumanosRequest(pubKeyB64) {
        const res = await fetch("/api/humanos/create-request", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ pubKey: pubKeyB64 }),
        });
        return res.json(); // { link: "https://app.humanos.tech/link/..." }
      }

      window.addEventListener("message", async (event) => {
        if (event.origin !== humanosOrigin) return;
        if (!event.data?.encrypted) return;

        const { ephemeralPublicKey, iv, ciphertext } = event.data;

        const epkBytes = Uint8Array.from(atob(ephemeralPublicKey), (c) =>
          c.charCodeAt(0),
        );
        const epk = await crypto.subtle.importKey(
          "spki",
          epkBytes,
          { name: "ECDH", namedCurve: "P-256" },
          false,
          [],
        );
        const sharedBits = await crypto.subtle.deriveBits(
          { name: "ECDH", public: epk },
          privateKey,
          256,
        );
        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"],
        );
        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 events = JSON.parse(new TextDecoder().decode(plaintext));
        document.getElementById("result").textContent = JSON.stringify(
          events,
          null,
          2,
        );
      });

      init();
    </script>
  </body>
</html>
```

***

## Security Considerations

* **Origin validation**: The backend validates the browser-detected parent origin against your configured allowlist. If the origin is not registered, and `*` is not set, no encrypted payload is generated. The origin is never read from a query parameter.
* **Non-extractable private key**: Generate the key with `extractable: false` so no script can read 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 confidentiality and integrity. Tampered ciphertext fails decryption.
* **Trusted-session cookies**: The `humanos_trusted_session` cookie is `HttpOnly` + `Secure` + `SameSite=None`, only the SHA-256 hash of the token is stored server-side, and it is strictly scoped to a single subject + issuer with a bounded lifetime. It does not bypass origin validation.

***

## Dashboard Configuration

To enable the iframe postMessage channel:

1. Open your organization's **notification / embed settings** in the [Humanos Dashboard](https://app.humanos.tech).
2. Enable **iframe notifications**.
3. Add your allowed origins: **HTTPS only, up to 10**.

<div className="hm-tip">
  You can set `*` to allow **all** origins, but this disables clickjacking protection for the embed and is strongly discouraged in production. Prefer an explicit allowlist of your own HTTPS origins.
</div>

When iframe notifications are disabled, the iframe will not send any `postMessage` events regardless of the request configuration.
