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_ | 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:
{
"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
- Generate a long random secret, for example with
openssl rand -hex 32, and store it server-side asDOCUMENSO_WEBHOOK_SECRET. - In Documenso, click your avatar, choose Team settings, and open the Webhooks tab.
- Click Create Webhook.
- Enter the Webhook URL, select the events, and paste the secret into Secret.
- Click Create Webhook. The webhook is active straight away.
- 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.
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:
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 checks the
X-Documenso-Secretheader, 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
200straight 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_. 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_COMPLETEDis 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.
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.
Conclusion
Documenso webhooks are unsigned JSON POSTs, 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 sits in front of your receiver, checks the Documenso secret, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Documenso webhooks reliably in minutes.