# Guide to Documenso Webhooks: Features and Best Practices

Documenso webhooks tell your systems where each document is in the signing process: created, sent, opened, signed by each recipient, completed, rejected or cancelled, plus changes to your templates. If you send documents for signature with Documenso, the open-source signing platform, webhooks let you update your own records and trigger the next step in a workflow the moment something happens.

This guide covers how Documenso webhooks are configured and authenticated, the events and the shape of their payloads, how delivery and retries work, and the practices that keep a Documenso receiver reliable.

## What are Documenso webhooks?

A Documenso webhook belongs to a team. It has a URL, a set of subscribed events and an optional secret. When a subscribed event happens, Documenso sends a JSON `POST` describing the document or template and its recipients. Personal accounts cannot configure webhooks.

Documenso does not sign webhook payloads. Its setup page describes the secret as "used to sign the payload", but the verification page explains what actually happens: Documenso sends the secret itself, as plain text, in the `X-Documenso-Secret` header. Your receiver compares the header with the secret it expects. If you don't set a secret, the header is sent as an empty string.

## Documenso webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Team settings > Webhooks > Create Webhook** |
| Availability | Teams only |
| Events | 14 events for documents, recipients and templates |
| Authentication | Optional secret, sent verbatim in `X-Documenso-Secret` |
| Body signature | None |
| Replay window | None. No timestamp is sent or signed |
| Content type | `application/json` |
| Response window | A `2xx` within 10 seconds |
| Redirects | Not followed. A `3xx` counts as a failure |
| Retries | Depend on the background-job provider on self-hosted instances. Not documented for the hosted service |
| Auto-disable | Never. Future events keep being delivered after failures |
| Tooling | **Test** button, per-call logs with request and response, and **Resend** for failed calls |
| URL checks | Best-effort rejection of private and loopback addresses |

Documenso publishes no source IP addresses and no delivery ID header.

## Common events

The event name is in the body's `event` field, in upper case. Documenso's events page also uses dotted lower-case labels such as `document.completed` as headings, but those never appear in a payload.

| Event | Fires when |
| --- | --- |
| `DOCUMENT_CREATED` | A document is uploaded or created from a template |
| `DOCUMENT_SENT` | A document is sent to recipients |
| `DOCUMENT_OPENED` | A recipient opens the document for the first time |
| `DOCUMENT_SIGNED` | A recipient signs. Fires for each signature, not only the last |
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their action (signing, approving or viewing) |
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
| `DOCUMENT_REJECTED` | A recipient rejects the document |
| `DOCUMENT_CANCELLED` | A pending document is cancelled, or an owner or team member deletes a document |
| `RECIPIENT_EXPIRED` | A recipient's signing deadline passes before they sign or reject |
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
| `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, `TEMPLATE_DELETED` | A template is created, modified or deleted |
| `TEMPLATE_USED` | A document is created from a template. Fires alongside `DOCUMENT_CREATED` |

`DOCUMENT_CANCELLED` does not fire when a recipient hides a document from their inbox.

Every payload has four top-level keys: `event`, `payload`, `createdAt` and `webhookEndpoint`. Here is Documenso's `DOCUMENT_COMPLETED` example:

```json
{
  "event": "DOCUMENT_COMPLETED",
  "payload": {
    "id": 123,
    "envelopeId": "envelope_abcdefhiklmnorst",
    "title": "Contract",
    "status": "COMPLETED",
    "completedAt": "2024-01-15T10:30:00.000Z",
    "recipients": [
      {
        "id": 1,
        "envelopeId": "envelope_abcdefhiklmnorst",
        "email": "signer@example.com",
        "signingStatus": "SIGNED"
      }
    ]
  },
  "createdAt": "2024-01-15T10:30:00.000Z",
  "webhookEndpoint": "https://your-endpoint.com/webhook"
}

```

Real payloads carry much more: `status`, `source`, `documentMeta` with the email subject and signing order, and a full recipient list with `role`, `readStatus`, `signingStatus`, `sendStatus` and `rejectionReason`. `payload.id` is the legacy numeric v1 ID, and Documenso calls `payload.envelopeId` the canonical identifier. Each recipient repeats `envelopeId`, because one of its legacy `documentId` and `templateId` fields is always `null`. Payloads also include a legacy `Recipient` array that duplicates `recipients`.

For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED` and `TEMPLATE_DELETED`, the template's own ID is in `id` and `templateId` is `null`. `TEMPLATE_USED` describes the new document, with the originating template's ID in `templateId` and `source` set to `TEMPLATE`.

## Setting up Documenso webhooks

1. Generate a long random secret, for example with `openssl rand -hex 32`, and store it server-side as `DOCUMENSO_WEBHOOK_SECRET`.
2. In Documenso, click your avatar, choose **Team settings**, and open the **Webhooks** tab.
3. Click **Create Webhook**.
4. Enter the **Webhook URL**, select the events, and paste the secret into **Secret**.
5. Click **Create Webhook**. The webhook is active straight away.
6. Open the webhook, click **Test**, choose an event type and click **Send** to check your endpoint. The response appears in the call log.

If you receive through Hookdeck, create a Documenso source, use its URL as the webhook URL, and paste the same secret into the source's **Webhook Secret** field. Hookdeck accepts each request, then records any request whose `X-Documenso-Secret` does not match as failed verification, so it never becomes an event for your service. If you leave the secret empty in Documenso, the header arrives empty, so leave verification off on the source too.

