Webhooks

Webhooks let Papyrus push document lifecycle events to your backend in real time. This guide covers setup, signature verification, retries, and handler design. For the full event-by-event payload reference, use the Webhook Reference. For related document and template operations, use the API Reference.

When to use webhooks

Webhooks are a good fit when you want to:

  • Mark an internal record complete as soon as a document finishes
  • React immediately when a signer signs or declines
  • Track reminders, voids, and send activity without polling
  • Keep downstream systems in sync with Papyrus

If webhooks are enabled for your team or plan, configure them from Settings > Developer.

Create a webhook

  1. Go to Settings > Developer
  2. Scroll to the Webhooks section
  3. Enter your destination URL
  4. Choose the events you want
  5. Click Create Webhook

Papyrus generates a signing secret for each webhook. Copy it when it is shown.

Important: The current secret is only shown once. Store it in a secrets manager or environment variable before you leave the page.

Event types

Event When it fires
document.sent A document is sent to signer recipients
recipient.signed An individual signer completes signing
recipient.declined An individual signer declines the signing request
document.completed All required signers have finished and Papyrus finalizes the file
document.voided The sender voids a document
reminder.sent Papyrus sends a reminder email, whether it was manual or automatic

Payload contract

Webhook payloads now use the same camelCase conventions as the REST API.

Every webhook uses this envelope:

{
    "id": "01JQZXA7Y6J5P2N5Q2M6A2R8F9",
    "event": "document.completed",
    "occurredAt": "2026-03-25T18:15:00Z",
    "data": {}
}

Shared rules:

  • Every payload includes data.document
  • document.* events populate data.recipients and set data.recipient to null
  • recipient.* and reminder.sent populate data.recipient and set data.recipients to null
  • The document object may include externalId; treat it as optional metadata and rely on Papyrus IDs plus the webhook id for core processing

Use the Webhook Reference for exact payloads and example bodies for every event. The API Reference covers incoming REST endpoints; webhook deliveries are documented on these webhook pages because Papyrus sends them outbound to your application.

When you send a test ping from the dashboard, Papyrus sends:

{
    "id": "01JQZXC8N7H4R2V9B5P1A6T3K8",
    "event": "ping",
    "occurredAt": "2026-03-25T18:15:00Z",
    "data": {
        "message": "Ping from Papyrus"
    }
}

Verify the signature

Every webhook request includes these headers:

  • X-Webhook-Signature
  • X-Webhook-Id
  • X-Webhook-Event
  • X-Webhook-Attempt

Compute an HMAC-SHA256 over the raw request body using your webhook secret, then compare it to X-Webhook-Signature.

PHP example

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);

Node.js example

const crypto = require('crypto');
const express = require('express');

function verifySignature(payload, signature, secret) {
    const expected =
        'sha256=' +
        crypto.createHmac('sha256', secret).update(payload).digest('hex');

    if (!signature || signature.length !== expected.length) {
        return false;
    }

    return crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature),
    );
}

app.post(
    '/webhooks/papyrus',
    express.raw({ type: 'application/json' }),
    (req, res) => {
        const signature = req.headers['x-webhook-signature'];
        const rawBody = req.body.toString('utf-8');

        if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
            return res.status(401).send('Invalid signature');
        }

        const event = JSON.parse(rawBody);
        res.status(200).send('OK');
    },
);

Delivery and retries

Papyrus sends the first delivery attempt immediately and records every attempt in delivery history.

Papyrus retries on:

  • Network failures and timeouts
  • 408 Request Timeout
  • 429 Too Many Requests
  • Any 5xx response

Papyrus does not retry most 4xx responses because they usually indicate a permanent handler issue.

Retry schedule:

  1. First attempt immediately
  2. Retry after 1 minute
  3. Retry after 5 minutes
  4. Retry after 15 minutes
  5. Retry after 1 hour
  6. Retry after 6 hours

Delivery statuses

Status Meaning
pending The delivery has been queued but not attempted yet
retrying The last attempt failed with a retryable error
delivered Your endpoint returned a 2xx response
failed Papyrus exhausted retries or encountered a non-retryable fail

You can inspect delivery history from Settings > Developer > Webhooks.

Build a reliable webhook handler

This page is the implementation guide. Use it for setup, signature validation, retry expectations, and operational behavior. Use the Webhook Reference when you need the exact JSON shape for a specific event.

Return success quickly

Papyrus waits up to 15 seconds for a response. Keep the request itself light:

  1. Verify the signature
  2. Parse the payload
  3. Enqueue internal work
  4. Return 200 OK

Handle duplicates safely

Webhook delivery is at least once, so your handler must be idempotent. Deduplicate on id first. The X-Webhook-Id header contains the same value as the payload envelope.

Use externalId when reconciling

If you set externalId when creating a document, Papyrus includes it in webhook payloads so your backend can correlate directly to your own records.

If you do not use externalId, the webhook still contains everything required to operate safely:

  • id for webhook-level idempotency
  • data.document.id for the Papyrus document identity
  • data.recipient.id for recipient-specific events

recipient.signed tells you a signer finished their part. document.completed follows when Papyrus finishes finalizing the document, so your backend should not assume those two events arrive at exactly the same time.

Managing existing webhooks

Disable without deleting

To pause future deliveries temporarily:

  1. Go to Settings > Developer
  2. Find the webhook
  3. Click Toggle

Rotate the secret

If a secret may have leaked:

  1. Go to Settings > Developer
  2. Find the webhook
  3. Click Rotate Secret
  4. Update your application immediately

Send a test ping

Use Ping from the webhook row to verify reachability and signature handling without waiting for a real document event.

Review delivery history

The deliveries view shows the latest event, attempt count, last HTTP status, and timing information for each delivery.

Best practices

  • Verify X-Webhook-Signature on the raw request body
  • Use HTTPS for every webhook destination
  • Treat id as the idempotency key
  • Return a 2xx response only after your app has accepted the event
  • Queue heavier downstream work
  • Log failures with the Papyrus event type and webhook ID

Where to go next

Last updated: April 11, 2026