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​

EventWhen it firesdata.status
agreement.createdAn agreement is created through the API, in the dashboard, or from a template. Creating an envelope with status: "sent" sends agreement.created and then agreement.sent.CREATED
agreement.sentThe agreement is sent to its recipients.SENT
agreement.deliveredEvery recipient's invitation email is confirmed delivered while the agreement is still SENT. Sent at most once per agreement (see the note below).DELIVERED
agreement.viewedA recipient opens the agreement for the first time. Sent once per recipient.Unchanged
agreement.recipient_signedA recipient finishes signing. Sent once per signer.Unchanged (usually IN_PROGRESS)
agreement.completedEvery recipient has finished their part and the signed document is ready to download.COMPLETED
agreement.declinedA recipient declines to sign. This ends the agreement for every recipient.DECLINED
agreement.voidedThe agreement is voided (cancelled).VOIDED
agreement.recalledThe sender recalls a sent agreement for editing before anyone has signed. It returns to draft and can be edited and sent again.CREATED
agreement.expiredThe agreement passes its expiration date before it is completed.EXPIRED
Not every agreement sends agreement.delivered

It waits until every recipient's invitation email is confirmed delivered, and is skipped once signing has started. With sequential routing, later recipients are not invited until earlier ones sign, so the event is not sent. Track progress with agreement.viewed and agreement.recipient_signed rather than waiting for agreement.delivered.

Propper Click sends click.* events (click.acceptance.completed, click.evidence.created, click.evidence.signed, click.evidence.failed) to the same endpoints. See Verifying webhook signatures for those.

Webhook Payload​

Common Structure​

Every delivery is a JSON envelope:

{
"id": "1d4c7e2a-9b3f-4e81-a6d0-5c2f8b7e9a13",
"type": "agreement.completed",
"version": 1,
"occurredAt": "2026-01-15T12:00:04.512Z",
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"source": "sign-api",
"data": {
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"agreementId": "b2e4f915-7c8a-4d13-9f60-1a8c5e3b7d92",
"title": "Service Agreement",
"status": "COMPLETED",
"occurredAt": "2026-01-15T12:00:04.512Z",
"completedAt": "2026-01-15T12:00:04.512Z"
}
}
FieldTypeDescription
idstring (UUID)Unique event ID. It stays the same when a delivery is retried, so use it to detect duplicates.
typestringThe event type, for example agreement.completed.
versionintegerEnvelope version. Currently 1.
occurredAtstring (ISO 8601)When the change happened.
orgIdstring (UUID)Your organization ID.
sourcestringThe Propper service that produced the event. Informational only: it can differ between event types, so don't branch on it.
dataobjectThe event payload, described below.

Agreement data​

Every agreement.* event carries these fields in data:

FieldTypeDescription
orgIdstring (UUID)Your organization ID.
agreementIdstring (UUID)The agreement ID. This is the same value as envelopeId in the envelopes API.
titlestringThe agreement title. Can be empty for a draft.
statusstringThe agreement's status when the event was emitted: CREATED, SENT, DELIVERED, IN_PROGRESS, COMPLETED, DECLINED, VOIDED, or EXPIRED.
occurredAtstring (ISO 8601)When the change happened.

The envelopes API reports status with its own values (for example, IN_PROGRESS here is signed there, and EXPIRED is timedout), so don't compare the two directly.

Some events add fields:

EventAdditional data fields
agreement.createdcreatedBy: ID of the user who created the agreement (optional)
agreement.sentrecipients: array of recipient objects
agreement.deliveredrecipient: the recipient whose delivery confirmation completed the set
agreement.viewedrecipient: the recipient who opened the agreement
agreement.recipient_signedrecipient: the recipient who signed
agreement.completedcompletedAt: when the agreement completed (ISO 8601)
agreement.declinedrecipient: the recipient who declined; reason (optional)
agreement.voidedvoidedBy: ID of the user who voided the agreement (optional); reason (optional)
agreement.recalledrecalledBy: ID of the user who recalled the agreement (optional); reason (optional)
agreement.expiredexpiredAt: when the agreement expired (ISO 8601)

Optional fields can be null or absent. A recipient object has these fields:

FieldTypeDescription
idstring (UUID)The recipient ID.
emailstring or nullThe recipient's email address. null for an in-person signer without one.
namestring or nullThe recipient's name (optional).
rolestringThe recipient's role, for example SIGNER, CARBON_COPY, or IN_PERSON_SIGNER.
orderintegerThe recipient's routing order (optional).

Event-Specific Payloads​

agreement.recipient_signed​

{
"id": "8f1a6c2e-4b7d-4a91-9c3e-2d5f8b0a7e14",
"type": "agreement.recipient_signed",
"version": 1,
"occurredAt": "2026-01-15T11:30:12.481Z",
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"source": "sign-api",
"data": {
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"agreementId": "b2e4f915-7c8a-4d13-9f60-1a8c5e3b7d92",
"title": "Service Agreement",
"status": "IN_PROGRESS",
"occurredAt": "2026-01-15T11:30:12.481Z",
"recipient": {
"id": "d7c3a018-5f2b-4e96-8a41-6b0d9c2e5f37",
"email": "signer@propper.ai",
"name": "Jordan Lee",
"role": "SIGNER",
"order": 1
}
}
}

agreement.completed​

