diff --git a/.agents/skills/create-justification/SKILL.md b/.agents/skills/create-justification/SKILL.md deleted file mode 100644 index 78a2aaea9..000000000 --- a/.agents/skills/create-justification/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-justification -description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: decision-making ---- - -## What I do - -I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets: - -- A unique three-word identifier (e.g., `swift-emerald-river`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" "Justification content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-justification.ts "decision-name" << HEREDOC -Multi-line -justification content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `swift-emerald-river-decision-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Decision Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-plan/SKILL.md b/.agents/skills/create-plan/SKILL.md deleted file mode 100644 index 8ceb2ef8c..000000000 --- a/.agents/skills/create-plan/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-plan -description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: planning ---- - -## What I do - -I help you create new plan files in the `.agents/plans/` directory. Each plan file gets: - -- A unique three-word identifier (e.g., `happy-blue-moon`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" "Plan content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-plan.ts "feature-name" << HEREDOC -Multi-line -plan content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `happy-blue-moon-feature-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Feature Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.agents/skills/create-scratch/SKILL.md b/.agents/skills/create-scratch/SKILL.md deleted file mode 100644 index e44e4779d..000000000 --- a/.agents/skills/create-scratch/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: create-scratch -description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title -license: MIT -compatibility: opencode -metadata: - audience: agents - workflow: exploration ---- - -## What I do - -I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets: - -- A unique three-word identifier (e.g., `calm-teal-cloud`) -- Frontmatter with the current date and formatted title -- Content you provide - -## How to use - -Run the script with a slug and content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" "Scratch content here" -``` - -Or use heredoc for multi-line content: - -```bash -npx tsx scripts/create-scratch.ts "note-name" << HEREDOC -Multi-line -scratch content -goes here -HEREDOC -``` - -## File format - -Files are created as: `{three-word-id}-{slug}.md` - -Example: `calm-teal-cloud-note-name.md` - -The file includes frontmatter: - -```markdown ---- -date: 2026-01-13 -title: Note Name ---- - -Your content here -``` - -## When to use me - -Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ed7f27d..e3b1007da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: build_app: name: Build App runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,6 +33,7 @@ jobs: build_docker: name: Build Docker Image runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d74f30387..b5ac9017a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -11,6 +11,7 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + timeout-minutes: 60 permissions: actions: read contents: read diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80d188964..00132070d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,7 @@ on: jobs: deploy: runs-on: ubuntu-latest + timeout-minutes: 60 steps: - name: Checkout code diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 34d7a478f..d8589bf1b 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -7,6 +7,7 @@ on: jobs: label-when-assigned: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Label issue uses: actions/github-script@v6 diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index 92b559d11..fd4a60151 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -7,6 +7,7 @@ on: jobs: label_issues: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write steps: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 15fe7cbfa..5c3eeaac2 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -13,6 +13,7 @@ jobs: contents: read pull-requests: write runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 50137d2e1..f28fc4e1f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,7 @@ jobs: build_and_publish_platform_containers: name: Build and publish platform containers runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -78,6 +79,7 @@ jobs: create_and_publish_manifest: name: Create and publish manifest runs-on: ubuntu-latest + timeout-minutes: 60 needs: build_and_publish_platform_containers steps: - name: Checkout diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 0dab3392d..34e731436 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -15,6 +15,7 @@ jobs: validate-pr: name: Validate PR title runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c9c12ce59..aed53da8c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,6 +7,7 @@ on: jobs: stale: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: issues: write pull-requests: write diff --git a/.github/workflows/translations-force-pull.yml b/.github/workflows/translations-force-pull.yml index 5d804df63..b0893a2aa 100644 --- a/.github/workflows/translations-force-pull.yml +++ b/.github/workflows/translations-force-pull.yml @@ -18,6 +18,7 @@ jobs: pull_translations: name: Force pull translations runs-on: ubuntu-latest + timeout-minutes: 10 environment: Translations permissions: contents: write diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index 0e3703438..ea65548f2 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -16,6 +16,7 @@ jobs: pull_translations: name: Pull translations runs-on: ubuntu-latest + timeout-minutes: 10 environment: Translations permissions: contents: write diff --git a/.github/workflows/translations-upload.yml b/.github/workflows/translations-upload.yml index 0e80def07..b81b6375f 100644 --- a/.github/workflows/translations-upload.yml +++ b/.github/workflows/translations-upload.yml @@ -14,6 +14,7 @@ jobs: extract_translations: name: Extract and upload translations runs-on: ubuntu-latest + timeout-minutes: 30 environment: Translations permissions: contents: write diff --git a/.npmrc b/.npmrc index 75baad7f0..cbc6b6537 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -# min-release-age = 7 +min-release-age = 7 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` | 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; } diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index 84e228115..a5ac58807 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -81,7 +81,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?err} - POSTGRES_DB=${POSTGRES_DB:?err} healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] interval: 10s timeout: 5s retries: 5 diff --git a/apps/docs/package.json b/apps/docs/package.json index 9f345d14c..9539148d0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,13 +10,12 @@ "postinstall": "fumadocs-mdx" }, "dependencies": { - "@radix-ui/react-tabs": "^1.1.13", - "fumadocs-core": "16.5.0", - "fumadocs-mdx": "14.2.6", - "fumadocs-ui": "16.5.0", + "fumadocs-core": "16.14.3", + "fumadocs-mdx": "15.2.3", + "fumadocs-ui": "16.14.3", "lucide-react": "^0.563.0", "mermaid": "^11.12.2", - "next": "16.2.6", + "next": "16.3.0", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index bcc93e039..1b7c14350 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,11 +12,11 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "16.2.6" + "next": "16.3.0" }, "devDependencies": { "@types/node": "^20", - "@types/react": "18.3.27", + "@types/react": "^19.2.17", "typescript": "5.6.2" } } diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx index 9adebb36e..ed7184ef7 100644 --- a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -18,7 +18,6 @@ export type DocumentPreferencesResetDialogProps = { onReset: () => Promise; showAiFeatures?: boolean; showDocumentVisibility?: boolean; - showIncludeSenderDetails?: boolean; }; export const DocumentPreferencesResetDialog = ({ @@ -26,7 +25,6 @@ export const DocumentPreferencesResetDialog = ({ onReset, showAiFeatures = false, showDocumentVisibility = false, - showIncludeSenderDetails = false, }: DocumentPreferencesResetDialogProps) => { const [open, setOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); @@ -92,29 +90,12 @@ export const DocumentPreferencesResetDialog = ({
  • Default signature settings
  • - {showIncludeSenderDetails && ( -
  • - Send on behalf of team -
  • - )} -
  • - Include the signing certificate in the document -
  • -
  • - Include the audit logs in the document -
  • Default recipients
  • Delegate document ownership
  • -
  • - Default envelope expiration -
  • -
  • - Default signing reminders -
  • {showAiFeatures && (
  • AI features diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index c76bc00a2..9bbec2636 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -1,7 +1,7 @@ import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useSession } from '@documenso/lib/client-only/providers/session'; -import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { DOCUMENSO_CLOUD_ENTERPRISE_CTA_URL, IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { AppError } from '@documenso/lib/errors/app-error'; import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription'; import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n'; @@ -380,7 +380,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: ))} diff --git a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx index 7c08cf7d3..147cf3b40 100644 --- a/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-delete-dialog.tsx @@ -17,28 +17,25 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Prisma } from '@prisma/client'; +import type { Team, TeamEmail, TeamEmailVerification } from '@prisma/client'; import { useState } from 'react'; import { useRevalidator } from 'react-router'; export type TeamEmailDeleteDialogProps = { trigger?: React.ReactNode; teamName: string; - team: Prisma.TeamGetPayload<{ - include: { - teamEmail: true; - emailVerification: { - select: { - expiresAt: true; - name: true; - email: true; - }; - }; - }; - }>; + team: Pick; + teamEmail: Pick | null; + emailVerification: Pick | null; }; -export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDeleteDialogProps) => { +export const TeamEmailDeleteDialog = ({ + trigger, + teamName, + team, + teamEmail, + emailVerification, +}: TeamEmailDeleteDialogProps) => { const [open, setOpen] = useState(false); const { _ } = useLingui(); @@ -83,11 +80,11 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele }); const onRemove = async () => { - if (team.teamEmail) { + if (teamEmail) { await deleteTeamEmail({ teamId: team.id }); } - if (team.emailVerification) { + if (emailVerification) { await deleteTeamEmailVerification({ teamId: team.id }); } @@ -121,13 +118,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele - {team.teamEmail?.name || team.emailVerification?.name} + {teamEmail?.name || emailVerification?.name} } - secondaryText={{team.teamEmail?.email || team.emailVerification?.email}} + secondaryText={{teamEmail?.email || emailVerification?.email}} /> diff --git a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx index 3fbddc3c2..449d5ec36 100644 --- a/apps/remix/app/components/dialogs/team-email-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-email-update-dialog.tsx @@ -23,7 +23,8 @@ import { useRevalidator } from 'react-router'; import type { z } from 'zod'; export type TeamEmailUpdateDialogProps = { - teamEmail: TeamEmail; + teamId: number; + teamEmail: Pick; trigger?: React.ReactNode; } & Omit; @@ -33,7 +34,7 @@ const ZUpdateTeamEmailFormSchema = ZUpdateTeamEmailMutationSchema.pick({ type TUpdateTeamEmailFormSchema = z.infer; -export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { +export const TeamEmailUpdateDialog = ({ teamId, teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => { const [open, setOpen] = useState(false); const { t } = useLingui(); @@ -53,7 +54,7 @@ export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmai const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => { try { await updateTeamEmail({ - teamId: teamEmail.teamId, + teamId, data: { name, }, diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 669f5274c..c8726ae22 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -1,6 +1,7 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError } from '@documenso/lib/errors/app-error'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -32,6 +33,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; import { BrandingLogo } from '~/components/general/branding-logo'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { injectCss } from '~/utils/css-vars'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; @@ -162,9 +164,12 @@ export const EmbedSignDocumentV1ClientPage = ({ ); } + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index b144d1c46..15902715d 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -26,6 +26,7 @@ import { useState } from 'react'; import { match, P } from 'ts-pattern'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; @@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({ } catch (err) { onDocumentError?.(); + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`Failed to complete the document. Please try again.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } finally { diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index e556ff6cf..1cb5be35d 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -29,6 +29,7 @@ import { useOptionalCurrentTeam } from '~/providers/team'; import { useCspNonce } from '~/utils/nonce'; import { FormStickySaveBar } from './form-sticky-save-bar'; +import { InheritableField } from './inheritable-field'; const ZBrandingPreferencesFormSchema = z.object({ brandingEnabled: z.boolean().nullable(), @@ -210,11 +211,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingEnabled" render={({ field }) => ( - - - Enable Custom Branding - - + Enable Custom Branding} + testId="branding-enabled" + > @@ -372,7 +379,7 @@ export function BrandingPreferencesForm({ )} - + )} /> @@ -380,11 +387,13 @@ export function BrandingPreferencesForm({ control={form.control} name="brandingCompanyDetails" render={({ field }) => ( - - - Brand Details - - + Brand Details} + testId="branding-company-details" + >