Skip to main content

Sign Webhooks

Receive real-time notifications when signing events occur instead of polling the API.

Why Use Webhooks?

ApproachProsCons
PollingSimple to implementWastes API calls, delayed updates
WebhooksReal-time, efficientRequires public endpoint

Webhooks eliminate polling, reduce API usage, and provide instant updates.

Setup

1. Configure Webhook Endpoint

In the Propper Dashboard:

  1. Go to Settings → Webhooks
  2. Click Add Endpoint
  3. Enter your webhook URL (must be HTTPS)
  4. Select events to subscribe to
  5. 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

EventDescriptionWhen Triggered
document.createdEnvelope createdDraft or sent envelope created
document.sentSent for signingEnvelope status → sent
document.viewedRecipient openedFirst view by any recipient
document.signedRecipient signedOne recipient completes
document.completedAll signedLast recipient completes
document.declinedRecipient declinedSigner refuses to sign
document.voidedCancelledSender voids envelope
document.expiredExpiredPast 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

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');
});

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 timingSafeEqual to 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:

AttemptDelay
1Immediate
21 minute
35 minutes
430 minutes
52 hours
68 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:

  1. Go to Settings → Webhooks
  2. Click Test on your endpoint
  3. Select an event type
  4. 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

  1. Check endpoint URL - Must be HTTPS and publicly accessible
  2. Check firewall - Allow incoming requests from Propper IPs
  3. Check SSL certificate - Must be valid, not self-signed
  4. Check Dashboard - View delivery attempts and errors

Signature Verification Fails

  1. Check secret - Make sure you're using the correct webhook secret
  2. Check encoding - Payload must be raw body, not parsed JSON
  3. Check timing - Ensure signature hasn't been modified

Events Processing Twice

  1. Implement idempotency - Track processed event IDs
  2. Check retry behavior - May be retrying due to slow response

Next Steps