Embedded Signing
Embed the signing experience directly within your application for a seamless user experience.
When to Use Embedded Signing
| Use Case | Recommendation |
|---|---|
| Customer-facing portals | Embedded - Keep users in your app |
| Internal HR workflows | Embedded - Streamlined employee experience |
| B2B contracts via email | Email - Recipients may not have accounts |
| High-volume bulk signing | Email - Less integration overhead |
| Mobile apps | Embedded - 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
- JavaScript
- Python
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"
}]
}
}'
const createEmbeddedEnvelope = async (accountId, accessToken) => {
const response = await fetch(
`https://api.propper.ai/restapi/v2.1/accounts/${accountId}/envelopes`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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', // Enables embedded signing
}],
},
}),
}
);
return response.json();
};
import requests
def create_embedded_envelope(account_id: str, access_token: str) -> dict:
response = requests.post(
f"https://api.propper.ai/restapi/v2.1/accounts/{account_id}/envelopes",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={
"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", # Enables embedded signing
}],
},
},
)
return response.json()
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
- JavaScript
- Python
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..."
}
const getSigningUrl = async (accountId, envelopeId, accessToken) => {
const response = await fetch(
`https://api.propper.ai/restapi/v2.1/accounts/${accountId}/envelopes/${envelopeId}/views/recipient`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
returnUrl: 'https://yourapp.com/signing-complete',
authenticationMethod: 'none',
clientUserId: 'user-123',
email: 'signer@example.com',
userName: 'John Smith',
}),
}
);
const { url } = await response.json();
return url;
};
def get_signing_url(account_id: str, envelope_id: str, access_token: str) -> str:
response = requests.post(
f"https://api.propper.ai/restapi/v2.1/accounts/{account_id}/envelopes/{envelope_id}/views/recipient",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={
"returnUrl": "https://yourapp.com/signing-complete",
"authenticationMethod": "none",
"clientUserId": "user-123",
"email": "signer@example.com",
"userName": "John Smith",
},
)
return response.json()["url"]
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.
| Event | Description | Data |
|---|---|---|
signing_complete | Signer finished signing all required fields | { envelopeId, recipientId } |
decline | Signer declined to sign | { envelopeId, recipientId, reason } |
session_timeout | Session expired (5 min idle) | { envelopeId } |
viewing_complete | Signer finished viewing (for view-only recipients) | { envelopeId, recipientId } |
ttl_expired | Signing URL expired | { envelopeId } |
exception | Error occurred during signing | { errorCode, message } |
cancel | Signer clicked cancel/close | { envelopeId } |
fax_pending | Fax 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
- Check CSP headers - Ensure
frame-srcallows*.propper.ai - Verify URL hasn't expired - URLs expire in 5 minutes
- Check browser console - Look for CORS or security errors
- Ensure HTTPS - HTTP won't work in production
PostMessage Events Not Received
- Validate origin correctly - Don't use strict equality, use
includes()orstartsWith() - Check event listener timing - Add listener before loading iframe
- Inspect event data - Log all events to see what's being sent
Session Timeout Issues
- Handle
session_timeoutevent - Refresh the signing URL - Implement retry logic - Automatically fetch new URL and reload
- 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
- API Reference: Generate Recipient View - Full endpoint documentation
- Webhooks Guide - Get notified when signing completes
- Error Handling - Handle errors gracefully