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}/oauth2/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
// X-Propper-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
function verifySignature(payload, header, secret, toleranceSeconds = 300) {
const parts = (header || '').split(',').map((part) => part.split('='));
const timestamp = parts.find(([key]) => key === 't')?.[1];
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
const ts = Number(timestamp);
if (!timestamp || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
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('utf8');
if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
console.log(`Webhook received: ${event.type}`);
// agreementId is the same ID as envelopeId in the envelopes API
const { agreementId, recipient, reason } = event.data;
switch (event.type) {
case 'agreement.sent':
console.log(`Agreement ${agreementId} sent to recipients`);
break;
case 'agreement.viewed':
console.log(`Agreement ${agreementId} viewed by ${recipient?.email}`);
break;
case 'agreement.recipient_signed':
console.log(`Agreement ${agreementId} signed by ${recipient?.email}`);
break;
case 'agreement.completed':
console.log(`Agreement ${agreementId} completed!`);
// Download the signed document
await downloadSignedDocument(agreementId, `./signed-${agreementId}.pdf`);
break;
case 'agreement.declined':
console.log(`Agreement ${agreementId} declined: ${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}/oauth2/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: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
header = request.headers.get("x-propper-signature", "")
payload = request.get_data() # raw bytes
parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
timestamp = next((value for key, value in parts if key == "t"), None)
signatures = [value for key, value in parts if key == "v1"]
try:
fresh = timestamp is not None and abs(time.time() - int(timestamp)) <= 300
except ValueError: # malformed t= value
fresh = False
expected = hmac.new(
os.environ["WEBHOOK_SECRET"].encode(),
(timestamp or "").encode() + b"." + payload,
hashlib.sha256,
).hexdigest()
if not fresh or not any(hmac.compare_digest(expected, s) for s in signatures):
print("Invalid webhook signature")
return "Invalid signature", 401
event = request.get_json()
event_type = event.get("type")
print(f"Webhook received: {event_type}")
# agreementId is the same ID as envelopeId in the envelopes API
data = event.get("data", {})
agreement_id = data.get("agreementId")
recipient_email = (data.get("recipient") or {}).get("email")
if event_type == "agreement.sent":
print(f"Agreement {agreement_id} sent to recipients")
elif event_type == "agreement.viewed":
print(f"Agreement {agreement_id} viewed by {recipient_email}")
elif event_type == "agreement.recipient_signed":
print(f"Agreement {agreement_id} signed by {recipient_email}")
elif event_type == "agreement.completed":
print(f"Agreement {agreement_id} completed!")
download_signed_document(agreement_id, f"./signed-{agreement_id}.pdf")
elif event_type == "agreement.declined":
reason = data.get("reason")
print(f"Agreement {agreement_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/oauth2/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 the signing page:
Webhook received: agreement.viewed
Agreement abc123-def456-ghi789 viewed by client@example.com
# When signer completes:
Webhook received: agreement.recipient_signed
Agreement abc123-def456-ghi789 signed by client@example.com
Webhook received: agreement.completed
Agreement 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