e-Próspera

Embedded agreement and identity verification

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.

View as Markdown

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. Both paths start from the same partner-created application.

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

EnvironmentEmbed origin
Productionhttps://embed.eprospera.com
Staginghttps://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:

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

  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

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

POST /api/v1/partner/residency_applications/{id}/embed_sessions

POST /api/v1/partner/residency_applications/{applicationId}/embed_sessions
Authorization: Bearer pk-REDACTED
Content-Type: application/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:

{
  "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"
  }
}
FieldMeaning
sessionIdIdentifier for support requests. Not needed by your page.
embedUrlOne-time iframe URL. Treat it as a secret; never log it.
tokenExpiresAtThe iframe must load embedUrl before this time, 10 minutes after issuance.
flowExpiresAtThe 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

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

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

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

EventEmitted whenYour action
SESSION_READYThe iframe has verified its frame and is ready for activationSend ACTIVATE once, within 30 seconds
AGREEMENT_SIGNEDstate.agreement moved to signed; the acceptance is persistedUpdate your UI
KYC_STARTEDstate.kyc moved to started or pendingUpdate your UI. This is not approval
KYC_COMPLETEDstate.kyc moved to completed after e-Próspera stored Veriff's signed decisionWait for APPLICATION_SUBMITTED
KYC_FAILEDstate.kyc moved to failed; a terminal non-approved decision was storedLet the applicant use the retry action shown inside the iframe
APPLICATION_SUBMITTEDstate.submission moved to submitted; the application is in Pending ReviewTreat the embedded flow as complete. Poll the application for status
SESSION_EXPIREDThe 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.

Veriff SDK completion is not approval

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.

Expiry and SESSION_EXPIRED

CauseNotes
embedUrl not loaded within 10 minutesThe iframe shows a "link expired" message
ACTIVATE not received within 30 seconds of SESSION_READYThe iframe shows an activation timeout
Two hours elapsed since issuanceApplies even mid-Veriff
The iframe was reloaded or navigatedThe in-memory proof key is gone
A new session was issued for the same applicationThe earlier session is revoked
The parent origin was removed, or the key or integration was suspendedRechecked 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

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

StatusBody errorCause
400Invalid request body with detailsMissing or malformed fields; codeChallenge must be 43 base64url characters
400parentOrigin is invalid.Not an HTTPS origin, or contains a path, query, fragment, or credentials
400parentOrigin is not allowed for this integration.Origin is not on the integration's approved embed origins
401Missing API key / Invalid API keyMissing, expired, or revoked key
403Insufficient permissionsKey lacks partner:person.embed_session.create
403Partner integration is not active.Integration is suspended or revoked
404Residency application not found.Unknown ID, another integration's application, or the application is not in Draft
409Application 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
429Too many requests10 per minute per key, 50 per hour per integration. Honor Retry-After
503Partner embed sessions are not enabled in this environment.The embed is not enabled in that environment. Contact e-Próspera

Troubleshooting

SymptomLikely 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 arriveListener installed after src, or message.origin / message.source checks reject the frame
Camera does not startConfirm allow="camera; microphone; fullscreen" on the iframe, then use the top-level Veriff fallback
SDK finished but no KYC_COMPLETEDe-Próspera is waiting for the signed Veriff decision. In staging, see Testing in staging
SESSION_EXPIRED right after a page refreshExpected. 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.

On this page