{
"id": "5a0e9d3b-1c74-4f2a-8b65-c7d1e2f9a038",
"type": "agreement.completed",
"version": 1,
"occurredAt": "2026-01-15T12:00:04.512Z",
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"source": "sign-api",
"data": {
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"agreementId": "b2e4f915-7c8a-4d13-9f60-1a8c5e3b7d92",
"title": "Service Agreement",
"status": "COMPLETED",
"occurredAt": "2026-01-15T12:00:04.512Z",
"completedAt": "2026-01-15T12:00:04.512Z"
}
}

The payload does not include the document. Download the signed PDF with GET /restapi/v2.1/accounts/{accountId}/envelopes/{agreementId}/documents/combined.

agreement.declined​

{
"id": "e6b2f871-0d3c-4a59-9e14-3f8a7c5d2b60",
"type": "agreement.declined",
"version": 1,
"occurredAt": "2026-01-15T10:00:37.090Z",
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"source": "sign-api",
"data": {
"orgId": "3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83",
"agreementId": "b2e4f915-7c8a-4d13-9f60-1a8c5e3b7d92",
"title": "Service Agreement",
"status": "DECLINED",
"occurredAt": "2026-01-15T10:00:37.090Z",
"recipient": {
"id": "d7c3a018-5f2b-4e96-8a41-6b0d9c2e5f37",
"email": "signer@propper.ai",
"name": "Jordan Lee",
"role": "SIGNER",
"order": 1
},
"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('utf8');

// Step 1: Verify signature
if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
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');
});

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

async function handleEvent(event) {
const { agreementId } = event.data;

switch (event.type) {
case 'agreement.sent':
console.log(`Agreement ${agreementId} sent`);
// Update your database
await db.agreements.update({
where: { id: agreementId },
data: { status: 'sent', sentAt: new Date(event.occurredAt) },
});
break;

case 'agreement.viewed':
console.log(`Agreement ${agreementId} viewed by recipient ${event.data.recipient.id}`);
// Log view event
await db.agreementEvents.create({
data: {
agreementId,
recipientId: event.data.recipient.id,
type: 'viewed',
timestamp: new Date(event.occurredAt),
},
});
break;

case 'agreement.recipient_signed':
console.log(`Recipient ${event.data.recipient.id} signed`);
// Update recipient status
await db.recipients.update({
where: { id: event.data.recipient.id },
data: { status: 'signed', signedAt: new Date(event.occurredAt) },
});
break;

case 'agreement.completed': {
console.log(`Agreement ${agreementId} completed!`);
// Download and store the signed document
const signedPdf = await downloadSignedDocument(agreementId);
await storage.upload(`signed/${agreementId}.pdf`, signedPdf);

// Notify relevant parties
await notificationService.send({
type: 'agreement_completed',
agreementId,
});
break;
}

case 'agreement.declined':
console.log(`Agreement ${agreementId} declined: ${event.data.reason}`);
// Alert the sender
await notificationService.alertSender({
agreementId,
reason: event.data.reason,
});
break;

case 'agreement.voided':
console.log(`Agreement ${agreementId} voided`);
await db.agreements.update({
where: { id: agreementId },
data: { status: 'voided' },
});
break;

case 'agreement.recalled':
console.log(`Agreement ${agreementId} recalled for editing`);
// Back in draft: the sender can edit it and send it again
await db.agreements.update({
where: { id: agreementId },
data: { status: 'draft' },
});
break;

case 'agreement.expired':
console.log(`Agreement ${agreementId} expired`);
// Optionally resend
await resendExpiredAgreement(agreementId);
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: t=<unix_seconds>,v1=<hex_hmac>[,v1=<hex_hmac>...]

t is the Unix time the delivery was signed. Each v1 is a hex-encoded HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook secret. While you rotate a secret, a delivery carries one v1 per active secret; accept the request if any of them matches.

Verification Algorithm​

const crypto = require('crypto');

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

// Reject stale or malformed timestamps to prevent replays
const ts = Number(timestamp);
if (!timestamp || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return false;
}

// Compute the expected signature over "<t>.<raw body>"
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest();

// Use timing-safe comparison
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Security
  • Always use timingSafeEqual to prevent timing attacks
  • Verify against the raw request body, before any JSON parsing
  • Reject requests whose t is more than 5 minutes from your server's clock
  • 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"
TIMESTAMP=$(date +%s)
PAYLOAD='{"id":"5a0e9d3b-1c74-4f2a-8b65-c7d1e2f9a038","type":"agreement.completed","version":1,"occurredAt":"2026-01-15T12:00:04.512Z","orgId":"3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83","source":"sign-api","data":{"orgId":"3c9d1e77-2a45-4f68-b0c1-9e7a4d2b6f83","agreementId":"b2e4f915-7c8a-4d13-9f60-1a8c5e3b7d92","title":"Test Agreement","status":"COMPLETED","occurredAt":"2026-01-15T12:00:04.512Z","completedAt":"2026-01-15T12:00:04.512Z"}}'
SIGNATURE=$(printf '%s.%s' "$TIMESTAMP" "$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: t=$TIMESTAMP,v1=$SIGNATURE" \
-d "$PAYLOAD"

Best Practices​

1. Respond Quickly​

Return 200 immediately, then process asynchronously:

app.post('/webhooks/sign', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString('utf8');

// Verify signature synchronously
if (!verifySignature(payload, req.headers['x-propper-signature'], process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}

// Queue for async processing
eventQueue.add(JSON.parse(payload));

// 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 'agreement.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​