## Securing Documenso webhooks

There is nothing to compute. Read `X-Documenso-Secret` and compare it with your stored secret in constant time. Reject an empty header, and fail closed when no secret is configured, so an empty header can never match an empty secret.

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

function verifyDocumensoSecret(headerValue, expectedSecret) {
  if (!headerValue || !expectedSecret) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(expectedSecret, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/documenso', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.DOCUMENSO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyDocumensoSecret(req.get('x-documenso-secret'), secret)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Route on event.event. Key records on event.payload.envelopeId.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac

def verify_documenso_secret(header_value, expected_secret: str) -> bool:
    if not header_value or not expected_secret:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), expected_secret.encode("utf-8"))

# In FastAPI: verify_documenso_secret(request.headers.get("x-documenso-secret"),
#                                     os.environ["DOCUMENSO_WEBHOOK_SECRET"])

```

Documenso's own samples skip verification when no secret is configured. Don't copy that part: a receiver that accepts everything when an environment variable is missing is open to anyone.

Documenso accepts both HTTP and HTTPS webhook URLs. Use HTTPS, because the secret travels in every request and anyone who captures one can forge others.

> **Make Documenso webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `X-Documenso-Secret` header, answers Documenso within its 10-second window, and durably queues every document and template event before your service sees it.

## Documenso webhook limitations and pain points

### A shared secret, not a signature

**The Problem:** The body can be altered in transit, and a captured request can be replayed.

**Why It Happens:** Documenso sends the secret rather than a signature computed over the body, and nothing carries a timestamp. The word "sign" in the setup page suggests otherwise, which leads some teams to look for an HMAC that isn't there.

**Workarounds:**

* Use HTTPS only and a long random secret.
* Rotate the secret periodically. Documenso suggests accepting both the old and new secrets on your server while you switch.
* For actions that matter, fetch the envelope from the Documenso API before acting on the webhook.

**How Hookdeck Can Help:** Hookdeck holds the secret and checks it on every request, so your own services don't need it, and requests with a missing or wrong secret never reach them.

### Retries depend on how Documenso is run

**The Problem:** You can't plan around a retry schedule, because there isn't one fixed schedule.

**Why It Happens:** On self-hosted instances, retries come from the background-job provider: the default Local provider makes 4 attempts back to back with no backoff, BullMQ makes 3 with exponential backoff starting at 1 second, and Inngest makes 5. All of these finish within seconds or minutes. Documenso does not say which policy the hosted service uses. After the last attempt, the delivery stays failed until someone resends it by hand.

**Workarounds:**

* Keep your endpoint highly available, because a short outage can exhaust every attempt.
* Check the webhook's call log for failed deliveries after any incident, and use **Resend**.
* Reconcile important documents through the API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then retries delivery to your service on a schedule you set and lets you replay anything that failed, so Documenso's short retry window stops being a risk.

### Tight timing and no redirects

**The Problem:** Deliveries fail even though your server eventually handles them.

**Why It Happens:** Documenso waits 10 seconds for a `2xx` and does not follow redirects. A slow handler, a trailing-slash redirect or an HTTP-to-HTTPS redirect all count as failures.

**Workarounds:**

* Register the exact final URL.
* Return `200` straight after authentication and do the work asynchronously.

**How Hookdeck Can Help:** Hookdeck responds to Documenso immediately and delivers to your service with its own timeout and retries.

### Multiple events for one action

**The Problem:** One signature produces several events, and your handler acts twice.

**Why It Happens:** A recipient signing fires `DOCUMENT_SIGNED` and, when their action is done, `DOCUMENT_RECIPIENT_COMPLETED`. Creating a document from a template fires both `DOCUMENT_CREATED` and `TEMPLATE_USED`. The last signature also triggers `DOCUMENT_COMPLETED`.

**Workarounds:**

* Subscribe only to the events you act on. If you only care about fully signed documents, `DOCUMENT_COMPLETED` is enough.
* Make handlers idempotent, keyed on `envelopeId`, the event name and the recipient.

**How Hookdeck Can Help:** Hookdeck filters can route events by `event` and by fields inside `payload`, so each service only receives the events it needs.

## Best practices

### Authenticate before parsing

The secret is in a header, so check it before you parse or log the body. Reject an empty `X-Documenso-Secret`, which means the webhook has no secret configured.

### Use `envelopeId` as your key

`payload.id` is a legacy numeric ID. Store `payload.envelopeId` and use each recipient's `envelopeId` to link recipients to their document.

### Deduplicate deliveries

Documenso advises handling duplicates gracefully but documents no event ID. Build a key from `event`, `payload.envelopeId`, `createdAt` and, for recipient events, the recipient's `id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Watch the call log

Documenso never disables a failing webhook, so failures don't announce themselves. Review the call log regularly, or alert on gaps in the events you expect.

### Acknowledge fast and process asynchronously

Return `200` within Documenso's 10-second window and do downstream work, such as downloading the signed PDF, afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Documenso webhooks are unsigned JSON `POST`s, authenticated by the secret you set, which arrives verbatim in `X-Documenso-Secret`. Always set a secret, compare it in constant time over HTTPS, reject empty headers, key records on `envelopeId`, respond within 10 seconds, and watch the call log, because Documenso's retries are short and it never disables a failing webhook.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Documenso secret, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Documenso webhooks reliably in minutes.