From 0099dd672a207c4efd276408e86e05cc9569f18f Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:23:48 +0000 Subject: [PATCH 1/4] feat(ui): redesign recipient field hover card (#3070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the popover shown when hovering a recipient field avatar in the envelope view. - Field-first hierarchy: header shows field-type icon + "{Type} field" with inline status (Signed/Pending/Read Only) as a colored dot + label - Recipient (name/email) moved to a recessed footer well as secondary context - Hide-field action moved from floating over the text to a ghost icon button in the footer well - Added a `FieldType` → icon map mirroring `field-selector.tsx` ## Screenshots | Before | After | | --- | --- | | Previous hover tooltip: centered badge, title and
recipient text | New hover card: field-first header with status,
recipient footer well | --- .../envelope-recipient-field-tooltip.tsx | 137 +++++++++++------- 1 file changed, 81 insertions(+), 56 deletions(-) diff --git a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx index b9a79586e..74c60ed13 100644 --- a/packages/ui/components/document/envelope-recipient-field-tooltip.tsx +++ b/packages/ui/components/document/envelope-recipient-field-tooltip.tsx @@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { Trans, useLingui } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; -import { SigningStatus } from '@prisma/client'; -import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { FieldType, SigningStatus } from '@prisma/client'; +import { + CalendarDaysIcon, + CheckSquareIcon, + ChevronDownIcon, + ContactIcon, + DiscIcon, + EyeOffIcon, + HashIcon, + LockIcon, + MailIcon, + TypeIcon, + UserIcon, +} from 'lucide-react'; +import { type ElementType, useCallback, useEffect, useState } from 'react'; import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template'; import { extractInitials } from '../../../lib/utils/recipient-formatter'; import { SignatureIcon } from '../../icons/signature'; import { cn } from '../../lib/utils'; import { Avatar, AvatarFallback } from '../../primitives/avatar'; -import { Badge } from '../../primitives/badge'; import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types'; import { PopoverHover } from '../../primitives/popover'; @@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps { showRecipientColors?: boolean; } -const getRecipientDisplayText = (recipient: { name: string; email: string }) => { - if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) { - return `${recipient.name} (${recipient.email})`; - } - - if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) { - return recipient.name; - } - - return recipient.email; +const FIELD_TYPE_ICONS: Record = { + [FieldType.SIGNATURE]: SignatureIcon, + [FieldType.FREE_SIGNATURE]: SignatureIcon, + [FieldType.INITIALS]: ContactIcon, + [FieldType.TEXT]: TypeIcon, + [FieldType.DATE]: CalendarDaysIcon, + [FieldType.EMAIL]: MailIcon, + [FieldType.NAME]: UserIcon, + [FieldType.NUMBER]: HashIcon, + [FieldType.RADIO]: DiscIcon, + [FieldType.CHECKBOX]: CheckSquareIcon, + [FieldType.DROPDOWN]: ChevronDownIcon, }; /** @@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({ }: EnvelopeRecipientFieldTooltipProps) { const { t } = useLingui(); + const FieldIcon = FIELD_TYPE_ICONS[field.type]; + const [hideField, setHideField] = useState(!showRecipientTooltip); const [coords, setCoords] = useState({ @@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({ } contentProps={{ - className: 'relative flex mb-4 w-fit flex-col p-4 text-sm', + className: 'flex w-64 flex-col overflow-hidden p-0 text-sm', + sideOffset: 20, + onOpenAutoFocus: (event) => event.preventDefault(), }} > - {showFieldStatus && ( - - {field?.fieldMeta?.readOnly ? ( - <> - - Read Only - - ) : field.recipient.signingStatus === SigningStatus.SIGNED ? ( - <> - - Signed - - ) : ( - <> - - Pending - - )} - - )} +
+ -

- +

{t(FRIENDLY_FIELD_TYPE[field.type])} field - -

+

-

{getRecipientDisplayText(field.recipient)}

+ {showFieldStatus && ( +
+ {field?.fieldMeta?.readOnly ? ( + <> + + + Read Only + + + ) : field.recipient.signingStatus === SigningStatus.SIGNED ? ( + <> + + + Signed + + + ) : ( + <> + + + Pending + + + )} +
+ )} +
- +
+
+

{field.recipient.name || field.recipient.email}

+ + {!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && ( +

{field.recipient.email}

+ )} +
+ + +
); From 05f646b326c558edfd920ff1db5836da6680b2fb Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:28:23 +0000 Subject: [PATCH 2/4] docs(api): document rate limit headers and 429 variants (#3133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The rate limits page claimed "No rate limit headers are currently provided" and advised a fixed 60-second wait. The middleware has been setting standard headers on every API response. ## Changes Made - Documented `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix epoch seconds) on every `/api/v1`, `/api/v2`, and `/api/v2-beta` response, and `Retry-After` (seconds, min 1) on 429s. - Explained that windows are fixed epoch-aligned 1-minute buckets, so the real wait is 1–60s — clients should honor `Retry-After` instead of sleeping a fixed 60s. - Showed both 429 body shapes: the global per-IP limiter's `{ "error": ... }` vs AppError-based `code`/`message`/`statusCode`. - Covered the three distinct 429 sources: global per-IP limit, organisation windowed limits, and monthly envelope quota (which sends no rate-limit headers). - Added `/api/v2-beta/*` to the documented scope; left the verified-correct 1000/min figure and plan-limits table untouched. ## Testing Performed Docs-only change. Verified against `rate-limit-middleware.ts`, `rate-limit.ts`, `check-organisation-rate-limits.ts`, `check-monthly-quota.ts`, and the remix server router. --- .../docs/developers/api/rate-limits.mdx | 77 +++++++++++++++---- .../developers/examples/common-workflows.mdx | 7 +- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index 95b0a68fe..878db97b6 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -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. -### 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. } ``` - - No rate limit headers are currently provided. When you receive a 429 response, wait at least 60 - seconds before retrying. - +### 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. --- diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index 704bf415f..5bdf32cdf 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -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; } From 914e325486561fe8bf23d8a949629681e2324da1 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:28:40 +0000 Subject: [PATCH 3/4] docs(trpc): add openapi descriptions to envelope cancel, delete and update routes (#3134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The envelope cancel, delete, and update routes rendered without descriptions in the generated OpenAPI reference. ## Changes Made - Added route-level OpenAPI `description` to `cancel-envelope.types.ts`, `delete-envelope.types.ts`, and `update-envelope.types.ts`. - Added field-level `.describe()` calls on request schemas, matching the style of sibling envelope-router schemas (e.g. `get-envelopes-by-ids.types.ts`, `distribute-envelope.types.ts`). ## Testing Performed `npx tsc --noEmit -p packages/trpc` passes with no errors. Metadata-only change — no runtime behavior affected. --- .../envelope-router/cancel-envelope.types.ts | 5 +++-- .../envelope-router/delete-envelope.types.ts | 3 ++- .../envelope-router/update-envelope.types.ts | 20 +++++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/trpc/server/envelope-router/cancel-envelope.types.ts b/packages/trpc/server/envelope-router/cancel-envelope.types.ts index 06a6637fb..59ab827c1 100644 --- a/packages/trpc/server/envelope-router/cancel-envelope.types.ts +++ b/packages/trpc/server/envelope-router/cancel-envelope.types.ts @@ -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; diff --git a/packages/trpc/server/envelope-router/delete-envelope.types.ts b/packages/trpc/server/envelope-router/delete-envelope.types.ts index 654d24c6d..8eaf7db9e 100644 --- a/packages/trpc/server/envelope-router/delete-envelope.types.ts +++ b/packages/trpc/server/envelope-router/delete-envelope.types.ts @@ -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; diff --git a/packages/trpc/server/envelope-router/update-envelope.types.ts b/packages/trpc/server/envelope-router/update-envelope.types.ts index c08da086f..a4a89f9ea 100644 --- a/packages/trpc/server/envelope-router/update-envelope.types.ts +++ b/packages/trpc/server/envelope-router/update-envelope.types.ts @@ -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; From d42254ff52cd6257380f076022c41152b08ef60e Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:28:55 +0000 Subject: [PATCH 4/4] docs(api): document cancel endpoint and fix get-many body shape (#3135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Documents API page: adds the missing Cancel Document section and fixes a fabricated request body on get-many that would fail schema validation for anyone copying the docs. ## Changes Made - Added a `## Cancel Document` section: `POST /envelope/cancel` with `{ envelopeId, reason? }`, PENDING-only (400 otherwise), not idempotent, two-stage access (404 if not visible, 401 without owner/MANAGER+), fires `DOCUMENT_CANCELLED` webhook, emails only SENT/OPENED non-CC non-rejected recipients. - Replaced the fabricated `envelopeIds: [...]` get-many body with the real nested selector: `{ "ids": { "type": "envelopeId" | "documentId" | "templateId", "ids": [...] } }` (string[] for envelopeId, number[] otherwise, 1–20 IDs). - Added the missing `### Response` for get-many (`{ "data": [...] }`) and documented silent filtering of inaccessible IDs (no 404). - Added `CANCELLED` to the status table, mermaid state diagram, transitions prose, and filter values. - Removed the nonexistent `source: "API"` value (real enum: `DOCUMENT | TEMPLATE | TEMPLATE_DIRECT_LINK`). - Fixed fabricated `pagination` wrappers to the real flat shape `{ data, count, currentPage, perPage, totalPages }`; fixed Field `id` type and mismatched code fences. - Migration guide: warned that get-many's body shape changed from `documentIds: number[]` — the breaking part of that migration. ## Testing Performed Docs-only change (plus the migration guide). Verified against the envelope-router types, `cancel-document.ts`, the cancel e2e spec, and `schema.prisma`. --- .../content/docs/developers/api/documents.mdx | 233 ++++++++++++++---- .../developers/api/migrate-to-envelopes.mdx | 4 +- 2 files changed, 189 insertions(+), 48 deletions(-) diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index dbe2e6a85..bd526e828 100644 --- a/apps/docs/content/docs/developers/api/documents.mdx +++ b/apps/docs/content/docs/developers/api/documents.mdx @@ -28,35 +28,62 @@ Each document contains one or more PDF files, a list of recipients, and the fiel A document object contains the following properties: -| Property | Type | Description | -| --------------- | -------------- | -------------------------------------------------------------- | -| `id` | string | Unique identifier (e.g., `envelope_abc123`) | -| `type` | string | `DOCUMENT` or `TEMPLATE` | -| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, or `REJECTED` | -| `title` | string | Document title | -| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `API` | -| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | -| `externalId` | string \| null | Your custom identifier for the document | -| `createdAt` | string | ISO 8601 timestamp | -| `updatedAt` | string | ISO 8601 timestamp | -| `completedAt` | string \| null | Timestamp when all recipients completed signing | -| `deletedAt` | string \| null | Timestamp if soft-deleted | -| `recipients` | array | List of recipients and their signing status | -| `fields` | array | Signature and form fields on the document | -| `envelopeItems` | array | PDF files attached to the document | -| `documentMeta` | object | Email settings, redirect URL, signing options | +| Property | Type | Description | +| ------------------- | -------------- | -------------------------------------------------------------------------------------------- | +| `id` | string | Unique identifier (e.g., `envelope_abc123`) | +| `secondaryId` | string | Legacy identifier in prefixed form (`document_123` for documents, `template_123` for templates) | +| `internalVersion` | number | Internal envelope schema version | +| `type` | string | `DOCUMENT` or `TEMPLATE` | +| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, or `CANCELLED` | +| `title` | string | Document title | +| `source` | string | How the document was created: `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | +| `visibility` | string | Who can view: `EVERYONE`, `ADMIN`, `MANAGER_AND_ABOVE` | +| `templateType` | string | Template visibility: `PUBLIC`, `PRIVATE`, or `ORGANISATION` (only meaningful for templates) | +| `externalId` | string \| null | Your custom identifier for the document | +| `userId` | number | ID of the user who owns the document | +| `teamId` | number | ID of the team the document belongs to | +| `folderId` | string \| null | ID of the folder containing the document | +| `templateId` | number \| null | Legacy ID of the template this document was created from | +| `authOptions` | object \| null | Access and action authentication requirements | +| `formValues` | object \| null | Pre-filled form values | +| `publicTitle` | string | Public title shown on profile and direct-link pages | +| `publicDescription` | string | Public description shown on profile and direct-link pages | +| `createdAt` | string | ISO 8601 timestamp | +| `updatedAt` | string | ISO 8601 timestamp | +| `completedAt` | string \| null | Timestamp when all recipients completed signing | +| `deletedAt` | string \| null | Timestamp if soft-deleted | +| `recipients` | array | List of recipients and their signing status | +| `fields` | array | Signature and form fields on the document | +| `envelopeItems` | array | PDF files attached to the document | +| `directLink` | object \| null | Direct-link signing configuration (`id`, `token`, `enabled`, `directTemplateRecipientId`) | +| `team` | object | Owning team (`id`, `url`) | +| `user` | object | Document owner (`id`, `name`, `email`) | +| `documentMeta` | object | Email settings, redirect URL, signing options | + +Documents created through the API have `source: "DOCUMENT"` — there is no separate `API` source value. To tag documents created by your integration, set `externalId` when creating them. ### Example Document Object ```json { "id": "envelope_abc123xyz", + "secondaryId": "document_123", + "internalVersion": 2, "type": "DOCUMENT", "status": "PENDING", - "source": "API", + "source": "DOCUMENT", "visibility": "EVERYONE", + "templateType": "PRIVATE", "title": "Service Agreement", "externalId": "contract-2025-001", + "userId": 1, + "teamId": 1, + "folderId": null, + "templateId": null, + "authOptions": null, + "formValues": null, + "publicTitle": "", + "publicDescription": "", "createdAt": "2025-01-15T10:30:00.000Z", "updatedAt": "2025-01-15T10:35:00.000Z", "completedAt": null, @@ -73,23 +100,41 @@ A document object contains the following properties: ], "fields": [ { - "id": "field_123", + "id": 123, + "secondaryId": "field_abc123", "type": "SIGNATURE", + "recipientId": 1, + "envelopeId": "envelope_abc123xyz", + "envelopeItemId": "envelope_item_xyz", "page": 1, - "positionX": 10, - "positionY": 80, - "width": 30, - "height": 5, - "recipientId": 1 + "positionX": "10", + "positionY": "80", + "width": "30", + "height": "5", + "customText": "", + "inserted": false, + "fieldMeta": null } ], "envelopeItems": [ { "id": "envelope_item_xyz", + "envelopeId": "envelope_abc123xyz", + "documentDataId": "doc_data_abc123", "title": "contract.pdf", "order": 1 } ], + "directLink": null, + "team": { + "id": 1, + "url": "your-team" + }, + "user": { + "id": 1, + "name": "Jane Smith", + "email": "jane@example.com" + }, "documentMeta": { "subject": "Please sign this document", "message": "Hi, please review and sign this agreement.", @@ -99,6 +144,8 @@ A document object contains the following properties: } ``` +Field position and size values are stored as decimals and serialized as strings in API responses. + ## List Documents Retrieve a paginated list of documents. @@ -114,7 +161,7 @@ GET /envelope | `page` | integer | Page number (default: 1) | | `perPage` | integer | Results per page (default: 10, max: 100) | | `type` | string | Filter by `DOCUMENT` or `TEMPLATE` | -| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | +| `status` | string | Filter by status: `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | | `source` | string | Filter by creation source | | `folderId` | string | Filter by folder ID | | `orderByColumn` | string | Sort field (only `createdAt` supported) | @@ -154,8 +201,8 @@ const response = await fetch(`${BASE_URL}/envelope`, { }, }); -const { data, pagination } = await response.json(); -console.log(`Found ${pagination.totalItems} documents`); +const { data, count } = await response.json(); +console.log(`Found ${count} documents`); // Filter by status const pendingResponse = await fetch( @@ -197,12 +244,10 @@ const pendingDocs = await pendingResponse.json(); ] } ], - "pagination": { - "page": 1, - "perPage": 10, - "totalPages": 5, - "totalItems": 42 - } + "count": 42, + "currentPage": 1, + "perPage": 10, + "totalPages": 5 } ``` @@ -628,6 +673,72 @@ The response includes signing URLs for each recipient: --- +## Cancel Document + +Cancel a pending document. This changes its status from `PENDING` to `CANCELLED`. + +``` +POST /envelope/cancel +``` + +### Request Body + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ----------------------------------- | +| `envelopeId` | string | Yes | Document ID | +| `reason` | string | No | Reason for cancelling the document | + +### Code Examples + + + +```bash +curl -X POST "https://app.documenso.com/api/v2/envelope/cancel" \ + -H "Authorization: api_xxxxxxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{ + "envelopeId": "envelope_abc123", + "reason": "The agreement is no longer needed." + }' +``` + + +```typescript +const response = await fetch('https://app.documenso.com/api/v2/envelope/cancel', { + method: 'POST', + headers: { + Authorization: 'api_xxxxxxxxxxxxxxxx', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + envelopeId: 'envelope_abc123', + reason: 'The agreement is no longer needed.', + }), +}); + +const { success } = await response.json(); +``` + + + +### Response + +```json +{ + "success": true +} +``` + +### Behavior + +- Only documents in `PENDING` status can be cancelled. Other statuses return `400`. +- Cancellation is not idempotent. Cancelling the same document again returns `400`. +- The document owner and team members with `MANAGER` or higher permissions can cancel it. Requests for documents you cannot view return `404`; requests for visible documents without sufficient permissions return `401`. +- A successful cancellation fires the `DOCUMENT_CANCELLED` webhook. +- Cancellation emails are sent only to eligible non-CC, non-rejected recipients who were sent or opened the document. + +--- + ## Delete Document Delete a document. Completed documents cannot be deleted. @@ -670,7 +781,7 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/delete', const { success } = await response.json(); -```` +``` @@ -680,7 +791,7 @@ const { success } = await response.json(); { "success": true } -```` +``` --- @@ -694,9 +805,11 @@ POST /envelope/get-many ### Request Body -| Field | Type | Required | Description | -| ------------- | ----- | -------- | --------------------- | -| `envelopeIds` | array | Yes | Array of document IDs | +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------------------------------------------------- | +| `ids` | object | Yes | ID selector containing `type` and `ids` | +| `ids.type` | string | Yes | `envelopeId`, `documentId`, or `templateId` | +| `ids.ids` | array | Yes | 1-20 IDs: strings for `envelopeId`; numbers for `documentId` or `templateId` | ### Code Examples @@ -707,12 +820,17 @@ curl -X POST "https://app.documenso.com/api/v2/envelope/get-many" \ -H "Authorization: api_xxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ - "envelopeIds": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + "ids": { + "type": "envelopeId", + "ids": ["envelope_abc123", "envelope_def456", "envelope_ghi789"] + } }' ``` ```typescript +const requestedIds = ['envelope_abc123', 'envelope_def456', 'envelope_ghi789']; + const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many', { method: 'POST', headers: { @@ -720,16 +838,36 @@ const response = await fetch('https://app.documenso.com/api/v2/envelope/get-many 'Content-Type': 'application/json', }, body: JSON.stringify({ - envelopeIds: ['envelope_abc123', 'envelope_def456', 'envelope_ghi789'], + ids: { + type: 'envelopeId', + ids: requestedIds, + }, }), }); -const documents = await response.json(); +const { data } = await response.json(); -```` +``` +### Response + +```json +{ + "data": [ + { + "id": "envelope_abc123", + "type": "DOCUMENT", + "status": "PENDING", + "title": "Service Agreement" + } + ] +} +``` + +The endpoint silently omits envelopes you cannot access instead of returning `404`. Compare `data.length` with `requestedIds.length` to detect omissions. + --- ## Document Statuses @@ -740,6 +878,7 @@ const documents = await response.json(); | `PENDING` | Document has been sent. Waiting for recipients to sign. | | `COMPLETED` | All recipients have signed. Document is sealed. | | `REJECTED` | A recipient rejected the document. | +| `CANCELLED` | The document was cancelled by its owner or a team member with `MANAGER` or higher permissions. | ### Status Transitions @@ -747,11 +886,13 @@ const documents = await response.json(); flowchart LR DRAFT --> PENDING --> COMPLETED PENDING --> REJECTED + PENDING --> CANCELLED ``` - **DRAFT to PENDING**: Call the distribute endpoint - **PENDING to COMPLETED**: All recipients complete their signing - **PENDING to REJECTED**: A recipient rejects the document +- **PENDING to CANCELLED**: The document owner or a team member with `MANAGER` or higher permissions cancels the document You cannot modify recipients or fields after a document moves to `PENDING` status. @@ -773,8 +914,8 @@ flowchart LR | Parameter | Values | Description | | ---------- | ------------------------------------------- | ------------------------- | | `type` | `DOCUMENT`, `TEMPLATE` | Filter by envelope type | -| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED` | Filter by status | -| `source` | `DOCUMENT`, `TEMPLATE`, `API` | Filter by creation source | +| `status` | `DRAFT`, `PENDING`, `COMPLETED`, `REJECTED`, `CANCELLED` | Filter by status | +| `source` | `DOCUMENT`, `TEMPLATE`, `TEMPLATE_DIRECT_LINK` | Filter by creation source | | `folderId` | string | Filter by folder | ### Sorting @@ -800,10 +941,10 @@ async function getAllPendingDocuments() { }, ); - const { data, pagination } = await response.json(); + const { data, currentPage, totalPages } = await response.json(); documents.push(...data); - hasMore = page < pagination.totalPages; + hasMore = currentPage < totalPages; page++; } diff --git a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx index 2bd5c8568..5a719e90c 100644 --- a/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx +++ b/apps/docs/content/docs/developers/api/migrate-to-envelopes.mdx @@ -119,7 +119,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com). | ------------------------------------------------- | ----------------------------------------------------- | | `GET /api/v2/document` | `GET /api/v2/envelope` | | `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` | -| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` | +| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` (body changes from `documentIds: number[]` to `ids: { type: "documentId"; ids: number[] }`) | | `POST /api/v2/document/create` | `POST /api/v2/envelope/create` | | `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` | | `POST /api/v2/document/update` | `POST /api/v2/envelope/update` | @@ -140,7 +140,7 @@ Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com). | ------------------------------------- | ------------------------------------------------ | | `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) | | `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` | -| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` | +| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` (body changes from `templateIds: number[]` to `ids: { type: "templateId"; ids: number[] }`) | | `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | | `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) | | `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |