Skip to main content

Embedded Signing

Embed the signing experience directly within your application for a seamless user experience.

When to Use Embedded Signing

Use CaseRecommendation
Customer-facing portalsEmbedded - Keep users in your app
Internal HR workflowsEmbedded - Streamlined employee experience
B2B contracts via emailEmail - Recipients may not have accounts
High-volume bulk signingEmail - Less integration overhead
Mobile appsEmbedded - Native webview integration

How It Works

Step 1: Create an Envelope with Embedded Recipients

To enable embedded signing, set the clientUserId field on recipients. This marks them as "captive" recipients who will sign within your application.

curl -X POST "https://api.propper.ai/restapi/v2.1/accounts/{accountId}/envelopes" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"emailSubject": "Please sign this document",
"status": "sent",
"documents": [{
"documentId": "1",
"name": "Contract.pdf",
"documentBase64": "JVBERi0xLjQK..."
}],
"recipients": {
"signers": [{
"email": "signer@example.com",
"name": "John Smith",
"recipientId": "1",
"routingOrder": "1",
"clientUserId": "user-123"
}]
}
}'
tip

The clientUserId can be any string that uniquely identifies the signer in your system (user ID, email, etc.). It's not sent to the signer—it's just for your reference.

Step 2: Generate a Signing URL

Request a short-lived URL that loads the signing experience.

curl -X POST "https://api.propper.ai/restapi/v2.1/accounts/{accountId}/envelopes/{envelopeId}/views/recipient" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"returnUrl": "https://yourapp.com/signing-complete",
"authenticationMethod": "none",
"clientUserId": "user-123",
"email": "signer@example.com",
"userName": "John Smith"
}'

Response:

{
"url": "https://sign.propper.ai/s/eyJhbGciOiJIUzI1NiIs..."
}
warning

Signing URLs expire in 5 minutes. Generate them just before displaying the signing UI, not in advance.

Step 3: Embed in an iframe

Basic iframe Integration

<iframe
id="signing-frame"
src="https://sign.propper.ai/s/eyJhbGciOiJIUzI1NiIs..."
width="100%"
height="800"
frameborder="0"
allow="camera; microphone"
></iframe>

React Integration

import { useState, useEffect, useCallback } from 'react';

function SigningEmbed({ envelopeId, onComplete, onDecline, onError }) {
const [signingUrl, setSigningUrl] = useState(null);
const [loading, setLoading] = useState(true);

// Fetch signing URL
useEffect(() => {
const fetchUrl = async () => {
try {
const response = await fetch(`/api/signing-url/${envelopeId}`, {
method: 'POST',
});
const { url } = await response.json();
setSigningUrl(url);
} catch (err) {
onError?.(err);
} finally {
setLoading(false);
}
};
fetchUrl();
}, [envelopeId]);

// Listen for signing events
useEffect(() => {
const handleMessage = (event) => {
// Validate origin
if (!event.origin.includes('propper.ai')) return;

const { type, data } = event.data;

switch (type) {
case 'signing_complete':
onComplete?.(data);
break;
case 'decline':
onDecline?.(data);
break;
case 'session_timeout':
onError?.(new Error('Session expired'));
break;
}
};

window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [onComplete, onDecline, onError]);

if (loading) {
return <div>Loading signing session...</div>;
}

return (
<iframe
src={signingUrl}
style={{ width: '100%', height: '800px', border: 'none' }}
allow="camera; microphone"
title="Document Signing"
/>
);
}

Vanilla JavaScript Integration

<div id="signing-container">
<div id="signing-loading">Loading...</div>
<iframe id="signing-frame" style="display: none; width: 100%; height: 800px; border: none;"></iframe>
</div>

<script>
async function initSigning(envelopeId) {
const container = document.getElementById('signing-container');
const loading = document.getElementById('signing-loading');
const frame = document.getElementById('signing-frame');

try {
// Fetch signing URL from your backend
const response = await fetch(`/api/signing-url/${envelopeId}`, {
method: 'POST',
});
const { url } = await response.json();

// Load the signing UI
frame.src = url;
frame.style.display = 'block';
loading.style.display = 'none';

// Listen for completion events
window.addEventListener('message', (event) => {
if (!event.origin.includes('propper.ai')) return;

switch (event.data.type) {
case 'signing_complete':
handleSigningComplete(event.data);
break;
case 'decline':
handleDecline(event.data);
break;
case 'session_timeout':
handleTimeout();
break;
}
});
} catch (error) {
loading.textContent = 'Failed to load signing session';
console.error('Signing error:', error);
}
}

function handleSigningComplete(data) {
console.log('Document signed!', data);
// Redirect or update UI
window.location.href = '/signing-complete';
}

function handleDecline(data) {
console.log('Signing declined:', data.reason);
// Handle decline
}

function handleTimeout() {
// Refresh the signing URL and reload iframe
initSigning(currentEnvelopeId);
}
</script>

PostMessage Events Reference

The signing iframe communicates with your application via the postMessage API.

EventDescriptionData
signing_completeSigner finished signing all required fields{ envelopeId, recipientId }
declineSigner declined to sign{ envelopeId, recipientId, reason }
session_timeoutSession expired (5 min idle){ envelopeId }
viewing_completeSigner finished viewing (for view-only recipients){ envelopeId, recipientId }
ttl_expiredSigning URL expired{ envelopeId }
exceptionError occurred during signing{ errorCode, message }
cancelSigner clicked cancel/close{ envelopeId }
fax_pendingFax submission pending{ envelopeId }

Event Payload Structure

interface SigningEvent {
type: 'signing_complete' | 'decline' | 'session_timeout' | ...;
data: {
envelopeId: string;
recipientId?: string;
reason?: string; // For decline events
errorCode?: string; // For exception events
message?: string; // For exception events
};
}

Security Considerations

Content Security Policy (CSP)

Add Propper's domain to your CSP frame-src directive:

Content-Security-Policy: frame-src 'self' https://*.propper.ai;

Origin Validation

Always validate the message origin before processing events:

window.addEventListener('message', (event) => {
// Only accept messages from Propper
const allowedOrigins = [
'https://app.propperai.com',
'https://app.propper.ai',
];

if (!allowedOrigins.some(origin => event.origin.startsWith(origin))) {
console.warn('Ignoring message from unknown origin:', event.origin);
return;
}

// Process the event
handleSigningEvent(event.data);
});

HTTPS Required

Embedded signing requires HTTPS in production. The iframe will not load over HTTP.

Mobile Considerations

Responsive iframe

.signing-container {
position: relative;
width: 100%;
padding-bottom: 150%; /* Aspect ratio for mobile */
height: 0;
overflow: hidden;
}

.signing-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}

@media (min-width: 768px) {
.signing-container {
padding-bottom: 75%; /* Wider aspect ratio for desktop */
}
}

Native App WebView

For React Native or native mobile apps, use a WebView component:

// React Native
import { WebView } from 'react-native-webview';

function SigningWebView({ signingUrl, onComplete }) {
const handleMessage = (event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'signing_complete') {
onComplete(data);
}
};

return (
<WebView
source={{ uri: signingUrl }}
onMessage={handleMessage}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
);
}

Troubleshooting

iframe Shows Blank Page

  1. Check CSP headers - Ensure frame-src allows *.propper.ai
  2. Verify URL hasn't expired - URLs expire in 5 minutes
  3. Check browser console - Look for CORS or security errors
  4. Ensure HTTPS - HTTP won't work in production

PostMessage Events Not Received

  1. Validate origin correctly - Don't use strict equality, use includes() or startsWith()
  2. Check event listener timing - Add listener before loading iframe
  3. Inspect event data - Log all events to see what's being sent

Session Timeout Issues

  1. Handle session_timeout event - Refresh the signing URL
  2. Implement retry logic - Automatically fetch new URL and reload
  3. Show user feedback - Inform users when refreshing
let retryCount = 0;
const MAX_RETRIES = 3;

async function handleTimeout() {
if (retryCount >= MAX_RETRIES) {
showError('Session expired. Please refresh the page.');
return;
}

retryCount++;
showMessage('Refreshing signing session...');

const newUrl = await fetchSigningUrl(envelopeId);
document.getElementById('signing-frame').src = newUrl;
}

Next Steps