Sign Webhooks
Receive real-time notifications when signing events occur instead of polling the API.
Why Use Webhooks?
| Approach | Pros | Cons |
|---|---|---|
| Polling | Simple to implement | Wastes API calls, delayed updates |
| Webhooks | Real-time, efficient | Requires public endpoint |
Webhooks eliminate polling, reduce API usage, and provide instant updates.
Setup
1. Configure Webhook Endpoint
In the Propper Dashboard:
- Go to Settings → Webhooks
- Click Add Endpoint
- Enter your webhook URL (must be HTTPS)
- Select events to subscribe to
- Copy the webhook secret for signature verification
2. Create Your Handler
Your endpoint must:
- Accept POST requests
- Respond with 2xx status within 30 seconds
- Verify the webhook signature
- Handle events idempotently
Event Types
| Event | Description | When Triggered |
|---|---|---|
document.created | Envelope created | Draft or sent envelope created |
document.sent | Sent for signing | Envelope status → sent |
document.viewed | Recipient opened | First view by any recipient |
document.signed | Recipient signed | One recipient completes |
document.completed | All signed | Last recipient completes |
document.declined | Recipient declined | Signer refuses to sign |
document.voided | Cancelled | Sender voids envelope |
document.expired | Expired | Past expiration date |
Webhook Payload
Common Structure
{
"id": "evt_abc123def456",
"type": "document.completed",
"created": "2024-01-15T12:00:00Z",
"data": {
"document": {
"id": "env_xyz789",
"name": "Service Agreement",
"status": "completed"
}
}
}
Event-Specific Payloads
document.signed
{
"id": "evt_abc123",
"type": "document.signed",
"created": "2024-01-15T11:30:00Z",
"data": {
"document": {
"id": "env_xyz789",
"name": "Service Agreement",
"status": "in_progress"
},
"signer": {
"id": "rcp_def456",
"email": "signer@example.com",
"name": "John Smith",
"signedAt": "2024-01-15T11:30:00Z"
}
}
}
document.completed
{
"id": "evt_def456",
"type": "document.completed",
"created": "2024-01-15T12:00:00Z",
"data": {
"document": {
"id": "env_xyz789",
"name": "Service Agreement",
"status": "completed",
"completedAt": "2024-01-15T12:00:00Z"
},
"downloadUrl": "/restapi/v2.1/accounts/{accountId}/envelopes/env_xyz789/documents/combined"
}
}
document.declined
{
"id": "evt_ghi789",
"type": "document.declined",
"created": "2024-01-15T10:00:00Z",
"data": {
"document": {
"id": "env_xyz789",
"name": "Service Agreement",
"status": "declined"
},
"signer": {
"id": "rcp_def456",
"email": "signer@example.com",
"name": "John Smith"
},
"reason": "Terms are not acceptable"
}
}
Webhook Handler Examples
- JavaScript (Express)
- Python (Flask)
- cURL (Testing)
import express from 'express';
import crypto from 'crypto';
const app = express();
// Must use raw body for signature verification
app.post('/webhooks/sign', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-propper-signature'];
const payload = req.body.toString();
// Step 1: Verify signature
if (!verifySignature(payload, signature)) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Step 2: Parse event
const event = JSON.parse(payload);
console.log(`Received event: ${event.type}`);
// Step 3: Handle event (async, but respond immediately)
handleEvent(event).catch(err => {
console.error('Error handling event:', err);
});
// Step 4: Respond quickly
res.status(200).send('OK');
});
function verifySignature(payload, signature) {
const expected = `sha256=${crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('hex')}`;
return crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expected)
);
}
async function handleEvent(event) {
switch (event.type) {
case 'document.sent':
console.log(`Document ${event.data.document.id} sent`);
// Update your database
await db.envelopes.update({
where: { id: event.data.document.id },
data: { status: 'sent', sentAt: new Date(event.created) },
});
break;
case 'document.viewed':
console.log(`Document ${event.data.document.id} viewed`);
// Log view event
await db.envelopeEvents.create({
data: {
envelopeId: event.data.document.id,
type: 'viewed',
timestamp: new Date(event.created),
},
});
break;
case 'document.signed':
console.log(`Signer ${event.data.signer.email} signed`);
// Update signer status
await db.recipients.update({
where: { id: event.data.signer.id },
data: { status: 'signed', signedAt: new Date(event.data.signer.signedAt) },
});
break;
case 'document.completed':
console.log(`Document ${event.data.document.id} completed!`);
// Download and store signed document
const signedPdf = await downloadSignedDocument(event.data.document.id);
await storage.upload(`signed/${event.data.document.id}.pdf`, signedPdf);
// Notify relevant parties
await notificationService.send({
type: 'document_completed',
documentId: event.data.document.id,
});
break;
case 'document.declined':
console.log(`Document ${event.data.document.id} declined: ${event.data.reason}`);
// Alert the sender
await notificationService.alertSender({
documentId: event.data.document.id,
reason: event.data.reason,
});
break;
case 'document.voided':
console.log(`Document ${event.data.document.id} voided`);
await db.envelopes.update({
where: { id: event.data.document.id },
data: { status: 'voided' },
});
break;
case 'document.expired':
console.log(`Document ${event.data.document.id} expired`);
// Optionally resend
await resendExpiredDocument(event.data.document.id);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/sign', methods=['POST'])
def handle_webhook():
# Step 1: Verify signature
signature = request.headers.get('x-propper-signature', '')
payload = request.get_data(as_text=True)
if not verify_signature(payload, signature):
print('Invalid webhook signature')
return 'Invalid signature', 401
# Step 2: Parse event
event = request.get_json()
print(f"Received event: {event['type']}")
# Step 3: Handle event
try:
handle_event(event)
except Exception as e:
print(f"Error handling event: {e}")
# Still return 200 to prevent retries for application errors
# Step 4: Respond quickly
return 'OK', 200
def verify_signature(payload: str, signature: str) -> bool:
secret = os.environ['WEBHOOK_SECRET']
expected = 'sha256=' + hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
def handle_event(event: dict):
event_type = event['type']
document = event['data'].get('document', {})
document_id = document.get('id')
if event_type == 'document.sent':
print(f"Document {document_id} sent")
# Update your database
db.envelopes.update(
id=document_id,
status='sent',
sent_at=event['created']
)
elif event_type == 'document.viewed':
print(f"Document {document_id} viewed")
# Log view event
db.envelope_events.create(
envelope_id=document_id,
event_type='viewed',
timestamp=event['created']
)
elif event_type == 'document.signed':
signer = event['data'].get('signer', {})
print(f"Signer {signer.get('email')} signed")
# Update signer status
db.recipients.update(
id=signer.get('id'),
status='signed',
signed_at=signer.get('signedAt')
)
elif event_type == 'document.completed':
print(f"Document {document_id} completed!")
# Download and store signed document
signed_pdf = download_signed_document(document_id)
storage.upload(f"signed/{document_id}.pdf", signed_pdf)
# Notify relevant parties
notification_service.send(
type='document_completed',
document_id=document_id
)
elif event_type == 'document.declined':
reason = event['data'].get('reason', 'No reason provided')
print(f"Document {document_id} declined: {reason}")
# Alert the sender
notification_service.alert_sender(
document_id=document_id,
reason=reason
)
elif event_type == 'document.voided':
print(f"Document {document_id} voided")
db.envelopes.update(id=document_id, status='voided')
elif event_type == 'document.expired':
print(f"Document {document_id} expired")
# Optionally resend
resend_expired_document(document_id)
else:
print(f"Unhandled event type: {event_type}")
if __name__ == '__main__':
app.run(port=3000)
# Simulate a webhook for testing
curl -X POST "http://localhost:3000/webhooks/sign" \
-H "Content-Type: application/json" \
-H "x-propper-signature: sha256=$(echo -n '{"type":"document.completed","data":{}}' | openssl dgst -sha256 -hmac 'your-webhook-secret' | cut -d' ' -f2)" \
-d '{
"id": "evt_test123",
"type": "document.completed",
"created": "2024-01-15T12:00:00Z",
"data": {
"document": {
"id": "env_xyz789",
"name": "Test Document",
"status": "completed"
}
}
}'
Signature Verification
Always verify webhook signatures to ensure requests are from Propper.
Signature Format
x-propper-signature: sha256=<hex-encoded-hmac>
Verification Algorithm
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
// Compute expected signature
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Security
- Always use
timingSafeEqualto prevent timing attacks - Never log or expose your webhook secret
- Reject requests with invalid signatures
Retry Behavior
If your endpoint fails (non-2xx response or timeout), Propper retries:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
After 6 failures, the webhook is marked as failed.
Handling Retries
Implement idempotency to handle duplicate deliveries:
async function handleEvent(event) {
// Check if already processed
const existing = await db.webhookEvents.findUnique({
where: { eventId: event.id },
});
if (existing) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process the event
await processEvent(event);
// Mark as processed
await db.webhookEvents.create({
data: {
eventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
}
Testing Webhooks
Local Development
Use a tunneling service like ngrok:
# Start your local server
node webhook-server.js
# In another terminal, start ngrok
ngrok http 3000
# Use the ngrok URL in Propper dashboard
# https://abc123.ngrok.io/webhooks/sign
Webhook Playground
Test webhooks from the Propper Dashboard:
- Go to Settings → Webhooks
- Click Test on your endpoint
- Select an event type
- Click Send Test
Manual Testing
# Generate test signature
SECRET="your-webhook-secret"
PAYLOAD='{"type":"document.completed","data":{"document":{"id":"test"}}}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)"
# Send test request
curl -X POST "http://localhost:3000/webhooks/sign" \
-H "Content-Type: application/json" \
-H "x-propper-signature: $SIGNATURE" \
-d "$PAYLOAD"
Best Practices
1. Respond Quickly
Return 200 immediately, then process asynchronously:
app.post('/webhooks/sign', (req, res) => {
// Verify signature synchronously
if (!verifySignature(req.body, req.headers['x-propper-signature'])) {
return res.status(401).send('Invalid signature');
}
// Queue for async processing
eventQueue.add(JSON.parse(req.body));
// Respond immediately
res.status(200).send('OK');
});
2. Implement Idempotency
Use event IDs to prevent duplicate processing:
const processedEvents = new Set();
async function handleEvent(event) {
if (processedEvents.has(event.id)) {
return; // Already processed
}
await processEvent(event);
processedEvents.add(event.id);
}
3. Handle All Event Types
Always include a default case:
switch (event.type) {
case 'document.completed':
// Handle
break;
// ... other cases
default:
console.log(`Unhandled event: ${event.type}`);
// Don't throw - still return 200
}
4. Log Everything
app.post('/webhooks/sign', (req, res) => {
const eventId = req.body.id;
const eventType = req.body.type;
console.log({
message: 'Webhook received',
eventId,
eventType,
timestamp: new Date().toISOString(),
});
// ... handle event
console.log({
message: 'Webhook processed',
eventId,
duration: Date.now() - startTime,
});
});
5. Set Up Monitoring
Monitor your webhook endpoint for:
- Response times (should be < 30s)
- Error rates
- Queue depth (if using async processing)
Troubleshooting
Webhooks Not Received
- Check endpoint URL - Must be HTTPS and publicly accessible
- Check firewall - Allow incoming requests from Propper IPs
- Check SSL certificate - Must be valid, not self-signed
- Check Dashboard - View delivery attempts and errors
Signature Verification Fails
- Check secret - Make sure you're using the correct webhook secret
- Check encoding - Payload must be raw body, not parsed JSON
- Check timing - Ensure signature hasn't been modified
Events Processing Twice
- Implement idempotency - Track processed event IDs
- Check retry behavior - May be retrying due to slow response
Next Steps
- Error Handling - Handle webhook errors
- Basic Workflow Example - Complete integration
- API Reference - Full documentation