Compare commits

...
Author SHA1 Message Date
ephraimduncan 6ace46fefd docs(trpc): add openapi descriptions to envelope cancel, delete and update routes
- these routes rendered without descriptions in the generated API reference
- add route-level descriptions and field-level .describe() calls matching sibling schemas
2026-07-30 22:04:00 +00:00
ephraimduncan 478229aa90 docs(api): address rate limit review findings
- document the real 429 bodies: v1 returns { message }, v2 returns the structured error object
- exclude CORS preflight responses from the header guarantee
- describe monthly quotas as organisation-wide api/document/email counters, not envelope-only
- retry example: honor Retry-After exactly; cap only the exponential fallback delay
2026-07-30 22:04:00 +00:00
ephraimduncan d3eb0c7999 docs(api): document rate limit headers and 429 variants
- document X-RateLimit-Limit/-Remaining/-Reset on every v1/v2/v2-beta response
- document Retry-After on 429s and epoch-aligned 1-minute windows (real wait is 1-60s)
- show both 429 body shapes: global error key vs AppError code/message/statusCode
- cover the three 429 sources including the headerless monthly quota; add v2-beta to scope
2026-07-30 22:04:00 +00:00
ephraimduncan 918e42b992 docs(webhooks): address review findings
- qualify the SSRF guard as best-effort (no DNS-rebinding coverage, fails open on lookup errors)
- use envelope IDs from the actual generator alphabet (no digits possible)
- fix template-events intro: templateId is null except on TEMPLATE_USED
2026-07-30 21:57:43 +00:00
ephraimduncan f21eddef19 docs(webhooks): correct retry policy, timeout and payload reference
- replace fabricated retry schedule with provider behavior (local 4, BullMQ 3, Inngest 5 attempts)
- fix webhook timeout from 30s to 10s and define failure semantics (3xx not followed, code 0)
- clarify failed deliveries never auto-disable a webhook; document SSRF rules and http:// support
- add envelopeId to all payload examples; frame numeric id as legacy v1 identifier
- remove phantom documentMeta field; fix hardcoded timezone/dateFormat values
- add missing status/source enum values and document the RECIPIENT_EXPIRED event
2026-07-30 21:37:24 +00:00
9 changed files with 207 additions and 63 deletions
@@ -11,6 +11,12 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
The rate limit applies to:
- `/api/v1/*`
- `/api/v2/*`
- `/api/v2-beta/*`
**Limit:** 1000 requests per minute per IP address
**Response:** 429 Too Many Requests
@@ -19,7 +25,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Rate Limit Response
### Global per-IP 429 Response
```json
{
@@ -27,10 +33,22 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
}
```
<Callout type="warn">
No rate limit headers are currently provided. When you receive a 429 response, wait at least 60
seconds before retrying.
</Callout>
### Rate Limit Headers
Responses from `/api/v1/*`, `/api/v2/*`, and `/api/v2-beta/*` include these headers. The only
exception is CORS preflight (`OPTIONS`) requests, which are answered before the rate limiter runs
and carry no rate limit headers:
| Header | Description |
| ----------------------- | ---------------------------------------------------------------------- |
| `X-RateLimit-Limit` | Maximum requests allowed in the current global window |
| `X-RateLimit-Remaining` | Requests remaining in the current global window |
| `X-RateLimit-Reset` | End of the current global window, as a Unix epoch timestamp in seconds |
A 429 response from a windowed limiter also includes `Retry-After`, in seconds, with a minimum
value of `1`. The global API limit uses fixed, epoch-aligned one-minute buckets, so the actual wait
until the next window is between 1 and 60 seconds. Honor `Retry-After` exactly instead of sleeping
for a fixed 60 seconds. See the [Retry-After handling example](/docs/developers/examples/common-workflows#error-handling-patterns).
## Resource Limits
@@ -44,24 +62,55 @@ Beyond HTTP rate limits, your account has usage limits based on your subscriptio
| Total Recipients | 10 | Unlimited | Unlimited | Unlimited |
| Direct Templates | 3 | Unlimited | Unlimited | Unlimited |
### Error Response
### Organisation Limit 429 Responses
When you exceed a resource limit:
Organisation windowed limits and organisation monthly quotas produce 429 responses whose body
shape depends on the API version, and neither matches the global per-IP limiter's
`{ "error": "..." }` body.
On `/api/v1/*`, the body contains only a message:
```json
{
"error": "You have reached your document limit for this month. Please upgrade your plan.",
"code": "LIMIT_EXCEEDED",
"statusCode": 400
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
```
On `/api/v2/*` and `/api/v2-beta/*`, the body is a structured error object:
```json
{
"message": "Too many requests, please try again later. Contact support if you require higher limits.",
"code": "TOO_MANY_REQUESTS",
"data": {
"code": "TOO_MANY_REQUESTS",
"httpStatus": 429,
"appError": {
"code": "TOO_MANY_REQUESTS",
"message": "Too many requests, please try again later. Contact support if you require higher limits."
}
}
}
```
Organisation windowed limit responses include the `X-RateLimit-*` headers and `Retry-After` for
their own window. Monthly quota responses carry no quota-specific rate limit headers or
`Retry-After` because the quota is not a time window; rely on the status code and message instead.
## Error Codes
| Code | Status | Description |
| ------------------- | ------ | ----------------------------- |
| `TOO_MANY_REQUESTS` | 429 | HTTP rate limit exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
| Code | Status | Description |
| ------------------- | ------ | ------------------------------------------------------------------ |
| `TOO_MANY_REQUESTS` | 429 | Global per-IP, organisation windowed, or monthly quota exceeded |
| `LIMIT_EXCEEDED` | 400 | Resource usage limit exceeded |
There are three sources of `TOO_MANY_REQUESTS` responses:
1. The global per-IP limit, returning the `{ "error": "..." }` body shown above.
2. Organisation windowed rate limits for the `api`, `document`, and `email` counters.
3. Organisation monthly quotas for the same three counters. Every authenticated API request
consumes the `api` counter, so any endpoint can return this 429 once the monthly API quota is
exhausted — not just envelope-related ones.
---
@@ -1000,9 +1000,12 @@ async function fetchWithRetry(
// Retry on rate limit
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : baseDelayMs * Math.pow(2, attempt);
// Honor Retry-After exactly; the cap only applies to the exponential fallback.
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
console.log(`Rate limited, waiting ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, maxDelayMs)));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
@@ -33,13 +33,14 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------- | --------- | ------------------------------------------------------ |
| `id` | number | Document or template ID |
| `id` | number | Legacy numeric v1 document or template ID |
| `envelopeId` | string | Canonical v2 identifier (`envelope_` + 16 characters) |
| `externalId` | string? | External identifier for integration |
| `userId` | number | Owner's user ID |
| `authOptions` | object? | Document-level authentication options |
| `formValues` | object? | PDF form values associated with the document |
| `title` | string | Document or template title |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` |
| `visibility` | string | Document visibility setting |
| `createdAt` | datetime | Document creation timestamp |
| `updatedAt` | datetime | Last modification timestamp |
@@ -47,8 +48,8 @@ All webhook events share a common structure:
| `deletedAt` | datetime? | Deletion timestamp |
| `teamId` | number? | Team ID if document belongs to a team |
| `templateId` | number? | Template ID if created from a template |
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
| `documentMeta` | object | Document metadata (subject, message, signing options) |
| `source` | string | Source: `DOCUMENT`, `TEMPLATE`, or `TEMPLATE_DIRECT_LINK` |
| `documentMeta` | object? | Nullable document metadata (subject, message, signing options) |
| `recipients` | array | List of recipient objects |
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
@@ -60,7 +61,6 @@ All webhook events share a common structure:
| `subject` | string? | Email subject line |
| `message` | string? | Email message body |
| `timezone` | string | Timezone for date display |
| `password` | string? | Document access password (if set) |
| `dateFormat` | string | Date format string |
| `redirectUrl` | string? | URL to redirect after signing |
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
@@ -77,8 +77,9 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------------- | --------- | ------------------------------------------ |
| `id` | number | Recipient ID |
| `documentId` | number? | Parent document ID |
| `templateId` | number? | Template ID if created from a template |
| `envelopeId` | string | Canonical parent envelope ID |
| `documentId` | number? | Legacy parent document ID; null for templates |
| `templateId` | number? | Legacy parent template ID; null for documents |
| `email` | string | Recipient email address |
| `name` | string | Recipient name |
| `token` | string | Unique signing token |
@@ -94,6 +95,8 @@ All webhook events share a common structure:
| `sendStatus` | string | `NOT_SENT` or `SENT` |
| `rejectionReason` | string? | Reason if recipient rejected |
Use `recipient.envelopeId` as the reliable parent link. The legacy `documentId` and `templateId` fields depend on the parent envelope type, so one of them is always null.
---
## Document Lifecycle Events
@@ -111,6 +114,7 @@ Triggered when a new document is created.
"event": "DOCUMENT_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -129,9 +133,8 @@ Triggered when a new document is created.
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -145,6 +148,7 @@ Triggered when a new document is created.
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -166,6 +170,7 @@ Triggered when a new document is created.
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -203,6 +208,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"event": "DOCUMENT_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -221,9 +227,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -237,6 +242,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -258,6 +264,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"Recipient": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -295,12 +302,14 @@ The recipient's `readStatus` changes to `OPENED`.
"event": "DOCUMENT_OPENED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -328,6 +337,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"event": "DOCUMENT_SIGNED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "COMPLETED",
"title": "contract.pdf",
"source": "DOCUMENT",
@@ -335,6 +345,7 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
"recipients": [
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -361,12 +372,14 @@ Triggered when an individual recipient completes their required action (signing,
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -395,6 +408,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 1,
"authOptions": null,
@@ -413,9 +427,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
@@ -429,6 +442,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"recipients": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -451,6 +465,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -475,6 +490,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"Recipient": [
{
"id": 50,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
@@ -497,6 +513,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
},
{
"id": 51,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
@@ -537,12 +554,14 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
"event": "DOCUMENT_REJECTED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -561,7 +580,7 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
### `document.cancelled`
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
Triggered when a pending document is explicitly cancelled with `POST /envelope/cancel`, or when a document owner or team member deletes a document. Deleting a draft or pending document hard-deletes it, while deleting a completed document soft-deletes it.
This event is **not** triggered when a recipient hides a document from their inbox.
@@ -572,6 +591,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"externalId": null,
"userId": 3,
"authOptions": null,
@@ -591,7 +611,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
@@ -606,6 +625,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"recipients": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -627,6 +647,7 @@ This event is **not** triggered when a recipient hides a document from their inb
"Recipient": [
{
"id": 7,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
@@ -651,6 +672,45 @@ This event is **not** triggered when a recipient hides a document from their inb
}
```
### `recipient.expired`
Triggered when a recipient's signing deadline passes on a pending document before they sign or reject it.
**Event name:** `RECIPIENT_EXPIRED`
The recipient's `expiresAt` contains the signing deadline, and `expirationNotifiedAt` is set when the expiration is processed.
```json
{
"event": "RECIPIENT_EXPIRED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"expiresAt": "2024-04-22T11:51:00.000Z",
"expirationNotifiedAt": "2024-04-22T11:52:00.000Z",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.reminder.sent`
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
@@ -662,12 +722,14 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"envelopeId": "envelope_abcdefhiklmnorst",
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
@@ -686,7 +748,7 @@ Triggered when a reminder email is sent to a recipient who has not yet completed
## Template Events
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads. For `TEMPLATE_CREATED`, `TEMPLATE_UPDATED`, and `TEMPLATE_DELETED` the template's own legacy numeric ID is in `id` and `templateId` is `null`. Only `TEMPLATE_USED` — whose payload describes the new document envelope created from the template — carries the originating template's legacy ID in `templateId`, with `source` set to `TEMPLATE`.
### `template.created`
@@ -699,9 +761,10 @@ Triggered when a new template is created.
"event": "TEMPLATE_CREATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -721,9 +784,10 @@ Triggered when a template's settings, recipients, or fields are modified.
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "My Updated Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -743,9 +807,10 @@ Triggered when a template is deleted.
"event": "TEMPLATE_DELETED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Deleted Template",
"status": "DRAFT",
"templateId": 10,
"templateId": null,
"source": "TEMPLATE",
"recipients": []
},
@@ -765,6 +830,7 @@ Triggered when a document is created from a template. This event fires alongside
"event": "TEMPLATE_USED",
"payload": {
"id": 10,
"envelopeId": "envelope_abcdefhiklmnorst",
"title": "Document from Template",
"status": "DRAFT",
"templateId": 10,
@@ -791,7 +857,8 @@ Triggered when a document is created from a template. This event fires alongside
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
| `DOCUMENT_CANCELLED` | Pending document explicitly cancelled, or document deleted | `status: "CANCELLED"` after explicit cancellation; deletion may remove or soft-delete the document |
| `RECIPIENT_EXPIRED` | Recipient signing deadline passes | Recipient `expiresAt` passed, `expirationNotifiedAt` set |
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
### Template Events
@@ -821,7 +888,7 @@ When processing webhook events:
**Process idempotently** — Webhooks may be retried, so handle duplicate events
</Step>
<Step>
**Respond quickly** — Return a 200 status code within 30 seconds
**Respond quickly** — Return a `2xx` status code within 10 seconds
</Step>
</Steps>
@@ -42,12 +42,14 @@ Documenso supports webhook events for the full document lifecycle (created, sent
"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"
}
@@ -58,6 +60,8 @@ Documenso supports webhook events for the full document lifecycle (created, sent
}
```
`payload.id` is the legacy numeric v1 ID. Use `payload.envelopeId` as the canonical v2 identifier. Each recipient repeats `envelopeId` as the reliable parent link because the legacy `documentId` and `templateId` fields depend on the parent envelope type, leaving one of them null.
---
## See Also
@@ -148,7 +148,7 @@ func main() {
</Tabs>
<Callout type="warn">
Always respond with a `200 OK` status within 30 seconds. Documenso will retry failed deliveries.
Always respond with a `2xx` status within 10 seconds. Documenso will retry failed deliveries according to the configured background-job provider.
</Callout>
## Configuring Webhooks in Documenso via the Dashboard
@@ -184,7 +184,7 @@ Fill in the following fields:
| Field | Description |
| ----- | ----------- |
| **Webhook URL** | The HTTPS endpoint that will receive webhook events |
| **Webhook URL** | The HTTP or HTTPS endpoint that will receive webhook events |
| **Events** | Select which events should trigger this webhook |
| **Secret** (optional) | A secret key used to sign the payload for verification |
</Step>
@@ -202,12 +202,21 @@ Your webhook endpoint must meet these requirements:
| Requirement | Details |
| ----------- | ------- |
| **Protocol** | HTTPS required (HTTP not allowed in production) |
| **Response** | Must return `2xx` status code within 30 seconds |
| **Protocol** | HTTP and HTTPS are accepted; use HTTPS in production |
| **Response** | Must return a `2xx` status code within 10 seconds |
| **Method** | Must accept HTTP POST requests |
| **Content-Type** | Must accept `application/json` payloads |
| **Availability** | Must be publicly accessible from the internet |
<Callout type="warn">
Documenso performs a best-effort check that rejects webhook URLs which use or resolve to private
or loopback addresses. This is not a complete SSRF mitigation — it does not cover DNS rebinding
and fails open on DNS lookup errors or timeouts — so self-hosted deployments should still enforce
network-level egress rules. Self-hosters that need to deliver to a hostname resolving to a
private address can add that hostname to the comma-separated
`NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` environment variable.
</Callout>
<Callout type="info">
For local development, use a tunneling service like [ngrok](https://ngrok.com) or [localtunnel](https://localtunnel.me) to expose your local server.
</Callout>
@@ -225,7 +234,8 @@ When creating a webhook, you can subscribe to one or more events:
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
| `DOCUMENT_REJECTED` | A recipient rejects the document |
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
| `DOCUMENT_CANCELLED` | A pending document is explicitly cancelled or a document owner deletes it |
| `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` | A new template is created |
| `TEMPLATE_UPDATED` | A template is modified |
@@ -318,17 +328,17 @@ Documenso will attempt to deliver the same payload again
## Retry Policy
When a webhook delivery fails (non-2xx response or timeout), Documenso automatically retries with exponential backoff:
A delivery fails when the endpoint returns a non-`2xx` response, the 10-second timeout expires, or the request fails. Redirects are not followed, so `3xx` responses also fail. Network and SSRF-blocked requests are recorded with response code `0`.
| Attempt | Delay |
| ------- | ----- |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
For self-hosted deployments, retries are handled by the background-job provider selected with `NEXT_PRIVATE_JOBS_PROVIDER`:
After 5 failed attempts, the webhook is marked as failed and no further automatic retries occur. You can manually resend failed webhooks from the dashboard.
| Provider | Total attempts | Retry timing |
| -------- | -------------- | ------------ |
| Local (default) | 4 | Back-to-back, with no backoff |
| BullMQ | 3 | Exponential backoff starting at 1 second |
| Inngest | 5 | Inngest platform backoff |
Only the individual delivery (`WebhookCall`) record is marked as failed. Documenso does not automatically disable the webhook or apply a circuit breaker, so future matching events continue to be delivered. After automatic attempts are exhausted, you can manually resend a failed delivery from the dashboard.
<Callout type="warn">
If your endpoint consistently fails, consider reviewing your server logs and ensuring your endpoint meets all [URL requirements](#webhook-url-requirements).
@@ -255,6 +255,7 @@ const validEvents = [
'DOCUMENT_REJECTED',
'DOCUMENT_CANCELLED',
'DOCUMENT_REMINDER_SENT',
'RECIPIENT_EXPIRED',
'TEMPLATE_CREATED',
'TEMPLATE_UPDATED',
'TEMPLATE_DELETED',
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to delete.'),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to update.'),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;