# Embedded agreement and identity verification (/partner-embed)

Host the Agreement of Coexistence and Veriff identity verification inside your own page with a PKCE-bound e-Próspera iframe. Origins, activation, events, expiry, errors, and troubleshooting.



The embed lets an applicant accept the Agreement of Coexistence (AOC) and complete identity
verification inside an e-Próspera-controlled iframe on your page, without leaving your product
and without first claiming an e-Próspera portal account. Your page receives status-only
events. It never receives the signature, identity documents, the Veriff URL, provider
decision details, or applicant personal data.

The embed is an alternative to the hosted portal handoff described in
[Partner Keys](/partner-keys#secure-application-flow). Both paths start from the same
partner-created application.

## Prerequisites [#prerequisites]

* A Partner Key with `partner:person.embed_session.create`.
* Your page's exact HTTPS origin registered as an **approved embed origin** on the
  integration. This is a separate list from redirect origins; see
  [Partner onboarding](/partner-onboarding#what-to-send-e-próspera).
* An application that is still in `Draft`, contains all required data, has proof of address
  or a sworn address statement, and has a paid invoice for its residency type. Session
  creation returns `409` otherwise.

The applicant does not need to log in to e-Próspera or use the claim-account email for this
flow. An already approved Veriff result and a still-valid AOC acceptance are reused.

## Origins and Content Security Policy [#origins-and-content-security-policy]

| Environment | Embed origin                          |
| ----------- | ------------------------------------- |
| Production  | `https://embed.eprospera.com`         |
| Staging     | `https://staging-embed.eprospera.com` |

If your page sets a Content Security Policy, allow the embed origin as a child frame and keep
every existing source your page needs:

```text
Content-Security-Policy: frame-src https://embed.eprospera.com;
```

The embed sets its own `frame-ancestors` policy to exactly the `parentOrigin` you supplied.
A frame loaded from any other origin, or navigated to as a top-level page, is denied.

## Sequence [#sequence]

1. In the applicant's browser, generate a PKCE verifier and its S256 challenge. The verifier
   stays in browser memory.
2. Your backend calls the embed-session endpoint with the challenge and your exact parent
   origin. Authorize your signed-in user first and confirm the application belongs to them.
3. Install your `message` listener, then set the iframe `src` to the returned `embedUrl`.
4. The iframe emits `SESSION_READY`. Reply with `ACTIVATE` and the verifier within 30 seconds.
5. The applicant reads and signs the AOC in the iframe (`AGREEMENT_SIGNED`).
6. The applicant completes Veriff inside the iframe, or in a top-level tab when the browser
   cannot run it nested (`KYC_STARTED`, then `KYC_COMPLETED` or `KYC_FAILED`).
7. When e-Próspera has persisted an approved decision from Veriff's signed webhook, it moves
   the application to `Pending Review` and emits `APPLICATION_SUBMITTED`. Do not call
   `/submit` for this flow.

## 1. Generate PKCE values in the browser [#1-generate-pkce-values-in-the-browser]

```ts
function base64url(bytes: Uint8Array) {
  let value = '';
  for (const byte of bytes) value += String.fromCharCode(byte);
  return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function createEmbedPkce() {
  const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = base64url(verifierBytes);
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(codeVerifier),
  );
  return { codeVerifier, codeChallenge: base64url(new Uint8Array(digest)) };
}
```

Do not generate the verifier on your backend, place it in a URL, or record it in logs or
analytics.

## 2. Create the session from your backend [#2-create-the-session-from-your-backend]

[POST /api/v1/partner/residency\_applications/\{id}/embed\_sessions](/reference/post-residency-applications-id-embed-sessions)

```http
POST /api/v1/partner/residency_applications/{applicationId}/embed_sessions
Authorization: Bearer pk-REDACTED
Content-Type: application/json
```

```json
{
  "parentOrigin": "https://apply.partner.example",
  "codeChallenge": "<43-character-base64url-S256-challenge>",
  "codeChallengeMethod": "S256",
  "locale": "en"
}
```

Keep `parentOrigin` in trusted server configuration. Do not accept it from the browser.
`locale` is `en` or `es` and sets the iframe's initial language; the applicant can switch
languages inside the frame.

A successful response is `201`:

```json
{
  "data": {
    "sessionId": "b2c6d1a4-3f0e-4b8a-9d2e-5c7f8a1b2c3d",
    "embedUrl": "https://embed.eprospera.com/session/<one-time-token>",
    "tokenExpiresAt": "2026-09-15T14:10:00.000Z",
    "flowExpiresAt": "2026-09-15T16:00:00.000Z"
  }
}
```

| Field            | Meaning                                                                                           |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| `sessionId`      | Identifier for support requests. Not needed by your page.                                         |
| `embedUrl`       | One-time iframe URL. Treat it as a secret; never log it.                                          |
| `tokenExpiresAt` | The iframe must load `embedUrl` before this time, 10 minutes after issuance.                      |
| `flowExpiresAt`  | The activated flow ends at this time, two hours after issuance, even if the applicant is mid-step |

Issuance is **not idempotent** and does not use `Idempotency-Key`. Every call creates a new
link and revokes any earlier issued or active session for the same application. Request a new
session only when you need a replacement link.

## 3. Mount and activate the iframe [#3-mount-and-activate-the-iframe]

```html
<iframe
  id="eprospera-embed"
  title="Complete your ePróspera application"
  allow="camera; microphone; fullscreen"
  referrerpolicy="strict-origin"
></iframe>
```

Install the listener before assigning `src`; a fast `SESSION_READY` is otherwise missed. On
`SESSION_READY`, send the verifier directly from the browser to the exact embed origin.

```ts
type EmbedState = {
  agreement: 'pending' | 'signed';
  kyc: 'not_started' | 'started' | 'pending' | 'completed' | 'failed';
  submission: 'waiting' | 'submitting' | 'submitted';
};

type EProsperaEmbedEvent = {
  source: 'eprospera.embed';
  version: 1;
  event:
    | 'SESSION_READY'
    | 'AGREEMENT_SIGNED'
    | 'KYC_STARTED'
    | 'KYC_COMPLETED'
    | 'KYC_FAILED'
    | 'APPLICATION_SUBMITTED'
    | 'SESSION_EXPIRED';
  state: EmbedState;
};

const EVENT_NAMES = new Set([
  'SESSION_READY',
  'AGREEMENT_SIGNED',
  'KYC_STARTED',
  'KYC_COMPLETED',
  'KYC_FAILED',
  'APPLICATION_SUBMITTED',
  'SESSION_EXPIRED',
]);
const AGREEMENT_STATES = new Set(['pending', 'signed']);
const KYC_STATES = new Set([
  'not_started',
  'started',
  'pending',
  'completed',
  'failed',
]);
const SUBMISSION_STATES = new Set(['waiting', 'submitting', 'submitted']);

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null;
}

function isEProsperaEmbedEvent(value: unknown): value is EProsperaEmbedEvent {
  if (!isRecord(value) || !isRecord(value.state)) return false;
  return (
    value.source === 'eprospera.embed' &&
    value.version === 1 &&
    EVENT_NAMES.has(value.event as string) &&
    AGREEMENT_STATES.has(value.state.agreement as string) &&
    KYC_STATES.has(value.state.kyc as string) &&
    SUBMISSION_STATES.has(value.state.submission as string)
  );
}

export function mountEProsperaEmbed(input: {
  iframe: HTMLIFrameElement;
  embedUrl: string;
  codeVerifier: string;
  onEvent: (event: EProsperaEmbedEvent) => void;
}) {
  const embedOrigin = new URL(input.embedUrl).origin;

  const receiveMessage = (message: MessageEvent) => {
    if (
      message.origin !== embedOrigin ||
      message.source !== input.iframe.contentWindow
    ) {
      return;
    }
    if (!isEProsperaEmbedEvent(message.data)) return;

    input.onEvent(message.data);

    if (message.data.event === 'SESSION_READY') {
      input.iframe.contentWindow?.postMessage(
        {
          source: 'eprospera.partner',
          version: 1,
          event: 'ACTIVATE',
          codeVerifier: input.codeVerifier,
        },
        embedOrigin,
      );
    }
  };

  window.addEventListener('message', receiveMessage);
  input.iframe.src = input.embedUrl;

  return () => {
    window.removeEventListener('message', receiveMessage);
    input.iframe.removeAttribute('src');
  };
}
```

Validate all of the following before acting on any message: `message.origin` equals the
origin of `embedUrl`, `message.source` equals the iframe's `contentWindow`, `source` is
`eprospera.embed`, `version` is `1`, `event` is a known name, and every `state` value is a
known enum. Never use `"*"` as a `postMessage` target origin.

The `ACTIVATE` message must carry `source: 'eprospera.partner'`, `version: 1`, and a
`codeVerifier` of 43 to 128 characters. Unknown fields are rejected.

After activation, the iframe holds a non-extractable signing key in memory and proves
possession on every request. Reloading or navigating the iframe destroys that key; your
backend must issue a replacement link.

## Events [#events]

Every outbound event carries the full `state` snapshot and nothing else: no IDs, timestamps,
personal data, signature data, or Veriff details.

| Event                   | Emitted when                                                                      | Your action                                                          |
| ----------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `SESSION_READY`         | The iframe has verified its frame and is ready for activation                     | Send `ACTIVATE` once, within 30 seconds                              |
| `AGREEMENT_SIGNED`      | `state.agreement` moved to `signed`; the acceptance is persisted                  | Update your UI                                                       |
| `KYC_STARTED`           | `state.kyc` moved to `started` or `pending`                                       | Update your UI. This is not approval                                 |
| `KYC_COMPLETED`         | `state.kyc` moved to `completed` after e-Próspera stored Veriff's signed decision | Wait for `APPLICATION_SUBMITTED`                                     |
| `KYC_FAILED`            | `state.kyc` moved to `failed`; a terminal non-approved decision was stored        | Let the applicant use the retry action shown inside the iframe       |
| `APPLICATION_SUBMITTED` | `state.submission` moved to `submitted`; the application is in `Pending Review`   | Treat the embedded flow as complete. Poll the application for status |
| `SESSION_EXPIRED`       | The flow can no longer continue (see below)                                       | Ask your backend for a replacement session                           |

Several events can arrive from one state change, in the order listed above. Drive your UI from
`state`, and use the event name only as a trigger.

<Callout title="Veriff SDK completion is not approval" type="warn">
  The Veriff SDK reports browser lifecycle progress only. `KYC_COMPLETED` is
  emitted after e-Próspera persists Veriff's signed decision webhook, which can
  arrive minutes after the applicant finishes capture. Keep the iframe mounted
  and wait.
</Callout>

## Expiry and `SESSION_EXPIRED` [#expiry-and-session_expired]

| Cause                                                                  | Notes                                     |
| ---------------------------------------------------------------------- | ----------------------------------------- |
| `embedUrl` not loaded within 10 minutes                                | The iframe shows a "link expired" message |
| `ACTIVATE` not received within 30 seconds of `SESSION_READY`           | The iframe shows an activation timeout    |
| Two hours elapsed since issuance                                       | Applies even mid-Veriff                   |
| The iframe was reloaded or navigated                                   | The in-memory proof key is gone           |
| A new session was issued for the same application                      | The earlier session is revoked            |
| The parent origin was removed, or the key or integration was suspended | Rechecked on every request                |

The iframe polls its status every four seconds while active and stops after submission. In
every expiry case, request a new session from your backend and mount a fresh iframe. A
`KYC_FAILED` result does **not** expire the session; the applicant can retry from inside the
iframe while it remains active.

## Top-level Veriff fallback [#top-level-veriff-fallback]

Some browsers block camera or storage access inside a nested frame. In that case the iframe
offers the applicant a user-initiated **Open Veriff in a new tab** action. Popups must be
allowed for your page. After capture, the tab shows an e-Próspera "Verification submitted"
page telling the applicant to return to your application. Keep the original iframe mounted:
it continues polling and will emit `KYC_COMPLETED` and `APPLICATION_SUBMITTED` when the signed
decision arrives.

## Errors on session creation [#errors-on-session-creation]

| Status | Body `error`                                                                                                                                                                               | Cause                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| `400`  | `Invalid request body` with `details`                                                                                                                                                      | Missing or malformed fields; `codeChallenge` must be 43 base64url characters        |
| `400`  | `parentOrigin is invalid.`                                                                                                                                                                 | Not an HTTPS origin, or contains a path, query, fragment, or credentials            |
| `400`  | `parentOrigin is not allowed for this integration.`                                                                                                                                        | Origin is not on the integration's approved embed origins                           |
| `401`  | `Missing API key` / `Invalid API key`                                                                                                                                                      | Missing, expired, or revoked key                                                    |
| `403`  | `Insufficient permissions`                                                                                                                                                                 | Key lacks `partner:person.embed_session.create`                                     |
| `403`  | `Partner integration is not active.`                                                                                                                                                       | Integration is suspended or revoked                                                 |
| `404`  | `Residency application not found.`                                                                                                                                                         | Unknown ID, another integration's application, or the application is not in `Draft` |
| `409`  | `Application prerequisite "<field>" is incomplete.` / `Proof of address must be complete before creating an embed session.` / `Payment must be complete before creating an embed session.` | Complete the missing step and retry                                                 |
| `429`  | `Too many requests`                                                                                                                                                                        | 10 per minute per key, 50 per hour per integration. Honor `Retry-After`             |
| `503`  | `Partner embed sessions are not enabled in this environment.`                                                                                                                              | The embed is not enabled in that environment. Contact e-Próspera                    |

## Troubleshooting [#troubleshooting]

| Symptom                                      | Likely cause                                                                                                                            |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Iframe shows "Secure session unavailable"    | Wrong parent origin, top-level navigation, expired link, or failed activation handshake                                                 |
| Iframe shows "Secure activation timed out"   | `ACTIVATE` was not sent within 30 seconds; check that the listener was installed before setting `src`                                   |
| No events arrive                             | Listener installed after `src`, or `message.origin` / `message.source` checks reject the frame                                          |
| Camera does not start                        | Confirm `allow="camera; microphone; fullscreen"` on the iframe, then use the top-level Veriff fallback                                  |
| SDK finished but no `KYC_COMPLETED`          | e-Próspera is waiting for the signed Veriff decision. In staging, see [Testing in staging](/testing-in-staging#partner-keys-in-staging) |
| `SESSION_EXPIRED` right after a page refresh | Expected. Reloading destroys the proof key; issue a replacement session                                                                 |

When you report a problem, send the timestamp in UTC, the HTTP status, the endpoint path, and
the `sessionId` or application ID. Never send the Partner Key, the verifier, the `embedUrl`,
or applicant data.
