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.
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 returns409otherwise.
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
| 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:
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
- In the applicant's browser, generate a PKCE verifier and its S256 challenge. The verifier stays in browser memory.
- 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.
- Install your
messagelistener, then set the iframesrcto the returnedembedUrl. - The iframe emits
SESSION_READY. Reply withACTIVATEand the verifier within 30 seconds. - The applicant reads and signs the AOC in the iframe (
AGREEMENT_SIGNED). - The applicant completes Veriff inside the iframe, or in a top-level tab when the browser
cannot run it nested (
KYC_STARTED, thenKYC_COMPLETEDorKYC_FAILED). - When e-Próspera has persisted an approved decision from Veriff's signed webhook, it moves
the application to
Pending Reviewand emitsAPPLICATION_SUBMITTED. Do not call/submitfor 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"
}
}| 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
<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.
| 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.
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
| 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
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
| 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
| 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 |
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.
Partner onboarding
How an approved organization is provisioned for the Partner API: what to send e-Próspera, how origins and keys work, what to test before production, and how to report problems.
Agent recipes
End-to-end API key and OAuth workflows you can copy, paste, and run. The CLI path is recommended when your runtime can execute shell commands; direct HTTP examples are included for custom clients and environments where the CLI is not available.