Basic Signing Workflow
A complete, end-to-end example of sending a document for signature and handling the response.
Overview
This example demonstrates:
- Creating an envelope with a PDF document
- Adding a signer with signature fields
- Sending for signature via email
- Tracking status via webhook
- Downloading the signed document
Prerequisites
- Propper API credentials (client ID and secret)
- A PDF document to send for signing
- A webhook endpoint to receive events
Complete Code Example
- JavaScript (Node.js)
- Python
- cURL (Step by step)
// sign-workflow.js
import fs from 'fs/promises';
import express from 'express';
import crypto from 'crypto';
const BASE_URL = 'https://api.propper.ai/restapi/v2.1';
const AUTH_URL = 'https://auth.propper.ai';
const ACCOUNT_ID = process.env.PROPPER_ACCOUNT_ID;
// Token management
let tokenCache = null;
async function getAccessToken() {
if (tokenCache && tokenCache.expiresAt > Date.now()) {
return tokenCache.token;
}
const response = await fetch(`${AUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: process.env.PROPPER_CLIENT_ID,
client_secret: process.env.PROPPER_CLIENT_SECRET,
scope: 'sign:read sign:write',
}),
});
if (!response.ok) {
throw new Error('Failed to get access token');
}
const { access_token, expires_in } = await response.json();
tokenCache = {
token: access_token,
expiresAt: Date.now() + (expires_in * 1000) - 60000, // 1 min buffer
};
return access_token;
}
// API helper
async function apiRequest(path, options = {}) {
const token = await getAccessToken();
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`API Error: ${error.errorCode} - ${error.message}`);
}
// Handle 204 No Content
if (response.status === 204) {
return null;
}
return response.json();
}
// Step 1: Create and send envelope
async function sendForSignature(pdfPath, signerEmail, signerName) {
// Read PDF and convert to base64
const pdfBuffer = await fs.readFile(pdfPath);
const documentBase64 = pdfBuffer.toString('base64');
const envelope = await apiRequest(`/accounts/${ACCOUNT_ID}/envelopes`, {
method: 'POST',
body: JSON.stringify({
emailSubject: 'Please sign: Service Agreement',
emailBlurb: 'Please review and sign this agreement at your earliest convenience.',
status: 'sent', // 'created' for draft, 'sent' to send immediately
documents: [
{
documentId: '1',
name: 'Service Agreement.pdf',
documentBase64,
order: '1',
},
],
recipients: {
signers: [
{
email: signerEmail,
name: signerName,
recipientId: '1',
routingOrder: '1',
tabs: {
signHereTabs: [
{
documentId: '1',
pageNumber: '1',
xPosition: '100',
yPosition: '600',
anchorString: '/sig1/', // Alternative: use anchor text
},
],
dateSignedTabs: [
{
documentId: '1',
pageNumber: '1',
xPosition: '300',
yPosition: '600',
},
],
},
},
],
},
}),
});
console.log(`Envelope created and sent: ${envelope.envelopeId}`);
console.log(`Status: ${envelope.status}`);
return envelope;
}
// Step 2: Check envelope status
async function getEnvelopeStatus(envelopeId) {
const envelope = await apiRequest(
`/accounts/${ACCOUNT_ID}/envelopes/${envelopeId}?include=recipients`
);
console.log(`Envelope ${envelopeId}:`);
console.log(` Status: ${envelope.status}`);
console.log(` Recipients:`);
if (envelope.recipients?.signers) {
for (const signer of envelope.recipients.signers) {
console.log(` - ${signer.name} (${signer.email}): ${signer.status}`);
}
}
return envelope;
}
// Step 3: Download signed document
async function downloadSignedDocument(envelopeId, outputPath) {
const token = await getAccessToken();
const response = await fetch(
`${BASE_URL}/accounts/${ACCOUNT_ID}/envelopes/${envelopeId}/documents/combined`,
{
headers: { 'Authorization': `Bearer ${token}` },
}
);
if (!response.ok) {
throw new Error('Failed to download document');
}
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(outputPath, buffer);
console.log(`Signed document saved to: ${outputPath}`);
}
// Step 4: Webhook handler
function createWebhookServer(port = 3000) {
const app = express();
app.post('/webhooks/sign', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify webhook signature
const signature = req.headers['x-propper-signature'];
const payload = req.body.toString();
const expectedSignature = `sha256=${crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex')}`;
if (signature !== expectedSignature) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
console.log(`Webhook received: ${event.type}`);
switch (event.type) {
case 'document.sent':
console.log(`Document ${event.data.document.id} sent to recipients`);
break;
case 'document.viewed':
console.log(`Document ${event.data.document.id} viewed by ${event.data.signer?.email}`);
break;
case 'document.signed':
console.log(`Document ${event.data.document.id} signed by ${event.data.signer?.email}`);
break;
case 'document.completed':
console.log(`Document ${event.data.document.id} completed!`);
// Download the signed document
await downloadSignedDocument(
event.data.document.id,
`./signed-${event.data.document.id}.pdf`
);
break;
case 'document.declined':
console.log(`Document ${event.data.document.id} declined: ${event.data.reason}`);
break;
}
res.status(200).send('OK');
});
app.listen(port, () => {
console.log(`Webhook server listening on port ${port}`);
});
return app;
}
// Main execution
async function main() {
try {
// Start webhook server
createWebhookServer(3000);
// Send document for signature
const envelope = await sendForSignature(
'./contract.pdf',
'client@example.com',
'John Smith'
);
// Check status after a delay (or use webhooks instead)
setTimeout(async () => {
await getEnvelopeStatus(envelope.envelopeId);
}, 5000);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
}
main();
# sign_workflow.py
import os
import base64
import hashlib
import hmac
import time
from pathlib import Path
from typing import Optional
import requests
from flask import Flask, request, jsonify
BASE_URL = "https://api.propper.ai/restapi/v2.1"
AUTH_URL = "https://auth.propper.ai"
ACCOUNT_ID = os.environ["PROPPER_ACCOUNT_ID"]
# Token management
_token_cache = {"token": None, "expires_at": 0}
def get_access_token() -> str:
"""Get a valid access token, refreshing if needed."""
if _token_cache["token"] and _token_cache["expires_at"] > time.time():
return _token_cache["token"]
response = requests.post(
f"{AUTH_URL}/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": os.environ["PROPPER_CLIENT_ID"],
"client_secret": os.environ["PROPPER_CLIENT_SECRET"],
"scope": "sign:read sign:write",
},
)
response.raise_for_status()
data = response.json()
_token_cache["token"] = data["access_token"]
_token_cache["expires_at"] = time.time() + data["expires_in"] - 60 # 1 min buffer
return _token_cache["token"]
def api_request(path: str, method: str = "GET", **kwargs) -> Optional[dict]:
"""Make an authenticated API request."""
token = get_access_token()
response = requests.request(
method,
f"{BASE_URL}{path}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
**kwargs,
)
if not response.ok:
error = response.json()
raise Exception(f"API Error: {error.get('errorCode')} - {error.get('message')}")
if response.status_code == 204:
return None
return response.json()
# Step 1: Create and send envelope
def send_for_signature(pdf_path: str, signer_email: str, signer_name: str) -> dict:
"""Create an envelope and send for signature."""
# Read PDF and convert to base64
pdf_content = Path(pdf_path).read_bytes()
document_base64 = base64.b64encode(pdf_content).decode("utf-8")
envelope = api_request(
f"/accounts/{ACCOUNT_ID}/envelopes",
method="POST",
json={
"emailSubject": "Please sign: Service Agreement",
"emailBlurb": "Please review and sign this agreement at your earliest convenience.",
"status": "sent", # "created" for draft, "sent" to send immediately
"documents": [
{
"documentId": "1",
"name": "Service Agreement.pdf",
"documentBase64": document_base64,
"order": "1",
}
],
"recipients": {
"signers": [
{
"email": signer_email,
"name": signer_name,
"recipientId": "1",
"routingOrder": "1",
"tabs": {
"signHereTabs": [
{
"documentId": "1",
"pageNumber": "1",
"xPosition": "100",
"yPosition": "600",
}
],
"dateSignedTabs": [
{
"documentId": "1",
"pageNumber": "1",
"xPosition": "300",
"yPosition": "600",
}
],
},
}
]
},
},
)
print(f"Envelope created and sent: {envelope['envelopeId']}")
print(f"Status: {envelope['status']}")
return envelope
# Step 2: Check envelope status
def get_envelope_status(envelope_id: str) -> dict:
"""Get the current status of an envelope."""
envelope = api_request(
f"/accounts/{ACCOUNT_ID}/envelopes/{envelope_id}?include=recipients"
)
print(f"Envelope {envelope_id}:")
print(f" Status: {envelope['status']}")
print(" Recipients:")
signers = envelope.get("recipients", {}).get("signers", [])
for signer in signers:
print(f" - {signer['name']} ({signer['email']}): {signer['status']}")
return envelope
# Step 3: Download signed document
def download_signed_document(envelope_id: str, output_path: str) -> None:
"""Download the completed signed document."""
token = get_access_token()
response = requests.get(
f"{BASE_URL}/accounts/{ACCOUNT_ID}/envelopes/{envelope_id}/documents/combined",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
Path(output_path).write_bytes(response.content)
print(f"Signed document saved to: {output_path}")
# Step 4: Webhook handler
def create_webhook_app() -> Flask:
"""Create a Flask app to handle webhooks."""
app = Flask(__name__)
@app.route("/webhooks/sign", methods=["POST"])
def handle_webhook():
# Verify webhook signature
signature = request.headers.get("x-propper-signature", "")
payload = request.get_data(as_text=True)
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
print("Invalid webhook signature")
return "Invalid signature", 401
event = request.get_json()
event_type = event.get("type")
print(f"Webhook received: {event_type}")
document_id = event.get("data", {}).get("document", {}).get("id")
signer_email = event.get("data", {}).get("signer", {}).get("email")
if event_type == "document.sent":
print(f"Document {document_id} sent to recipients")
elif event_type == "document.viewed":
print(f"Document {document_id} viewed by {signer_email}")
elif event_type == "document.signed":
print(f"Document {document_id} signed by {signer_email}")
elif event_type == "document.completed":
print(f"Document {document_id} completed!")
download_signed_document(document_id, f"./signed-{document_id}.pdf")
elif event_type == "document.declined":
reason = event.get("data", {}).get("reason")
print(f"Document {document_id} declined: {reason}")
return "OK", 200
return app
# Main execution
def main():
import threading
# Start webhook server in background
app = create_webhook_app()
server_thread = threading.Thread(
target=lambda: app.run(port=3000, debug=False),
daemon=True,
)
server_thread.start()
print("Webhook server started on port 3000")
try:
# Send document for signature
envelope = send_for_signature(
"./contract.pdf",
"client@example.com",
"John Smith",
)
# Check status after a delay
time.sleep(5)
get_envelope_status(envelope["envelopeId"])
# Keep main thread alive for webhooks
print("\nWaiting for webhooks... (Ctrl+C to exit)")
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Error: {e}")
exit(1)
if __name__ == "__main__":
main()
#!/bin/bash
# sign-workflow.sh
# Configuration
export PROPPER_BASE_URL="https://api.propper.ai/restapi/v2.1"
export PROPPER_AUTH_URL="https://auth.propper.ai"
# Set these in your environment:
# PROPPER_ACCOUNT_ID, PROPPER_CLIENT_ID, PROPPER_CLIENT_SECRET
# Step 1: Get access token
echo "Getting access token..."
TOKEN_RESPONSE=$(curl -s -X POST "$PROPPER_AUTH_URL/oauth/token" \
-H "Content-Type: application/json" \
-d "{
\"grant_type\": \"client_credentials\",
\"client_id\": \"$PROPPER_CLIENT_ID\",
\"client_secret\": \"$PROPPER_CLIENT_SECRET\",
\"scope\": \"sign:read sign:write\"
}")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
if [ "$ACCESS_TOKEN" == "null" ]; then
echo "Failed to get access token"
exit 1
fi
echo "Token obtained successfully"
# Step 2: Prepare document (base64 encode)
echo "Encoding document..."
DOCUMENT_BASE64=$(base64 -i ./contract.pdf)
# Step 3: Create and send envelope
echo "Creating envelope..."
ENVELOPE_RESPONSE=$(curl -s -X POST \
"$PROPPER_BASE_URL/accounts/$PROPPER_ACCOUNT_ID/envelopes" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"emailSubject\": \"Please sign: Service Agreement\",
\"emailBlurb\": \"Please review and sign this agreement.\",
\"status\": \"sent\",
\"documents\": [{
\"documentId\": \"1\",
\"name\": \"Service Agreement.pdf\",
\"documentBase64\": \"$DOCUMENT_BASE64\",
\"order\": \"1\"
}],
\"recipients\": {
\"signers\": [{
\"email\": \"client@example.com\",
\"name\": \"John Smith\",
\"recipientId\": \"1\",
\"routingOrder\": \"1\",
\"tabs\": {
\"signHereTabs\": [{
\"documentId\": \"1\",
\"pageNumber\": \"1\",
\"xPosition\": \"100\",
\"yPosition\": \"600\"
}]
}
}]
}
}")
ENVELOPE_ID=$(echo "$ENVELOPE_RESPONSE" | jq -r '.envelopeId')
STATUS=$(echo "$ENVELOPE_RESPONSE" | jq -r '.status')
echo "Envelope created: $ENVELOPE_ID"
echo "Status: $STATUS"
# Step 4: Check status (after some time)
echo ""
echo "Checking envelope status..."
sleep 5
STATUS_RESPONSE=$(curl -s \
"$PROPPER_BASE_URL/accounts/$PROPPER_ACCOUNT_ID/envelopes/$ENVELOPE_ID?include=recipients" \
-H "Authorization: Bearer $ACCESS_TOKEN")
echo "$STATUS_RESPONSE" | jq '{
envelopeId: .envelopeId,
status: .status,
signers: [.recipients.signers[] | {name, email, status}]
}'
# Step 5: Download signed document (run this after signing completes)
download_signed() {
echo "Downloading signed document..."
curl -s \
"$PROPPER_BASE_URL/accounts/$PROPPER_ACCOUNT_ID/envelopes/$ENVELOPE_ID/documents/combined" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-o "signed-$ENVELOPE_ID.pdf"
echo "Saved to: signed-$ENVELOPE_ID.pdf"
}
# Uncomment to download after completion:
# download_signed
Environment Setup
Create a .env file:
PROPPER_ACCOUNT_ID=your-organization-uuid
PROPPER_CLIENT_ID=your-client-id
PROPPER_CLIENT_SECRET=your-client-secret
WEBHOOK_SECRET=your-webhook-secret
Running the Example
- JavaScript
- Python
- cURL
# Install dependencies
npm install express
# Run
node sign-workflow.js
# Install dependencies
pip install requests flask
# Run
python sign_workflow.py
# Make executable
chmod +x sign-workflow.sh
# Run
./sign-workflow.sh
Expected Output
Webhook server listening on port 3000
Envelope created and sent: abc123-def456-ghi789
Status: sent
Envelope abc123-def456-ghi789:
Status: sent
Recipients:
- John Smith (client@example.com): sent
# When signer opens email:
Webhook received: document.viewed
Document abc123-def456-ghi789 viewed by client@example.com
# When signer completes:
Webhook received: document.signed
Document abc123-def456-ghi789 signed by client@example.com
Webhook received: document.completed
Document abc123-def456-ghi789 completed!
Signed document saved to: ./signed-abc123-def456-ghi789.pdf
Next Steps
- Embedded Signing - Sign within your app
- Templates - Reuse document structures
- Bulk Send Example - Send to multiple recipients