# TurboDocx > Turbocharging your Document Workflows This file contains all documentation content in a single document following the llmstxt.org standard. # Deliverable API This guide covers the TurboDocx Deliverable API — everything you need to programmatically generate documents from templates, manage deliverables, and download files using the v1 API endpoints. ![Deliverable API](/img/template-generation-api/template-api.jpg) ## Overview A **deliverable** is a document generated from a template by substituting variables with actual content. The Deliverable API lets you automate the entire document lifecycle: creation, retrieval, updates, downloads, and organization. ### Key Features - **Template-Based Generation**: Create documents by injecting variables into pre-configured templates - **Variable Substitution**: Support for text, rich text, images, and markdown content types - **Automatic PDF Generation**: PDFs are generated automatically when a deliverable is created - **File Downloads**: Download source files (DOCX/PPTX) or generated PDFs - **Tagging & Filtering**: Tag deliverables and filter by tags, search queries, and more - **Pagination & Sorting**: Built-in pagination and column sorting for list endpoints :::tip Prefer using an SDK? We offer official SDKs that handle authentication, error handling, and type safety for you. [View all SDKs →](/docs/SDKs) ::: ## TLDR; Quick Start Here's the fastest way to create a deliverable and download it: ```bash # 1. Create a deliverable from a template curl -X POST "https://api.turbodocx.com/v1/deliverable" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ -H "Content-Type: application/json" \ -d '{ "templateId": "YOUR_TEMPLATE_ID", "name": "My Document", "variables": [ { "placeholder": "{CompanyName}", "text": "Acme Corporation", "mimeType": "text" } ] }' # 2. Download the source file (DOCX/PPTX) curl -X GET "https://api.turbodocx.com/v1/deliverable/file/DELIVERABLE_ID" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ --output "my-document.docx" # 3. Download the PDF curl -X GET "https://api.turbodocx.com/v1/deliverable/file/pdf/DELIVERABLE_ID" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ --output "my-document.pdf" ``` ### All Endpoints at a Glance | Method | Endpoint | Description | | -------- | ----------------------------------------- | ---------------------------------- | | `GET` | `/v1/deliverable` | List deliverables | | `POST` | `/v1/deliverable` | Create deliverable from template | | `GET` | `/v1/deliverable/:id` | Get a single deliverable | | `PATCH` | `/v1/deliverable/:id` | Update a deliverable | | `DELETE` | `/v1/deliverable/:id` | Delete a deliverable (soft delete) | | `GET` | `/v1/deliverable/file/:deliverableId` | Download source file (DOCX/PPTX) | | `GET` | `/v1/deliverable/file/pdf/:deliverableId` | Download PDF file | ### Complete Workflow Example Here's a full workflow that creates a deliverable, retrieves it, and downloads both the source file and PDF: ## Prerequisites Before you begin, ensure you have: - **API Access Token**: Bearer token for authentication - **Organization ID**: Your organization identifier - **A Template**: At least one uploaded template with extracted variables ### Getting Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or retrieve your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ## Authentication All Deliverable API requests require authentication using a Bearer token and your organization ID. ### Required Headers | Header | Value | Required | Description | | -------------------- | ----------------------- | -------- | ------------------------------------ | | `Authorization` | `Bearer YOUR_API_TOKEN` | Yes | Your API access token | | `x-rapiddocx-org-id` | `YOUR_ORGANIZATION_ID` | Yes | Your organization identifier | | `Content-Type` | `application/json` | Yes\* | Required for POST and PATCH requests | | `User-Agent` | `TurboDocx API Client` | No | Recommended for identification | ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID Content-Type: application/json ``` :::caution Keep Your Tokens Secure Never expose your API tokens in client-side code or public repositories. Always use environment variables or a secrets manager to store credentials. ::: --- ## Endpoint 1: List Deliverables Retrieve a paginated list of deliverables in your organization, with optional filtering and sorting. ### Endpoint ```http GET https://api.turbodocx.com/v1/deliverable ``` ### Query Parameters | Parameter | Type | Required | Default | Description | | ---------- | ------- | -------- | ------- | ---------------------------------------- | | `limit` | Integer | No | 6 | Number of results per page (1–100) | | `offset` | Integer | No | 0 | Number of results to skip for pagination | | `query` | String | No | — | Search query to filter by name | | `showTags` | Boolean | No | false | Include tags in the response | ### Example Request ```bash curl -X GET "https://api.turbodocx.com/v1/deliverable?limit=10&offset=0&showTags=true" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" ``` ### Response ```json { "data": { "results": [ { "id": "39697685-ca00-43b8-92b8-7722544c574f", "name": "Employee Contract - John Smith", "description": "Employment contract for senior developer", "templateId": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "createdBy": "user-uuid-123", "email": "admin@company.com", "fileSize": 287456, "fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "defaultFont": "Arial", "fonts": [{ "name": "Arial", "usage": "body" }], "isActive": true, "createdOn": "2024-01-15T14:12:10.721Z", "updatedOn": "2024-01-15T14:13:45.724Z", "tags": [ { "id": "tag-uuid-1", "label": "hr", "isActive": true, "updatedOn": "2024-01-10T10:00:00.000Z", "createdOn": "2024-01-10T10:00:00.000Z", "createdBy": "user-uuid-123", "orgId": "org-uuid-123" }, { "id": "tag-uuid-2", "label": "contract", "isActive": true, "updatedOn": "2024-01-10T10:00:00.000Z", "createdOn": "2024-01-10T10:00:00.000Z", "createdBy": "user-uuid-123", "orgId": "org-uuid-123" } ] } ], "totalRecords": 42 } } ``` ### Response Fields | Field | Type | Description | | ----------------------- | ------- | ---------------------------------------------------------------- | | `data.results` | Array | Array of deliverable objects | | `data.totalRecords` | Integer | Total number of deliverables matching the query | | `results[].id` | String | Unique deliverable identifier (UUID) | | `results[].name` | String | Deliverable name | | `results[].description` | String | Deliverable description | | `results[].templateId` | String | Template used for generation | | `results[].createdBy` | String | User ID of the creator | | `results[].email` | String | Email of the creator | | `results[].fileSize` | Integer | File size in bytes | | `results[].fileType` | String | MIME type of the generated file | | `results[].defaultFont` | String | Default font used | | `results[].fonts` | JSON | Array of font objects with `name` and `usage` | | `results[].isActive` | Boolean | Whether the deliverable is active (not deleted) | | `results[].createdOn` | String | ISO 8601 creation timestamp | | `results[].updatedOn` | String | ISO 8601 last update timestamp | | `results[].tags` | Array | Tags (only when `showTags=true`) — see [Tag Object](#tag-object) | --- ## Endpoint 2: Create Deliverable Generate a new deliverable document by injecting variables into a template. This is the primary endpoint for document generation. ### Endpoint ```http POST https://api.turbodocx.com/v1/deliverable ``` :::important Authorization Required This endpoint requires one of these roles: **administrator**, **contributor**, or **user**. It also checks your organization's deliverable and storage limits. ::: ### Request Body | Field | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------------------ | | `name` | String | **Yes** | Deliverable name (3–255 characters) | | `templateId` | String | **Yes** | Template ID to generate from | | `variables` | Array | **Yes** | Array of variable objects for substitution | | `description` | String | No | Description (up to 65,535 characters) | | `tags` | Array | No | Array of tag strings to associate | ### Variable Object Structure Each variable in the `variables` array represents a placeholder in the template to be substituted: | Field | Type | Required | Description | | --------------- | --------------- | -------- | ---------------------------------------------------------------------------------------- | | `placeholder` | String | Yes | Template placeholder (e.g., `{CompanyName}`) | | `text` | String | Yes\* | Value to inject (\* not required if `variableStack` is provided or `isDisabled` is true) | | `mimeType` | String | Yes | Content type: `text`, `html`, `image`, or `markdown` | | `isDisabled` | Boolean | No | Skip this variable during generation | | `subvariables` | Array | No | Nested sub-variables (for HTML content with dynamic placeholders) | | `variableStack` | Array or Object | No | Multiple instances (for repeating content) | | `aiPrompt` | String | No | AI prompt for content generation (max 16,000 chars) | ### Example Request ```bash curl -X POST "https://api.turbodocx.com/v1/deliverable" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ -H "Content-Type: application/json" \ -d '{ "templateId": "YOUR_TEMPLATE_ID", "name": "Employee Contract - John Smith", "description": "Employment contract for new senior developer", "variables": [ { "placeholder": "{EmployeeName}", "text": "John Smith", "mimeType": "text" }, { "placeholder": "{CompanyName}", "text": "TechCorp Solutions Inc.", "mimeType": "text" }, { "placeholder": "{JobTitle}", "text": "Senior Software Engineer", "mimeType": "text" }, { "mimeType": "html", "placeholder": "{ContactBlock}", "text": "Contact: {contactName}Phone: {contactPhone}", "subvariables": [ { "placeholder": "{contactName}", "text": "Jane Doe", "mimeType": "text" }, { "placeholder": "{contactPhone}", "text": "(555) 123-4567", "mimeType": "text" } ] } ], "tags": ["hr", "contract", "employee"] }' ``` ### Example with Variable Stacks (Repeating Content) Variable stacks allow you to inject multiple instances of a variable — useful for tables, lists, or repeating sections: ```json { "templateId": "YOUR_TEMPLATE_ID", "name": "Project Report", "variables": [ { "placeholder": "{ProjectPhase}", "mimeType": "html", "variableStack": { "0": { "text": "Phase 1: Assess environment", "mimeType": "html" }, "1": { "text": "Phase 2: Remediate findings", "mimeType": "html" }, "2": { "text": "Phase 3: Continue monitoring", "mimeType": "html" } } } ] } ``` ### Response ```json { "data": { "results": { "deliverable": { "id": "39697685-ca00-43b8-92b8-7722544c574f", "name": "Employee Contract - John Smith", "description": "Employment contract for new senior developer", "templateId": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "createdBy": "user-uuid-123", "createdOn": "2024-01-15T14:12:10.721Z", "updatedOn": "2024-01-15T14:12:10.721Z", "isActive": true, "defaultFont": "", "fonts": null } } } } ``` ### Response Fields | Field | Type | Description | | -------------------------- | ------- | ----------------------------------------------- | | `data.results.deliverable` | Object | The created deliverable object | | `deliverable.id` | String | Unique deliverable identifier (UUID) | | `deliverable.name` | String | Deliverable name | | `deliverable.description` | String | Deliverable description | | `deliverable.templateId` | String | Template used for generation | | `deliverable.createdBy` | String | User ID of the creator | | `deliverable.createdOn` | String | ISO 8601 creation timestamp | | `deliverable.updatedOn` | String | ISO 8601 last update timestamp | | `deliverable.isActive` | Boolean | Active status (always `true` on creation) | | `deliverable.defaultFont` | String | Default font (empty string if not specified) | | `deliverable.fonts` | JSON | Font metadata array (null if not yet generated) | :::info Request Cancellation If the client disconnects during generation, the server will detect the cancellation, clean up any partially created resources, and return a `499` status code. This prevents orphaned deliverables from consuming storage. ::: --- ## Endpoint 3: Get Deliverable Retrieve a single deliverable by ID, including its variables and optionally tags. ### Endpoint ```http GET https://api.turbodocx.com/v1/deliverable/:id ``` ### Path Parameters | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------------------------- | | `id` | String (UUID) | Yes | The unique identifier of the deliverable | ### Query Parameters | Parameter | Type | Required | Description | | ---------- | ------- | -------- | ---------------------------- | | `showTags` | Boolean | No | Include tags in the response | ### Example Request ```bash curl -X GET "https://api.turbodocx.com/v1/deliverable/39697685-ca00-43b8-92b8-7722544c574f?showTags=true" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" ``` ### Response ```json { "data": { "results": { "id": "39697685-ca00-43b8-92b8-7722544c574f", "name": "Employee Contract - John Smith", "description": "Employment contract for new senior developer", "templateId": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "templateName": "Employment Contract Template", "templateNotDeleted": true, "defaultFont": "Arial", "fonts": [{ "name": "Arial", "usage": "body" }], "email": "admin@company.com", "fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "isActive": true, "createdOn": "2024-01-15T14:12:10.721Z", "updatedOn": "2024-01-15T14:13:45.724Z", "variables": [ { "placeholder": "{EmployeeName}", "text": "John Smith", "mimeType": "text" } ], "tags": [ { "id": "tag-uuid-1", "label": "hr", "isActive": true, "updatedOn": "2024-01-10T10:00:00.000Z", "createdOn": "2024-01-10T10:00:00.000Z", "createdBy": "user-uuid-123", "orgId": "org-uuid-123" }, { "id": "tag-uuid-2", "label": "contract", "isActive": true, "updatedOn": "2024-01-10T10:00:00.000Z", "createdOn": "2024-01-10T10:00:00.000Z", "createdBy": "user-uuid-123", "orgId": "org-uuid-123" } ] } } } ``` ### Response Fields | Field | Type | Description | | ---------------------------- | ------- | ---------------------------------------------------------------- | | `results.id` | String | Unique deliverable identifier | | `results.name` | String | Deliverable name | | `results.description` | String | Deliverable description | | `results.templateId` | String | Template ID used for generation | | `results.templateName` | String | Template name | | `results.templateNotDeleted` | Boolean | Whether the source template still exists | | `results.defaultFont` | String | Default font used | | `results.fonts` | JSON | Array of font objects with `name` and `usage` | | `results.email` | String | Creator's email | | `results.fileType` | String | MIME type of the generated file | | `results.isActive` | Boolean | Active status | | `results.createdOn` | String | ISO 8601 creation timestamp | | `results.updatedOn` | String | ISO 8601 last update timestamp | | `results.variables` | Array | Parsed variable objects with values | | `results.tags` | Array | Tags (only when `showTags=true`) — see [Tag Object](#tag-object) | --- ## Endpoint 4: Update Deliverable Update an existing deliverable's metadata or tags. All fields are optional — only include the fields you want to change. ### Endpoint ```http PATCH https://api.turbodocx.com/v1/deliverable/:id ``` :::important Authorization Required This endpoint requires one of these roles: **administrator**, **contributor**, or **user**. ::: ### Path Parameters | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------------------------- | | `id` | String (UUID) | Yes | The unique identifier of the deliverable | ### Request Body | Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------------------------- | | `name` | String | No | Updated name (3–255 characters) | | `description` | String | No | Updated description (up to 65,535 characters) | | `tags` | Array | No | Replace all tags (existing tags are removed first) | :::caution Tag Replacement When you provide `tags` in the update request, **all existing tags are replaced**. To add a tag, you must include the full list of desired tags. To remove all tags, pass an empty array. ::: ### Example Request ```bash curl -X PATCH "https://api.turbodocx.com/v1/deliverable/39697685-ca00-43b8-92b8-7722544c574f" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Employee Contract - John Smith (Final)", "tags": ["hr", "contract", "finalized"] }' ``` ### Response ```json { "data": { "message": "Deliverable updated successfully", "deliverableId": "39697685-ca00-43b8-92b8-7722544c574f" } } ``` ### Response Fields | Field | Type | Description | | -------------------- | ------ | ----------------------------- | | `data.message` | String | Success confirmation message | | `data.deliverableId` | String | ID of the updated deliverable | --- ## Endpoint 5: Delete Deliverable Soft-delete a deliverable. The deliverable is marked as inactive and will no longer appear in list results, but its data is retained. ### Endpoint ```http DELETE https://api.turbodocx.com/v1/deliverable/:id ``` :::important Authorization Required This endpoint requires one of these roles: **administrator**, **contributor**, or **user**. ::: ### Path Parameters | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ---------------------------------------- | | `id` | String (UUID) | Yes | The unique identifier of the deliverable | ### Example Request ```bash curl -X DELETE "https://api.turbodocx.com/v1/deliverable/39697685-ca00-43b8-92b8-7722544c574f" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" ``` ### Response ```json { "data": { "message": "Deliverable deleted successfully", "deliverableId": "39697685-ca00-43b8-92b8-7722544c574f" } } ``` ### Response Fields | Field | Type | Description | | -------------------- | ------ | ----------------------------- | | `data.message` | String | Success confirmation message | | `data.deliverableId` | String | ID of the deleted deliverable | --- ## Endpoint 6: Download Source File (DOCX/PPTX) Download the original generated document file in its source format (DOCX or PPTX). ### Endpoint ```http GET https://api.turbodocx.com/v1/deliverable/file/:deliverableId ``` :::important Feature & Authorization Required This endpoint requires: - **Role**: administrator, contributor, or user - **License feature**: `hasFileDownload` must be enabled for your organization ::: ### Path Parameters | Parameter | Type | Required | Description | | --------------- | ------------- | -------- | ---------------------------------------- | | `deliverableId` | String (UUID) | Yes | The unique identifier of the deliverable | ### Example Request ```bash curl -X GET "https://api.turbodocx.com/v1/deliverable/file/39697685-ca00-43b8-92b8-7722544c574f" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ --output "employee-contract.docx" ``` ### Response Returns the binary content of the generated document with appropriate headers: ```http Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document Content-Disposition: attachment; filename="Employee Contract - John Smith.docx" ``` For PowerPoint templates, the content type will be: ```http Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation ``` --- ## Endpoint 7: Download PDF File Download the PDF version of a generated deliverable. ### Endpoint ```http GET https://api.turbodocx.com/v1/deliverable/file/pdf/:deliverableId ``` :::important Authorization Required This endpoint requires one of these roles: **administrator**, **contributor**, or **user**. ::: ### Path Parameters | Parameter | Type | Required | Description | | --------------- | ------------- | -------- | ---------------------------------------- | | `deliverableId` | String (UUID) | Yes | The unique identifier of the deliverable | ### Example Request ```bash curl -X GET "https://api.turbodocx.com/v1/deliverable/file/pdf/39697685-ca00-43b8-92b8-7722544c574f" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ --output "employee-contract.pdf" ``` ### Response Returns the binary PDF content: ```http Content-Type: application/pdf Content-Disposition: attachment; filename="Employee Contract - John Smith.pdf" ``` --- ## Tag Object When `showTags=true` is passed, tag arrays contain full tag objects with the following fields: | Field | Type | Description | | ----------- | ------- | ---------------------------------- | | `id` | String | Tag unique identifier (UUID) | | `label` | String | Tag display name | | `isActive` | Boolean | Whether the tag is active | | `updatedOn` | String | ISO 8601 last update timestamp | | `createdOn` | String | ISO 8601 creation timestamp | | `createdBy` | String | User ID of the tag creator | | `orgId` | String | Organization ID the tag belongs to | --- ## Best Practices ### Variable Preparation - Always match `placeholder` values exactly with your template placeholders (e.g., `{CompanyName}`) - Use `subvariables` for structured data within HTML content — the parent variable must have `mimeType: "html"` with HTML containing independent placeholder tokens, and each subvariable provides a value for one of those tokens - Use `variableStack` for repeating content (tables, list items) - Set `mimeType` to `html` when injecting formatted content ### Pagination - Use `limit` and `offset` for efficient data retrieval - The default `limit` is 6; set it up to 100 for larger result sets - Use `totalRecords` from the response to calculate total pages ### Tag Filtering - When passing `selectedTags`, all specified tags must match (AND logic) - Tags can be passed as a single UUID string or an array of UUIDs ### Error Handling - Always check the HTTP status code before parsing the response body - Implement retry logic with exponential backoff for `5xx` errors - Handle `499` (Client Closed Request) gracefully — the server has already cleaned up --- ## Error Handling ### HTTP Status Codes | Status | Description | Common Cause | | ------ | --------------------- | -------------------------------------------------- | | `200` | Success | Request completed successfully | | `400` | Bad Request | Validation error — check required fields and types | | `401` | Unauthorized | Invalid or missing Bearer token | | `403` | Forbidden | Missing role permission or invalid org ID | | `404` | Not Found | Deliverable or template ID does not exist | | `422` | Unprocessable Entity | Field constraint violation | | `429` | Too Many Requests | Rate limit exceeded — use exponential backoff | | `499` | Client Closed Request | Request cancelled during deliverable generation | | `500` | Internal Server Error | Server-side error — contact support | ### Error Response Format ```json { "error": "ValidationError", "message": "\"name\" length must be at least 3 characters long" } ``` ### Common Issues **Variable validation errors** - Ensure `text` is provided for each variable (unless `variableStack` is present or `isDisabled` is true) - Check that `mimeType` is one of: `text`, `html`, `image`, `markdown` - When using `html` mimeType with subvariables, ensure the parent `text` contains the subvariable placeholder tokens **Template not found** - Verify the `templateId` exists and has been fully processed (placeholders extracted) - Templates must be active (not deleted) **License limit exceeded** - Your organization may have reached its deliverable or storage quota - Contact your administrator to upgrade your plan **File download permission errors** - Source file download (`/v1/deliverable/file/:id`) requires the `hasFileDownload` feature to be enabled on your organization's license - Ensure your user role is administrator, contributor, or user (viewers cannot download) --- # Create Image Variable (Folder) This endpoint creates a reusable variable scoped to a template folder or to your organization's global knowledge base, independent of any single template. Despite the name, it is not limited to images: `mimeType` also accepts `text` and `html`, and the same endpoint is used for [Read Variables (Folder)](/docs/API/read-variables-folder) to later list. ## When to use it Use this endpoint to build a shared library of content, such as a company logo, a standard address block, or boilerplate legal language, that multiple templates can reference by placeholder without duplicating the content in each one. ## Example request ```bash curl -X POST "https://api.turbodocx.com/Variable" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Company Logo", "placeholder": "{CompanyLogo}", "mimeType": "image", "text": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB...", "templateFolderId": "9d2b1c63-0f77-4a9c-b1d0-2c5e6f7a8b90", "allowRichTextInjection": true }' ``` Optionally send `templateFolderId` (scopes the variable to that folder) or `isGlobal: true` (adds it to your org-wide knowledge base); at most one may be set, and sending both is rejected. `text` must be a base64 `data:` URI when `mimeType` is `"image"`, or plain text/HTML otherwise. `placeholder` must be unique within its folder or knowledge base and, if set, must be wrapped in curly braces, for example `{CompanyLogo}`. ## Example response On success the endpoint returns the created variable: ```json { "data": { "results": { "variable": { "id": "e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b", "name": "Company Logo", "placeholder": "{CompanyLogo}", "mimeType": "image", "isGlobal": false, "templateFolderId": "9d2b1c63-0f77-4a9c-b1d0-2c5e6f7a8b90", "allowRichTextInjection": true, "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z" } } } } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 403 | `isGlobal: true` and the key's role is `user` (creating a knowledge-base variable requires administrator or contributor) | Empty (status only) | | 400 | `mimeType` or `text` is missing, or both `isGlobal` and `templateFolderId` are set | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | | 400 | `placeholder` is not wrapped in `{ }`, or already exists in that folder or knowledge base | `{ "message", "type": "TemplateError", "data": [{ "message", "type", "data": { "explanation", "context" } }] }` | ## Related endpoints - [Read Variables (Folder)](/docs/API/read-variables-folder) to list variables you have created - [Update Variable by ID](/docs/API/update-variable-by-id) to edit a variable after creating it - [Delete Variables (by IDs)](/docs/API/delete-variables-by-i-ds) to remove variables in bulk --- # Create Tag The Create Tag endpoint adds a new tag to your organization. Tags are a flat, organization-wide label set; once created, a tag can be attached to templates (with [Edit Template Metadata](/docs/API/edit-template-metadata)) or variables to make them easier to filter and organize. ## When to use it Use this endpoint to build a tag picker that lets users create new tags on the fly, or to seed a starting set of tags when provisioning an organization. ## Example request ```bash curl -X POST "https://api.turbodocx.com/Tag" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{"label": "legal"}' ``` ## Example response ```json { "data": { "results": { "id": "7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90", "label": "legal", "isActive": true, "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z" } } } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | `label` is missing or not between 1 and 255 characters | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Read Tag](/docs/API/read-tag) to list existing tags before creating a duplicate - [Update Tag](/docs/API/update-tag) to rename a tag - [Delete Tags (by IDs)](/docs/API/delete-tags-by-i-ds) to remove tags you no longer need --- # Create Webhook The Create Webhook endpoint registers a webhook that TurboDocx calls when TurboSign events happen in your organization, such as a document being signed or completed. Each webhook needs a unique `name` among your org's active webhooks; every TurboDocx SDK sends `"signature"`. A second create call with that name conflicts (409) only while an active webhook already has it; if you paused a webhook via [Update Webhook](/docs/API/update-webhook)'s `isActive: false` instead of deleting it, creating a new one with the same name succeeds and leaves two rows sharing that name, and by-name lookups on the other endpoints may then hit either one. Delete the old webhook (see [Delete Webhook](/docs/API/delete-webhook)) before reusing its name, rather than just pausing it. ## When to use it Use this endpoint once, during integration setup, to start receiving signature lifecycle events instead of polling the API. To change the URLs or subscribed events later, use [Update Webhook](/docs/API/update-webhook) rather than creating a new one. ## Example request ```bash curl -X POST "https://api.turbodocx.com/api/webhooks" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "signature", "urls": ["https://example.com/webhooks/turbodocx"], "events": ["signature.document.completed", "signature.document.voided"] }' ``` `urls` accepts up to 10 HTTPS endpoints (plain HTTP is rejected); `events` must be one or more of the values listed in [Get Webhook](/docs/API/get-webhook)'s `availableEvents`. Requires an API key with the administrator role. ## Example response On success the endpoint returns `201 Created`: ```json { "data": { "id": "b7e2c4a1-3f9d-4e6a-8c1b-5d0f7a2e9c34", "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "signature", "urls": ["https://example.com/webhooks/turbodocx"], "events": ["signature.document.completed", "signature.document.voided"], "secret": "whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET", "isActive": true, "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z", "secretExists": true }, "message": "Webhook created successfully. Save the secret - it won't be shown again." } ``` `secret` is only ever returned in full on create and on [Regenerate Webhook Secret](/docs/API/regenerate-webhook-secret); every other endpoint returns a masked `maskedSecret` instead. Use `secret` to verify the `X-TurboDocx-Signature` header on incoming webhook calls. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 400 | `name`, `urls`, or `events` is missing or invalid, or any URL is not HTTPS | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | | 409 | A webhook named `signature` already exists in your organization | `{ "message", "error": "WebhookNameTaken", "data": { "constraint", "orgId", "name" } }` | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) to view the webhook you created, its delivery stats, and available event types - [Update Webhook](/docs/API/update-webhook) to change its URLs, events, or active state - [Test Webhook](/docs/API/test-webhook) to send a sample event before going live --- # Delete Tags (by IDs) The Delete Tags (by IDs) endpoint deactivates one or more tags in a single call. It is a soft delete: matching tags are marked inactive rather than removed from the database, so they immediately stop appearing in [Read Tag](/docs/API/read-tag) and tag pickers. ## When to use it Use this endpoint to let users bulk-remove tags they no longer need, instead of calling a single-tag delete endpoint in a loop. ## Example request ```bash curl -X DELETE "https://api.turbodocx.com/Tag/Bulk/Action" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{"ids": ["7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90", "9d2b1c63-0f77-4a9c-b1d0-2c5e6f7a8b90"]}' ``` ## Example response On success the endpoint returns `200 OK` with an empty data object: ```json { "data": {} } ``` Deletion is idempotent: IDs that do not exist, or belong to another organization, are silently skipped rather than causing an error. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | `ids` is missing or not an array | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Read Tag](/docs/API/read-tag) to find the tag IDs to delete - [Create Tag](/docs/API/create-tag) to add a new tag - [Update Tag](/docs/API/update-tag) to rename a tag instead of deleting it --- # Delete Template The Delete Template endpoint removes a template from your TurboDocx workspace so it can no longer be used to generate documents. Documents you have already generated from the template are not affected, so this is a safe way to retire unused or outdated templates without touching delivered files. There is no un-delete endpoint, so treat deletion as final. ## When to use it Use this endpoint to remove templates that are no longer in rotation, deduplicate templates created during testing, or let your users manage their own template library from inside your app. If you only want to change a template's name, description, or tags, use [Edit Template Metadata](/docs/API/edit-template-metadata) instead of deleting and re-uploading. ## Example request ```bash curl -X DELETE "https://api.turbodocx.com/template/2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` Replace the path segment with the `TemplateId` returned by [Get Templates and Folders](/docs/API/get-templates-and-folders). ## Example response On success the endpoint returns `200 OK` with an empty data object: ```json { "data": {} } ``` Deletion is idempotent from the caller's side: deleting an ID that does not exist, or was already deleted, still returns `200` with `{ "data": {} }` rather than a `404`. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 423 | The template is locked | `{ "error": "Resource is locked", "message", "data": { "locked": true, "lockedBy", "lockedOn" } }` | | 400 | The `id` path parameter is not a valid UUID | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Get Templates and Folders](/docs/API/get-templates-and-folders) to find the `TemplateId` to delete - [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values) to create a replacement template - [Edit Template Metadata](/docs/API/edit-template-metadata) to rename or retag instead of deleting --- # Delete Variables (by IDs) The Delete Variables (by IDs) endpoint removes one or more variables from your knowledge base or a template folder in a single call, along with their versions, tags, and (for image variables) their stored files. ## When to use it Use this endpoint to let users bulk-clean their variable library, instead of calling a single-variable delete endpoint in a loop. ## Example request ```bash curl -X DELETE "https://api.turbodocx.com/Variable/Bulk/Action" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{"ids": ["e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b"]}' ``` Each entry in `ids` is a `variableMapId`, the same `id` returned by [Read Variables (Folder)](/docs/API/read-variables-folder). ## Example response On success the endpoint returns `200 OK` with an empty data object: ```json { "data": {} } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | `ids` is missing or not an array | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Read Variables (Folder)](/docs/API/read-variables-folder) to find the variable IDs to delete - [Update Variable by ID](/docs/API/update-variable-by-id) to edit a variable instead of deleting it --- # Delete Webhook The Delete Webhook endpoint permanently removes a webhook and all of its delivery history. Unlike [Delete Template](/docs/API/delete-template), this is a hard delete: the webhook row and its delivery records are removed from the database, not deactivated. There is no un-delete. ## When to use it Use this endpoint when you are decommissioning an integration and no longer want TurboDocx to call your endpoint. If you only want to pause delivery temporarily, use [Update Webhook](/docs/API/update-webhook) with `isActive: false` instead, so you keep the secret and delivery history. ## Example request ```bash curl -X DELETE "https://api.turbodocx.com/api/webhooks/signature" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` ## Example response On success the endpoint returns `200 OK`: ```json { "message": "Webhook deleted successfully" } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) to confirm the webhook's configuration before deleting it - [Update Webhook](/docs/API/update-webhook) to pause delivery instead of deleting - [Create Webhook](/docs/API/create-webhook) to register a new webhook afterward --- # Edit Template Metadata The Edit Template Metadata endpoint updates a template's name, description, folder, or tags without touching its file or variables. Send only the fields you want to change; fields you omit are left as-is. ## When to use it Use this endpoint to rename a template, move it into a different folder (`templateFolderId`), or replace its tags after you have already uploaded it. To change the file itself, delete the template and upload a new one with [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values). ## Example request ```bash curl -X PATCH "https://api.turbodocx.com/template/2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "SOW Template (v2)", "description": "Standard statement of work, updated for 2026 pricing", "tags": [{"id": "7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90"}] }' ``` Sending `tags` replaces the template's entire tag list: existing tags not included in the array are removed. Each entry only needs the tag's `id`, from [Create Tag](/docs/API/create-tag) or [Read Tag](/docs/API/read-tag). ## Example response On success the endpoint returns `200 OK` with no response body. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 423 | The template is locked | `{ "error": "Resource is locked", "message", "data": { "locked": true, "lockedBy", "lockedOn" } }` | | 400 | A field fails validation (for example `name` under 3 characters) | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Get Template by ID](/docs/API/get-template-by-id) to see the current metadata before editing - [Delete Template](/docs/API/delete-template) to remove a template instead of editing it - [Get Templates and Folders](/docs/API/get-templates-and-folders) to find the `TemplateId` to edit --- # Extract Template Placeholders and Generate Preview The Extract Template Placeholders and Generate Preview endpoint parses an uploaded DOCX or PPTX file and returns every `{placeholder}` variable and font it found, without creating a template. By default it also renders a PDF preview of the file and returns it inline. This lets you show a user exactly what variables a file contains, and what it looks like, before they commit to uploading it as a template. ## When to use it Use this endpoint to build an "upload and preview" step ahead of [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values): show the detected placeholders so the user can confirm or rename them, and show the rendered PDF so they can confirm it's the right file. Pass `skipFile=true` if you only need the extracted variables and fonts and want to skip the (slower) PDF render. ## Example request ```bash curl -X POST "https://api.turbodocx.com/template/file?skipFile=true" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -F "file=@./sow-template.docx" ``` ## Example response With `skipFile=true`: ```json { "data": { "results": { "filetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "vars": [ { "placeholder": "{CustomerName}", "name": "CustomerName", "mimeType": "text", "order": 0, "count": 1, "allowRichTextInjection": false } ], "fonts": [{ "name": "Calibri" }] } } } ``` Without `skipFile`, the response also includes `templatePdf`, the rendered PDF of the uploaded file. It is a JSON-serialized Node.js Buffer, not a base64 string: an object of the form `{"type": "Buffer", "data": [37, 80, 68, 70, ...]}`, where `data` is an array of the PDF's raw bytes. To use it, reconstruct the binary from the byte array, for example `Buffer.from(templatePdf.data)` in Node.js or `new Uint8Array(templatePdf.data)` in the browser. Pass the `vars` array as the `variables` field (JSON-stringified) when you subsequently call [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values). ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | No file was attached, or the file could not be uploaded ("Improper File Upload") | `{ "message", "error", "data": { "explanation", "context" } }` | | 400 | The file type is not supported (not DOCX, PPTX, or HTML) | `{ "message": "Unsupported File Type", "error", "data": { "explanation", "context" } }` | | 400 | Your plan's template or storage limit is reached | `{ "message", "type", "data": { "explanation", "context" } }` | | 503 | The storage service is temporarily unavailable | `{ "message", "error", "data": { "explanation", "context" } }` | ## Related endpoints - [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values) to create the template from the same file - [Get Templates and Folders](/docs/API/get-templates-and-folders) to browse existing templates --- # Get Template by ID The Get Template by ID endpoint returns a single template's full details: its metadata, creator, and the variables available to it. The variables array merges the template's own variables with any inherited from its folder and from your organization's global (knowledge base) variables, so it reflects everything the template can fill in at generation time. ## When to use it Use this endpoint to load a template's details and variable list before generating a document, to check whether a template is TurboDocx-provided (and therefore read-only), or to confirm a metadata change made with [Edit Template Metadata](/docs/API/edit-template-metadata). ## Example request ```bash curl "https://api.turbodocx.com/template/2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34?showTags=true" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` Pass `showTags=true` to include the template's `tags` array in the response; it is omitted by default. ## Example response ```json { "data": { "results": { "id": "2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34", "name": "SOW Template", "description": "Standard statement of work", "isActive": true, "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "templateFolderId": null, "defaultFont": "Calibri", "fonts": [{ "name": "Calibri" }], "metadata": {}, "templateFileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "isTurboDocxProvided": false, "deliverableCount": 12, "variables": [ { "placeholder": "{CustomerName}", "name": "CustomerName", "mimeType": "text", "order": 0, "count": 1 } ] } } } ``` `isTurboDocxProvided` is `true` for templates from the built-in TurboDocx library; those cannot be edited in place and must be downloaded and re-uploaded to customize. Each entry in `variables` mirrors the variable fields returned by generation, with `isTemplateFolder` set on variables inherited from the template's folder. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 404 | No active template with that ID exists in your organization | Empty (status only) | | 400 | The `TemplateID` path parameter is not a valid UUID | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Get Templates and Folders](/docs/API/get-templates-and-folders) to list templates and find a `TemplateId` - [Edit Template Metadata](/docs/API/edit-template-metadata) to rename, retag, or move this template - [Delete Template](/docs/API/delete-template) to remove this template --- # Get Templates and Folders The Get Templates and Folders endpoint lists the templates and folders at the root of your TurboDocx workspace, including each item's ID, name, parent folder, and tags. By default it returns only root-level items; pass `type=template` to enumerate every template across all folders. It is the starting point for most integrations: call it to discover the `TemplateId` values you pass to document generation, or to build a template picker inside your own app. ## When to use it Use this endpoint to populate a "choose a template" dropdown, sync your template library into another system, or look up the ID of a template before generating a document or deleting it. Results include each item's `tags` by default (`showTags` defaults to `true`); page through large libraries with `limit` and `offset`. ## Example request ```bash curl "https://api.turbodocx.com/template-item?limit=25&offset=0" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` ## Example response Responses are wrapped in a `data` object. `results` is the array of items and `totalRecords` is the total count for paging. ```json { "data": { "results": [ { "id": "2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34", "name": "SOW Template", "description": "Standard statement of work", "type": "template", "isActive": true, "templateFolderId": null, "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "email": "jane@example.com", "deliverableCount": 12, "templateCount": 0, "fileSize": 48213, "fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z", "tags": ["sales", "legal"] }, { "id": "7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90", "name": "Proposals", "type": "folder", "isActive": true, "templateFolderId": null, "templateCount": 5, "deliverableCount": 0 } ], "totalRecords": 2 } } ``` Each item's `type` is either `"template"` or `"folder"`. `createdBy` is the creator's user ID (a UUID), not a display name; use `email` for the human identity. `fileType` is the file's MIME type (for example `application/vnd.openxmlformats-officedocument.wordprocessingml.document` for a DOCX). The `tags` array is included when `showTags` is true (the default). ## Pagination and filtering Page with `limit` (default `9`) and `offset` (default `0`): request successive pages by increasing `offset` by `limit`, and use `totalRecords` to know when to stop. Other supported query parameters: - `query` — free-text search across template names - `selectedTags` — one or more tag IDs (GUIDs) to filter by - `column0` + `order0` — sort by `name`, `createdOn`, `updatedOn`, `email`, or `templateCount`, in `asc` or `desc` order - `showTags` — set to `false` to omit the `tags` array ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 400 | A query parameter fails validation (for example a non-numeric `limit`) | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Get Template by ID](/docs/API/get-template-by-id) to fetch one template's full details - [Upload Template with Optional Default Values](/docs/API/upload-template-with-optional-default-values) to add a new template - [Delete Template](/docs/API/delete-template) to remove a template you found here --- # Get Webhook Stats The Get Webhook Stats endpoint returns delivery statistics for this specific webhook over a time window you choose, broken down by event type. Unlike the `deliveryStats` on [Get Webhook](/docs/API/get-webhook) (which cover your whole organization's last 30 days), this endpoint is scoped to one webhook and lets you pick the window. ## When to use it Use this endpoint to build a delivery health dashboard for a single webhook: overall success rate, average response time, and which event types are failing most, over a period you control. ## Example request ```bash curl "https://api.turbodocx.com/api/webhooks/signature/stats?days=7" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` `days` defaults to `30` and accepts 1 to 365. ## Example response ```json { "data": { "webhook": { "id": "b7e2c4a1-3f9d-4e6a-8c1b-5d0f7a2e9c34", "name": "signature", "isActive": true, "events": ["signature.document.completed", "signature.document.voided"], "urls": ["https://example.com/webhooks/turbodocx"] }, "period": { "days": 7, "from": "2026-04-25T00:00:00.000Z", "to": "2026-05-02T00:00:00.000Z" }, "summary": { "totalDeliveries": 42, "successfulDeliveries": 40, "failedDeliveries": 1, "pendingRetries": 1, "successRate": 95.24, "avgResponseTime": 184, "lastSuccessfulDelivery": "2026-05-01T22:10:05.000Z", "lastFailedDelivery": "2026-04-29T11:02:41.000Z" }, "eventBreakdown": [ { "eventType": "signature.document.completed", "total": 30, "successful": 29, "failed": 1, "successRate": 96.67 } ] } } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | | 400 | `days` is outside 1 to 365 | Validation error from the query schema | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) for the webhook's current configuration - [List Webhook Deliveries](/docs/API/list-webhook-deliveries) to inspect individual delivery attempts behind these numbers --- # Get Webhook The Get Webhook endpoint returns a webhook's current configuration (URLs, subscribed events, active state), its secret status, delivery statistics for your organization, and the full list of event types you can subscribe to. ## When to use it Use this endpoint to display a webhook's settings in your own dashboard, to check whether it is active before troubleshooting missing events, or to read `availableEvents` so you can build an event-picker UI without hardcoding the list. ## Example request ```bash curl "https://api.turbodocx.com/api/webhooks/signature" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` `signature` is the webhook's `name`, not its `id`; every TurboDocx SDK-created webhook uses that name. ## Example response ```json { "data": { "id": "b7e2c4a1-3f9d-4e6a-8c1b-5d0f7a2e9c34", "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "signature", "urls": ["https://example.com/webhooks/turbodocx"], "events": ["signature.document.completed", "signature.document.voided"], "isActive": true, "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z", "secretExists": true, "maskedSecret": "whs***7f8", "deliveryStats": { "totalDeliveries": 214, "successfulDeliveries": 209, "failedDeliveries": 3, "pendingRetries": 2 }, "availableEvents": [ "signature.document.sent", "signature.document.viewed", "signature.document.signed", "signature.document.recipient_signed", "signature.document.completed", "signature.document.finalization_failed", "signature.document.voided" ] } } ``` `deliveryStats` covers all webhook deliveries across your organization over the last 30 days, not only this webhook's. For per-delivery detail, or stats scoped to a custom time window, use [Get Webhook Stats](/docs/API/get-webhook-stats). ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | ## Related endpoints - [Update Webhook](/docs/API/update-webhook) to change its URLs, events, or active state - [Get Webhook Stats](/docs/API/get-webhook-stats) for delivery stats scoped to this webhook over a custom period - [List Webhook Deliveries](/docs/API/list-webhook-deliveries) to inspect individual delivery attempts --- # List Webhook Deliveries The List Webhook Deliveries endpoint returns the individual delivery attempts made to your signature webhook's URLs, newest first, with each attempt's status, HTTP response code, and retry count. ## When to use it Use this endpoint to build a delivery log for debugging: find which attempts failed, filter to a specific event type, or locate a delivery's `id` to pass to [Replay Webhook Delivery](/docs/API/replay-webhook-delivery). ## Example request ```bash curl "https://api.turbodocx.com/api/webhooks/signature/deliveries?limit=20&offset=0&isDelivered=false" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` Supported filters: `eventType`, `isDelivered` (`true`/`false`), and `httpStatus` (an exact status code). `limit` defaults to `20`, `offset` to `0`. > **Known limitation:** `isDelivered=true` and `httpStatus` do not currently filter results correctly due to a backend issue: `isDelivered=true` returns the same (undelivered-only) results as `isDelivered=false`, and `httpStatus` is ignored entirely regardless of value. Only `isDelivered=false` behaves as documented today. ## Example response ```json { "data": { "results": [ { "id": "d3a1c2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "eventType": "signature.document.completed", "url": "https://example.com/webhooks/turbodocx", "httpStatus": 502, "responseBody": "Bad Gateway", "attemptCount": 2, "maxAttempts": 3, "isDelivered": false, "deliveredAt": null, "nextRetryAt": "2026-05-02T09:15:00.000Z", "errorMessage": "Request failed with status code 502", "status": "retrying", "createdOn": "2026-05-02T09:05:00.000Z", "updatedOn": "2026-05-02T09:10:00.000Z" } ], "totalRecords": 1, "limit": 20, "offset": 0 } } ``` `status` summarizes the row as `"delivered"`, `"failed"` (all `maxAttempts` attempts exhausted), `"retrying"`, or `"pending"`. Failed deliveries retry automatically with backoff (immediate, then +1 minute, then +5 minutes) for up to `maxAttempts` (3) attempts total before landing in the dead-letter state. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | ## Related endpoints - [Replay Webhook Delivery](/docs/API/replay-webhook-delivery) to manually retry a delivery from this list - [Get Webhook Stats](/docs/API/get-webhook-stats) for aggregate numbers instead of individual deliveries - [Test Webhook](/docs/API/test-webhook) to generate a fresh delivery on demand --- # Notify Webhook The Notify Webhook endpoint sends a one-off event delivery to every URL configured on your signature webhook. It calls the exact same backend logic as [Test Webhook](/docs/API/test-webhook): both accept the same body and return the same response shape. This endpoint exists for API surface symmetry with the "notify" naming some integrations expect; new integrations should call Test Webhook instead. ## When to use it Use this endpoint (or Test Webhook) to confirm your receiving endpoint handles a TurboSign event correctly, without waiting for a real document to reach that state. ## Example request ```bash curl -X POST "https://api.turbodocx.com/api/webhooks/signature/notify" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "eventType": "signature.document.completed", "payload": {"documentId": "doc_abc123", "status": "completed"} }' ``` Both `eventType` and `payload` are optional; omit them to send a default sample payload for a default event type. ## Example response ```json { "data": { "deliveries": [ { "id": "d3a1c2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "eventType": "signature.document.completed", "url": "https://example.com/webhooks/turbodocx", "httpStatus": 200, "attemptCount": 1, "maxAttempts": 3, "isDelivered": true, "status": "delivered" } ], "summary": { "total": 1, "successful": 1, "failed": 0, "errors": [] } }, "message": "Manual notification sent successfully to all URLs" } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | | 400 | The webhook exists but `isActive` is `false` | `{ "error": "Cannot send notification to inactive webhook" }` | ## Related endpoints - [Test Webhook](/docs/API/test-webhook) for the same behavior under TurboDocx's documented name - [List Webhook Deliveries](/docs/API/list-webhook-deliveries) to review past delivery attempts - [Get Webhook](/docs/API/get-webhook) to check the webhook's configuration first --- # Read Tag The Read Tag endpoint lists the active tags in your organization, sorted alphabetically by `label` by default. Despite the singular name, it returns a paginated array, not a single tag. To fetch one tag by its ID, use `GET /Tag/:id`, which returns a single tag object under `data.results` (404 if not found). ## When to use it Use this endpoint to populate a tag picker or filter dropdown, or to search for an existing tag by name before deciding whether to create a new one with [Create Tag](/docs/API/create-tag). ## Example request ```bash curl "https://api.turbodocx.com/Tag?limit=25&offset=0&query=legal" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` `limit` defaults to `6`, `offset` to `0`. `query` filters by a case-insensitive match on `label`. Sort with `column0` (`label`, `createdOn`, or `updatedOn`) and `order0` (`asc` or `desc`). ## Example response ```json { "data": { "results": [ { "id": "7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90", "label": "legal", "isActive": true, "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z" } ], "totalRecords": 1 } } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 400 | A query parameter fails validation | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Create Tag](/docs/API/create-tag) to add a new tag - [Update Tag](/docs/API/update-tag) to rename an existing tag - [Delete Tags (by IDs)](/docs/API/delete-tags-by-i-ds) to remove tags in bulk - `GET /Tag/:id` to fetch a single tag by ID --- # Read Variables (Folder) The Read Variables (Folder) endpoint lists reusable variables scoped to your organization's global knowledge base or to a specific template folder. These are standalone variables you manage independently of any one template, for reuse across many templates (for example a company address or a standard clause). ## When to use it Use this endpoint to build a variable library browser, or to look up a variable's `id` before updating it with [Update Variable by ID](/docs/API/update-variable-by-id) or deleting it with [Delete Variables (by IDs)](/docs/API/delete-variables-by-i-ds). ## Example request ```bash curl "https://api.turbodocx.com/Variable?isGlobal=true&limit=25&offset=0&showTags=true" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` Optionally pass `isGlobal=true` (your org-wide knowledge base) or `templateFolderId=` (a specific folder) to scope the list; at most one may be set, and passing both is rejected. Omit both to list across your accessible scope. `limit` defaults to `6`, `offset` to `0`, and `query` filters by name. ## Example response ```json { "data": { "results": [ { "id": "e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b", "variableMapId": "e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b", "name": "Company Address", "placeholder": "{CompanyAddress}", "description": "Standard mailing address block", "mimeType": "text", "text": "123 Main St, Suite 400, Austin, TX 78701", "isGlobal": true, "templateFolderId": null, "allowRichTextInjection": false, "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "updatedOn": "2026-05-01T14:22:10.000Z" } ], "totalRecords": 1 } } ``` An image variable's `text` holds a `data:` URI rather than plain text. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 400 | Both `isGlobal` and `templateFolderId` are set, or a query parameter fails validation | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Update Variable by ID](/docs/API/update-variable-by-id) to change a variable found here - [Delete Variables (by IDs)](/docs/API/delete-variables-by-i-ds) to remove variables in bulk - [Get Template by ID](/docs/API/get-template-by-id) to see how folder and global variables merge into a template --- # Regenerate Webhook Secret The Regenerate Webhook Secret endpoint issues a new HMAC secret for your signature webhook and immediately replaces the old one. Use the returned secret to verify the `X-TurboDocx-Signature` header on incoming webhook calls. ## When to use it Use this endpoint if the current secret may have leaked, or as part of a routine credential rotation. Because the change takes effect immediately, update your webhook receiver's stored secret before or right after calling this endpoint; deliveries signed with the old secret will fail verification on your side as soon as it rotates. ## Example request ```bash curl -X POST "https://api.turbodocx.com/api/webhooks/signature/regenerate" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Accept: application/json" ``` ## Example response ```json { "data": { "id": "b7e2c4a1-3f9d-4e6a-8c1b-5d0f7a2e9c34", "secret": "whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET", "regeneratedAt": "2026-05-02T09:20:00.000Z" }, "message": "Webhook secret regenerated successfully. Save the new secret - it won't be shown again." } ``` The full `secret` is only returned here and on [Create Webhook](/docs/API/create-webhook); [Get Webhook](/docs/API/get-webhook) only ever returns a masked `maskedSecret`. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) to confirm the secret was rotated (`maskedSecret` changes) - [Update Webhook](/docs/API/update-webhook) to change URLs or events without rotating the secret - [Test Webhook](/docs/API/test-webhook) to confirm your receiver validates the new secret correctly --- # Replay Webhook Delivery The Replay Webhook Delivery endpoint manually retries a specific past delivery, identified by its `deliveryId`. It creates a brand-new delivery row for the attempt (the original row is left as-is) and immediately re-sends the event to the URL the original delivery was made to (not every URL configured on the webhook, if it has more than one). ## When to use it Use this endpoint to retry a delivery that permanently failed (exhausted its automatic retries) after you have fixed whatever was wrong with your receiving endpoint, instead of waiting for the same event to occur again. ## Example request ```bash curl -X POST "https://api.turbodocx.com/api/webhooks/signature/replay" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{"deliveryId": "d3a1c2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}' ``` Get `deliveryId` from [List Webhook Deliveries](/docs/API/list-webhook-deliveries). ## Example response On success the endpoint returns `200 OK` with the new delivery: ```json { "data": { "id": "f2e1d0c9-8b7a-4695-a3b2-1c0d9e8f7a6b", "eventType": "signature.document.completed", "url": "https://example.com/webhooks/turbodocx", "attemptCount": 0, "maxAttempts": 3, "isDelivered": false, "status": "pending", "createdOn": "2026-05-02T09:30:00.000Z", "updatedOn": "2026-05-02T09:30:00.000Z" }, "message": "Webhook delivery replayed successfully - new delivery attempt created" } ``` Check the new delivery's status afterward with [List Webhook Deliveries](/docs/API/list-webhook-deliveries). ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists, or `deliveryId` does not belong to it | `{ "error": "Webhook or delivery not found" }` | | 400 | `deliveryId` is missing or not a valid UUID | Validation error from the body schema | ## Related endpoints - [List Webhook Deliveries](/docs/API/list-webhook-deliveries) to find failed deliveries to replay - [Test Webhook](/docs/API/test-webhook) to send a fresh sample event instead of replaying a past one - [Get Webhook Stats](/docs/API/get-webhook-stats) to see whether replays are improving your success rate --- # Test Webhook The Test Webhook endpoint sends a sample event delivery to every URL configured on your signature webhook and returns a per-URL summary of the result. It creates a real delivery record, identical to a delivery triggered by an actual TurboSign event, so it also shows up in [List Webhook Deliveries](/docs/API/list-webhook-deliveries). ## When to use it Use this endpoint right after creating or updating a webhook to confirm your receiving endpoint is reachable and returns a 2xx response, before relying on it for real signature events. ## Example request ```bash curl -X POST "https://api.turbodocx.com/api/webhooks/signature/test" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "eventType": "signature.document.completed", "payload": {"documentId": "doc_abc123", "status": "completed"} }' ``` Both `eventType` and `payload` are optional; omit them to send a default sample payload for a default event type. ## Example response ```json { "data": { "deliveries": [ { "id": "d3a1c2b4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "eventType": "signature.document.completed", "url": "https://example.com/webhooks/turbodocx", "httpStatus": 200, "attemptCount": 1, "maxAttempts": 3, "isDelivered": true, "status": "delivered" } ], "summary": { "total": 1, "successful": 1, "failed": 0, "errors": [] } }, "message": "Test webhook sent successfully to all URLs" } ``` If a URL returns a non-2xx status or times out, its entry has `isDelivered: false` and `status: "retrying"` (while attempts remain) or `"failed"` (once all attempts are exhausted), with details in `errorMessage`. `summary.failed`/`summary.errors` do not currently reflect HTTP-level failures; they only count a database error while creating the delivery record, so a URL that returns 4xx/5xx or times out through all attempts still increments `summary.successful` and leaves `summary.errors` empty. Inspect each `deliveries[]` entry's `isDelivered`, `status`, and `errorMessage` to detect a failing receiver rather than relying on `summary.failed`. ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | | 400 | The webhook exists but `isActive` is `false` | `{ "error": "Cannot test inactive webhook" }` | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) to confirm the webhook's URLs and events before testing - [List Webhook Deliveries](/docs/API/list-webhook-deliveries) to review this and past delivery attempts - [Replay Webhook Delivery](/docs/API/replay-webhook-delivery) to retry a specific failed delivery --- ## TurboDocx API Documentation Version: 1.0.0 TurboDocx API Documentation Welcome to the TurboDocx API Documentation. This comprehensive guide is designed to provide developers with all the information required to interact with the TurboDocx platform programmatically. Whether you're looking to automate document creation, manage your templates, or integrate TurboDocx features into your application, you'll find the necessary endpoints, parameters, and examples here. ## **Getting started guide** Before diving into the specifics of the API, ensure that you have obtained the necessary Access Token and have the correct permissions to access the desired resources. Keep your Access Token secure and do not expose them in publicly accessible areas such as GitHub, client-side code, etc. **The Authentication and Organization ID is a variable set at the root of the collection.** ## Base URL All API requests should be made to the base URL provided upon your API key registration. Typically, it looks like this: ``` typescript https://api.turbodocx.com ``` ## Authentication TurboDocx is working on it's API Key flows, and for the time being, we recommend grabbing the access token used from your user's accounts to leverage our APIs. You can find an example on how to get your access token in the `Blueprint` folder. To use the Access Token, use the following under the `Authorization Header` ``` typescript Authorization: Bearer YOUR_ACCESS_TOKEN ``` ### Authentication error response If an Access Token is missing, malformed, or invalid, you will receive an HTTP 401 Unauthorized response code. ### **Need some help?** In case you have questions, go through our tutorials, click through out UI and network trace it, or visit our [Discord](https://discord.gg/NYKwz4BcpX) to review topics, ask questions, and learn from others. --- # Update Tag The Update Tag endpoint changes a tag's `label`. It is a `PUT`, but in practice `label` is the only field you should send. `orgId` is not an accepted body field and returns a validation error if sent. `id` is different: it isn't rejected by validation, and it isn't ignored either; the endpoint applies whatever body you send, so an `id` that differs from the `TagId` in the URL will overwrite the tag's actual `id` instead of being discarded. Omit `id` from the body and let the URL path segment identify the tag to update. ## When to use it Use this endpoint to rename a tag across your organization; every template or variable already tagged with it keeps the same tag `id`, so the rename applies everywhere the tag is used. ## Example request ```bash curl -X PUT "https://api.turbodocx.com/Tag/7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{"label": "legal-2026"}' ``` ## Example response On success the endpoint returns `200 OK` with the updated tag object directly (not wrapped in a `data` envelope, unlike most other TurboDocx endpoints): ```json { "id": "7c1a0b52-9e88-4f0d-b3a2-1d4c6f8e2a90", "label": "legal-2026", "isActive": true, "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-02T09:00:00.000Z" } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | `TagId` is not a valid UUID, or `label` fails validation | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | ## Related endpoints - [Read Tag](/docs/API/read-tag) to find the `TagId` to update - [Create Tag](/docs/API/create-tag) to add a new tag instead - [Delete Tags (by IDs)](/docs/API/delete-tags-by-i-ds) to remove tags in bulk --- # Update Variable (by ID) The Update Variable (by ID) endpoint replaces a knowledge-base or template-folder variable's content and metadata. Unlike [Edit Template Metadata](/docs/API/edit-template-metadata), this is a full replace, not a partial patch: `mimeType` and `text` are required on every call. `isGlobal` and `templateFolderId` are both optional; at most one may be set. ## When to use it Use this endpoint to edit a reusable variable you found with [Read Variables (Folder)](/docs/API/read-variables-folder), for example correcting its text, changing its name, or replacing its tag list. ## Example request ```bash curl -X PUT "https://api.turbodocx.com/Variable/e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "name": "Company Address", "placeholder": "{CompanyAddress}", "mimeType": "text", "text": "123 Main St, Suite 400, Austin, TX 78701", "allowRichTextInjection": true, "isGlobal": true, "tags": [] }' ``` `{VariableId}` in the path is the `variableMapId` returned by [Read Variables (Folder)](/docs/API/read-variables-folder), not the underlying `Variable.id`. Optionally send `isGlobal: true` or `templateFolderId`, but not both; sending both is rejected. For an image variable, set `mimeType` to `"image"` and `text` to a base64 data string. ## Example response On success the endpoint returns `200 OK` with the updated variable wrapped in a `variable` key (not the `data` envelope most other TurboDocx endpoints use): ```json { "variable": { "id": "e4f5a6b7-8c9d-4e0f-a1b2-3c4d5e6f7a8b", "name": "Company Address", "placeholder": "{CompanyAddress}", "mimeType": "text", "text": "123 Main St, Suite 400, Austin, TX 78701", "isGlobal": true, "templateFolderId": null, "updatedOn": "2026-05-02T09:00:00.000Z" } } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user. For `isGlobal: true` updates (as in the example above), only administrator and contributor are allowed; the `user` role is also rejected with `403`. | Empty (status only) | | 400 | `mimeType` or `text` is missing, or both `isGlobal` and `templateFolderId` are set | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | | 409 | The variable was deleted or modified by someone else before this update landed | `{ "error", "data": { "explanation" } }` | ## Related endpoints - [Read Variables (Folder)](/docs/API/read-variables-folder) to find the `variableMapId` to update - [Delete Variables (by IDs)](/docs/API/delete-variables-by-i-ds) to remove variables instead of editing them --- # Update Webhook The Update Webhook endpoint changes a webhook's URLs, subscribed events, name, or active state. Every field is optional; send only what you want to change, and the rest is left untouched. ## When to use it Use this endpoint to rotate delivery URLs, add or remove subscribed event types, or temporarily pause delivery by setting `isActive` to `false` without deleting the webhook and losing its secret and delivery history. ## Example request ```bash curl -X PATCH "https://api.turbodocx.com/api/webhooks/signature" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -H "Content-Type: application/json" \ -d '{ "urls": ["https://example.com/webhooks/turbodocx"], "events": ["signature.document.completed"], "isActive": true }' ``` `urls` and `events` each replace the webhook's full list, they do not merge with the existing values. If you include `name`, it must still be unique among your organization's active webhooks. ## Example response On success the endpoint returns `200 OK` with the updated webhook, in the same shape as [Get Webhook](/docs/API/get-webhook) (without `deliveryStats` or `availableEvents`): ```json { "data": { "id": "b7e2c4a1-3f9d-4e6a-8c1b-5d0f7a2e9c34", "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "signature", "urls": ["https://example.com/webhooks/turbodocx"], "events": ["signature.document.completed"], "isActive": true, "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-02T09:10:00.000Z", "secretExists": true, "maskedSecret": "whs***7f8" }, "message": "Webhook updated successfully" } ``` ## Common errors | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator | Empty (status only) | | 404 | No webhook with that name exists in your organization | `{ "error": "Webhook not found" }` | | 400 | A field fails validation, or a URL in `urls` is not HTTPS | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | | 409 | Renaming to a name another active webhook already uses | `{ "message", "error": "WebhookNameTaken", "data": { "constraint", "orgId", "name" } }` | ## Related endpoints - [Get Webhook](/docs/API/get-webhook) to view the current configuration before editing - [Regenerate Webhook Secret](/docs/API/regenerate-webhook-secret) to rotate the signing secret without changing anything else - [Delete Webhook](/docs/API/delete-webhook) to remove the webhook entirely --- # Upload Template with Optional Default Values The Upload Template endpoint adds a new DOCX or PPTX template to your TurboDocx workspace in a single multipart request. Alongside the file, you can define the template's variables (the placeholders TurboDocx fills at generation time), give each one a default value, and attach tags. This lets you provision templates programmatically instead of uploading them by hand in the UI, which is how most developers onboard templates at scale. ## When to use it Use this endpoint to let your users bring their own templates, to migrate a library of documents into TurboDocx during onboarding, or to keep templates in version control and push updates from CI. Once uploaded, generate documents from the returned template ID and manage it with [Get Templates and Folders](/docs/API/get-templates-and-folders) and [Delete Template](/docs/API/delete-template). ## Request fields | Field | Type | Required | Description | | ----- | ---- | :------: | ----------- | | `templateFile` | file (binary) | Yes | The DOCX or PPTX file to upload. | | `name` | string | Yes | Display name for the template, for example `SOW Template`. | | `variables` | JSON string | Yes | Array of placeholder definitions with optional default values. Send `[]` if the template has none. | | `description` | string | No | Human-readable description. | | `tags` | JSON string | No | Array of tags for filtering and organization. | The "optional default values" in the name refers to the per-variable defaults, not to the `variables` field itself: the field is required, but each variable's default value is optional. ## Example request ```bash curl -X POST "https://api.turbodocx.com/template" \ -H "Authorization: Bearer $TURBODOCX_API_KEY" \ -H "x-rapiddocx-org-id: $TURBODOCX_ORG_ID" \ -F "templateFile=@./sow-template.docx" \ -F "name=SOW Template" \ -F "description=Standard statement of work" \ -F 'variables=[{"placeholder":"CustomerName","name":"CustomerName","allowRichTextInjection":false,"order":0},{"placeholder":"ProjectName","name":"ProjectName","allowRichTextInjection":false,"order":1},{"placeholder":"BillRate","name":"BillRate","allowRichTextInjection":false,"order":2}]' \ -F 'tags=["sales","legal"]' ``` Each entry in `variables` maps a placeholder in your document (for example `{CustomerName}`) to a named variable. Set `allowRichTextInjection` to `true` for fields that accept formatted content such as logos or rich blocks. ## Example response On success the endpoint returns `201 Created`. The new template row is returned under `data.results.template`: ```json { "data": { "results": { "template": { "id": "2b8f1c9e-4d3a-4a7c-9f21-6b0d5e9a1c34", "name": "SOW Template", "description": "Standard statement of work", "orgId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "createdBy": "f5e6d7c8-9a0b-4c1d-2e3f-4a5b6c7d8e9f", "isActive": true, "templateFolderId": null, "defaultFont": null, "fonts": null, "metadata": null, "projectspaceId": null, "createdOn": "2026-05-01T14:22:10.000Z", "updatedOn": "2026-05-01T14:22:10.000Z" } } } } ``` Use the returned `id` as the `TemplateId` for generating documents or for [Delete Template](/docs/API/delete-template). ## Common errors Error bodies vary by type: schema validation uses `{ "message", "type": "ValidationError", "data": { "errors": [...] } }`, while file, storage, and plan-limit errors use `{ "message", ("error" | "type"), "data": { "explanation", "context" } }`. | Status | When | Response body | | ------ | ---- | ------------- | | 401 | Missing or invalid API key/token, or the organization cannot be resolved | Empty (status only) | | 403 | The key's role is not administrator, contributor, or user | Empty (status only) | | 400 | `name` or `variables` is missing, or `variables`/`tags` is not valid JSON (schema validation) | `{ "message", "type": "ValidationError", "data": { "errors": [...] } }` | | 400 | The uploaded file is invalid ("Improper File Upload") | `{ "message", "error", "data": { "explanation", "context" } }` | | 400 | Your plan's template or storage limit is reached | `{ "message", "type", "data": { "explanation", "context" } }` | | 503 | The storage service is temporarily unavailable | `{ "message", "error", "data": { "explanation", "context" } }` | ## Related endpoints - [Extract Template Placeholders and Generate Preview](/docs/API/extract-template-placeholders-and-generate-preview) to auto-detect placeholders from a file before uploading - [Get Templates and Folders](/docs/API/get-templates-and-folders) to confirm the upload and retrieve the new template ID - [Delete Template](/docs/API/delete-template) to remove a template you uploaded --- ## Configuring SharePoint and OneDrive # SharePoint and OneDrive Integration The SharePoint integration in TurboDocx brings powerful document management capabilities to your organization's SharePoint environment. This seamless integration allows for efficient importing of templates from different SharePoint sites and User's OneDrive and effortless exporting of deliverables back to SharePoint. ## Configuring SharePoint and OneDrive for Business with TurboDocx This guide will provide you step-by-step instructions on creating a SharePoint integration that can connect to your SharePoint sites as well as OneDrive. We will be using the Microsoft Graph API to access the SharePoint and OneDrive resources. Pre-requisites: An Office365 Tenant with SharePoint and OneDrive for Business Enabled Access to the Admin/Azure portal and the ability to register an application. ## Step 1 : Register your application in Entra ID (Azure Active Directory): 1. Sign in to the SharePoint Dashboard (or Azure Admin Portal) and click on "Entra ID (Azure Active Directory)" in the respective menu. Click on the "App Registrations and then click on "New registration" ![Entra ID App Registrations page with New Registration button highlighted](/img/sharepoint_and_onedrive/app-reg.png) 2. Enter a name for the application such as "TurboDocx Sharepoint integration", select the appropriate account type, and select Single Page Application with the redirect uri: "https://app.turbodocx.com" ![Register an application form with name, account type, and redirect URI fields](/img/sharepoint_and_onedrive/Register_Application.png) 3. Click on the "Register Button" to create the application. 4. Note down the Application (Client) ID and the Directory (tenant) ID, as you will need them later. ![Alt text](/img/sharepoint_and_onedrive/Get_Site_ID_and_Client_ID.png) ## Step 2 : Configure API Permissions: 1. In the "App registrations" menu, click on "API" permissions" in the left-hand menu. 2. Click on "Add a permission" 3. Click "Microsoft Graph" and then "Delegated permissions" ![Alt text](/img/sharepoint_and_onedrive/Click_Delegated_Permissions.png) 4. Search for the following permissions and add them: Sites.Read.All - This permission allows TurboDocx to read all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. Please note - TurboDocx does not make API calls to your SharePoint site without user interaction. This will mirror whatever permissions the user has in the SharePoint site. Files.ReadWrite.All - This permission allows TurboDocx to read and write (create, edit, and delete) all files in OneDrive for Business, SharePoint document libraries, and Microsoft Teams files on behalf of the signed-in user. With this permission, TurboDocx can access and manage files stored in OneDrive and SharePoint, including creating new files, updating existing files, moving or deleting files, and even sharing files with others. :::note TurboDocx does not make API calls to your SharePoint site without user interaction.Keep in mind that these are delegated permissions, which means TurboDocx can access these resources on behalf of the signed-in user. The level of access TurboDocx will have depends on the user's actual permissions in SharePoint and OneDrive. ::: ![Alt text](/img/sharepoint_and_onedrive/Graph_Permissions.png) 5. After clicking save, navigate back to "Add Permission" and click SharePoint. Click "SharePoint" and then "Delegated Permissions" ![Alt text](/img/sharepoint_and_onedrive/SharePoint_Delegated_Permissions.png) 6. Search for the following permissions and add them: **AllSites.Read** - This permission allows TurboDocx to read all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. With this permission, TurboDocx can access and retrieve information about the SharePoint site content, such as document libraries, lists, and list items. **AllSites.Write** - This permission allows TurboDocx to write to all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. With this permission, TurboDocx can create, edit, and delete SharePoint site content. This allows TurboDocx to save Deliverables to your SharePoint site. **MyFiles.Read** - This permission allows TurboDocx to read the signed-in user's files in OneDrive for Business. With this permission, TurboDocx can access and retrieve information about the user's files, such as file names, file types, and file metadata. MyFiles.Write - This permission allows TurboDocx to write to the signed-in user's files in OneDrive for Business. With this permission, TurboDocx can create, edit, and delete the user's files :::note TurboDocx does not make API calls to your SharePoint site without user interaction. This user's access will mirror whatever permissions the user has in the SharePoint site. Keep in mind that these are delegated permissions, which means TurboDocx can access these resources on behalf of the signed-in user. The level of access TurboDocx will have depends on the user's actual permissions in SharePoint and OneDrive. ::: ![Alt text](/img/sharepoint_and_onedrive/SharePoint_Permissions_Select.png) 7. The finished permission sets should look like the below: ![Alt text](/img/sharepoint_and_onedrive/Finished_Permission_Set.png) ## Step 3: Determine the default site that TurboDocx will open and get the path 1. The easiest way to do this is to navigate to your Site and find the path you want users to see first. 2. Take note of the URL path and notate it for future use. In this case it is "DocumentationTeam" ![Alt text](/img/sharepoint_and_onedrive/GetSiteName.png) ## Step 4: Login to your TurboDocx Tenant and go to Features and integrations 1. As an admin within your TurboDocx tenant, click your account avatar at the bottom of the left sidebar, click "Settings", then click "Features and integrations" in the Settings menu. 2. On the **SharePoint/OneDrive** card, make sure **Hide SharePoint/OneDrive in UI** is turned off. 3. Click **Configure SharePoint/OneDrive** ![SharePoint/OneDrive card with the Configure SharePoint/OneDrive button highlighted](/img/sharepoint_and_onedrive/Configure_Sharepoint_button.png) 4. Fill out the following fields with the information you have noted from previous steps and click "Save": Tenant Name - This is the name of the SharePoint tenant are connecting to. This can be found by looking in the browser bar and it should resemble "TenantName.SharePoint.com" Path to List (optional) - The site or document library your users land on when they first open the SharePoint file picker. Use the site name noted in the previous step. Path to Folder (optional) - A folder inside that library to open by default. **Client ID** - This is the Application (client) ID noted from the previous step in the EntraID (AzureAD) console. **Tenant ID** - This is the Directory (tenant) ID noted from the previous step in the EntraID (AzureAD) console. ![SharePoint and OneDrive settings dialog filled in, with the Save button highlighted](/img/sharepoint_and_onedrive/SharePoint_Configuration_in_TurboDocx.png) 5. Test importing and Exporting templates using the steps noted in the following sections. ## Importing Templates One of the primary benefits of the SharePoint integration in TurboDocx is the ability to directly import templates from your SharePoint document libraries. This feature removes the need to manually upload templates to TurboDocx, streamlining the process and saving you both time and effort. By simply connecting your SharePoint tenant to TurboDocx, you can effortlessly access and import your templates. The integration offers a seamless browsing experience, enabling you to navigate through your SharePoint document libraries and folders to choose the desired templates for import. Whether you have pre-existing templates or wish to import templates created using other applications such as Microsoft Word or Office 365, the SharePoint integration simplifies the process. Upon selecting the templates, TurboDocx securely imports them into your TurboDocx account, ready for customization and document generation. This ensures that your templates are readily available within TurboDocx, eliminating the need for manual file transfers or duplicate storage. ![Alt text](/img/sharepoint_and_onedrive/Import_with_SharePoint.png) ## Exporting Deliverables The SharePoint integration also enables you to export deliverables generated by TurboDocx back to your SharePoint and One Drive for business accounts. Once you have personalized and generated documents based on your templates, you can seamlessly export them to your SharePoint storage for easy access and sharing. Exporting deliverables to SharePoint and One Drive for Business ensures that your documents are stored securely in the cloud, providing a reliable backup and making them accessible from any device with an internet connection. By leveraging the familiar SharePoint interface, you can organize your deliverables into folders, share them with collaborators, and control access permissions as needed. --- # SCIM User Provisioning TurboDocx supports SCIM 2.0 user provisioning, so your identity provider can automatically create, update, and deactivate TurboDocx accounts within your organization as employees join, change roles, or leave. SCIM manages accounts; pair it with [Single Sign-On](./Single-Sign-On.md) if you also want your identity provider to handle authentication. ## Prerequisites - A TurboDocx organization that already exists (SCIM manages users inside your organization, it doesn't create the organization itself). - Admin access to a SCIM-capable app in your identity provider. TurboDocx's SCIM implementation has been exercised against Microsoft Entra ID (Azure AD) in production. - A SCIM base URL and bearer token for your organization, issued by TurboDocx. There's no self-service page to generate this token today, so it comes from TurboDocx support. ## Setting up SCIM provisioning 1. Contact TurboDocx support to request SCIM provisioning for your organization. TurboDocx issues a SCIM base URL and a bearer token unique to your organization. 2. In your identity provider's provisioning app, enter the base URL as the SCIM endpoint and set the token as the Bearer/Authorization credential. 3. Map your identity provider's user attributes to the SCIM 2.0 User schema TurboDocx expects: `name.givenName`, `name.familyName`, `emails` (with a primary address), and `active`. Most SCIM apps do this mapping by default. 4. Turn on provisioning and run your identity provider's test or "provision a single user" action first, then confirm the account appears in TurboDocx before assigning the rest of your users. ## What SCIM provisioning does - **Create:** a new user assigned in your identity provider creates a matching TurboDocx account in your organization. If an inactive TurboDocx account with the same email already exists, SCIM reactivates it instead of creating a duplicate. - **Read:** your identity provider can look up TurboDocx users by SCIM filter and page through results, the same way it would query any other SCIM-compliant application. - **Update:** attribute changes your identity provider pushes with a SCIM `replace` operation, name, email, and active status, update the matching TurboDocx account. - **Deactivate:** removing or disabling a user in your identity provider deactivates their TurboDocx account and removes them from your organization. It does not delete the underlying account record or their documents. ## Troubleshooting **New employees aren't appearing in TurboDocx.** Confirm provisioning is actually turned on for the TurboDocx app in your identity provider, assigning a user to the app isn't always the same setting as enabling provisioning. Also check that the bearer token configured in your identity provider still matches the one TurboDocx issued; a rotated or mistyped token causes every provisioning request to fail. **A field update from my identity provider isn't showing up in TurboDocx.** TurboDocx's SCIM implementation applies `replace` operations. Some identity providers, including Microsoft Entra ID for the `displayName` field, send `add` instead of `replace` for certain attributes; those requests are accepted without an error but the field isn't updated. If an attribute update isn't taking effect, check whether your identity provider can be configured to send `replace` for that field. **A user I removed from my identity provider still shows as active in TurboDocx.** Confirm your identity provider is configured to actually deprovision (not just unassign) the user when they're removed. Some identity providers only stop syncing a removed user rather than sending a deactivation request. ## FAQ **Does SCIM also let users sign in?** No. SCIM only manages accounts, creation, updates, and deactivation. For sign-in, set up [Single Sign-On](./Single-Sign-On.md) separately. **Which identity providers work with TurboDocx's SCIM support?** Any identity provider that implements the SCIM 2.0 User schema can connect. TurboDocx's implementation has been tested against Microsoft Entra ID. **Does deactivating a SCIM user delete their documents?** No. Deactivation removes the person's access and their membership in your organization, but does not delete the documents, templates, or signature records tied to that account. **Can SCIM provision groups, not just users?** TurboDocx's current SCIM support is focused on individual user accounts. If your identity provider syncs group membership, confirm with TurboDocx support before relying on it. --- # Single Sign-On (SSO) Configuration TurboDocx supports enterprise single sign-on over SAML 2.0 and OpenID Connect (OIDC), so your organization's members authenticate through your own identity provider instead of a separate TurboDocx password. SSO is an Enterprise-plan feature that the TurboDocx team enables per organization. ## Prerequisites - A TurboDocx Enterprise plan. - An identity provider that speaks SAML 2.0 or OIDC. TurboDocx's SSO has been used in production with Microsoft Entra ID (Azure AD); other SAML 2.0 or OIDC-compliant providers can also be connected. - Admin access to that identity provider, to create and configure a TurboDocx application or connection. - Ownership of the email domain(s) your users sign in with. TurboDocx verifies your domain before trusting sign-ins from your identity provider, so an unverified domain can't be used to claim an existing account. ## Setting up SSO There's no self-service toggle for SSO today. TurboDocx configures the connection with you: 1. Contact TurboDocx support to request SSO for your organization, and let them know whether your identity provider uses SAML 2.0 or OIDC. 2. Provide your identity provider's connection details: SAML metadata (a metadata URL or XML file) for SAML, or the client ID and issuer URL for OIDC, along with the email domain(s) your organization signs in with. 3. TurboDocx configures the connection on its side and verifies ownership of your domain. 4. Test the connection with one user before rolling it out. Have that user sign in from the TurboDocx login page; they should be redirected to your identity provider, authenticate there, and land back in TurboDocx as a member of your organization. Once testing succeeds, ask your identity provider admin to assign the TurboDocx application to the rest of your users. ## Troubleshooting **A user signed in through SSO but didn't get linked to their existing TurboDocx account.** TurboDocx only auto-links an SSO sign-in to an existing account when your identity provider reports the user's email as verified. An unverified email creates a new account instead of linking to the old one; this is intentional, it stops one person from claiming another person's account by asserting their address. Confirm your identity provider marks the email attribute as verified. **Sign-in redirects to your identity provider but the user ends up in the wrong TurboDocx organization, or isn't added to one.** Check that the signing-in user's email domain is one of the domains verified for your organization. A user whose email is on a different domain won't be matched to your organization automatically. **A user with a personal address (for example, a Gmail account) can't sign in through your organization's SSO.** That's expected: your SSO connection is scoped to your verified domain(s). Invite that person as a regular TurboDocx member instead. ## FAQ **Which protocols does TurboDocx support?** SAML 2.0 and OpenID Connect (OIDC). **Does SSO replace TurboDocx passwords?** Once SSO is configured, your organization's members sign in through your identity provider rather than a TurboDocx password. **Is SSO included in my plan?** SSO is included with the TurboDocx Enterprise plan. **Can I combine SSO with automated user provisioning?** Yes. Pair SSO with [SCIM Provisioning](./SCIM-Provisioning.md) so accounts are created, updated, and deactivated automatically as your identity provider changes, on top of SSO handling how those users sign in. --- # How to Track Your Documents from the Dashboard Your Dashboard is the first place to look when you want to know what your documents are doing right now. The most common reason to open it is simple: you sent some documents out for signature, and you want to know which ones have not come back yet. This guide walks you through exactly that, from opening the Dashboard to sending a reminder. ## What You'll Learn - 📊 **Open the Dashboard** and pick the time period you care about - ✍️ **Read the signature flow** to see how far your documents got - ⏰ **Find the documents that have stalled** and are waiting on someone - 📧 **Send a reminder** to the people who still need to sign - 🌍 **See where in the world** your documents are being signed ## Step 1: Open the Dashboard 1. Look at the narrow sidebar running down the far left of the screen. 2. Click the **bar chart icon**. It is the second icon from the top, directly below the house icon. ![Left sidebar with the bar chart Dashboard icon highlighted](/img/dashboard/open_dashboard.png) The Dashboard opens and shows everything at a glance. ![The TurboDocx Dashboard showing the signing activity globe, the in flight panel, and the signature flow](/img/dashboard/dashboard_overview.png) :::note Your Dashboard will look different from the pictures here. It only ever shows your own organization's documents, so the numbers, names and places are yours. ::: ## Step 2: Choose the time period Everything on the Dashboard is filtered to one time period. Before you read any number, check that the period is the one you want. 1. Find the dropdown in the top right corner of the page. It says **This Week** when you first arrive. 2. Click it. ![The Dashboard header with the This Week time period dropdown highlighted](/img/dashboard/time_range.png) 3. Pick the period you want from the list: **This Week**, **This Month**, **Last 6 Months**, or **This Year**. ![The time period dropdown open, showing This Week, This Month, Last 6 Months and This Year](/img/dashboard/time_range_options.png) The whole page updates to match your choice. ## Step 3: Read the signature flow The **Signature Flow** panel is the wide strip across the middle of the page. It answers one question: of everything you sent out, how far did it get? ![The Signature Flow panel highlighted, showing counts for Sent, Viewed and Signed](/img/dashboard/signature_flow.png) It reads left to right, and each step is a stage a document passes through: | Stage | What it means | |-------|---------------| | **Sent** | You sent the document out. It is now with your signers. | | **Viewed** | A signer opened it. The small percentage tells you how many of your sent documents got that far. | | **Signed** | Everybody signed. The document is finished. | On the far right of the panel, **Typically signed in** tells you how long your documents usually take to come back. That is your realistic answer when somebody asks "how long will this take?" :::tip A big gap between **Sent** and **Viewed** usually means your emails are not reaching people. A big gap between **Viewed** and **Signed** means they are reaching people who are not finishing. The two problems have very different fixes, so it is worth knowing which one you have. ::: You may also see a small line under **Signed** such as `1 finishing up`. Some documents take a moment to be certified after the last person signs. They are already signed, and they need no action from you. The line disappears on its own. ## Step 4: Find the documents that have stalled Now for the part you came for. Look at the **In Flight** panel on the right hand side. This is everything that is still out there, waiting. The three numbers at the top break it down: - **Awaiting signature**: sent, but nobody has signed yet. - **Viewed, not signed**: somebody opened it and then stopped. These are your warmest leads for a nudge. - **Stalled over 72h**: nothing has happened for three days. Underneath them is **Needs a Nudge**, a short list of the specific documents that have been sitting the longest. ![The Needs a Nudge list highlighted inside the In Flight panel](/img/dashboard/needs_a_nudge.png) Each row shows the document name on the left, and how long it has been waiting on the right. A value like `4d` means four days. ## Step 5: Send a reminder 1. Click any document in the **Needs a Nudge** list. 2. TurboDocx takes you to your TurboSign documents, where that document is listed. 3. Find the document in the list and click the **⋮** (three dots) button at the end of its row. 4. Choose **Send Reminder**. Everyone who still has to sign gets a reminder email. People who already signed are not emailed again. :::note If **Send Reminder** is greyed out, that document is not waiting on a signer any more. The most common reason is that everybody has now signed and TurboDocx is finishing the document off, in which case there is nobody left to remind. ::: :::tip **Send Reminder** works on its own, separately from any automatic reminder schedule you have set up. You can use it even if automatic reminders are switched off, and even if a document has already had all the automatic reminders it was allowed. For the full set of things you can do to a document after sending it, including resending the original email and voiding a document, see [Managing Your Signatures](./TurboSign/Managing-Your-Signatures.md). ::: ## Step 6: See where your documents are being signed The large panel at the top left is **Signing Activity**. It plots the places your documents were sent from and signed in, with a line joining the sender to each signer on the same document. Three small buttons sit in the top right corner of that panel. ![The three Signing Activity buttons highlighted: globe, list and fullscreen](/img/dashboard/globe_controls.png) From left to right, they are: | Button | What it does | |--------|--------------| | 🌐 **Globe** | Shows the spinning globe. Drag it to turn it, and scroll to zoom. | | ☰ **List** | Shows the same information as a plain list of places instead. | | ⤢ **Fullscreen** | Opens the globe across the whole screen. | Click the **fullscreen** button for a much bigger view, with the list of places down the side. Click a place in that list and the globe turns to face it. Press **Escape**, or click the **✕** in the top right, to come back. ![The signing activity globe in fullscreen, with the list of locations down the left side](/img/dashboard/globe_fullscreen.png) If you would rather read the numbers than look at a map, click the **list** button. ![The Signing Activity panel showing the list of locations, with the list button highlighted](/img/dashboard/locations_list.png) Each row gives you the place, how many documents it accounts for, and how those documents are doing. :::note The globe needs a reasonably modern graphics card. If yours cannot draw it, or if the globe fails to load, TurboDocx shows the list instead so you still get the information. The buttons stay available either way, so you can always switch back. Locations come from the general area a document was opened in, accurate to roughly the nearest city. TurboDocx never shows a street address, and the underlying network addresses are never sent to your browser. ::: ## Optional: Switch to a dark Dashboard The Dashboard can be shown light or dark, independently of the rest of TurboDocx. 1. Click the **moon** button in the top right, just to the left of the time period dropdown. ![The Dashboard header with the dark theme moon button highlighted](/img/dashboard/theme_toggle.png) The page switches to a dark theme. Click the same button, now showing a **sun**, to switch back. ![The Dashboard in its dark theme](/img/dashboard/dark_mode.png) Your choice is remembered, so the Dashboard will look the same the next time you open it. ## What else is on the Dashboard Scroll down and you will find four more panels. ![The lower half of the Dashboard showing Deliverables, Time to Generate, Return, Quoting and Recent Deliverables](/img/dashboard/lower_panels.png) | Panel | What it tells you | |-------|-------------------| | **Deliverables** | How many documents you generated in the period. | | **Time to Generate** | How long generating one usually takes. | | **Return** | Roughly how much time and money you saved, compared with how long this work took before TurboDocx. Click **How this is calculated** to see the workings, and **Update Usage Metrics** to correct the assumptions it uses. | | **Quoting** | Your TurboQuote pipeline: open value, how many quotes are active, how many you won, and your win rate. This panel only appears if your plan includes TurboQuote. | | **Recent Deliverables** | The documents you generated most recently, newest first. Click **View All** to see the rest. | ## Troubleshooting **A panel says there is nothing to show.** That panel had no activity in the time period you picked. Try a longer period, such as **This Year**. **The numbers look too low.** Check the time period dropdown in the top right first. **This Week** is the default, and it is the most common reason a Dashboard looks emptier than expected. **The Quoting panel is missing.** Quoting only appears for plans that include TurboQuote. Contact your administrator if you think it should be there. ## What's Next - [Managing Your Signatures](./TurboSign/Managing-Your-Signatures.md): resend, remind, void and download signed documents. - [Setting up TurboSign](./TurboSign/Setting-up-TurboSign.md): send your first document for signature. - [How to Create a Deliverable](./TurboDocx-Templating/How-to-Create-a-Deliverable.md): generate a document from one of your templates. --- ## ConnectWise PSA Integration # Automate Documents from ConnectWise PSA Data TurboDocx integrates directly with your ConnectWise PSA environment to eliminate manual copy-paste and transform your real data into documents, proposals, and presentations. Pull in data from companies, tickets, configurations, and more — and generate polished deliverables instantly. ## What You Can Create * **📄 Statements of Work (SOWs)**: Pull in configurations, timelines, and tasks from project boards * **🗌 Ticket Summaries**: Turn tickets into polished summaries for clients * **💼 Client Reports**: Generate reports using real company data and services * **🧠 Technical Docs**: Document configurations, assets, or custom fields from ConnectWise * **🚰 Implementation Guides**: Auto-generate guides from projects and recurring service tickets * **📊 Executive Briefs**: Create management-facing updates using ConnectWise reporting fields ## Before You Begin :::tip Not a PSA Admin? Don’t worry — this guide walks you through step-by-step. You don’t have to be a PSA wizard. If you can follow a recipe, you can follow this. 🍻 ::: You'll need: * Admin access to your ConnectWise PSA account * Your ConnectWise tenant endpoint URL (e.g., `https://yourcompany.connectwise.com`) * 5 minutes of time and a steady Wi-Fi signal 🚀 After completing the setup, you'll have: * **Tenant Endpoint**: Your ConnectWise server URL (e.g., `https://yourcompany.connectwise.com`) * **Company Name**: Your ConnectWise company identifier (Site or Company ID) * **Public Key**: API public key * **Private Key**: API private key * **Client ID**: ConnectWise client application ID (you can get this from [https://developer.connectwise.com/ClientID](https://developer.connectwise.com/ClientID)) :::tip Quick PSA Primer ConnectWise PSA uses "API Members". You'll create a special user with read-only API access for TurboDocx. ::: ## Step 1: Create a Security Role and API Member ### A. Create a Read-Only Security Role 1. Log into the ConnectWise PSA dashboard 2. Navigate to **System** in the left navigation 3. Click on **Security Roles** ![Creating Security Role](/img/connectwise_integration/SecurityRoleCreation.png) 4. Press the **"+" (plus)** button in the top-left corner to create a new role 5. Set the **Role ID** to `TurboDocxIntegration` 6. Configure the following permissions with **Inquire Level** access: #### Company * **Company Maintenance** – Inquire All * **Company/Contact Group Maintenance** – Inquire All * **Configurations** – Inquire All * **Contacts** – Inquire All * **CRM/Sales Activities** – Inquire All * **Manage Attachments** – Inquire All * **Notes** – Inquire All * **Reports** – Inquire All * **Team Members** – Inquire All #### Finance * **Agreement Invoicing** – Inquire All * **Agreement Sales** – Inquire All * **Agreements** – Inquire All * **Billing View Time** – Inquire All * **Invoicing** – Inquire All * **Reports** – Inquire All #### Projects * **All Permissions** – Inquire All #### Sales * **Closed Opportunity** – Inquire All * **Opportunity** – Inquire All * **Sales Orders** – Inquire All * **Sales Orders Finance** – Inquire All #### Service Desk * **Change Approvals** – Inquire All * **Close Service Tickets** – Inquire All * **Merge Tickets** – Inquire All * **Reports** – Inquire All * **Resource Scheduling** – Inquire All * **Service Tickets** – Inquire All * **Service Tickets – Finance** – Inquire All #### System * **Member Maintenance** – Inquire All * **My Company** – Inquire All #### Time and Expense * **Time Entry** – Inquire 7. Save the new role :::tip Best Practice The TurboDocx integration uses **read-only access** (Inquire level) to retrieve data from ConnectWise without making any modifications to your system. ::: ### B. Add API Member 1. Go to **System → Members** ![Navigate to ConnectWise Members](/img/connectwise_integration/NavigateToCWMembers.png) 2. Click the **API Members** tab ![Click API Members Tab](/img/connectwise_integration/ClickAPIMembers.png) 3. Click **"+" (Add New Member)** ![Click Plus to Add New API Member](/img/connectwise_integration/ClickPlusOnAPIMembers.png) 4. Fill out the form: ![Fill Out API Member Form](/img/connectwise_integration/FillOutAPIMember.png) * **Member ID**: `TurboDocx` * **Role ID**: `TurboDocxIntegration` * **Level**: Corporate * **Name/Email**: `Your CW Admin`, `CWAdmin@yourcompany.com` * **Location & Business Unit**: Required fields ### C. Generate API Keys 1. Scroll to **API Keys** at the bottom ![Click API Keys Section](/img/connectwise_integration/ClickAPIKeys.png) 2. Click **"+"**, name it `TurboDocx Key` 3. Save and securely store the **Public** and **Private** API keys ## Step 2: Configure TurboDocx 1. Log into your TurboDocx dashboard 2. Click your account avatar, then go to **Settings → Features and integrations** 3. Find **ConnectWise PSA Integration** and click **Configure** ![TurboDocx ConnectWise Configuration Fields](/img/connectwise_integration/TurboDocxFields.png) ### Enter Required Information * **Tenant Endpoint**: e.g. `https://yourcompany.connectwise.com` (or select from regional options) * **Company Name**: Your ConnectWise Site or Company ID * **Public Key**: The public API key generated in Step 2 * **Private Key**: The private API key generated in Step 2 * **Client ID**: Your ConnectWise client application ID Click **Save Settings**, then **Test Connection** to validate access. :::tip What Testing the Connection Does It validates your credentials and pulls your ticket types, boards, statuses, configurations, and custom fields for use in documents. ::: ## Step 3: Generate Documents from ConnectWise 1. Open the **Document Generator** in TurboDocx 2. Click **New Document** 3. Choose a template 4. Under **Change Source**, select **ConnectWise PSA** ![Click on ConnectWise PSA Agent in App Library](/img/connectwise_integration/ClickonConnectWisePSAAgentInAppLibrary.png) 5. Choose the object type: Tickets, Projects, Companies, etc. 6. Select records you want to use ![Access to Records in TurboDocx](/img/connectwise_integration/AccessToRecordsCWInTurbo.png) ### Example Prompts * “Create an SOW for this project with timelines, configurations, and tasks.” * “Generate a config documentation PDF for selected assets.” * “Summarize all open tickets for this company.” ## Troubleshooting ### "Invalid Credentials" Error * Check API keys, Company Name (Site/Company ID), and Tenant Endpoint * Confirm role permissions and member status * Verify Client ID is correct ### "Connection Failed" Error * Click **Test Connection** to verify settings * Ensure your PSA instance has data to access * Check that your tenant endpoint URL is correct ### “Permission Denied” * Review the API member’s security role permissions :::tip Still Stuck? Take a screenshot, note the step, and send it to our support team. ::: ## Security and Privacy * 🔐 Read-only access only * 📦 Encrypted data transfers * 🔒 API keys are not stored permanently * 🔍 Access can be revoked at any time in ConnectWise ## Final Thoughts You're ready to: * Generate project documents with no formatting headaches * Turn tickets and configs into polished deliverables * Automate technical documentation and reporting :::tip Last Tip Clean ConnectWise data = clean, impressive documents. Keep your CRM tidy! ::: --- ## Appendix: Permission Requirements Explained Understanding why TurboDocx requires specific Inquire permissions helps ensure proper setup and security compliance. ### Company Permissions **Company Maintenance** - **Inquire All** - Without this, TurboDocx can't query what companies exist for document generation **Company/Contact Group Maintenance** - **Inquire All** - Provides access to company and contact grouping data for organizational document generation **Configurations** - **Inquire All** - Enables access to company configuration data for technical documentation and reports **Contacts** - **Inquire All** - Essential for retrieving contact data to populate documents with recipient information **CRM/Sales Activities** - **Inquire All** - Without this, TurboDocx can't access sales activity data for inclusion in reports and proposals **Manage Attachments** - **Inquire All** - Required to access existing attachments for document templating **Notes** - **Inquire All** - Allows TurboDocx to review company and contact notes for inclusion in generated documents **Reports** - **Inquire All** - Provides access to existing report data for comprehensive document generation **Team Members** - **Inquire All** - Required to identify team members for assignment and attribution in generated documents ### Finance Permissions **Agreement Invoicing** - **Inquire All** - Provides access to agreement invoicing data for generating billing documents and financial reports **Agreement Sales** - **Inquire All** - Enables access to agreement sales data for creating sales reports and contract documentation **Agreements** - **Inquire All** - Provides access to agreement data for generating contracts, SOWs, and billing documents **Billing View Time** - **Inquire All** - Allows access to time tracking data for creating detailed billing reports and invoices **Invoicing** - **Inquire All** - Enables access to invoicing data for generating financial reports and billing summaries **Reports** - **Inquire All** - Provides access to financial reporting data for comprehensive document generation ### Project Management Permissions **All Permissions** - **Inquire All** - Comprehensive access to all project data enables generation of project documentation, status reports, and project summaries ### Sales Permissions **Closed Opportunity** - **Inquire All** - Provides access to closed opportunity data for won/lost analysis and historical sales reporting **Opportunity** - **Inquire All** - Essential for accessing active opportunity data to generate proposals, quotes, and sales reports **Sales Orders** - **Inquire All** - Enables access to sales order data for order documentation and fulfillment tracking **Sales Orders Finance** - **Inquire All** - Provides access to financial aspects of sales orders for billing and revenue reporting ### Service Desk Permissions **Change Approvals** - **Inquire All** - Provides access to change approval data for compliance and change management reporting **Close Service Tickets** - **Inquire All** - Allows access to completed ticket data for historical reporting and closure documentation **Merge Tickets** - **Inquire All** - Enables access to merged ticket data for comprehensive service history tracking **Reports** - **Inquire All** - Provides access to service desk reporting data for comprehensive document generation **Resource Scheduling** - **Inquire All** - Provides access to scheduling data for resource allocation reports and planning documents **Service Tickets** - **Inquire All** - Essential for accessing ticket data to generate service reports and client summaries **Service Tickets – Finance** - **Inquire All** - Enables access to ticket financial data for billing reports and cost analysis ### System Permissions **Member Maintenance** - **Inquire All** - Required to identify team members for assignment and attribution in generated documents **My Company** - **Inquire All** - Provides access to company configuration and branding information for document customization ### Time and Expense Permissions **Time Entry** - **Inquire** - Enables access to time tracking data for billing reports and project summaries :::note Security Note All permissions are set at the **Inquire** level to ensure TurboDocx operates with read-only access, maintaining data security while enabling document generation functionality. ::: --- *May your configs be documented, your projects delivered, and your SOWs write themselves — automatically.* --- # Fireflies AI Integration Transform your meeting recordings into professional documents automatically. TurboDocx's Fireflies integration eliminates manual note-taking by converting meeting transcripts into polished deliverables, streamlining your post-meeting workflow. ## What You Can Create - **📄 Meeting Summaries**: Convert discussions into formatted meeting minutes and action items - **📊 Client Presentations**: Turn discovery calls into compelling presentation decks - **📋 Project Proposals**: Use requirement gathering sessions to create detailed proposals - **📝 Follow-up Reports**: Generate comprehensive meeting reports with key takeaways - **💼 Sales Materials**: Transform prospect calls into customized sales presentations - **🔄 Automated Workflows**: Connect meeting transcripts directly to your document templates ## Before You Begin To use the Fireflies integration, you'll need: - A Fireflies account with recorded meetings and transcripts - Administrative access to your Fireflies account to generate API keys - Meeting recordings stored in Fireflies with transcription enabled :::tip If you're not sure whether you have transcription enabled, check your Fireflies settings or look for transcript availability in your recorded meetings. ::: ## Step 1: Configuring Fireflies.ai ### Get Your API Key 1. Log in to [app.fireflies.ai](https://app.fireflies.ai) 2. Navigate to the **"Integrations"** section in your dashboard ![Fireflies Integration Section](/img/fireflies_integration/click_on_integration.png) 3. Click on **"Fireflies API"** ![Fireflies API Integration](/img/fireflies_integration/click_on_fireflies.png) 4. Click on **"Get API KEY"** ![Get API Key](/img/fireflies_integration/click_on_get_api_key.png) 5. **Copy your API key** - You'll need this value when configuring TurboDocx ![Copy API Key](/img/fireflies_integration/copy_api_key.png) :::tip Keep Your API Key Safe Your API key is like a password - never share it publicly or include it in emails. TurboDocx will store it securely once you enter it. ::: ## Step 2: Configuring TurboDocx ### 🔧 How to Configure Fireflies in Settings 1. Click your **account avatar** at the bottom of the left sidebar, then click **Settings** ![Account menu with the Settings item highlighted](/img/zoom_integration/GoToSettings.png) 2. In the menu on the left of the Settings page, click **Features and integrations** ![Settings menu with Features and integrations highlighted](/img/zoom_integration/GoToFeaturesAndIntegrations.png) 3. Scroll down to the **Fireflies** card 4. Click **Configure Fireflies** ![Click Configure Fireflies](/img/fireflies_integration/configure_fireflies.png) 5. A Fireflies Configuration pop-up will appear 6. Take the **API Key** you obtained earlier, paste it into the appropriate field, and click **Save Configuration** ![Save API Key](/img/fireflies_integration/save_api_key.png) ## Step 3: Using Your Fireflies Integration Congratulations! Your Fireflies integration is now ready to use. Here's how to access your meeting transcripts and use them in your documents. ### Accessing Meeting Transcripts 1. Click the source dropdown and click **"Change Source"** 2. Go to the **App Library** tab 3. Click **"Transcript Providers"** ![Meeting Providers](/img/zoom_integration/MeetingProviders.png) 4. Click **"Fireflies"** and then you should be able to click the transcript 5. You'll see a list of your recent meeting recordings with transcripts ![Transcripts Shown](/img/fireflies_integration/transcripts.png) ## Troubleshooting If you're having trouble with your Fireflies integration, here are some common issues and their solutions. ### "No Meetings Found" - **Cause**: No recorded meetings with transcripts available - **Solution**: Ensure your meetings are recorded in Fireflies and have transcription enabled ### "Authentication Failed" - **Cause**: Incorrect API key or expired authentication - **Solution**: - Double-check your API key - Try re-authenticating by entering your API key again :::tip Getting Help If you continue to experience issues: - Verify all steps in this guide have been completed - Check that your Fireflies account has meeting recordings with transcripts - Ensure you have the correct API key from your Fireflies account - Contact your TurboDocx support team for assistance ::: ## Security and Privacy Your data security and privacy are important to us. Here's how your information is protected when using the Fireflies integration. ### How Your Data is Protected - **Secure Authentication**: TurboDocx uses API key authentication for secure access - **Read-Only Access**: We only read your data as per your request in the flow - we never modify your Fireflies data - **Encrypted Storage**: Your API key is stored encrypted in our secure systems - **Encrypted Transmission**: All data transfers are encrypted using industry-standard protocols ## Best Practices ### For Optimal Results - **Enable Transcription**: Always enable transcription when recording meetings in Fireflies - **Use Clear Audio**: Ensure good audio quality for more accurate transcripts - **Descriptive Meeting Names**: Use clear, descriptive names for your meetings to easily identify them later - **Regular Cleanup**: Periodically review and organize your meeting recordings ### Meeting Recording Tips - Start recording at the beginning of important meetings - Speak clearly and avoid overlapping conversations - Use a good quality microphone when possible - Consider recording in a quiet environment ## Finished Congratulations on setting up your Fireflies integration! You can now seamlessly import meeting transcripts and use them to create professional documents with TurboDocx. --- # Google Drive Integration TurboDocx connects to your Google Drive account through Google's own file picker, so you can import a Google Doc, Google Slides file, or Office document as a template, and export a generated deliverable straight into a Drive folder. Google Drive import and export are available on TurboDocx plans that include the integration. ## Prerequisites - A Google account with access to the files or folders you want to import from or export to. - Google Drive enabled on your TurboDocx plan. If it isn't, clicking an Import or Export to Google Drive option opens an upgrade prompt instead of the file picker. - An organization admin hasn't hidden Google Drive from the interface in **Settings → Features and integrations**. ## Importing a template from Google Drive 1. From template creation, choose the option to import from Google Drive. 2. The first time you do this, Google asks you to sign in and grant TurboDocx access to your Drive. TurboDocx only requests this access when you actually use the picker. 3. Google's file picker opens, showing files in your Drive. You can select a Google Doc or a `.docx` file; if your organization has PowerPoint template support enabled, Google Slides and `.pptx` files are also selectable. 4. Pick a file. Google Docs and Google Slides are converted to `.docx` and `.pptx` automatically as they're imported; Word and PowerPoint files come in as-is. 5. The imported file opens in TurboDocx's template editor, ready for you to add variables and finish setting it up. ![TurboDocx file picker browsing Google Drive folders to import templates](https://image.typedream.com/cdn-cgi/image/width=3840,format=auto,fit=scale-down,quality=100/https://api.typedream.com/v0/document/public/de39171b-a5c9-49c5-bd9c-c2dfd5d632a2/2P7GcbCddIpTtDMDZpQltzuC1ff_Import_from_Google_Drive.png) ## Exporting a deliverable to Google Drive 1. After generating a deliverable, choose the option to export it to Google Drive. 2. Grant Drive access if you haven't already. The picker opens in folder-selection mode instead of file-selection mode. 3. Choose the destination folder in your Drive. 4. TurboDocx uploads the deliverable into that folder and opens the folder in a new browser tab so you can confirm it arrived. ![TurboDocx export dialog saving a generated deliverable to Google Drive](https://image.typedream.com/cdn-cgi/image/width=3840,format=auto,fit=scale-down,quality=100/https://api.typedream.com/v0/document/public/de39171b-a5c9-49c5-bd9c-c2dfd5d632a2/2P7LjJqYUUFwjrpmKHYZreunEVm_Export_to_Google_Drive.png) ## Troubleshooting **I don't see the file I'm looking for in the picker.** Only Google Docs, Google Slides, `.docx`, and `.pptx` files are shown, and only from your own Drive. Other file types, and files that live only in a Shared Drive, won't appear in the picker today. **Nothing happens after I close the Google picker.** If you cancel the picker without selecting a file (or folder, when exporting), TurboDocx treats it as canceled and doesn't import or upload anything. Click the button again to retry. **The Google Drive option opens an upgrade prompt instead of the picker.** Google Drive import and export are gated by your TurboDocx plan. The prompt means your organization's current plan doesn't include the integration. **An admin can't find the Google Drive option anywhere in the app.** Check **Settings → Features and integrations**. An admin can hide Google Drive from the interface even when it's enabled on the plan, separately from whether the plan includes it. ## FAQ **Which file types can I import?** Google Docs and `.docx` files always. Google Slides and `.pptx` files too, if your organization has PowerPoint (presentation) template support turned on. **Does TurboDocx store my Google password?** No. Sign-in happens through Google's own consent screen. TurboDocx receives a temporary access token to read or write the files you pick, never your Google credentials. **Can I import from or export to a Shared Drive?** Not currently. Import and export both work against your personal Drive. **Where do exported documents land?** In whichever folder you choose in the picker at export time. TurboDocx doesn't create or reuse a default folder for you, you pick the destination every time. --- ## HubSpot Integration # Turn Your HubSpot Data into Professional Documents & Presentations Say goodbye to copy-pasting customer information! TurboDocx's HubSpot integration automatically pulls your real CRM data to create personalized documents, proposals, and presentations. No more "John Doe" placeholder text — use actual customer names, deals, and details. ## What You Can Create - **📊 Client Proposals**: Use real deal data to create compelling, personalized proposals - **📄 Contact Reports**: Generate comprehensive reports with actual customer information - **📋 Meeting Summaries**: Create professional meeting notes using your HubSpot data - **💼 Sales Presentations**: Build custom presentations with live customer data - **📝 Follow-up Documents**: Generate personalized follow-ups using contact details - **🔄 Automated Reports**: Create recurring reports with fresh data from HubSpot ## Before You Begin :::tip For Our Technology-Shy Friends Don't worry! We've made this guide so detailed that any team member can follow it. Think of it as cooking instructions, but for software — and just as tasty! 😄 ::: To use the HubSpot integration, you'll need: - A HubSpot account with Private App Access (free or paid — we don't judge!) - Administrative access to create a HubSpot app - About 10 minutes and a cup of coffee ☕ - This guide (which you're already reading — you're ahead of the game!) :::tip Quick note This process involves creating something called a "private app" in HubSpot. Think of it as giving TurboDocx secure access to your HubSpot data — and only the data you want to share. ::: ## Step 1: Creating Your HubSpot Private App The first step is creating a HubSpot private app. Think of this as getting a library card — it gives TurboDocx permission to temporarily read your HubSpot data. ### Navigate to HubSpot Developer Settings 1. **Open your web browser** and go to [hubspot.com](https://hubspot.com) - Use Chrome, Firefox, Safari, or Edge 2. **Log into your HubSpot account** - Use your normal email and password - If you forgot your password, there's a "Forgot Password?" link (we've all been there!) ![HubSpot Login](/img/hubspot-integration/hubspot-dashboard.png) 3. **Find the Settings gear** in the top right corner - It looks like a little wheel with teeth ⚙️ - If you can't find it, it's probably hiding next to your profile picture ![HubSpot Settings Gear](/img/hubspot-integration/hubspot-settings.png) 4. **Click on the Settings gear** - A menu will appear ### Navigate to Integrations 1. **Look for "Integrations"** in the left sidebar - It's usually about halfway down the list - If you don't see it, try scrolling down slowly (no need to rush!) ![Navigate to Integrations](/img/hubspot-integration/create-private-app-steps.png) 2. **Click on "Integrations"** - The menu will expand to show more options 3. **Find and click "Private Apps"** - It might be hiding under the Integrations section - Think of it as a secret menu item at your favorite restaurant ![Navigate to Integrations](/img/hubspot-integration/create-private-app-steps.png) ### Create Your Private App 1. **Click "Create a private app"** - Usually a big, friendly blue button - If there's no button, you might need admin permissions (time to find your BFF at your IT department!) ![Navigate to Integrations](/img/hubspot-integration/create-private-app-steps.png) 2. **Fill in the basic information**: - **App name**: Type something memorable like "TurboDocx Integration" or "TurboDocx Document Generator" - **Description**: Add a simple description like "This connects my HubSpot data to TurboDocx for document and presentation generation" - **Logo**: Optional - it's up to you! ![App Basic Info](/img/hubspot-integration/app-logo-description.png) :::tip Pro Tip Choose an app name you'll remember six months from now. "App123" might seem clever today, but future you will not be amused! 😏 ::: ### Configure App Permissions (The Important Part!) 3. **Click on the "Scopes" tab** - This is where you tell HubSpot what data TurboDocx can access - It’s like setting clear boundaries — you choose exactly what TurboDocx can read from your CRM. ![Scopes Tab](/img/hubspot-integration/add-scope-step.png) 4. **Add the required scopes** (permissions) - Each scope is like giving TurboDocx permission to read a specific type of data ![Scopes Tab](/img/hubspot-integration/scope-adding-step.png) :::tip Attention to Detail Required You'll be adding about 50 different permissions. It's like checking off a very long grocery list — but necessary for the feast ahead! 🛒 ::: **Here's the complete list of scopes to add** (copy each one EXACTLY): **CRM Lists:** - `crm.lists.read` **CRM Objects (the big list):** - `crm.objects.appointments.read` - `crm.objects.carts.read` - `crm.objects.commercepayments.read` - `crm.objects.companies.read` - `crm.objects.contacts.read` - `crm.objects.courses.read` - `crm.objects.custom.read` - `crm.objects.deals.read` - `crm.objects.feedback_submissions.read` - `crm.objects.goals.read` - `crm.objects.invoices.read` - `crm.objects.leads.read` - `crm.objects.line_items.read` - `crm.objects.listings.read` - `crm.objects.marketing_events.read` - `crm.objects.orders.read` - `crm.objects.owners.read` - `crm.objects.partner-clients.read` - `crm.objects.partner-services.read` - `crm.objects.products.read` - `crm.objects.quotes.read` - `crm.objects.services.read` - `crm.objects.subscriptions.read` - `crm.objects.users.read` **CRM Pipelines:** - `crm.pipelines.orders.read` **CRM Schemas:** - `crm.schemas.appointments.read` - `crm.schemas.carts.read` - `crm.schemas.commercepayments.read` - `crm.schemas.companies.read` - `crm.schemas.contacts.read` - `crm.schemas.courses.read` - `crm.schemas.custom.read` - `crm.schemas.deals.read` - `crm.schemas.invoices.read` - `crm.schemas.line_items.read` - `crm.schemas.listings.read` - `crm.schemas.orders.read` - `crm.schemas.quotes.read` - `crm.schemas.services.read` - `crm.schemas.subscriptions.read` **Sales & Communication:** - `sales-email-read` **Scheduler:** - `scheduler.meetings.meeting-link.read` **Settings:** - `settings.currencies.read` - `settings.security.security_health.read` - `settings.users.read` - `settings.users.teams.read` **Tax:** - `tax_rates.read` ### How to Add Each Scope **For each scope in the list above:** 1. **Find the search box** (it might say "Search for scopes") 2. **Click inside the search box** 3. **Type the scope name exactly** (every dot and dash matters!) 4. **Look for the dropdown** that appears below 5. **Click on the matching result** 6. **Repeat for the next scope** ![Adding Scopes Process](/img/hubspot-integration/checked-scopes.png) :::tip Almost Done Yes, it’s a few extra clicks — but it’s easy. Just check the boxes and you’ll be on your way to automatic documents in no time. Think of it like flipping switches to power things up. ⚡ ::: ### Create Your App 5. **Click "Create app"** when you're done adding scopes - Take a moment to admire your handiwork first! ![Create App Final](/img/hubspot-integration/scope-added.png) ![Create App Final](/img/hubspot-integration/app-created.png) 6. **You'll see the Access Token (needed for later)** - This is super important! - leave this page open to use this key later ![Access Token](/img/hubspot-integration/click-on-auth.png) ![Access Token](/img/hubspot-integration/access-token-copiying.png) :::warning Handle With Care This token is kind of like a temporary library card — it lets TurboDocx read only the data you’ve approved. But just like a password, you don’t want it ending up in the wrong hands. Keep it private, and if it ever gets shared by accident, no worries — you can always generate a fresh one right here. ::: ## Step 2: Configuring TurboDocx Now we'll connect your shiny new HubSpot app to TurboDocx. This is like introducing two friends who are perfect for each other! ### Navigate to TurboDocx Settings 1. **Open the account menu** - Click your **account avatar** at the bottom of the left sidebar ![Left sidebar with the account avatar highlighted](/img/hubspot-integration/open-account-menu.png) 2. **Click on "Settings"** - It's in the menu that opens above your avatar ![Account menu with the Settings item highlighted](/img/hubspot-integration/settings-link.png) 3. **Click on "Features and integrations"** - It's in the menu on the left of the Settings page ![Settings menu with Features and integrations highlighted](/img/hubspot-integration/features-and-integrations.png) ### Configure HubSpot Integration 4. **Find the HubSpot card** - Look for the HubSpot logo under the integrations ![HubSpot card highlighted on the Features and integrations page](/img/hubspot-integration/select-configure-hubspot.png) 5. **Click "Configure HubSpot"** - A popup or form will appear - This is where the magic happens! ✨ ![Configure HubSpot Button](/img/hubspot-integration/hubspot-config-modal.png) 6. **Enter your Access Token** - Copy the Access Token from your HubSpot private app page and paste it here ![Access Token](/img/hubspot-integration/access-token-copiying.png) ![Access Token](/img/hubspot-integration/hubspot-access-token-copied.png) 7. **Click "Save Configuration"** - Cross your fingers (optional, but recommended!) ![Access Token](/img/hubspot-integration/click-save-configuration.png) ### Sync Your HubSpot Data 9. **Click "Refresh Fields"** - This button appears after the connection test succeeds - It downloads all your custom HubSpot fields and data - It also tests if your access token works ![Access Token](/img/hubspot-integration/refresh-fields.png) 10. **Wait for the field sync** - This can take 1-3 minutes - Time to check your email or practice your victory dance! 💃 :::tip Success Celebration If you've made it this far, you deserve a pat on the back! You've successfully connected HubSpot to TurboDocx. That's no small feat — you're basically a tech wizard now! 🧙‍♂️ ::: ## Step 3: Using Your HubSpot Integration Time to put your new integration to work! This is where the magic happens — turning your HubSpot data into beautiful documents. ### Creating Your First Document 1. **Go to document generation** - Look for "Create Document", "New Document", or similar - This is usually on your main dashboard ![Document Generation](/img/hubspot-integration/home-page.png) 2. **Select Template** - Click on the template you want to work with ![Change Source](/img/hubspot-integration/template-page.png) 3. **Change your data source** - Click on the "Change Source" dropdown - Select "Change Resource" from the menu ![Change Source](/img/hubspot-integration/change-resource.png) 4. **Go to the App Library** - Click on the "App Library" tab - This shows all your connected integrations ![App Library](/img/hubspot-integration/app-library.png) 5. **Select CRM category** - Look for "CRM" and click on it - This filters to show only CRM integrations ![Select CRM](/img/hubspot-integration/crm-select.png) 6. **Choose HubSpot** - Click on "HubSpot" (you should see the orange logo) - It should show as "Connected" ![Choose HubSpot](/img/hubspot-integration/select-hubspot.png) 7. **Click "Continue"** - This takes you to the HubSpot agent interface ![Continue Button](/img/hubspot-integration/continue-click.png) ### Using the HubSpot Agent 8. **Select your records** (optional but helpful) - Click "Select Records" to choose specific contacts, deals, or companies - This helps the AI focus on the right data ![Select Records](/img/hubspot-integration/select-resource-btn.png) 9. **Choose relevant records** - Click on contacts, deals, or companies relevant to your document - Selected items will be highlighted - Click "Save" to confirm your record selection ![Choose Records](/img/hubspot-integration/select-records.png) 11. **Give instructions to the AI** - Type what kind of document you want in plain English - Be specific about what you want to create - Sit back and watch the magic happen! - Generation typically takes 30 seconds to 2 minutes ![AI Instructions](/img/hubspot-integration/prompt-continue.png) **Example prompts:** - "Create a professional proposal section for the selected deal using the contact's information" - "Generate a follow-up slide summarizing our recent meeting with this contact" - "Create a company overview report using the selected company data" - "Draft a project kickoff overview for the selected deal and contacts" 13. **Review your document** - Check that all the information looks correct - Make any necessary edits - Marvel at your personalized, professional document! :::tip Pro Document Tips - Be specific in your instructions — "Create a proposal" vs. "Create a detailed Q3 marketing proposal section for ABC Corp with pricing and timeline" - Select the right records — more relevant data = better documents - Don't be afraid to regenerate if the first attempt isn't perfect - Save successful prompts for future use! ::: ## Troubleshooting Even the best-laid plans sometimes go awry. Here are solutions to common issues: ### "I Can't Find the Settings Gear in HubSpot" **Solution**: - Look in the top right corner of your HubSpot dashboard - It's usually next to your profile picture - If you still can't find it, try refreshing the page or logging out and back in ### "Invalid Access Token" Error **Solution**: - Double-check that you copied the entire token (it's usually quite long) - Make sure there are no extra spaces at the beginning or end - Verify your HubSpot private app is still active - If all else fails, create a new private app and get a fresh token ### "Permission Denied" Error **Solution**: - Check that you added all the required scopes to your HubSpot private app - Make sure you're an admin in your HubSpot account - Verify the private app is enabled and not deactivated ### "No Records Found" in TurboDocx **Solution**: - Make sure you have actual data in your HubSpot account (contacts, deals, companies) - Click "Refresh Fields" again in **Settings → Features and integrations** - Check that your HubSpot private app has the right permissions ### "The Agent Doesn't Understand My Instructions" **Solution**: - Be more specific in your prompts - Use simpler language - Include the type of document you want (email, proposal, report, etc.) - Try selecting more specific records :::tip When All Else Fails If you're still stuck, don't panic! Take a screenshot of any error messages, note exactly what step you're on, and contact your support team. We're here to help, not judge your tech skills! 🤝 ::: ## Security and Privacy Your data security is important to us (and should be to you too!): ### How Your Data is Protected - **Secure Authentication**: We use OAuth 2.0 (fancy industry-standard security) - **Limited Permissions**: TurboDocx only gets permission to read your data, not change it - **Encrypted Transmission**: All data transfers are encrypted (like sending a letter in a locked box) ### Best Practices - **Keep Your Token Secret**: Don't share your access token with anyone - **Regular Reviews**: Periodically check which integrations have access to your data - **Rotate Tokens**: Periodically rotate your tokens for maximal security posture. ## Tips for Success ### Getting the Best Results **Keep Your HubSpot Data Clean**: - Use consistent naming conventions - Fill in important fields (contact info, deal values, etc.) - Keep your data up-to-date **Write Clear Instructions**: - Be specific about what you want - Mention the type of document - Include any special requirements **Select the Right Records**: - Choose records that are relevant to your document - Don't select too many records at once - Quality over quantity! ### Advanced Tips **For Better Proposals**: - Select the contact, deal, and company records - Include deal value and timeline information - Mention specific services or products **For Better Reports**: - Select relevant companies and contacts - Include date ranges if applicable - Specify the type of analysis you want **For Better Follow-ups**: - Select recent meeting attendees - Include deal or project context - Mention next steps or action items ## What's Next? Congratulations! You've successfully: - ✅ Created a HubSpot private app (you're basically a developer now!) - ✅ Connected it to TurboDocx (networking skills: unlocked!) - ✅ Generated your first document (content creation: mastered!) ### Now You Can: - Create personalized proposals in minutes instead of hours - Build comprehensive reports using your CRM information - Automate document creation for your entire team ### Next Steps: 1. **Train your team** on being a prompting pro 2. **Create document and presentation templates** for common use cases 3. **Experiment with different AI prompts** to find what works best :::tip Final Words of Wisdom Remember, you're not just creating documents or presentations— you're creating more time for yourself by automating repetitive tasks. Every minute saved on copy-pasting customer data is a minute you can spend on more important things (like actually talking to customers!). 🎉 ::: ## Getting Help **If you need assistance:** 1. **Check this guide first** (you'd be surprised how often the answer is right here!) 2. **Take screenshots** of any error messages 3. **Note the exact step** where you got stuck 4. **Contact your TurboDocx support team** with the details **Remember**: There's no such thing as a bad question. We've all been there, and we're here to help you succeed! 💪 --- *Happy document generating! May your proposals be persuasive, your reports be comprehensive, and your follow-ups be timely! 🚀* --- # OneDrive and SharePoint Integration The OneDrive and SharePoint integration in TurboDocx offers a streamlined and effective way to manage your document generation process. With this integration, you can import templates from OneDrive or SharePoint and export deliverables back to these platforms, enhancing your workflow and collaboration efficiency. ## Configuration ### Configuring SharePoint and OneDrive for Business with TurboDocx This guide will provide you step-by-step instructions on creating a SharePoint integration that can connect to your SharePoint sites as well as OneDrive. We will be using the Microsoft Graph API to access the SharePoint and OneDrive resources. Pre-requisites: - An Office365 Tenant with SharePoint and OneDrive for Business Enabled - Access to the Admin/Azure portal and the ability to register an application. ### Step 1: Register your application in Entra ID (Azure Active Directory): 1. Sign in to the SharePoint Dashboard (or Azure Admin Portal) and click on "Entra ID (Azure Active Directory)" in the respective menu. Click on the "App Registrations and then click on "New registration" ![Entra ID App Registrations page with New Registration button highlighted](/img/sharepoint_and_onedrive/app-reg.png) 2. Enter a name for the application such as "TurboDocx Sharepoint integration", select the appropriate account type, and select Single Page Application with the redirect uri: "https://app.turbodocx.com" ![Register an application form with name, account type, and redirect URI fields](/img/sharepoint_and_onedrive/Register_Application.png) 3. Click on the "Register Button" to create the application. 4. Note down the Application (Client) ID and the Directory (tenant) ID, as you will need them later. ![Alt text](/img/sharepoint_and_onedrive/Get_Site_ID_and_Client_ID.png) ### Step 2: Configure API Permissions: 1. In the "App registrations" menu, click on "API" permissions" in the left-hand menu. 2. Click on "Add a permission" 3. Click "Microsoft Graph" and then "Delegated permissions" ![Alt text](/img/sharepoint_and_onedrive/Click_Delegated_Permissions.png) 4. Search for the following permissions and add them: **Sites.Read.All** - This permission allows TurboDocx to read all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. :::note TurboDocx does not make API calls to your SharePoint site without user interaction. This will mirror whatever permissions the user has in the SharePoint site. ::: **Files.ReadWrite.All** - This permission allows TurboDocx to read and write (create, edit, and delete) all files in OneDrive for Business, SharePoint document libraries, and Microsoft Teams files on behalf of the signed-in user. With this permission, TurboDocx can access and manage files stored in OneDrive and SharePoint, including creating new files, updating existing files, moving or deleting files, and even sharing files with others. :::note TurboDocx does not make API calls to your SharePoint site without user interaction. Keep in mind that these are delegated permissions, which means TurboDocx can access these resources on behalf of the signed-in user. The level of access TurboDocx will have depends on the user's actual permissions in SharePoint and OneDrive. ::: ![Alt text](/img/sharepoint_and_onedrive/Graph_Permissions.png) 5. After clicking save, navigate back to "Add Permission" and click SharePoint. Click "SharePoint" and then "Delegated Permissions" ![Alt text](/img/sharepoint_and_onedrive/SharePoint_Delegated_Permissions.png) 6. Search for the following permissions and add them: **AllSites.Read** - This permission allows TurboDocx to read all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. With this permission, TurboDocx can access and retrieve information about the SharePoint site content, such as document libraries, lists, and list items. **AllSites.Write** - This permission allows TurboDocx to write to all site collections, sites, lists, and list items in SharePoint on behalf of the signed-in user. With this permission, TurboDocx can create, edit, and delete SharePoint site content. This allows TurboDocx to save Deliverables to your SharePoint site. **MyFiles.Read** - This permission allows TurboDocx to read the signed-in user's files in OneDrive for Business. With this permission, TurboDocx can access and retrieve information about the user's files, such as file names, file types, and file metadata. **MyFiles.Write** - This permission allows TurboDocx to write to the signed-in user's files in OneDrive for Business. With this permission, TurboDocx can create, edit, and delete the user's files :::note TurboDocx does not make API calls to your SharePoint site without user interaction. This user's access will mirror whatever permissions the user has in the SharePoint site. Keep in mind that these are delegated permissions, which means TurboDocx can access these resources on behalf of the signed-in user. The level of access TurboDocx will have depends on the user's actual permissions in SharePoint and OneDrive. ::: ![Alt text](/img/sharepoint_and_onedrive/SharePoint_Permissions_Select.png) 7. The finished permission sets should look like the below: ![Alt text](/img/sharepoint_and_onedrive/Finished_Permission_Set.png) ### Step 3: Determine the default site that TurboDocx will open and get the path 1. The easiest way to do this is to navigate to your Site and find the path you want users to see first. 2. Take note of the URL path and notate it for future use. In this case it is "DocumentationTeam" ![Alt text](/img/sharepoint_and_onedrive/GetSiteName.png) ### Step 4: Login to your TurboDocx Tenant and go to Features and integrations 1. As an admin within your TurboDocx tenant, click your account avatar at the bottom of the left sidebar, click "Settings", then click "Features and integrations" in the Settings menu. 2. On the **SharePoint/OneDrive** card, make sure **Hide SharePoint/OneDrive in UI** is turned off. 3. Click **Configure SharePoint/OneDrive** ![SharePoint/OneDrive card with the Configure SharePoint/OneDrive button highlighted](/img/sharepoint_and_onedrive/Configure_Sharepoint_button.png) 4. Fill out the following fields with the information you have noted from previous steps and click "Save": **Tenant Name** - This is the name of the SharePoint tenant are connecting to. This can be found by looking in the browser bar and it should resemble "TenantName.SharePoint.com" **Path to List (optional)** - The site or document library your users land on when they first open the SharePoint file picker. Use the site name noted in the previous step. **Path to Folder (optional)** - A folder inside that library to open by default. **Client ID** - This is the Application (client) ID noted from the previous step in the EntraID (AzureAD) console. **Tenant ID** - This is the Directory (tenant) ID noted from the previous step in the EntraID (AzureAD) console. ![SharePoint and OneDrive settings dialog filled in, with the Save button highlighted](/img/sharepoint_and_onedrive/SharePoint_Configuration_in_TurboDocx.png) 5. Test importing and Exporting templates using the steps noted in the following sections. ## Importing Templates One of the primary benefits of the SharePoint integration in TurboDocx is the ability to directly import templates from your SharePoint document libraries. This feature removes the need to manually upload templates to TurboDocx, streamlining the process and saving you both time and effort. By simply connecting your SharePoint tenant to TurboDocx, you can effortlessly access and import your templates. The integration offers a seamless browsing experience, enabling you to navigate through your SharePoint document libraries and folders to choose the desired templates for import. Whether you have pre-existing templates or wish to import templates created using other applications such as Microsoft Word or Office 365, the SharePoint integration simplifies the process. Upon selecting the templates, TurboDocx securely imports them into your TurboDocx account, ready for customization and document generation. This ensures that your templates are readily available within TurboDocx, eliminating the need for manual file transfers or duplicate storage. ![Alt text](/img/sharepoint_and_onedrive/Import_with_SharePoint.png) ## Exporting Deliverables The SharePoint integration also enables you to export deliverables generated by TurboDocx back to your SharePoint and One Drive for business accounts. Once you have personalized and generated documents based on your templates, you can seamlessly export them to your SharePoint storage for easy access and sharing. Exporting deliverables to SharePoint and One Drive for Business ensures that your documents are stored securely in the cloud, providing a reliable backup and making them accessible from any device with an internet connection. By leveraging the familiar SharePoint interface, you can organize your deliverables into folders, share them with collaborators, and control access permissions as needed. --- ## Salesforce Integration # Turn Your Salesforce Data into Professional Documents & Presentations Say goodbye to copy-pasting customer information! TurboDocx's Salesforce integration automatically pulls your real CRM data to create personalized documents, proposals, and presentations. No more "John Doe" placeholder text — use actual account names, opportunities, and details. :::tip Sending documents for e-signature from Salesforce? [TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce) adds a **Send for Signature** button to your records and saves the signed PDF back automatically. It uses the connection you set up on this page. ::: ## What You Can Create - **📊 Sales Proposals**: Use real opportunity data to create compelling, personalized proposals - **📄 Account Reports**: Generate comprehensive reports with actual customer information - **📋 Meeting Summaries**: Create professional meeting notes using your Salesforce data - **💼 Sales Presentations**: Build custom presentations with live customer data - **📝 Follow-up Documents**: Generate personalized follow-ups using contact details - **🔄 Automated Reports**: Create recurring reports with fresh data from Salesforce ## Before You Begin :::tip For Our Technology-Shy Friends Don't worry! We've made this guide so detailed that any team member can follow it. Think of it as cooking instructions, but for software — and just as tasty! 😄 ::: To use the Salesforce integration, you'll need: - A TurboDocx **Pro** or **Enterprise** plan, which include the Salesforce integration - A Salesforce account with permission to create External Client Apps (any edition works!) - System Administrator access to create a Salesforce connected app - About 15 minutes and a cup of coffee ☕ - This guide (which you're already reading — you're ahead of the game!) :::tip Quick note This process involves creating something called a "connected app" in Salesforce. Think of it as giving TurboDocx secure access to your Salesforce data — and only the data you want to share. ::: ## Step 1: Create a Private Salesforce External Client App Time to get VIP access to your Salesforce data! 🎭 This step guides you through creating a new external client app in Salesforce, which will serve as the secure bridge for your TurboDocx application. Think of it as getting backstage passes to your very own Salesforce concert! 🎫 ### Log in to Salesforce 1. **Open your web browser** and go to [salesforce.com](https://salesforce.com) - Use Chrome, Firefox, Safari, or Edge 2. **Log into your Salesforce account** - Use your normal email and password with appropriate administrative privileges - If you forgot your password, there's a "Forgot Password?" link (we've all been there!) ![Salesforce Login Screen](/img/salesforce-integration/Login_Window.png) ### Navigate to Setup Home 3. **Find the Setup gear** in the top right corner - It looks like a little wheel with teeth ⚙️ - It's located next to your profile picture icon on the home page ![Setup Icon and Menu](/img/salesforce-integration/Setup_Icon_and_Menu.png) 4. **Click on "Setup"** - Select "Setup" from the dropdown menu - You will be navigated to the Setup Home page ![Salesforce Setup Home Page](/img/salesforce-integration/Setup_Home_Page.png) ### Go to App Manager 5. **Navigate through the left sidebar menu** - From the Setup Home page, scroll down in the left sidebar - Navigate to: **Platform Tools > Apps > App Manager** ![Navigation to App Manager in Sidebar](/img/salesforce-integration/Navigation_to_App_Manager.png) ### Create a New External Client App 6. **Click "New App", then "New External Client App"** - On the App Manager screen, click the **New App** dropdown - Choose **New External Client App** from the list (not "New Lightning App") ![App Manager Screen with New External Client App Button](/img/salesforce-integration/New_External_Client_App_Button.png) ### Configure Basic Information 7. **Fill in the Basic Information section**: - **External Client App Name**: Type "TurboDocx Integration App" or "TurboDocx Document Generator" - **API Name**: This will auto-populate based on the App Name, but you can adjust if needed - **Contact Email**: Enter a relevant contact email address - **Distribution State**: Set this to "Local" ![Basic Information Section Fields](/img/salesforce-integration/Basic_Information.png) :::tip Pro Tip Choose an app name you'll remember six months from now. "App123" might seem clever today, but future you will not be amused! 😏 ::: ### Configure API Settings 8. **Set up OAuth Settings** - Proceed to the "API Settings" section - **Enable OAuth Settings**: Check this box - **Callback URL**: Enter `https://api.turbodocx.com/oauth/salesforce/callback` as the callback URL - Salesforce only accepts callback URLs that start with `https://`. An `http://` address is rejected - Changes to an External Client App, such as the callback URL or its policies, can take several minutes (up to about 10) to apply. Until then, signing in to a sandbox or scratch org through `test.salesforce.com` may return `redirect_uri_mismatch`. If it does, wait a few minutes, or sign in through your org's My Domain login URL ![API Settings with OAuth Enabled](/img/salesforce-integration/OAuth_Enabled.png) 9. **Select OAuth Scopes** - In the **Selected OAuth Scopes** section, select the following scopes: - `Access unique user identifiers (openid)` - `Manage user data via APIs (api)` - `Manage user data via Web browsers (web)` - `Perform requests at any time (refresh_token, offline access)` ![OAuth Scopes Selected](/img/salesforce-integration/OAuth_Scopes_Selected.png) :::tip Attention to Detail Required You'll be adding several different permissions. It's like checking off a grocery list — but necessary for the feast ahead! 🛒 ::: 10. **Configure Flow Enablement** - In the "Flow Enablement" section under OAuth settings, make sure only the following checkbox is ticked - **Enable Authorization Code and Credentials Flow** ![Flow and Enablement Settings](/img/salesforce-integration/Flow_Enablement_settings.png) 11. **Configure Security** - In the "Security" section under OAuth settings, tick the following checkbox: - **Issue JSON Web Token (JWT)-based access tokens for named users** - New External Client Apps also have these options switched on and greyed out, so you can't change them. Leave them as they are. TurboDocx works with all of them: - **Require PKCE for Supported Authorization Flows** - **Enable Refresh Token Rotation** - **Limit Idle Refresh Token TTL to 30 Days** - Leave the other checkboxes in this section unticked ![Security Settings](/img/salesforce-integration/Security_Section.png) 12. **Create the External Client App** - Click "Create" to create your external client app - Take a moment to admire your handiwork first! ![Save External Client App](/img/salesforce-integration/create_app.png) :::tip Achievement Unlocked: App Creator! 🏆 Look at you go! You've successfully created your Salesforce External Client App. You're basically a mini developer now! 👩‍💻 Now we need to configure its policies and get those important credentials. ⚡ ::: ## Step 2: Configure External Client App Policies and Retrieve Credentials After creating the app, you need to adjust its access policies and retrieve the necessary credentials for TurboDocx. ### Access App Details Page 1. **Navigate to your app's detail page** - You should be automatically redirected to the detail page of the app you just created as soon as you hit the create button - If not, navigate to External Client App Manager **(Platform Tools > Apps > External Client App > External Client App Manager)** and click on the name of your newly created app ![Connected App Detail Page](/img/salesforce-integration/Connected_app_details_page.png) ### Edit Policies 2. **Locate the Policies section** - On the app's detail page, find the "Policies" section - Click the "Edit" button located below the policies section ![Policies Section with Edit Button](/img/salesforce-integration/policies_section.png) 3. **Adjust OAuth Policies** - In the "Edit" view, locate the OAuth policies and configure the following settings: - **Permitted Users**: Select **"All users can self-authorize"** - **Named User JWT-Based Access Token Settings**: Select **"Set app-specific token timeout"**, then select **30 Minutes** - **Refresh Token Policy**: Leave **"Expire refresh token if not used for specific time"** selected, with the 30-day limit Salesforce sets - **IP Relaxation**: Select exactly **"Relax IP restrictions"**, not the similar **"Relax IP restrictions for activated devices"** ![OAuth Policies Configuration](/img/salesforce-integration/OAuth_policies.png) :::note The 30-day refresh token limit New External Client Apps no longer offer **"Refresh token is valid until revoked"**. A refresh token expires if it isn't used for 30 days. You don't need to do anything about this: TurboDocx keeps the connection active automatically. If the connection ever stops working, for example because a Salesforce admin revokes it, TurboDocx shows a **Reconnect Salesforce** message in the Salesforce settings dialog and emails your organization's admins. To see the message, open **Settings > Features and integrations** and click **Configure SalesForce** on the Salesforce card. Reconnecting is safe. ::: 4. **Save Changes** - Click "Save" to apply the policy changes ![Save Button](/img/salesforce-integration/Save_policies.png) ### Retrieve Consumer Key and Secret 5. **Find Settings section** - On the same app details page, Navigate to the "Settings" section - Here you don't need to edit anything as we have already configured it while creating the app ![Settings Section](/img/salesforce-integration/connected_app_settings_section.png) 6. **Navigate to the OAuth Settings part under the settings section** - You will now see a button of **Consumer Key and Secret** - Click on this button. Salesforce opens a new browser tab for the next two steps ![Consumer Key and Secret Button](/img/salesforce-integration/Consumer_key_and_secret_button.png) 7. **Complete identity verification** - You will be prompted to verify your identity via an OTP being sent to your registered email - Complete this verification step ![Identity Verification Prompt](/img/salesforce-integration/Identity_Verification_page.png) 8. **Copy your credentials** - After successful verification, your Consumer Key and Consumer Secret will be displayed - Copy both the **Consumer Key** and **Consumer Secret** - these credentials are essential for connecting your TurboDocx application to Salesforce ![Consumer Key and Secret Display](/img/salesforce-integration/Consumer_key_and_secret_page.png) :::warning Handle With Care 🔐 These consumer keys are like VIP backstage passes to your Salesforce show. They let TurboDocx access only the data you've approved, but you definitely don't want random people crashing your party! 🎉 Keep them private, and if they ever get shared by accident, no worries — you can always generate fresh ones right here. It's like getting a new set of keys! 🔑 ::: ## Step 3: Configuring TurboDocx Now we'll connect your shiny new Salesforce external client app to TurboDocx. This is like introducing two friends who are perfect for each other at a party — and watching the magic happen! 🎉✨ ### Navigate to TurboDocx Settings 1. **Open the account menu** - Click your **account avatar** at the bottom of the left sidebar ![Left sidebar with the account avatar highlighted](/img/salesforce-integration/Turbodocx_dashboard.png) 2. **Click on "Settings"** - It's in the menu that opens above your avatar ![Account menu with the Settings item highlighted](/img/salesforce-integration/turbodocx_settings_page.png) 3. **Click on "Features and integrations"** - It's in the menu on the left of the Settings page ![Settings menu with Features and integrations highlighted](/img/salesforce-integration/features_and_integrations.png) ### Configure Salesforce Integration 4. **Find the Salesforce card** - Look for the Salesforce logo under the integrations ![Salesforce card highlighted on the Features and integrations page](/img/salesforce-integration/Salesforce_integration_page.png) 5. **Click "Configure Salesforce"** 🚀 - A popup or form will appear - This is where the magic happens! Time to make these two apps best friends! ✨👯‍♀️ ![Salesforce card with the Configure SalesForce button highlighted](/img/salesforce-integration/configure_salesforce_button.png) ![Configuration Modal](/img/salesforce-integration/configuration_modal.png) :::note Connect a sandbox The dialog has an **Environment** list, set to **Production** by default. To connect a Salesforce sandbox, choose **Sandbox** there before you save, so the connection signs in at `test.salesforce.com`. If your sandbox uses a My Domain login, click **Use Custom Domain** on the Salesforce login page and enter it. ::: 6. **Enter your Consumer Key and Consumer Secret** - Paste the **Consumer Key** from your Salesforce app into **Consumer Key (Client ID)** - Paste the **Consumer Secret** from your Salesforce app into **Consumer Secret (Client Secret)** ![Key Entry Form](/img/salesforce-integration/key_and_secret_entered.png) 7. **Click "Save Configuration"** - Cross your fingers (optional, but recommended!) ![Save Configuration Button](/img/salesforce-integration/Save_config_button.png) ### Establish the OAuth Flow for Salesforce 8. **Click "Connect to Salesforce"** - Save your configuration first (step 7), then click this button - You'll be redirected to Salesforce to authorize the connection ![Connection Button](/img/salesforce-integration/connect_to_salesforce_button.png) 9. **Authorize TurboDocx in Salesforce** - Salesforce will ask you to log in and confirm the connection - Enter your username and password to login ### Sync Your Salesforce Data 10. **Click "Refresh Fields"** - This button appears after the connection test succeeds - It downloads all your custom Salesforce fields and data - It also tests if your connection works ![Field Refresh Button](/img/salesforce-integration/refresh_fields.png) 11. **Wait for the field sync** ⏳ - This can take 2-5 minutes depending on your Salesforce org size - Perfect time to check your email, grab a snack, or practice your victory dance! 💃🍿 - Pro tip: The bigger your Salesforce org, the more impressive your setup skills! 💪 :::tip Success Celebration 🎊 If you've made it this far, you deserve a pat on the back! You've successfully connected Salesforce to TurboDocx. That's no small feat — you're basically a tech wizard now! 🧙‍♂️ Time for a victory dance! 💃🕺 ::: ## Step 4: Using Your Salesforce Integration Time to put your new integration to work! This is where the real magic happens — turning your boring Salesforce data into beautiful, professional documents that'll make your colleagues go "WOW!" 🤩✨ ### Creating Your First Document 1. **Go to document generation** - Look for "Create Document", "New Document", or similar - This is usually on your main dashboard ![Document Creation Page](/img/salesforce-integration/Create_document_button.png) 2. **Select Template** - Click on the template you want to work with ![Template Selection](/img/salesforce-integration/template_selection_page.png) 3. **Change your data source** - Click on the "Change Source" dropdown - Select "Change Resource" from the menu ![Source Change Dropdown](/img/salesforce-integration/source_change_dropdown.png) 4. **Go to the App Library** - Click on the "App Library" tab - This shows all your connected integrations ![App Library Interface](/img/salesforce-integration/app_library_interface.png) 5. **Select CRM category** - Look for "CRM" and click on it - This filters to show only CRM integrations ![CRM Category Selection](/img/salesforce-integration/CRM_category_selection.png) 6. **Choose Salesforce** - Click on "Salesforce" (you should see the Salesforce logo) - It should show as "Connected" ![Salesforce Selection](/img/salesforce-integration/Salesforce_selection.png) 7. **Click "Continue"** - This takes you to the Salesforce agent interface ![Continue Button](/img/salesforce-integration/salesforce_continue_button.png) ### Using the Salesforce Agent 8. **Select your records** (optional but helpful) - Click "Select Records" to choose specific accounts, opportunities, contacts and many other fields - This helps the AI focus on the right data ![Record Selection Interface](/img/salesforce-integration/record_selection_interface.png) 9. **Choose relevant records** - Click on accounts, opportunities, contacts or other fields relevant to your document - Selected items will be highlighted - Click "Save" to confirm your record selection ![Record Selection Process](/img/salesforce-integration/record_selection_process.png) 10. **Give instructions to the AI** 🤖 - Type what kind of document you want in plain English (no tech jargon needed!) - Be specific about what you want to create - Sit back, relax, and watch the magic happen! ✨ - Generation typically takes 30 seconds to 2 minutes (perfect time for a coffee sip! ☕) ![Prompt Input Interface](/img/salesforce-integration/prompt_input_interface.png) **Example prompts:** - "Create a professional proposal section for the selected opportunity using the account's information" - "Generate a follow-up slide summarizing our recent meeting with this account" - "Create a company overview report using the selected account data" - "Draft a project kickoff overview for the selected opportunity and contacts" 11. **Review your document** 👀 - Check that all the information looks correct - Make any necessary edits - Marvel at your personalized, professional document and do a little happy dance! 💃🕺 :::tip Pro Document Tips - Be specific in your instructions — "Create a proposal" vs. "Create a detailed Q3 marketing proposal section for ABC Corp with pricing and timeline" - Select the right records — more relevant data = better documents - Don't be afraid to regenerate if the first attempt isn't perfect - Save successful prompts for future use! ::: ## Troubleshooting 🤔 Even the best-laid plans sometimes go awry (Murphy's Law is real, folks!). Don't worry — we've got your back! Here are solutions to common issues: ### "I Can't Find the Setup Menu in Salesforce" **Solution**: - Look in the top right corner of your Salesforce interface - Click on your profile picture or the gear icon - Select "Setup" from the dropdown menu - If you still can't find it, you might need System Administrator permissions ### "I Can't Find 'New External Client App'" **Solution**: - Make sure you're in App Manager (Platform Tools > Apps > App Manager) - Click the **New App** dropdown, then choose **New External Client App** (not "New Lightning App") - If you don't see this option, you might need System Administrator permissions - Some Salesforce orgs might have different permission requirements ### "Invalid Consumer Key/Secret" Error **Solution**: - Double-check that you copied the entire key and secret (they're usually quite long) - Make sure there are no extra spaces at the beginning or end - Verify your Salesforce external client app is still active - Complete the identity verification step when accessing "Manage Consumer Details" - If all else fails, create a new external client app and get fresh keys ### "Permission Denied" Error **Solution**: - Check that you added all the required OAuth scopes to your Salesforce external client app - Verify you selected: OpenID, API, Web, and Refresh token scopes - Make sure you're a System Administrator in your Salesforce org - Verify the external client app is enabled and policies are configured correctly ### "redirect_uri_mismatch" Error **Solution**: - Check that the callback URL in your Salesforce external client app is exactly `https://api.turbodocx.com/oauth/salesforce/callback` - Changes to an external client app, such as the callback URL or its policies, can take several minutes (up to about 10) to apply. During that time, signing in to a sandbox or scratch org through `test.salesforce.com` may return this error - Wait a few minutes and try again, or sign in through your org's My Domain login URL instead ### "Reconnect Salesforce" Message in TurboDocx **Solution**: - This means TurboDocx can no longer use the connection, for example because a Salesforce admin revoked it or the connected user can no longer log in - TurboDocx shows this message in the Salesforce settings dialog, not on the Salesforce card itself, and also emails your organization's admins - Open **Settings > Features and integrations** in TurboDocx, click **Configure SalesForce** on the Salesforce card, and connect Salesforce again - Reconnecting is safe ### "No Records Found" in TurboDocx **Solution**: - Make sure you have actual data in your Salesforce org (accounts, opportunities, contacts) - Click "Refresh Fields" again in **Settings → Features and integrations** - Check that your Salesforce external client app has the right permissions and policies configured ### "The Agent Doesn't Understand My Instructions" **Solution**: - Be more specific in your prompts - Use simpler language - Include the type of document you want (email, proposal, report, etc.) - Try selecting more specific records :::tip When All Else Fails 🎆 If you're still stuck, don't panic! Take a deep breath — you've got this! 💪 Take a screenshot of any error messages, note exactly what step you're on, and contact our support team. We're here to help, not judge your tech skills (we've all been there!). Think of us as your friendly tech support sidekicks! 🤝🦾 ::: ## Security and Privacy Your data security is important to us (and should be to you too!): ### How Your Data is Protected - **Secure Authentication**: We use OAuth 2.0 (fancy industry-standard security) - **Scoped Access**: The connection uses your Salesforce user's API access, so it can only reach what that user can reach. In practice TurboDocx reads the fields you map. If you use [TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce), it also adds signed files to records and can update the one field you choose under **On Completion** - **Encrypted Transmission**: All data transfers are encrypted (like sending a letter in a locked box) ### Best Practices - **Keep Your Keys Secret**: Don't share your consumer key and secret with anyone - **Regular Reviews**: Periodically check which integrations have access to your data - **Monitor Connected Apps**: Regularly review connected apps in your Salesforce org ## Tips for Success ### Getting the Best Results **Keep Your Salesforce Data Clean**: - Use consistent naming conventions - Fill in important fields (account info, opportunity values, etc.) - Keep your data up-to-date **Write Clear Instructions**: - Be specific about what you want - Mention the type of document - Include any special requirements **Select the Right Records**: - Choose records that are relevant to your document - Don't select too many records at once - Quality over quantity! ### Advanced Tips **For Better Proposals**: - Select the account, opportunity, and contact records - Include opportunity value and close date information - Mention specific products or services **For Better Reports**: - Select relevant accounts and opportunities - Include date ranges if applicable - Specify the type of analysis you want **For Better Follow-ups**: - Select recent meeting attendees - Include opportunity or project context - Mention next steps or action items ## What's Next? 🎆 Congratulations, integration superstar! You've successfully: - ✅ Created a Salesforce external client app (you're basically a developer now! 👩‍💻) - ✅ Configured its policies and retrieved credentials (security expert: level unlocked! 🔒) - ✅ Connected it to TurboDocx (networking ninja: mastered! 🥷) - ✅ Generated your first document (content creation wizard: achieved! 🧙‍♀️) ### Now You Can: 🎉 - Create personalized proposals in minutes instead of hours (time saver level: EXPERT! ⏱️) - Build comprehensive reports using your CRM information (data wizard status: UNLOCKED! 📊) - Automate document creation for your entire team (team hero achievement: EARNED! 🦾) ### Next Steps: 🚀 1. **Train your team** on being a prompting pro (share your newfound expertise!) 2. **Create document and presentation templates** for common use cases (you're basically a template architect now!) 3. **Experiment with different AI prompts** to find what works best (become the AI whisperer of your office! 🤖🗣️) :::tip Final Words of Wisdom 🎓 Remember, you're not just creating documents or presentations — you're creating more time for yourself by automating repetitive tasks. Every minute saved on copy-pasting customer data is a minute you can spend on more important things (like actually talking to customers, or grabbing that extra cup of coffee! ☕). You're officially a productivity ninja now! 🎉🥷 ::: ## Getting Help **If you need assistance:** 1. **Check this guide first** (you'd be surprised how often the answer is right here!) 2. **Take screenshots** of any error messages 3. **Note the exact step** where you got stuck 4. **Contact our TurboDocx support team** with the details **Remember**: There's no such thing as a bad question. We've all been there, and we're here to help you succeed! You're part of the TurboDocx family now! 💪🏠 --- _Happy document generating, integration champion! May your proposals be persuasive, your reports be comprehensive, your follow-ups be timely, and your coffee always be hot! ☕🚀✨_ --- # Microsoft Teams Integration 🚀 Coming Soon Transform your Microsoft Teams meetings into professional documents and presentations with AI-powered automation. ## What's Coming We're actively developing a comprehensive Microsoft Teams integration that will revolutionize how you handle meeting documentation and follow-ups. Here's what you can expect: ### 🎯 Planned Features 📄 Meeting Documentation Convert Teams meeting transcripts into formatted documents Generate meeting minutes with action items Create follow-up reports automatically Extract key decisions and takeaways 📊 Presentation Generation Turn discovery calls into client presentations Create pitch decks from meeting discussions Generate training materials from sessions Build project proposals from requirement gatherings 🤖 AI-Powered Automation Intelligent content extraction from transcripts Smart formatting and structuring Context-aware document generation Automated workflow triggers 🔄 Seamless Integration Direct access to Teams cloud recordings OAuth-based secure authentication Real-time transcript processing Multi-tenant support ### 🎨 Document Types You'll Be Able to Create **📋 Business Documents** - Meeting summaries and action items - Project status reports - Client consultation notes - Team retrospective documents **📈 Sales & Marketing Materials** - Customer discovery presentations - Sales proposal documents - Product demo follow-ups - Marketing campaign briefs **🎓 Training & Education** - Workshop documentation - Training session summaries - Educational content creation - Knowledge base articles **💼 HR & Operations** - Interview documentation - Team meeting notes - Process documentation - Compliance reports ## Why Teams Integration Matters ⚡ Accelerate Post-Meeting Workflows Eliminate manual note-taking and document creation after every Teams meeting 🎯 Improve Meeting ROI Transform every Teams meeting into actionable, professional deliverables 🤝 Enhance Team Collaboration Create consistent documentation standards across all Teams meetings 📊 Leverage Your Microsoft Investment Maximize the value of your Teams platform with intelligent document automation ## Stay Updated ### 📬 Get Notified When Teams Integration Launches Want to be among the first to know when Microsoft Teams integration becomes available? We'll keep you updated on our progress and let you know as soon as it's ready for use. **Contact our team to:** - Get early access notifications - Provide input on feature priorities - Schedule a preview demonstration - Discuss your specific Teams integration needs 📧 Email: team@turbodocx.com 💬 Subject: "Teams Integration Updates" ## Technical Preview ### 🔬 Beta Testing Program We're looking for organizations to participate in our Teams integration beta testing program. Beta participants will get: - **Early access** to Teams integration features - **Direct influence** on feature development - **Priority support** during implementation - **Reduced pricing** for early adopters **Requirements for beta participation:** - Active Microsoft Teams usage with cloud recording - Willingness to provide feedback and testing Interested in beta testing? Apply for Beta Access --- 🚀 The Future of Meeting Documentation Microsoft Teams integration will transform how your organization handles meeting follow-ups, documentation, and collaboration workflows. Stay tuned for updates on availability and beta program announcements. --- # TurboSign for Salesforce TurboSign for Salesforce lets your team send a TurboDocx document for e-signature straight from a Salesforce record. The document fills itself from the record's fields, the signers come from the record, and when everyone has signed, the signed PDF and its audit trail are saved back to the record. The record can also move to a new stage automatically, for example **Closed Won**. ## How it works 1. An admin builds a **document setup** once: which TurboDocx template to use, which Salesforce fields fill it, who signs, and where each person signs. 2. A sales rep opens a record, clicks **Send for Signature**, reviews the filled-in values and the finished document, and clicks **Send**. 3. Each signer gets an email, verifies their identity if your organization requires it, and signs in the browser. 4. When the last signer finishes, TurboDocx writes the signed PDF and the audit trail to the record's files and updates the stage you chose. Your TurboDocx API key never sits in a Salesforce field. It is stored encrypted in a Salesforce **External Credential** and added to each request by Salesforce itself, so nobody can read it back from Setup. ## Guides | If you want to… | Read | |---|---| | Install the package in your Salesforce org | [Install TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/install) | | Find the install link for your org type, or check your installed version | [One-click install links](/docs/Integrations/turbosign-for-salesforce/install-links) | | Set everything up in Salesforce Setup, click by click | [Set up TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/setup) | | Send a document and see what signers experience | [Send a document for signature](/docs/Integrations/turbosign-for-salesforce/send-and-sign) | | Configure it from the Salesforce CLI after installing | [Scripted setup for admins](/docs/Integrations/turbosign-for-salesforce/scripted-setup) | | Fix an error message | [Troubleshooting](/docs/Integrations/turbosign-for-salesforce/troubleshooting) | ## What you need - A Salesforce org where you are a System Administrator, on an edition that can run it. See [Which Salesforce orgs can use it](#which-salesforce-orgs-can-use-it). - A TurboDocx account with TurboSign, plus an **API key** and your **Organization ID**. See [Getting your credentials](/docs/TurboSign/API-Signatures#getting-your-credentials). - A TurboDocx template. Every spot where someone signs must already be in the template as a text token, for example `{sig}`. - The [Salesforce integration](/docs/Integrations/SalesForce) connected in TurboDocx. Saving signed files back to Salesforce depends on TurboDocx's Salesforce write-back feature, which is part of TurboDocx and uses that connection to save the signed PDF to the record. The Salesforce integration is included on the TurboDocx **Pro** and **Enterprise** plans. Sending for signature works without it. ## Which Salesforce orgs can use it Your Salesforce **edition** decides whether TurboSign for Salesforce can run in your org. The **install method** decides who puts it there. TurboSign for Salesforce includes custom Apex code, so it needs an edition that runs custom Apex. | Salesforce edition | Can use TurboSign for Salesforce | |---|---| | Enterprise | Yes | | Unlimited | Yes | | Performance | Yes | | Developer Edition | Yes | | Sandboxes of the editions above | Yes | | Professional | No | | Group | No | | Starter, Pro Suite, Essentials | Not supported | Professional and Group editions block custom Apex unless it ships in a managed package that passed the Salesforce AppExchange security review. TurboSign for Salesforce is not offered that way today. If you are on Starter, Pro Suite, or Essentials, or you are not sure which edition you have, check with your Salesforce account rep. ### How it gets installed | Method | Who can do it | Upgrades and removal | |---|---|---| | **[One-click install link](/docs/Integrations/turbosign-for-salesforce/install-links)** | Any Salesforce admin in your org, by clicking the link | A new version installs over the old one. Uninstall in one click. | | **[Deploy from source](/docs/Integrations/turbosign-for-salesforce/install#deploy-from-source-with-the-salesforce-cli)** with the Salesforce CLI | A developer, using `sf project deploy start` | Manual. Deploying to production runs your org's Apex tests, which must reach 75% code coverage. | There are two ways to install, and step-by-step instructions for both are in [Install TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/install). The method doesn't change which orgs can run TurboSign for Salesforce. It changes whether your own admin can install it, or whether someone has to deploy it for you. Either way, the person installing needs permission to install packages, which the **System Administrator** profile has. --- ## TurboSign for Salesforce one-click install links # One-click install links These links install the TurboSign for Salesforce package that TurboDocx publishes. Pick the link for the kind of org you're installing into, then follow [Install with a one-click link](/docs/Integrations/turbosign-for-salesforce/install#install-with-a-one-click-link). ## Version 1.1.1 (latest) | Install into | Link | |---|---| | Production or Developer Edition org | [Install in production or Developer Edition](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000oqUrAAI) | | Sandbox | [Install in a sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000oqUrAAI) | Version 1.1.1 fixes how messages, previews and the builder show values. It installs over 1.1.0 and 1.0.0. ## Version 1.1.0 | Install into | Link | |---|---| | Production or Developer Edition org | [Install in production or Developer Edition](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000o8LlAAI) | | Sandbox | [Install in a sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000o8LlAAI) | Version 1.1.0 saves the signed PDF and audit trail back to the record and adds the **Document** tab on the review screen. It installs over 1.0.0. ## Version 1.0.0 | Install into | Link | |---|---| | Production or Developer Edition org | [Install in production or Developer Edition](https://login.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000mtqDAAQ) | | Sandbox | [Install in a sandbox](https://test.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000mtqDAAQ) | The two links install the same package. Production and Developer Edition orgs sign in at `login.salesforce.com`, and sandboxes sign in at `test.salesforce.com`, so each link opens the right login page. :::tip Install in a sandbox first If you have a sandbox, install there first, finish the [setup](/docs/Integrations/turbosign-for-salesforce/setup), and send a test document before you install in production. To have the signed PDF saved back to the sandbox record, connect that sandbox in TurboDocx, as described in [Step 4 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-4-connect-salesforce-in-turbodocx). ::: :::tip Already logged in to your org? The links go through `login.salesforce.com` (or `test.salesforce.com`), which may ask you to log in again even when you're already signed in to your org. To skip that, replace the start of the link with your org's My Domain address, the one in your browser's address bar when you're in Salesforce. For example: `https://yourcompany.my.salesforce.com/packaging/installPackage.apexp?p0=04tbm000000o8LlAAI`. ::: :::note Logged in to a different org? The link opens in whichever org you log in to. If your browser is signed in to the wrong one, log out first, or open the link in a private browser window. ::: ## Newer versions Newer versions add features, and each one is listed with its own install links and what it adds on the [TurboSign for Salesforce releases page](https://github.com/TurboDocx/turbodocx-salesforce/releases). To upgrade, open the newer version's link in the org that already has the package. See [Upgrade to a newer version](/docs/Integrations/turbosign-for-salesforce/install#upgrade-to-a-newer-version). ## Check which version is installed 1. Open **Setup**. 2. In **Quick Find**, type `Installed Packages`, then click **Installed Packages**. 3. Find **TurboSign for Salesforce** in the list. The **Version Number** column shows the version you have. Versions 1.1.0 and 1.1.1 show as **1.1**, and version 1.0.0 as **1.0**. ![Installed Packages list with TurboSign for Salesforce and its Version Number highlighted](/img/turbosign-salesforce/install-05-installed-packages.png) Version 1.0 sends documents and collects signatures, but does not save signed files back to your Salesforce records. Saving signed files back, and the **Document** tab on the review screen, need version 1.1.0 or later. Newer versions are listed on the [releases page](https://github.com/TurboDocx/turbodocx-salesforce/releases). --- # Install TurboSign for Salesforce There are two ways to install TurboSign for Salesforce in your org. Most admins use the one-click install link. | Method | Best for | Who does it | |---|---|---| | [One-click install link](#install-with-a-one-click-link) | Most orgs | A Salesforce admin, in the browser | | [Deploy from source](#deploy-from-source-with-the-salesforce-cli) | Teams that review and deploy code themselves | A developer, with the Salesforce CLI | After either one, you configure the app in Salesforce Setup. To configure it after installing from the Salesforce CLI instead, see [Scripted setup for admins](/docs/Integrations/turbosign-for-salesforce/scripted-setup). Saving signed files back to your Salesforce records depends on TurboDocx's Salesforce write-back feature, which is part of TurboDocx. You turn it on by connecting Salesforce in TurboDocx, in [Step 4 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-4-connect-salesforce-in-turbodocx). ## Before you install - **You need the System Administrator profile** (or a user with permission to install packages) in the org. - **Your org must run custom Apex.** Enterprise, Unlimited, Performance, and Developer Edition orgs work, and so do their sandboxes. Professional and Group editions don't. See [Which Salesforce orgs can use it](/docs/Integrations/turbosign-for-salesforce#which-salesforce-orgs-can-use-it). - **No org to try it in?** A free [Salesforce Developer Edition](https://developer.salesforce.com/signup) org works. - **Try a sandbox first** if you have one. Install, set up, and send a test document there before production. To see the signed files saved back to the sandbox record, install version 1.1.0 or later and connect that sandbox in TurboDocx, as described in [Step 4 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-4-connect-salesforce-in-turbodocx). ## Install with a one-click link The install link installs TurboSign for Salesforce as an unlocked package. It takes a few minutes. 1. Open [One-click install links](/docs/Integrations/turbosign-for-salesforce/install-links) and click the link for your org: production or Developer Edition, or sandbox. 2. Log in to the org as a System Administrator, if Salesforce asks. If it asks even though you're already signed in, you can [use your My Domain address in the link](/docs/Integrations/turbosign-for-salesforce/install-links#version-110-latest) instead. 3. Check that the install page shows **TurboSign for Salesforce** and the version you expect. ![Package install page with the app name and version number highlighted](/img/turbosign-salesforce/install-01-install-page.png) 4. Select **Install for Admins Only**. ![Install options with Install for Admins Only highlighted](/img/turbosign-salesforce/install-02-admins-only.png) This gives the package's access only to admins for now. You give your reps access with the TurboSign permission sets in the next part, which is all they need. 5. If the page asks you to confirm that you're installing a non-Salesforce application, check the box. 6. Click **Install**. ![Install button highlighted](/img/turbosign-salesforce/install-03-install-button.png) 7. Wait for the install to finish, then click **Done**. If Salesforce says **This app is taking a long time to install**, click **Done**. The install keeps running, and Salesforce emails you when it finishes. ![Install page saying the app is taking a long time to install, with the Done button highlighted](/img/turbosign-salesforce/install-04-success.png) To confirm the install, open **Setup**, type `Installed Packages` in **Quick Find**, and click **Installed Packages**. **TurboSign for Salesforce** is in the list. See [Check which version is installed](/docs/Integrations/turbosign-for-salesforce/install-links#check-which-version-is-installed). ### Right after installing The package is installed but not connected yet. Finish these steps in [Set up TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/setup): 1. [Store your API key and Organization ID](/docs/Integrations/turbosign-for-salesforce/setup#step-1-store-your-api-key-and-organization-id) in the **TurboDocx API** External Credential. 2. [Check the Named Credential URL](/docs/Integrations/turbosign-for-salesforce/setup#step-2-check-where-salesforce-sends-requests). 3. [Assign the permission sets](/docs/Integrations/turbosign-for-salesforce/setup#step-3-give-people-access): **TurboSign Admin** to admins, **TurboSign User** to reps and to any automation user. 4. [Connect Salesforce in TurboDocx](/docs/Integrations/turbosign-for-salesforce/setup#step-4-connect-salesforce-in-turbodocx) so signed files are saved back to your records (version 1.1.0 or later). The connection needs the **api**, **refresh_token**, and **openid** scopes. 5. [Build a document setup](/docs/Integrations/turbosign-for-salesforce/setup#step-5-build-a-document-setup). 6. [Add the Send for Signature button](/docs/Integrations/turbosign-for-salesforce/setup#add-the-send-for-signature-button) to your Opportunity pages. ### Upgrade to a newer version 1. Find the newer version on the [releases page](/docs/Integrations/turbosign-for-salesforce/install-links#newer-versions). 2. Open its install link in the org that already has TurboSign for Salesforce. Salesforce shows it as an upgrade. 3. Select **Install for Admins Only**. 4. Click **Upgrade** (or **Install**). 5. Wait for the upgrade to finish, then click **Done**. You install a newer version over the old one. You don't uninstall first. You can't install an older version over a newer one. An upgrade puts the packaged URL back on the **TurboDocx API** Named Credential, so if you changed it, set it again. See [Step 2 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-2-check-where-salesforce-sends-requests). After an upgrade, open **TurboSign Setup**. If your templates load in **TurboDocx Template**, the connection still works. ### Uninstall :::danger Uninstalling removes your TurboSign data Uninstalling deletes everything the package added: your document setups, the signature status and log records, and the stored API key and Organization ID. Signed PDFs and audit trails already saved to your records are ordinary Salesforce files, so they stay. If you need the setups later, [retrieve them](/docs/Integrations/turbosign-for-salesforce/scripted-setup#start-from-the-builder) first. ::: 1. Open **Setup**. 2. In **Quick Find**, type `Installed Packages`, then click **Installed Packages**. 3. Click **Uninstall** next to **TurboSign for Salesforce**. 4. Read the list of what will be removed, check the box to confirm, and click **Uninstall**. If Salesforce won't uninstall because some components are still in use, it lists them. Remove those references first, for example the **Send for Signature** button on page layouts or in the **Highlights Panel**, the **TurboSign Signatures** panel on Lightning pages, and any Flow that uses a TurboSign action. Then uninstall again. ## Deploy from source with the Salesforce CLI Use this method if your team reviews the code and deploys it like your own. You get the same app as the install link, but upgrades and removal are up to you. You need: - [Git](https://git-scm.com) and the [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) (`sf`). - System Administrator access to the target org. 1. Get the source code: ```bash git clone https://github.com/TurboDocx/turbodocx-salesforce.git cd turbodocx-salesforce ``` To deploy a specific release, check out its tag (for example `git checkout v1.1.0`). Use 1.1.0 or later: earlier tags do not include the example setup the tests need or saving signed files back. Releases are listed on the [releases page](https://github.com/TurboDocx/turbodocx-salesforce/releases). 2. Log in to the org and give it an alias. These examples use `myorg`: ```bash # Production or Developer Edition sf org login web --alias myorg # Sandbox sf org login web --alias myorg --instance-url https://test.salesforce.com ``` 3. Deploy the app. Deploy both folders: `force-app` holds the app, and `unpackaged` holds an example setup called **Example Agreement** that the app's Apex tests use. Without it, the tests fail. ```bash sf project deploy start --source-dir force-app --source-dir unpackaged --target-org myorg ``` 4. Run the Apex tests. `RunLocalTests` runs every Apex test in your org except tests from installed managed packages, so it runs more than just this app's tests: ```bash sf apex run test --test-level RunLocalTests --code-coverage \ --result-format human --wait 30 --target-org myorg ``` :::note Deploying to production Production deploys always run Apex tests, and every deploy must keep at least 75% code coverage across your org's Apex. Your org's own tests run too, so a failing test anywhere blocks the deploy. To check first without changing anything, validate the deploy: ```bash sf project deploy validate --source-dir force-app --source-dir unpackaged \ --test-level RunLocalTests --target-org myorg ``` ::: Then follow the same [steps as after an install](#right-after-installing). The **Example Agreement** setup shows up in the document list on Opportunities. To hide it from reps, open **TurboSign Setup**, pick **Example Agreement** in **Edit an existing configuration**, and click **Deactivate**. Each later deploy from source switches it back on, because the tests need it, so deactivate it again after each deploy. To upgrade, pull the newer release and deploy again. Like a package upgrade, a deploy puts the source URL back on the **TurboDocx API** Named Credential, so [check it](/docs/Integrations/turbosign-for-salesforce/setup#step-2-check-where-salesforce-sends-requests) afterwards. To remove a source deploy, your developer deletes the components with a destructive deploy. There is no one-click uninstall. ## Configure from the command line After the app is installed, you can do the setup from the Salesforce CLI instead of clicking through Setup: store the credentials, assign permission sets, check the connection, and deploy document setups as metadata. This is useful when you set up several orgs (sandboxes, staging, production) the same way. See [Scripted setup for admins](/docs/Integrations/turbosign-for-salesforce/scripted-setup). --- # Scripted setup for TurboSign for Salesforce This page is for admins and developers who prefer scripts over clicking through Setup, or who need to set up several orgs (sandboxes, staging, production) the same way. Each section does the same thing as a step in [Set up TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/setup). ## Before you begin - Install the [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) (`sf`). - Make sure `node` is on your PATH (check with `node --version`). The credential step below uses [Node.js](https://nodejs.org) to build its request body. - Install the TurboSign for Salesforce package in the target org. See [Install TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/install). - Log in to the org and give it an alias. These examples use `myorg`: ```bash sf org login web --alias myorg ``` ## Create a Salesforce DX project The `sf project retrieve` and `sf project deploy` commands below only work inside a Salesforce DX project folder. Create one for your TurboSign configuration and work from inside it for the rest of this page: ```bash sf project generate --name turbosign-config cd turbosign-config ``` This creates a `turbosign-config` folder with an `sfdx-project.json` file and a `force-app/main/default` folder with Salesforce's standard starter files. Keep the folder in source control if you want to deploy the same setup to several orgs. ## Store the API key and Organization ID Salesforce stores principal parameters through the Connect REST API (`/named-credentials/credential`). They can't be deployed as metadata, which is by design: secrets never live in source control. Type the key at a silent prompt so it never appears on screen or in your shell history. The script sends it straight to Salesforce without saving it anywhere: ```bash # Runs in a subshell, so the values and traps never stay in your shell. ( trap 'stty echo 2>/dev/null' EXIT # always turn typing echo back on trap 'exit 130' INT TERM # Ctrl-C stops here; nothing is sent # Prompt for the values (the key is not echoed). Works in bash and zsh. printf 'TurboDocx API key: '; stty -echo; read -r TDX_KEY; stty echo; echo printf 'TurboDocx Organization ID: '; read -r TDX_ORG # Node.js builds the JSON (so quotes and backslashes are escaped) and pipes it # straight to the Salesforce CLI. "--body -" reads the body from that pipe, # so the key is never written to a file or shown on a command line. # First time: POST creates the parameters. To rotate the key later, use --method PUT. TDX_KEY="$TDX_KEY" TDX_ORG="$TDX_ORG" node -e ' const { TDX_KEY, TDX_ORG } = process.env; process.stdout.write(JSON.stringify({ externalCredential: "TurboDocx_API", principalName: "TurboDocxPrincipal", principalType: "NamedPrincipal", authenticationProtocol: "Custom", credentials: { ApiKey: { value: TDX_KEY, encrypted: true }, OrgId: { value: TDX_ORG, encrypted: true } } }));' | sf api request rest "/services/data/v62.0/named-credentials/credential" \ --method POST --body - --target-org myorg ) ``` If the `ApiKey` and `OrgId` parameters already exist, for example because you added them in the Setup UI, POST fails. Use the PUT form instead: run the same script with `--method PUT` in place of `--method POST`, the same as when you rotate the key. If you press Ctrl-C at a prompt, the script stops, turns typing echo back on, and sends nothing. If `sf` reports an error, nothing was saved, so fix the cause (for example log in again) and run the script again. Check that both parameters exist. Salesforce returns the names but never the values: ```bash sf api request rest \ "/services/data/v62.0/named-credentials/credential?externalCredential=TurboDocx_API&principalName=TurboDocxPrincipal&principalType=NamedPrincipal" \ --target-org myorg ``` You should see `ApiKey` and `OrgId` under `credentials`, each with `"encrypted": true`. ## Point the Named Credential at TurboDocx The package installs the **TurboDocx_API** Named Credential with the production URL. To use a different address, retrieve it, edit the URL, and deploy it back: ```bash sf project retrieve start --metadata NamedCredential:TurboDocx_API --target-org myorg # Edit force-app/main/default/namedCredentials/TurboDocx_API.namedCredential-meta.xml: # change the of the Url parameter sf project deploy start --metadata NamedCredential:TurboDocx_API --target-org myorg ``` Deploying the Named Credential does not touch the parameters you stored above. A package upgrade puts the packaged URL back, so deploy your Named Credential again after each upgrade. See [Step 2 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-2-check-where-salesforce-sends-requests). ## Assign permission sets `--on-behalf-of` takes Salesforce **usernames**, not email addresses. They often look alike, but in a sandbox the username usually ends with the sandbox name, for example `rep1@yourcompany.com.uat`. Find a user's username in **Setup**, **Users**. ```bash # Reps (and any integration or automation user that sends, reminds, or voids) sf org assign permset --name TurboSign_User \ --on-behalf-of rep1@yourcompany.com --on-behalf-of rep2@yourcompany.com --target-org myorg # Admins who build document setups sf org assign permset --name TurboSign_Admin --on-behalf-of admin@yourcompany.com --target-org myorg # The same in a sandbox called "uat" (log in first: sf org login web --instance-url https://test.salesforce.com --alias myuat) # The usernames carry the sandbox suffix sf org assign permset --name TurboSign_User --on-behalf-of rep1@yourcompany.com.uat --target-org myuat ``` ## Verify the connection This anonymous Apex makes one read-only call to TurboDocx with the stored credentials and prints the HTTP status. It changes nothing in either system. ```bash CHECK=$(mktemp -t check_turbodocx.XXXXXX.apex) cat > "$CHECK" Service Agreement false Source_SObject__cOpportunity Active__ctrue Deliverable_Template_Id__cREPLACE_WITH_YOUR_TEMPLATE_ID Document_Name_Template__cService Agreement ``` ```xml title="TurboSign_Variable_Mapping.Service_Agreement_ClientName.md-meta.xml" {ClientName} false Config__cService_Agreement Active__ctrue Placeholder__c{ClientName} Source_Field_Path__cAccount.Name Required__ctrue Mime_Type__ctext ``` ```xml title="TurboSign_Variable_Mapping.Service_Agreement_EffectiveDate.md-meta.xml" {EffectiveDate} false Config__cService_Agreement Active__ctrue Placeholder__c{EffectiveDate} Source_Field_Path__cCloseDate Required__ctrue Mime_Type__ctext ``` ```xml title="TurboSign_Recipient_Rule.Service_Agreement_Signer.md-meta.xml" Signer false Config__cService_Agreement Active__ctrue Role_Label__cSigner Name_Field_Path__cOwner.Name Email_Field_Path__cOwner.Email Signing_Order__c1 Required__ctrue ``` ```xml title="TurboSign_Field_Rule.Service_Agreement_sig.md-meta.xml" {sig} false Config__cService_Agreement Active__ctrue Anchor__c{sig} Field_Type__csignature Recipient_Role_Label__cSigner Placement__creplace Required__ctrue ``` ```xml title="TurboSign_Field_Rule.Service_Agreement_date.md-meta.xml" {date} false Config__cService_Agreement Active__ctrue Anchor__c{date} Field_Type__cdate Recipient_Role_Label__cSigner Placement__creplace Required__cfalse ``` Deploy them: ```bash sf project deploy start --source-dir force-app/main/default/customMetadata --target-org myorg ``` ### Start from the builder The fastest way to get correct records is to build a setup once in **TurboSign Setup**, then retrieve it. Two things about the builder matter when you retrieve: - **How it names records.** The setup record's name is the **Config API Name**, for example `Service_Agreement`. Every other record is named `_`, where the token or role is cleaned up like this: 1. Every character that isn't a letter or number becomes `_`, and runs of `_` (including underscores already in the token) collapse into one. 2. A `_` at the start or end is dropped. 3. If the result doesn't start with a letter, `X_` is put in front. So `{ClientName}` becomes `Service_Agreement_ClientName`, `{Client Name}` becomes `Service_Agreement_Client_Name`, `{sig}` becomes `Service_Agreement_sig`, and `{2nd_sig}` becomes `Service_Agreement_X_2nd_sig`. The full name is cut at 40 characters, and a `_` left at the end by the cut is dropped. So `{Primary Contact Mailing Address}` becomes `Service_Agreement_Primary_Contact_Mailin`. If two records in the same setup would get the same name, the first keeps it and the next ones get `_1`, `_2`, and so on (shortened further if needed to stay within 40 characters). The builder names data tokens first, then signers, then signing spots. So `{Client Name}` and `{Client_Name}` in the same setup become `Service_Agreement_Client_Name` and `Service_Agreement_Client_Name_1`. Signers you add in the builder get roles such as `signer_1`, so their records are named like `Service_Agreement_signer_1`. The numbers aren't always consecutive, so read the names from the org instead of guessing them. - **It never deletes.** When you remove a row and save, the builder sets that record's **Active** to false instead of deleting it. **Deactivate** does the same to the whole setup. Inactive records stay in the org and are ignored by sends. So list the exact names first, and keep only the active ones: ```bash for type in TurboSign_Variable_Mapping__mdt TurboSign_Recipient_Rule__mdt TurboSign_Field_Rule__mdt; do sf data query --target-org myorg --query \ "SELECT DeveloperName, Active__c FROM $type WHERE Config__r.DeveloperName = 'Service_Agreement'" done ``` Then retrieve the setup and each active record by its exact name. Don't use a wildcard such as `Service_Agreement_*`: it also matches another setup whose name starts the same way, such as `Service_Agreement_V2`, and it brings back the inactive rows. ```bash sf project retrieve start --target-org myorg \ --metadata "CustomMetadata:TurboSign_Config.Service_Agreement" \ --metadata "CustomMetadata:TurboSign_Variable_Mapping.Service_Agreement_ClientName" \ --metadata "CustomMetadata:TurboSign_Field_Rule.Service_Agreement_sig" # ...one --metadata line for every active record the query listed ``` If you deploy retrieved files to another org, leave out any file whose `Active__c` is `false`. ### Bring back a deactivated setup **Deactivate** switches off the setup and every record in it, and **TurboSign Setup** can't switch them back on. The reliable way to bring a setup back is to build it again in the builder. From the CLI, you can switch the records back on instead. Here the list and retrieve steps include the inactive rows, so this is the one case where you don't keep only the active ones. 1. Find the setup's exact name among the switched-off setups: ```bash sf data query --target-org myorg --query \ "SELECT DeveloperName, MasterLabel FROM TurboSign_Config__mdt WHERE Active__c = false" ``` 2. List every record of that setup, including the inactive ones, with the same loop as above (it doesn't filter on `Active__c`). 3. Retrieve the setup and the records you want back by their exact names. Don't use a wildcard. A record you removed in the builder before the setup was deactivated is inactive too, so leave out the ones you don't want back. ```bash sf project retrieve start --target-org myorg \ --metadata "CustomMetadata:TurboSign_Config.Service_Agreement" \ --metadata "CustomMetadata:TurboSign_Variable_Mapping.Service_Agreement_ClientName" \ --metadata "CustomMetadata:TurboSign_Field_Rule.Service_Agreement_sig" # ...one --metadata line for every record you want back ``` 4. In each retrieved file, change the `Active__c` value from `false` to `true`. 5. Deploy the files you changed: ```bash sf project deploy start --target-org myorg --source-dir force-app/main/default/customMetadata ``` If that folder holds files for other setups, deploy only this setup's files with one `--metadata` line each instead. The setup shows up again in **Edit an existing configuration** and in the document list reps see. ## Send from a Flow For automatic sends, for example when an Opportunity reaches a stage, add the **TurboSign: Send for Signature** action to a record-triggered Flow. Pass the record Id and the setup's API name. Flow sends run in the background, and failures are saved as **TurboSign Log** records (`TurboSign_Log__c`) instead of being shown to a user. Query them, or build a report on them, to monitor automated sends. Assign **TurboSign User** to the user the Flow runs as. --- # Send a document for signature from Salesforce This guide shows a rep how to send a document from an Opportunity, what each signer sees, and where the signed copy ends up. Your admin must finish [Set up TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/setup) first. ## Step 1: Start the send 1. Open the Opportunity. 2. Click **Send for Signature** at the top right. If you don't see it there, click the **▼** menu next to the other buttons and pick it from the list. ![Opportunity page with the Send for Signature button highlighted](/img/turbosign-salesforce/40-send-for-signature.png) 3. Under **Document**, pick the document you want to send. ![Document list with Service Agreement highlighted](/img/turbosign-salesforce/41-choose-document.png) 4. Click **Review**. TurboSign fills the document from the record. This can take a few seconds for long documents. ![Send dialog with the Review button highlighted](/img/turbosign-salesforce/42-review.png) ## Step 2: Check the values and signers The review screen opens on the **Details** tab. It lists every value going into the document, with where it came from. You can correct any value here. Your change only affects this send, not the record. Under **Signers**, check each person's name and email. You can correct these too. ![Review screen with the Signers section highlighted](/img/turbosign-salesforce/43-review-signers.png) ## Step 3: Check the document :::note Requires TurboSign for Salesforce 1.1.0 or later. ::: Click the **Document** tab to see the document as it will go out, with any corrections you made. TurboSign renders it the first time you open the tab, which can take a minute or two. Nothing is sent yet. ![Review screen with the Document tab highlighted](/img/turbosign-salesforce/44a-document-tab.png) The document opens right in the window. Scroll through it to check every page. Your browser's PDF controls, if it shows them, let you zoom or print. ![Document tab with the PDF preview of the filled-in Service Agreement highlighted](/img/turbosign-salesforce/44b-document-preview.png) - Signing spots of any kind (signature, initials, date, text, and so on) look blank in the preview. Each signer gets a box in that spot when the document goes out. - The render also checks the signing spots. **Details** lists every spot, and after the render it says **All signature spots found in the document**. If a spot is missing, it names the token and the signer instead. Ask your admin to fix the template or the setup before you send. - If you change a value after it renders, the tab shows **Your edits aren't in this preview yet**. Click **Update preview** to see the new version. **Send** always uses your latest values, even if you didn't update the preview. ![Document tab with the Update preview button highlighted](/img/turbosign-salesforce/44c-update-preview.png) - To view it full size, click **Open in a new tab**. - In the Salesforce mobile app, the tab asks you to open the record on a computer instead. You can still send from your phone. ## Step 4: Send Click **Send**. ![Review screen with the Send button highlighted](/img/turbosign-salesforce/44-send.png) You see **Sent for signature** only after TurboDocx has accepted the document and emailed the first signer. If something is wrong, you see the reason instead, for example `Required value {MonthlyRent} is empty on this record`. Fix the record (or ask your admin) and send again. If you see a message telling you to check before sending again, don't click **Send** yet. See [what to do](/docs/Integrations/turbosign-for-salesforce/troubleshooting#check-before-sending-again). ## What signers see Each signer gets an email with a link. Signers sign in order, so the second signer's email arrives after the first signer finishes. 1. If your organization requires identity verification, the signer clicks **Send Code** and gets a six-digit code by email or text message, depending on your settings. See [one-time passcodes](/docs/TurboSign/how-to-configure-one-time-passcode). ![Verify your identity screen with the Send Code button highlighted](/img/turbosign-salesforce/50-signer-send-code.png) 2. They type the code and click **Verify And Continue**. ![Code entry screen with Verify And Continue highlighted](/img/turbosign-salesforce/51-signer-enter-code.png) 3. They agree to sign electronically and click **Continue**. ![Consent screen with the Continue button highlighted](/img/turbosign-salesforce/52-signer-consent.png) 4. They click each **Signature** or **Initial** box. The first time, they type or draw their signature and click **Save**. The counter at the top shows how many spots are left. ![A Signature box highlighted in the document](/img/turbosign-salesforce/53-signer-signature-field.png) ![Signature dialog with the Save button highlighted](/img/turbosign-salesforce/54-signer-adopt-signature.png) 5. When every required spot is filled, they click **Submit Signature**. ![Top bar with the Submit Signature button highlighted](/img/turbosign-salesforce/55-signer-submit.png) ![Document Successfully Signed confirmation](/img/turbosign-salesforce/56-signer-done.png) ## Where the signed copy goes TurboDocx saves files only after every signer has signed and the document is sealed. Saving can take a minute after the last signature. A declined, voided, or expired document saves nothing to the record. When the document is complete, TurboDocx saves two files to the record: - the signed PDF, named like the document, and - the **Audit Trail** PDF, which records who signed, when, and from where. ![Notes & Attachments list with the signed PDF and audit trail highlighted](/img/turbosign-salesforce/45-signed-files.png) If your admin turned on **On Completion**, the record also moves forward, for example to **Closed Won**. ![Stage History showing the move to Closed Won](/img/turbosign-salesforce/46-stage-closed-won.png) ## Follow up on a sent document The **TurboSign Signatures** panel on the record lists the documents sent from it. If you don't see the panel, ask your admin to add it. In the panel you can: - click **Refresh** to pull the latest status and see how many people have signed, - click **Remind** to email signers who haven't signed yet, or - click **Void** to cancel the document. Type a **Reason**, then click **Void request**. Void can't be undone. :::tip React to declined or voided documents Nothing is saved to the record when a document is declined or voided. To act on those outcomes today, ask your admin to build a Flow on the **TurboSign Envelope** record's **Status** field. The status updates when a rep clicks **Refresh** in the **TurboSign Signatures** panel. ::: --- # Set up TurboSign for Salesforce This guide walks a Salesforce admin through the whole setup, from storing your TurboDocx credentials to saving your first document setup. It takes about 20 minutes. When you finish, your reps can send documents for signature from an Opportunity. :::tip Prefer the command line? Every credential and access step below can also be scripted. See [Scripted setup for admins](/docs/Integrations/turbosign-for-salesforce/scripted-setup). ::: ## Before you begin Have these ready: - **A TurboDocx account.** If you are signing up now, TurboDocx asks you to verify your email before it creates your workspace. Click the link in the verification email, then continue. - **Your TurboDocx API key and Organization ID.** In TurboDocx, open **Settings**, then **API keys**. That page shows the Organization ID. Create a key there and copy it right away: TurboDocx shows the key only once, when you create it. The Organization ID is also on **Features and integrations**. When you create the key, the **User** access level is enough for TurboSign for Salesforce. See [Getting your credentials](/docs/TurboSign/API-Signatures#getting-your-credentials). - **A TurboDocx template** with a text token wherever someone signs or dates, for example `{sig}` and `{date}`. - **System Administrator** access to your Salesforce org. - **A supported Salesforce edition**, such as Enterprise, Unlimited, Performance, or Developer Edition. See [Which Salesforce orgs can use it](/docs/Integrations/turbosign-for-salesforce#which-salesforce-orgs-can-use-it). - **The TurboSign for Salesforce package installed** in your org. See [Install TurboSign for Salesforce](/docs/Integrations/turbosign-for-salesforce/install). ## Step 1: Store your API key and Organization ID The package includes an External Credential called **TurboDocx API**. You add your key and Organization ID to it. Salesforce encrypts both, and nobody can view them again after you save. 1. Open **Setup** (the gear icon, top right, then **Setup**). 2. In the **Quick Find** box on the left, type `Named Credentials`, then click **Named Credentials**. ![Setup Quick Find with Named Credentials highlighted](/img/turbosign-salesforce/01-setup-named-credentials.png) 3. Click the **External Credentials** tab. ![Named Credentials page with the External Credentials tab highlighted](/img/turbosign-salesforce/02-external-credentials-tab.png) 4. Click **TurboDocx API**. ![External Credentials list with TurboDocx API highlighted](/img/turbosign-salesforce/03-turbodocx-api-credential.png) 5. In the **Principals** section, click the arrow at the end of the **TurboDocxPrincipal** row. ![Principals list with the row actions arrow highlighted](/img/turbosign-salesforce/04-principal-row-actions.png) 6. Click **Edit**. ![Row actions menu with Edit highlighted](/img/turbosign-salesforce/05-principal-edit.png) 7. Under **Authentication Parameters**, click **Add**. ![Edit Principal dialog with the Add button highlighted](/img/turbosign-salesforce/06-add-authentication-parameter.png) 8. In **Name**, type `ApiKey` exactly as shown (the capital letters matter). In **Value**, paste your TurboDocx API key. 9. Click **Add** again. In **Name**, type `OrgId`. In **Value**, paste your Organization ID. ![Edit Principal dialog with the OrgId parameter Name field highlighted](/img/turbosign-salesforce/07-parameter-name-apikey.png) 10. Click **Save**. ![Edit Principal dialog with the Save button highlighted](/img/turbosign-salesforce/08-principal-save.png) After you save, the **TurboDocxPrincipal** row shows **2** under **Authentication Parameters**. An **Authentication Status** of **Unknown** is normal for this kind of credential. Salesforce doesn't test it until the first request. The template list in [Step 5](#step-5-build-a-document-setup) is your first real check. ![TurboDocxPrincipal row with Authentication Parameters 2 and Authentication Status Unknown highlighted](/img/turbosign-salesforce/09-principal-saved.png) :::caution Use these exact names The package looks for parameters named `ApiKey` and `OrgId`. If either is missing or spelled differently, every send fails with **Field TurboDocx_API.ApiKey does not exist** (or `OrgId`). ::: To change the key later, edit the `ApiKey` value the same way. Nothing else needs to change. Change only the **Value**: if you change a parameter's **Name** in **Edit Principal**, Salesforce clears its value, so you have to paste it again. ## Step 2: Check where Salesforce sends requests The **TurboDocx API** Named Credential holds the TurboDocx address. It is set to `https://api.turbodocx.com` when you install, so usually you only need to look at it. 1. On the same External Credential page, scroll to **Related Named Credentials**. 2. Read the **URL** in the **TurboDocx API** row. It should be `https://api.turbodocx.com`, or the address your TurboDocx team gave you. If it is, go on to Step 3. ![Related Named Credentials list with the TurboDocx API URL highlighted](/img/turbosign-salesforce/09b-related-named-credential-url.png) To change the URL: 1. In the **Related Named Credentials** row, click **TurboDocx API**. 2. Click **Edit**. ![Named Credential detail page with the Edit button highlighted](/img/turbosign-salesforce/10-named-credential-edit.png) 3. Change **URL**. ![Edit Named Credential dialog with the URL field highlighted](/img/turbosign-salesforce/11-named-credential-url.png) 4. Click **Save**. :::caution Re-check the URL after every upgrade Installing a newer version of the package, or deploying it again from source, puts the packaged URL back on **TurboDocx API**. If you changed it, check it again after each upgrade or deploy. ::: ## Step 3: Give people access The package has two permission sets. Both include access to the TurboDocx credential. | Permission set | Assign it to | |---|---| | **TurboSign Admin** | Admins who build document setups | | **TurboSign User** | Reps who send documents | 1. In **Setup**, type `Permission Sets` in **Quick Find** and click **Permission Sets**. 2. The list can be long. Click the letter **T** above the list. 3. Click **TurboSign User**. 4. Click **Manage Assignments**. ![TurboSign User permission set with Manage Assignments highlighted](/img/turbosign-salesforce/12-manage-assignments.png) 5. Click **Add Assignment**. ![Current Assignments page with Add Assignment highlighted](/img/turbosign-salesforce/13-add-assignment.png) 6. If the list of users is empty, open **Select a List View** and pick **Recently Viewed**. A new org may have only that view. If your org has **All Users**, you can pick it instead. 7. Select your reps, then click **Next**. On the next page, leave **No expiration date** selected and click **Assign**. 8. Repeat for **TurboSign Admin** with your admins. :::note Automations need access too Requests run as whoever triggers them. If a Flow, a data load, or an integration user sends, reminds, or voids envelopes, assign **TurboSign User** to that user as well. ::: ## Step 4: Connect Salesforce in TurboDocx TurboDocx saves the signed PDF back to Salesforce through the Salesforce connection in your TurboDocx settings. :::note Which TurboDocx plans include it The Salesforce integration is included on the TurboDocx **Pro** and **Enterprise** plans. Sending documents for signature from Salesforce works without it. You need it to save the signed files and the signing status back to Salesforce. ::: If you don't have that connection yet, follow [Salesforce Integration, Steps 1 to 3](/docs/Integrations/SalesForce) to create the External Client App in Salesforce and connect it in TurboDocx. If you already use the TurboDocx Salesforce integration, check that it meets everything below. - **Connect the same Salesforce org the package is installed in.** The package sends its Salesforce org ID with each document. When the document is signed, TurboDocx saves the files through the connection to that same org. Only active connections of active TurboDocx users count. If no connection matches the org, nothing is saved, and TurboDocx records the failure. Saving signed files back needs TurboSign for Salesforce 1.1.0 or later, which sends the org ID. Earlier versions still send and sign, but the signed files are not saved back. - **Connect with the right Salesforce user.** That user must be able to create files and edit the records you send from, including the field you pick under **On Completion**. A dedicated integration user works best, so the connection doesn't break when a person leaves or changes their password. - **You can connect more than one Salesforce org.** For example, connect both a sandbox and production. Each send uses the connection to the org it was sent from. - **Check the app's scopes.** The connection needs the **api** scope, the **refresh_token** (offline access) scope, and the **openid** scope, because TurboDocx checks which Salesforce org each connection belongs to. See [Configure API Settings](/docs/Integrations/SalesForce#configure-api-settings). If you added **openid** to an existing app, reconnect Salesforce in TurboDocx. - **Leave the refresh token policy as Salesforce sets it.** New External Client Apps expire a refresh token that isn't used for 30 days, and the **Refresh token is valid until revoked** option no longer exists. TurboDocx keeps the connection active automatically. See [Edit Policies](/docs/Integrations/SalesForce#edit-policies). - **If the connection stops working**, for example because a Salesforce admin revokes it, TurboDocx shows a **Reconnect Salesforce** message in the Salesforce settings dialog and emails your organization's admins. To see the message, open **Settings > Features and integrations** and click **Configure SalesForce** on the Salesforce card. - **Reconnecting is safe.** Your current connection keeps working until the new one succeeds. :::note Sandboxes Saving signed files back to a sandbox works. Connect that sandbox in TurboDocx: in the Salesforce settings dialog, choose **Sandbox** in the **Environment** list, so the connection signs in at `test.salesforce.com`. If your sandbox uses a My Domain login, click **Use Custom Domain** on the Salesforce login page and enter it. ::: ## Step 5: Build a document setup A **document setup** tells TurboSign which template to use, which Salesforce fields fill it, who signs, and where. The steps below build a **Service Agreement** sent from an Opportunity. ### Start a new setup 1. Open the App Launcher (the nine dots, top left), search for **TurboSign Setup**, and open it. The builder opens with a blank form. If the form already holds another setup, click **New configuration** to clear it. ![TurboSign Configuration Builder with New configuration highlighted](/img/turbosign-salesforce/21-new-configuration.png) 2. Type the **Label** first, for example `Service Agreement`. The **Label** box is to the right of **Config API Name**. Reps pick setups by this label. 3. Check **Config API Name**, the box on the left. It fills in from the Label, for example `Service_Agreement`. You can change it before you save, using letters, numbers, and underscores only. ![Config API Name field highlighted](/img/turbosign-salesforce/22-config-name.png) 4. Leave **Source Object** as **Opportunity**. 5. Open **TurboDocx Template** and pick your template. The builder lists every token in the template under **Template Tokens**, including signing tokens such as `{sig}`. ![Template list with a template highlighted](/img/turbosign-salesforce/23-pick-template.png) ### Name the document Under **Document Name**, build the name each document gets, for example `Service Agreement - Acme Corp - October 9, 2026`: 1. Click **Clear** to remove the template name. 2. Click **Text**, click the new text box, and type `Service Agreement - ` (with the spaces). 3. Click **Insert field** and pick **Account Account Name (Name)**. The list is long, and this entry is far down. 4. Click **Text**, click the new text box, and type ` - `. 5. Click **Date**. The **Preview** line shows the result. Until you pick a record under [Test with a record](#test-and-save), it shows placeholders, for example `Service Agreement - [Account.Name] - (today's date)`. The **Separator** menu adds a single character with no spaces, so use **Text** when you want spacing. **Date Format** is optional. It sets how dates look in the document and in its name, using a pattern such as `MMMM d, yyyy` (for `October 9, 2026`). Leave it blank to use the default: dates from the record follow the sending user's Salesforce locale, and the date in the name looks like `October 9, 2026`. ### Fill the data tokens 1. Click **Suggest fields**. The builder guesses a Salesforce field for each data token. ![Template Tokens list with Suggest fields highlighted](/img/turbosign-salesforce/24-suggest-fields.png) 2. Check every suggestion. Suggestions are a starting point, and they are often wrong. To change one, pick the relationship in the first box (for example **Account**) and the field in the second box (for example **Account Name**). ![Source Field list with a field highlighted](/img/turbosign-salesforce/25-pick-source-field.png) 3. For a value that never comes from a field, leave the field empty and type it in **Default**. 4. Use **Required** to decide what happens when a value is empty: - **Checked:** the send stops and tells the rep which value is missing. Use this for values the document cannot go out without, such as the client name. - **Unchecked:** the spot is left blank in the document. Use this for values that only apply sometimes. ![A token row with the Required checkbox highlighted](/img/turbosign-salesforce/26-required-checkbox.png) Leave the signing tokens, such as `{sig}` and `{date}`, for [Place the signing spots](#place-the-signing-spots). ### Add the signers Each row under **Recipients** is one signer. They sign in the order shown. 1. For the first signer, pick the field that holds their **Signer name** and **Signer email**, for example the Opportunity owner's name and email. ![Signer name list with a name field highlighted](/img/turbosign-salesforce/27-signer-name-field.png) 2. To add someone who is not on the record, such as a person on your team who countersigns, click **Add recipient**. ![Add recipient button highlighted](/img/turbosign-salesforce/28-add-recipient.png) 3. Check **Fixed value** on that row and type their name and email. ![Recipient row with the Fixed value checkbox highlighted](/img/turbosign-salesforce/29-fixed-signer.png) 4. To change the order, use the up and down arrows next to the number, or drag the row. ![Move signer up button highlighted](/img/turbosign-salesforce/30-reorder-signers.png) 5. Uncheck **Required** on a signer who only signs sometimes. When their email is empty on the record, they are skipped. ### Place the signing spots The builder already lists every token in the template, including signing tokens such as `{sig}` and `{date}`. It adds each one as a **Data value** row with **Required** checked. Turn each signing token into a signing spot: 1. On the signing token's row, open **Kind**. ![Kind menu open on an existing token row](/img/turbosign-salesforce/31-add-token.png) 2. Pick what goes there: **Signature**, **Initial**, **Date**, **Text**, and so on. ![Kind list with Signature highlighted](/img/turbosign-salesforce/32-token-kind-signature.png) 3. In **Recipient**, pick who fills it in. ![Recipient list with the first signer highlighted](/img/turbosign-salesforce/33-token-recipient.png) 4. Set **Required**. **Signature** and **Initial** spots are always required. Other kinds, such as **Date** or **Text**, can be optional. 5. Repeat for every signing token. If a signing token isn't in the list, click **Add token**, type it exactly as it appears in your template, for example `{sig}`, and then set its **Kind** and **Recipient**. Use **Add token** only for a token the builder didn't find. :::tip Signature lines that don't apply If your template has signature lines for roles you don't use (a guarantor, for example), leave those tokens as **Data value** with nothing in them and uncheck **Required**. The line is left blank in the document instead of showing the raw token. ::: When every signing token is set, click **Check anchors against template**. Every token should say **found in the document**. Fix any that don't before you save. ![Anchor check results with a found-in-the-document line highlighted](/img/turbosign-salesforce/34-anchor-check-result.png) ### Move the record forward when everyone signs (optional) 1. Under **On Completion**, open **Set field** and pick the field to change, for example **Stage**. ![Set field list with Stage highlighted](/img/turbosign-salesforce/35-on-completion-field.png) 2. Pick the value in **To value**, for example **Closed Won**. ![To value list with Closed Won highlighted](/img/turbosign-salesforce/36-on-completion-value.png) :::note Closing an Opportunity can change its Close Date When an Opportunity moves to a Closed stage, such as **Closed Won**, Salesforce can also set its **Close Date** to the day it closed. This is standard Salesforce behavior, not something TurboSign changes. ::: ### Test and save 1. Under **Test with a record**, search for a real record and pick it. 2. Click **Preview values** to see what every token resolves to on that record. ![Preview values button highlighted](/img/turbosign-salesforce/37-test-with-record.png) ![Preview table with resolved values](/img/turbosign-salesforce/38-preview-values.png) 3. Click **Save Configuration**. A message may confirm that the setup is live. Either way, the setup now appears in the **Edit an existing configuration** list, which is how you can confirm it saved. ![Save Configuration button highlighted](/img/turbosign-salesforce/39-save-configuration.png) ### Change an existing setup 1. Open **TurboSign Setup**. 2. Pick the setup in **Edit an existing configuration**. The form loads it. 3. Make your changes. 4. Click **Save Configuration**. When you remove a token or a signer and save, the builder switches that row off instead of deleting it. Sends ignore it from then on. To start a new setup from an existing one, load it and click **Duplicate**. Type a **New API name for the copy**, then click **Create copy**. To stop reps from using a setup, load it and click **Deactivate**, then click **Deactivate** again to confirm. The setup disappears from the document list reps see and from **Edit an existing configuration**. Its records stay in your org, switched off, and **TurboSign Setup** can't switch them back on. If you may need the setup again, click **Duplicate** before you deactivate it. The copy is active, so reps see it in the document list too. This works best when you're replacing a setup with a changed copy. To bring back a setup you already deactivated, the reliable way is to build it again in **TurboSign Setup**. You can reuse its API name: saving overwrites the switched-off records, and rows you don't add again stay off. Admins who use the Salesforce CLI can instead switch its records back on, as described in [Bring back a deactivated setup](/docs/Integrations/turbosign-for-salesforce/scripted-setup#bring-back-a-deactivated-setup). ## Step 6: Put the Send button and status panel on the page ### Add the Send for Signature button Opportunity record pages show their buttons in one of two ways. To find out which one yours uses: 1. Open any Opportunity. 2. Click the gear icon, then **Edit Page**. 3. Click the **Highlights Panel** at the top of the page. If the properties pane on the right lists actions with an **Add Action** button, the page uses **Dynamic Actions**. If it offers **Upgrade Now** instead, the page still takes its buttons from the page layout, so use **page layout actions**. ![Lightning App Builder with the Highlights Panel selected and the Upgrade Now button highlighted](/img/turbosign-salesforce/48-highlights-panel-actions.png) **If the page uses Dynamic Actions**, stay in the Lightning App Builder: 1. With the **Highlights Panel** selected, click **Add Action**. 2. Search for `Send for Signature` and select it. 3. Click **Done**. 4. Click **Save**. 5. If Salesforce asks, activate the page as described in [Activate the page](#activate-the-page). ![Add Action dialog with Send for Signature highlighted](/img/turbosign-salesforce/49-add-action-send-for-signature.png) **If the page uses page layout actions:** 1. In **Setup**, open **Object Manager**. 2. Click **Opportunity**. 3. Click **Page Layouts**. 4. Click the layout your reps use. To find out which one that is, click **Page Layout Assignment** on the same page. It lists the layout each profile uses. In a new org, the **System Administrator** profile uses **Opportunity Layout**. 5. In the palette at the top, click **Mobile & Lightning Actions**. 6. If the **Salesforce Mobile and Lightning Experience Actions** section says it uses predefined actions, click the **override the predefined actions** link inside it. 7. Drag **Send for Signature** into the **Salesforce Mobile and Lightning Experience Actions** section, and drop it at the **start** of the list. The record page shows only the first few actions as buttons and puts the rest in a **▼** menu, so an action dropped at the end is easy to miss. 8. Click **Save**. Repeat for every page or layout your reps use. ### Add the TurboSign Signatures panel (optional) The panel shows each sent document's status on the record and lets reps refresh, remind, or void. Sending works without it. 1. Open any Opportunity. 2. Click the gear icon, then **Edit Page**. ![Gear menu with Edit Page highlighted](/img/turbosign-salesforce/47-edit-page.png) 3. Drag the **TurboSign Signatures** component from the **Components** list onto the page. 4. Click **Save**. 5. If Salesforce asks, activate the page as described below. ### Activate the page The first time you save a Lightning page, Salesforce asks you to activate it. Until you do, reps don't see your changes. 1. Click **Activate**. 2. Click **Assign as Org Default**. 3. Choose **Desktop and phone**. 4. Click **Next**, then **Save**. If reps use the standard **Sales** app and the button or panel still doesn't show, activate the page again and also assign it as the **App Default** for that app. You're done. Next, [send your first document](/docs/Integrations/turbosign-for-salesforce/send-and-sign). --- # Troubleshooting TurboSign for Salesforce Find the message you see, then follow the fix. ## "Field TurboDocx_API.ApiKey does not exist" (or OrgId) The External Credential is missing the `ApiKey` or `OrgId` parameter, or the name is spelled differently. The names are case sensitive. **Fix:** In **Setup**, go to **Named Credentials**, then **External Credentials**, then **TurboDocx API**, and edit **TurboDocxPrincipal**. Add the missing parameter with the exact name. See [Step 1 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-1-store-your-api-key-and-organization-id). ## "401" or "Could not load templates from TurboDocx: 401" TurboDocx rejected the key or the Organization ID. The key may have been revoked, or the Organization ID may belong to a different TurboDocx organization. **Fix:** Create a new API key in TurboDocx, check the Organization ID, and update both values on the principal. Then open **TurboSign Setup**: if the template list loads, the connection works. ## "We couldn't access the credential(s)" The user who clicked (or the user a Flow runs as) doesn't have access to the TurboDocx credential. **Fix:** Assign the **TurboSign User** permission set (or **TurboSign Admin** for admins) to that user. ## "Required value `{SomeToken}` is empty on this record" A data token marked **Required** has no value on this record and no default. In version 1.1.0 the message leaves out the token's name and reads **Required value is empty on this record**. The full message, with the token, is in the newest **TurboSign Log** record, which your admin can look up. **Fix:** Fill in the field on the record and send again. If the value only applies sometimes, ask your admin to uncheck **Required** for that token in **TurboSign Setup**, so the spot is left blank instead. ## "Signer 2 has no email on this record" A required signer's email field is empty. **Fix:** Add the email to the record, or type it in the **Signers** section of the review screen before you click **Send**. If that signer only signs sometimes, ask your admin to uncheck **Required** on that signer. ## "Check before sending again" The connection dropped after TurboDocx may already have sent the document. **Fix:** Before you click **Send** again, click **Refresh** in the **TurboSign Signatures** panel, or ask the signer whether they got an email. If the document went out, don't send it again, so the signers don't get two copies. If you don't see the panel, ask your admin to add it. In version 1.1.0, if Salesforce can't save the send's record after TurboDocx accepted the document (for example, because your org is over its storage limit), you may see **Send failed** instead, even though the signers got the email. If a send fails right after a long wait, ask the signer before you send again, and ask your admin to check your org's storage. ## "You have approached the limit of Deliverables for your plan" Each preview creates a short-lived document that counts toward your TurboDocx plan's deliverable limit while it renders. TurboSign removes it right after. Sending also creates a document. Once the limit is reached, TurboDocx refuses both previews and sends. **Fix:** Wait for your next billing period, or upgrade your TurboDocx plan. Then open the **Document** tab or click **Send** again. ## A signing token shows as plain text in the signed document A token in the template, such as `{guarantor_sig}`, isn't listed in the document setup. **Fix:** In **TurboSign Setup**, add the token. Make it a signing spot for the right signer, or a **Data value** with nothing in it and **Required** unchecked to leave the line blank. Click **Check anchors against template** before saving. ## The signed PDF never appears on the record TurboDocx saves the signed files only after every signer has signed, and it can take a minute after the last signature. A declined, voided, or expired document saves nothing. TurboDocx saves the files through the Salesforce connection to the org you sent from. The package sends its org ID with each document, and TurboDocx uses the connection whose Salesforce org matches it. Only active connections of active TurboDocx users count. TurboDocx retries a save after a temporary Salesforce error or an expired session, and a file that was already saved isn't saved twice. The outcome of each save is recorded in TurboDocx. The signing still completes, but the files can't be saved when: - your TurboDocx plan doesn't include the Salesforce integration (it is on the Pro and Enterprise plans), - no connection matches the org you sent from, for example because TurboDocx is connected to production and you sent from a sandbox, - the package is older than 1.1.0, which does not send the org ID (update the package), - the External Client App is missing the **openid** scope, so TurboDocx can't tell which org the connection belongs to, - the connection was revoked, for example by a Salesforce admin or after a password reset, or the TurboDocx user who made it was deactivated (TurboDocx then emails your organization's admins and shows a **Reconnect Salesforce** message when you open **Configure SalesForce** on the Salesforce card in **Settings**, then **Features and integrations**), - the connected Salesforce user can't see or edit the record, or - the record was deleted. **Fix:** In TurboDocx, open **Settings**, then **Features and integrations**, and connect Salesforce with a user who can edit the record, in the same org the package is installed in. To save to a sandbox, connect that sandbox. Your current connection keeps working until the new one succeeds. See [Step 4 of the setup guide](/docs/Integrations/turbosign-for-salesforce/setup#step-4-connect-salesforce-in-turbodocx). If the files arrived but the stage didn't change, check that the **On Completion** value is allowed for the record's record type, and that no validation rule blocks the change. When a save fails, TurboDocx tries it again every hour for about a day. So if you reconnect Salesforce (or fix the cause) within that time, the files arrive on their own. After that, contact TurboDocx support to resend the signed files. ## The Send for Signature button is missing The button hasn't been added to the Opportunity page your reps see. Where it goes depends on the page: - **Pages that use Dynamic Actions:** add **Send for Signature** to the **Highlights Panel** actions in the Lightning App Builder. Adding it to the page layout does nothing on these pages. - **Pages that use page layout actions:** add it to the **Salesforce Mobile and Lightning Experience Actions** section of every page layout your reps use. Also check that the rep has the **TurboSign User** permission set. **Fix:** Follow [Add the Send for Signature button](/docs/Integrations/turbosign-for-salesforce/setup#add-the-send-for-signature-button). ## The TurboSign Signatures panel is missing The panel is optional, so it only appears after an admin adds it to the Opportunity page. **Fix:** Ask your admin to follow [Add the TurboSign Signatures panel](/docs/Integrations/turbosign-for-salesforce/setup#add-the-turbosign-signatures-panel-optional). ## Sends from a Flow don't go out Flow sends run in the background, so errors don't appear on screen. They're saved as **TurboSign Log** records. **Fix:** Look at the newest TurboSign Log records (query `TurboSign_Log__c`, or build a report) for the error, and check that the Flow's running user has **TurboSign User**. --- ## How to Set Up Wrike AI Variables # How to Setup AI Variable Configuration AI variables let you define a prompt that TurboDocx uses to generate content automatically when a document is created from a Wrike automation. Instead of pulling a static value from a Wrike field, AI interprets the project context and writes the content for you. ## Prerequisites - A template uploaded to TurboDocx with at least one variable - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) ## Configure an AI Variable ### Step 1: Select Your Template Navigate to your templates and select the template you want to configure with an AI variable. ![Select Template](/img/wrike-integration/FieldMap01-SelectTemplate.jpeg) ### Step 2: Open Template Preferences Click **Edit Template & Preferences** to open the template configuration view. ![Edit Template and Preferences](/img/wrike-integration/FieldMap02-EditTemplatePreferences.jpeg) ### Step 3: Open Variable Settings Click the settings icon on the variable you want to configure with AI. ![Open Variable Settings](/img/wrike-integration/FieldMap03-OpenVariableSettings.jpeg) ### Step 4: Enable AI Click **"Use AI for variable"** to switch the variable to AI mode. This tells TurboDocx to generate content using a prompt instead of expecting manual input or a static field value. ![Use AI for variable](/img/wrike-integration/AIVar01-UseAIForVariable.jpeg) ### Step 5: Type in a Prompt Enter the prompt that AI will use to generate content for this variable. Write a clear, specific instruction describing what the AI should produce — for example, *"Write a professional project summary based on the task details and deliverables."* ![Type in a Prompt](/img/wrike-integration/AIVar02-TypePrompt.jpeg) :::tip The more specific your prompt, the better the output. Include details like tone, length, and what information to focus on. ::: ### Step 6: Save Changes Click **"Save Changes"** to persist the AI variable configuration. ![Save Changes](/img/wrike-integration/AIVar03-SaveChanges.jpeg) ## How It Works at Generation Time When a Wrike automation triggers document generation, TurboDocx sends the AI prompt along with the relevant Wrike project data to the AI model. The AI generates content based on your prompt and the project context, and the result is placed into the template variable. This is different from [static field mapping](./field-mapping.md), which inserts exact Wrike field values with no interpretation. AI variables are ideal for generating summaries, descriptions, recommendations, and other narrative content that benefits from intelligent synthesis of project data. :::tip You can mix AI variables and static field mappings in the same template. Use static mappings for structured data (dates, amounts, codes) and AI variables for narrative content (summaries, descriptions, recommendations). ::: --- ## How to Convert Wrike Documents to PDF # How to Convert Documents to PDF (Task, Project & Folder Triggers) The **Convert to PDF** automation action turns an attachment into a PDF and attaches the result back into Wrike when a status changes. It downloads the first attachment, converts it to PDF, uploads the PDF back, and leaves a comment with the conversion details. This action now fires not only when a **task** changes status, but also when a **project or folder** changes status, so you can drive conversions from any level of your Wrike workspace. ## Prerequisites - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) - A Wrike workflow status you want to use as the trigger, and the folder or project you want to monitor :::tip Start with the base setup This guide picks up at the automation's action step. If you have not connected Wrike or chosen a trigger status and folder yet, follow [Setting Up a Wrike Automation](./setting-up-automation.md) first. ::: ## Open the Wrike automation builder ### Step 1: Open the Wrike configuration On your template's **Details** page, click the **"..." (three-dot) button** in the top toolbar, then click **Configure Wrike** in the menu that opens. ![Template Details page with the Configure Wrike menu item highlighted](/img/convert_to_pdf/01_configure_wrike_menu.png) ### Step 2: Start a new automation The **Wrike Automations** window opens and lists your existing automations. Click the **Create Automation** button near the top right. ![Wrike Automations window with the Create Automation button highlighted](/img/convert_to_pdf/02_create_automation.png) ### Step 3: Choose a trigger status On the **When to trigger** step, click the **Trigger Status** field and pick the workflow status that should start the automation. Then choose the automation scope (Folder Level or Account Level) and click **Next**. ![When to trigger step with the Trigger Status field highlighted](/img/convert_to_pdf/03_trigger_status.png) :::note Tasks, projects, and folders all trigger the conversion The automation runs whenever the chosen status is applied. The conversion now also runs when a **project or folder** (not just a task) reaches the trigger status. The scope option above controls *where* the automation watches; the status change is what fires it, regardless of whether a task, project, or folder changes status. ::: ## Configure the PDF conversion ### Step 4: Select the Convert to PDF action On the **What to do** step, click the **Convert to PDF** card. Its description reads "Convert first attachment to PDF." Then click **Next**. ![Action selection step with the Convert to PDF card highlighted](/img/convert_to_pdf/04_select_convert_to_pdf.png) ### Step 5: Review how the conversion works The **Configure PDF Conversion** step opens with a **How it works** summary at the top: 1. Downloads the first attachment from the triggering item 2. Converts it to PDF format 3. Uploads the PDF back into Wrike 4. Leaves a comment with the conversion details ![Configure PDF Conversion step with the Attach Document To dropdown highlighted](/img/convert_to_pdf/05_configure_pdf.png) ### Step 6: Choose where the PDF is attached Under **Attachment Destination**, click the **Attach Document To** dropdown and choose where the converted PDF lands: - **Wrike Task** — attach to the task that triggered the automation. - **Parent Folder** — attach to the folder that contains the task. ![Attach Document To dropdown open showing Wrike Task and Parent Folder options](/img/convert_to_pdf/06_destination_options.png) Pick an option, then click **Next**. ### Step 7: Name and create the automation On the **Review & activate** step, the **Automation Blueprint** summarizes the trigger, scope, and action. Type a name for the automation in the **Name Your Automation** field, then click **Create Automation** to activate it. ![Review step with the Create Automation button highlighted](/img/convert_to_pdf/07_review_create.png) ## In-place versioning on re-runs If the automation runs again on the same task, project, or folder, the converted PDF is **updated in place** rather than added as a duplicate. The existing PDF attachment is replaced with the new version, so the item keeps a single, current PDF instead of accumulating copies each time the status changes. :::tip Re-trigger to refresh Update the source attachment and move the item back through the trigger status to regenerate the PDF. The previous PDF is replaced automatically. (If the previous PDF was deleted in Wrike, a fresh one is created instead.) ::: ## Related - [Setting Up a Wrike Automation](./setting-up-automation.md) - [How to Set Up Document Packages (Combined PDF)](./document-packages.md) - [Troubleshooting and FAQ](./troubleshooting.md) --- ## How to Set Up Wrike Document Generation # How to Setup Document Generation Automation After [setting up your Wrike automation](./setting-up-automation.md) with a trigger status and folder, follow these steps to configure it to automatically generate documents from a template. ## Configure Document Generation ### Step 1: Select Generate Document Click the **Generate Document** action card in the automation step list to select it as the action to perform when the trigger fires. ![Select Generate Document](/img/wrike-integration/Step10-SelectGenerateDocument.png) ### Step 2: Advance to Template Selection Click the **Next** button at the bottom-right of the Create Automation dialog to proceed to template selection. ![Advance to Next Step](/img/wrike-integration/Step11-AdvanceNextStep.png) ### Step 3: Browse Document Templates Click the **Browse** button in the Document Template section to open the template selection interface. ![Browse Document Templates](/img/wrike-integration/Step12-BrowseTemplates.png) ### Step 4: Choose a Template Click on the template card you want to use for automated document generation. ![Choose a Template](/img/wrike-integration/Step13-ChooseTemplate.png) ### Step 5: Confirm the Selected Template Click the **Select Template** button at the bottom-right corner of the dialog to finalize your template selection. ![Confirm Selected Template](/img/wrike-integration/Step14-ConfirmTemplate.png) ### Step 6: Proceed to Final Configuration Click the **Next** button to advance to the final configuration step. ![Proceed to Final Configuration](/img/wrike-integration/Step15-ProceedNextStep.png) ### Step 7: Name Your Automation Click inside the **NAME YOUR AUTOMATION** text input field and enter a descriptive name for your automation (e.g., "Project Delivery - Generate Proposal"). ![Name Your Automation](/img/wrike-integration/Step16-NameAutomation.png) ### Step 8: Create and Activate the Automation Click the **Create Automation** button to save and activate your automation workflow. ![Create and Activate Automation](/img/wrike-integration/Step17-CreateAutomation.png) ## Test the Integration Now that your automation is active, test it end-to-end: 1. **Open the Wrike folder** you configured in the automation 2. **Create a new task** with some project details and a description (the AI will use this data to populate your template) 3. **Change the task status** to the trigger status you configured (e.g., "Generate Document") 4. **Wait 30 seconds to 2 minutes** depending on document complexity 5. **Check the task attachments** — your generated document should appear! :::tip Testing Tips - Use descriptive task titles and descriptions — the AI uses this data to generate better documents - Fill in custom fields with relevant project information for richer output - Start with a simple template to verify the connection works before using complex ones ::: ## What's Next? - **[How to Setup Static Field Mapping](./field-mapping.md)** to template variables for static data like revenue and dates - **[How to Add Signature Anchors](./signature-anchors.md)** to your template for digital signing - **Create multiple automations** for different project types, templates, or trigger statuses - If something isn't working, see [Troubleshooting and FAQ](./troubleshooting.md) --- ## How to Combine Documents in Wrike # How to Set Up Document Packages (Combined PDF) A **Document Package** is a single combined PDF made from every attachment on a Wrike task or project. When a task or project changes to your trigger status, TurboDocx gathers its attachments, converts each one to PDF, merges them into one file (oldest first), and attaches the finished Document Package back into Wrike. This is the **Create Document Package** automation action. Use it to roll loose deliverables, photos, and supporting documents into one tidy hand-off file. ## Prerequisites - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) - A Wrike workflow status you want to use as the trigger, and the folder or project you want to monitor :::tip Start with the base setup This guide picks up at the automation's action step. If you have not connected Wrike or chosen a trigger status and folder yet, follow [Setting Up a Wrike Automation](./setting-up-automation.md) first. ::: ## Open the Wrike automation builder ### Step 1: Open the Wrike configuration On your template's **Details** page, click the **"..." (three-dot) button** in the top toolbar, then click **Configure Wrike** in the menu that opens. ![Template Details page with the Configure Wrike menu item highlighted](/img/document_packages/01_configure_wrike_menu.png) ### Step 2: Start a new automation The **Wrike Automations** window opens and lists your existing automations. Click the **Create Automation** button near the top right. ![Wrike Automations window with the Create Automation button highlighted](/img/document_packages/02_create_automation.png) ### Step 3: Choose a trigger status On the **When to trigger** step, click the **Trigger Status** field and pick the workflow status that should start the automation. Then choose the automation scope (Folder Level or Account Level) and click **Next**. ![When to trigger step with the Trigger Status field highlighted](/img/document_packages/03_trigger_status.png) :::note Works on tasks and projects The Document Package is built from whatever item hits the trigger status. When a **task** triggers, its attachments are merged; when a **project** triggers, the project's attachments are merged. ::: ## Configure the Document Package ### Step 4: Select the Create Document Package action On the **What to do** step, click the **Create Document Package** card. Its description reads "Combine all attachments into one merged PDF." Then click **Next**. ![Action selection step with the Create Document Package card highlighted](/img/document_packages/04_select_create_document_package.png) ### Step 5: Choose which file types to include The **Configure Document Package** step opens with a **How it works** summary at the top. Under **File Types to Include**, tick the attachment types you want merged into the package: - **PDF** - **Word (DOCX)** - **PowerPoint (PPTX)** - **Images (JPG/PNG)** Leave a type unchecked to skip those attachments. At least one type must be selected. ![File Types to Include section with all four checkboxes highlighted](/img/document_packages/05_file_types.png) ### Step 6: Name the output file (optional) In the **Output File Name** field, type a name for the combined PDF. Leave it blank to use the default name, **Document Package.pdf**. ![Output File Name field highlighted](/img/document_packages/06_output_file_name.png) ### Step 7: Choose where the Document Package is attached Under **Attachment Destination**, click the **Attach Document Package To** dropdown and choose where the finished file lands: ![Attach Document Package To dropdown highlighted](/img/document_packages/07_attach_destination.png) - **Wrike Task** — attach to the task that triggered the automation. - **Parent Folder** — attach to the folder that contains the task. ![Attach Document Package To dropdown open showing Wrike Task and Parent Folder options](/img/document_packages/08_destination_options.png) Pick an option, then click **Next**. ### Step 8: Name and create the automation On the **Review & activate** step, the **Automation Blueprint** summarizes the trigger, scope, action, file types, output file name, and attachment destination. Type a name for the automation in the **Name Your Automation** field, then click **Create Automation** to activate it. ![Review step with the Create Automation button highlighted](/img/document_packages/09_review_create.png) ## How it works at run time When a task or project changes to your trigger status, TurboDocx: 1. Gathers the attachments on the triggering task or project 2. Converts each included file to PDF and merges them oldest-first 3. Attaches the Document Package back into Wrike :::tip Re-running replaces the package in place If the automation runs again on the same item, the new Document Package **replaces the previous one in place** instead of creating a duplicate. Add or update attachments and re-trigger to refresh the combined file. ::: ## Related - [Setting Up a Wrike Automation](./setting-up-automation.md) - [How to Convert Documents to PDF (Task, Project & Folder Triggers)](./convert-to-pdf.md) - [Troubleshooting and FAQ](./troubleshooting.md) --- ## How to Set Up Wrike Field Mapping # How to Setup Static Field Mapping Static field mapping lets you map Wrike custom fields directly to TurboDocx template variables. When a document is generated, the exact value from the Wrike field is placed into the template variable — no AI interpretation or transformation is applied. ## Prerequisites - A template uploaded to TurboDocx with at least one variable - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) ## Map a Wrike Field ### Step 1: Select Your Template Navigate to your templates and select the template you want to configure with Wrike field mappings. ![Select Template](/img/wrike-integration/FieldMap01-SelectTemplate.jpeg) ### Step 2: Open Template Preferences Click **Edit Template & Preferences** to open the template configuration view. ![Edit Template and Preferences](/img/wrike-integration/FieldMap02-EditTemplatePreferences.jpeg) ### Step 3: Open Variable Settings Click the settings icon on the variable you want to map to a Wrike field. ![Open Variable Settings](/img/wrike-integration/FieldMap03-OpenVariableSettings.jpeg) ### Step 4: Open the Field Type Menu Click the field type selector to change the variable's data source. ![Open Field Type Menu](/img/wrike-integration/FieldMap04-OpenFieldTypeMenu.jpeg) ### Step 5: Select "Wrike Field" Choose **Wrike Field** from the field type options. This tells TurboDocx to pull data from a Wrike custom field when generating the document. ![Select Wrike Field](/img/wrike-integration/FieldMap05-SelectWrikeField.jpeg) ### Step 6: Search and Select the Wrike Field Use the search box to look up the Wrike custom field you want to map (e.g., "Revenue", "Budget", or any custom field in your Wrike workspace), then click it from the results to map it to your template variable. ![Search and Select Wrike Field](/img/wrike-integration/FieldMap07-ClickField.jpeg) ### Step 7: Save Changes Click **Save Changes** to persist the field mapping. ![Save Changes](/img/wrike-integration/FieldMap08-SaveChanges.jpeg) ## How It Works at Generation Time When a Wrike automation triggers document generation, TurboDocx reads the mapped custom field values from the Wrike task and places them directly into the corresponding template variables — the value is used exactly as-is with no AI processing. This is different from AI-mapped variables, which use AI to interpret and generate content from Wrike data. Static mapping is ideal for structured data like revenue figures, dates, project codes, and other fields where you need the exact value. :::tip You can map multiple variables in the same template to different Wrike fields. Repeat steps 3–7 for each variable you want to connect. ::: --- # How to Add a Wrike Image A **Wrike Image** variable pulls image attachments from the triggering Wrike task or folder into your document. When the Wrike automation runs, TurboDocx attaches the matching images into that variable's place in the template. You can attach all images, or filter them by file name. This is the image counterpart to the [Wrike Table](./table-variable.md) variable, and it reuses the same configuration flow. ## Prerequisites - A template uploaded to TurboDocx with at least one variable that is on its own line (a rich-text variable, so injected images have room to render) - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) ## Configure the image variable ### Step 1: Open the variable menu On your template's **Details** page, find the variable you want to turn into images, open its three-dot menu, and choose **Wrike Image**. ![Variable menu open with the Wrike Image option highlighted](/img/wrike_image/01_select_wrike_image.png) ### Step 2: Choose which images to attach - **Attach all images**: every image attachment on the triggering task or folder is included. - **Filter by name**: include only attachments whose file name contains one of your terms. Enter terms comma-separated; matching is case-insensitive and matches on substrings (for example `logo, header` matches `company-logo.png` and `Header_2024.jpg`). Only **PNG and JPEG** images are injected; other formats (GIF, WebP, BMP, TIFF, SVG) are skipped, regardless of the output format. ![Which attachments choice with the Attach all images and Filter by name options highlighted](/img/wrike_image/02_choose_images.png) ### Step 3: Set optional limits - **Max images**: cap how many images are attached (up to 20). - **If no images match** (optional): text that renders when no matching images are found. ![Max images field and the If no images match field highlighted](/img/wrike_image/03_limits.png) ### Step 4: Save Click **Save** in the image dialog to apply your configuration. That is the last step. The template details page saves your variable as you go, so there is no separate save action. ![Wrike Image dialog with the Save button highlighted](/img/wrike_image/04_save.png) ## What happens at generation time Images are injected by the Wrike automation when a document is generated, so a Wrike Image variable is **read-only** on the generation page (it shows an informational banner instead of an input). To enter content manually instead, use **Switch to manual input** on the generation page. ## Notes and limits - **Image cap**: a maximum of 20 images are attached per variable. - **Supported formats**: Only PNG and JPEG images are injected. GIF, WebP, BMP, TIFF, and SVG attachments are skipped. ## Related - [How to Add a Wrike Table](./table-variable.md) - [How to Setup Static Field Mapping](./field-mapping.md) - [Setting Up a Wrike Automation](./setting-up-automation.md) - [Troubleshooting and FAQ](./troubleshooting.md) --- ## Wrike Integration # Automate Document Generation from Wrike Projects TurboDocx integrates with Wrike to automatically generate professional documents, proposals, and presentations directly from your project management data. When a task status changes in Wrike, TurboDocx can automatically create and attach documents to your projects. :::tip See It in Action Want to see the full workflow before diving into setup? Check out the [End-to-End Example](./signature-workflow.md) — generate a proposal and send it for signature, all from Wrike. ::: ## What You Can Create - **Statements of Work (SOWs)**: Generate comprehensive SOWs from project folders with timelines and deliverables - **Project Proposals**: Create proposals from opportunity data in Wrike - **Status Reports**: Automatically generate project status reports from task data - **Meeting Summaries**: Turn Wrike tasks into professional meeting documentation - **Client Deliverables**: Generate client-facing documents from project information - **Executive Summaries**: Create management reports from Wrike folder data ## Before You Begin :::tip Not a Wrike Admin? This guide walks you through the entire setup process step-by-step. If you can create a task in Wrike, you can set this up! ::: You'll need: - Admin access to your Wrike workspace - Admin access to your TurboDocx organization - A template ready in TurboDocx (see [How to Create a Template](../../TurboDocx-Templating/How-to-Create-a-Template.md)) - About 5 minutes ## How It Works The Wrike integration uses a **status-triggered automation workflow** that you configure entirely through the TurboDocx UI: 1. **Connect Wrike** to TurboDocx using your Wrike API key 2. **Create an automation** that specifies which trigger status, folder, and template to use 3. When a task status changes to your trigger status, TurboDocx automatically generates the document 4. The finished document is attached back to the Wrike task ## Guides | Guide | Description | |-------|-------------| | [End-to-End Example](./signature-workflow.md) | Watch the full workflow in action — generate a proposal and send it for signature | | [Setting Up a Wrike Automation](./setting-up-automation.md) | Connect Wrike and create an automation with a trigger status and folder | | [How to Setup Document Generation Automation](./document-generation-automation.md) | Configure an automation to generate documents from a template | | [How to Setup E-Signature Automation](./signature-automation.md) | Generate documents and send them for e-signature automatically | | [How to Setup Static Field Mapping](./field-mapping.md) | Map Wrike custom fields (revenue, dates, etc.) directly to template variables | | [How to Setup AI Variable Configuration](./ai-variable.md) | Configure AI-driven variables that generate content from prompts during automation | | [How to Add a Wrike Table](./table-variable.md) | Turn a variable into a table of a folder or project's sub-items, with nesting and filtering | | [How to Add a Wrike Image](./image-variable.md) | Pull image attachments from the triggering task or folder into your document | | [How to Convert Documents to PDF](./convert-to-pdf.md) | Convert the first attachment to PDF when a task, project, or folder changes status, with in-place versioning | | [How to Set Up Document Packages (Combined PDF)](./document-packages.md) | Merge every attachment on a task or project into one combined PDF and attach it back to Wrike | | [How to Add Signature Anchors](./signature-anchors.md) | Configure signature anchor fields in your template for TurboSign | | [Troubleshooting and FAQ](./troubleshooting.md) | Common issues, solutions, and frequently asked questions | ## Workflow Ideas **SOW Generation:** 1. Create a project folder in Wrike with all deliverables as tasks 2. Move the folder to "Generate SOW" status 3. TurboDocx generates a comprehensive SOW from all tasks 4. Review and send to client **Proposal Automation:** 1. Create an opportunity in Wrike with project details and pricing 2. Change status to "Generate Proposal" 3. Review the AI-generated proposal 4. Change status to "Send for Signature" for digital signing **Weekly Reporting:** 1. Create a "Weekly Report" task linked to relevant project tasks 2. Every Friday, change the status to "Generate Document" 3. Auto-generated report appears in attachments --- # Setting Up a Wrike Automation This guide walks you through connecting your Wrike account to TurboDocx and creating an automation with a trigger status and monitored folder. Once complete, you'll configure the automation's action in a follow-up guide. ## Prerequisites Before starting, make sure you have: - A **Wrike API access token** (see [Get Your Wrike Access Token](#get-your-wrike-access-token) below) - A **template** in TurboDocx ready for document generation (see [How to Create a Template](../../TurboDocx-Templating/How-to-Create-a-Template.md)) - The **Wrike folder permalink** for the folder you want to monitor ## Get Your Wrike Access Token 1. Log into **Wrike** 2. Go to **Settings > Apps & Integrations > API** 3. Create a new **permanent access token** or use an existing one 4. Copy the token — you'll paste it in the next section For detailed instructions, see [Wrike's official API documentation](https://help.wrike.com/hc/en-us/articles/210409445-Wrike-API). ## Connect Wrike to TurboDocx ### Step 1: Open Wrike Integration Configuration Click your account avatar at the bottom of the left sidebar, click **Settings**, then click **Features and integrations** in the Settings menu. Locate the **Wrike Integration** card and click the **Configure Wrike** button to open the configuration panel. ![Wrike Integration card with the Configure Wrike button highlighted](/img/wrike-integration/Step01-OpenWrikeConfig.png) ### Step 2: Enter Your Wrike API Key :::tip Need an API key? See [Wrike's API documentation](https://help.wrike.com/hc/en-us/articles/210409445-Wrike-API) for how to generate a permanent access token. ::: In the Wrike Automations connection dialog, locate the **Enter your Wrike API key** text input field. Click inside the field and paste your Wrike API access token. ![Wrike Automations dialog with the Wrike API key field highlighted](/img/wrike-integration/Step02-FocusApiKeyInput.png) ### Step 3: Connect Your Wrike Account Click the **Connect Wrike** button to validate your API key and establish the connection between Wrike and TurboDocx. ![Wrike Automations dialog with the Connect Wrike button highlighted](/img/wrike-integration/Step03-ConnectWrike.png) ## Create Your First Automation ### Step 4: Start Creating an Automation Click the **+ Create Your First Automation** button in the Wrike Automations popup to begin setting up a new automation workflow. ![Create First Automation](/img/wrike-integration/Step04-CreateFirstAutomation.png) ### Step 5: Select a Trigger Status Click on the **Select one or more statuses...** input field inside the **Trigger Status** dropdown. Select one or more workflow statuses that should trigger the automation. ![Select Trigger Status](/img/wrike-integration/Step05-SelectTriggerStatus.png) ### Step 6: Choose the Specific Trigger Status In the Trigger Status dropdown list, click the status option you want to use as the trigger (e.g., **Generate Document – Project Delivery**). ![Choose Trigger Status Option](/img/wrike-integration/Step06-ChooseTriggerOption.png) ### Step 7: Enter the Wrike Folder Permalink Click on the **Wrike Folder Permalink** text box in the Folder Location section. Paste or type the Wrike folder URL that this automation should monitor. :::tip Getting Your Folder Permalink In Wrike, right-click on the folder or project you want to monitor and select **Copy Link**. The URL will look like: `https://www.wrike.com/open.htm?id=1234567890` ::: ![Enter Folder Permalink](/img/wrike-integration/Step07-EnterFolderPermalink.png) ### Step 8: Verify the Folder Permalink Click the **Verify Permalink** button to validate that the provided Wrike folder URL is correct and accessible. ![Verify Folder Permalink](/img/wrike-integration/Step08-VerifyPermalink.png) ### Step 9: Proceed to the Next Step Click the **Next** button at the bottom-right corner of the setup modal to continue to action configuration. ![Proceed to Next Step](/img/wrike-integration/Step09-ClickNext.png) ## What's Next? After completing the base automation setup above, choose which type of automation to configure: - **[How to Setup Document Generation Automation](./document-generation-automation.md)** — automatically generate documents from a template when the trigger fires - **[How to Setup E-Signature Automation](./signature-automation.md)** — generate documents and send them for digital signature via TurboSign --- ## How to Add Wrike Signature Anchors # How to Add Signature Anchors Signature anchors are special template variables that tell TurboSign where to place signature elements (name, date, signature) in your generated documents. This guide walks you through adding them to your template. ## Prerequisites - A template uploaded to TurboDocx with signature variables (e.g., `{SalesSigner}`) - An e-signature automation configured (see [How to Setup E-Signature Automation](./signature-automation.md)) ## Add Signature Anchors ### Step 1: Select Your Template Navigate to your templates and select the template you want to configure with signature anchors. ![Select Template](/img/wrike-integration/FieldMap01-SelectTemplate.jpeg) ### Step 2: Open Template Preferences Click **Edit Template & Preferences** to open the template configuration view. ![Edit Template and Preferences](/img/wrike-integration/FieldMap02-EditTemplatePreferences.jpeg) ### Step 3: Edit the Signature Variable Click the edit icon on the signature variable you want to configure as an anchor (e.g., **SalesSigner**). ![Edit Signature Variable](/img/wrike-integration/SigAnchor04-OpenFieldTypeMenu.jpeg) ### Step 4: Open the Field Type Menu In the variable settings panel, click the **...** menu button next to the Default Value field to see the available field type options. ![Open Field Type Menu](/img/wrike-integration/SigAnchor05-ExpandFieldOptions.jpeg) ### Step 5: Select "Wrike Signature Field" Choose **Wrike Signature Field** from the dropdown. This marks the variable as a TurboSign signature anchor that will be used to place signature elements in the generated document. ![Select Wrike Signature Field](/img/wrike-integration/SigAnchor06-SelectWrikeSignatureField.jpeg) ### Step 6: Save Changes The field now shows **TurboSign Signature Anchor**. Click **Save Changes** to persist the configuration. ![Save Changes](/img/wrike-integration/SigAnchor07-SaveChanges.jpeg) Your signature anchor is now configured. The variable will show a **Signature Anchor** tag to confirm it's set up correctly. ![Signature Anchor Complete](/img/wrike-integration/SigAnchor08-Complete.jpeg) :::tip Repeat steps 3–6 for each signature variable in your template (e.g., `{SalesSignerName}`, `{SalesSignerSignature}`, `{ClientSignerName}`, etc.). ::: :::caution Anchor Names Must Match Your E-Signature Automation The signature anchor variable names you configure here **must exactly match** the anchor tags in your [How to Setup E-Signature Automation](./signature-automation.md). For example, if you set up `{SalesSignerSignature}` as an anchor here, the anchor tag in your e-signature automation must also be `{SalesSignerSignature}`. If they don't match, TurboSign won't be able to place the signature fields. See [How to Setup E-Signature Automation](./signature-automation.md) for how to configure the anchor tags on the automation side. ::: --- ## How to Set Up Wrike E-Signature Automation # How to Setup E-Signature Automation After [setting up your Wrike automation](./setting-up-automation.md) with a trigger status and folder, follow these steps to configure it to generate documents and automatically send them for e-signature. ## Configure E-Signature Automation ### Step 1: Select Send for E-Signature Click the **Send for e-signature** action card to select it as the automation action. ![Select E-Signature Action](/img/wrike-integration/SigAuto01-SelectESignature.jpeg) ### Step 2: Proceed to Signer Configuration Click **Next** to continue to the signer configuration step. ![Click Next](/img/wrike-integration/SigAuto02-ClickNext.jpeg) ## Choose a Delivery Mode Before configuring signers, choose **how** the automation delivers the document. This is the first section of the signature step, and it decides which of the sections below you need to fill in. ![The Delivery Mode section with the "Place signature fields manually" option highlighted](/img/wrike-integration/SigAuto-DeliveryMode.png) | Mode | What happens when the automation fires | Anchor tags needed? | |---|---|---| | **Send for review first** | Posts a Wrike comment linking to the TurboDocx review page. You check the field placement, then click **Send** to deliver the signature emails. | Yes | | **Send directly to signers** | Signature emails go out immediately. The Wrike comment confirms the request was sent. | Yes | | **Place signature fields manually** | Posts a Wrike comment linking to the TurboDocx **field editor**. You drag the signature fields onto the document, then click **Send**. | **No** | ### When to use "Place signature fields manually" Use it when your document has **no anchor tags** — the `{CustomerSignature}`-style placeholders that tell TurboDocx where each field goes. The other two modes read those tags to position fields automatically, so a document without them can't be processed. That's common when the file is a scanned PDF, comes from a third party, or is a one-off contract nobody wants to template. In this mode: - The **Map Document Fields** section disappears — there is nothing to map, so no anchor tags are required. - You still configure **recipients** below. The field editor needs to know who each field belongs to, so every recipient you add must have a Wrike email field — in this mode and in the other two. - Nothing is sent to signers until you place the fields and press **Send** yourself. Even if the automation is also set to send directly, manual placement takes priority — a document with no fields would give signers nothing to sign. Your existing field mappings are kept if you switch to this mode, so you can switch back without reconfiguring them. :::tip Every recipient needs at least one field In the field editor, **Send** stays disabled until *each* recipient has at least one field assigned to them. If you added three signers, place at least one field for all three — a signer with no fields has nothing to sign, so the editor won't let the document go out. ::: ## Configure Signers :::note The steps below apply to **all three** delivery modes. If you chose **Place signature fields manually**, complete this section and then skip ahead to [Post-Signature Settings](#post-signature-settings) — the **Map Document Fields** steps won't appear. ::: ### Step 3: Select the First Recipient's Email Field Select the Wrike field that contains the first recipient's email address. This field will be read from the Wrike task to determine who receives the signature request. ![Select Recipient Email](/img/wrike-integration/SigAuto03-RecipientEmail.jpeg) ### Step 4: Select the Recipient's Name Field (Optional) Optionally, choose the field that represents the name of the recipient. This will be used in the signature request email. ![Select Recipient Name](/img/wrike-integration/SigAuto04-RecipientName.jpeg) ### Step 5: Add Additional Signers (Optional) Optionally, add additional signers if your document requires multiple signatures. ![Add Additional Signers](/img/wrike-integration/SigAuto05-AddSigners.jpeg) ## Map Document Fields ### Step 6: Add a Document Field Click **Add Field** to begin mapping document fields that the signer will need to fill out. ![Add Field](/img/wrike-integration/SigAuto06-AddField.jpeg) ### Step 7: Choose Document Fields Click and choose the document field(s) that need to be completed by the signer. ![Choose Document Fields](/img/wrike-integration/SigAuto07-ChooseDocField.jpeg) ### Step 8: Select the Field Type Select the specific field type for the document field (e.g., signature, date, text). ![Select Field Type](/img/wrike-integration/SigAuto08-ChooseDocField2.jpeg) ### Step 9: Set the Anchor Tag Change the anchor tag to match the placeholder in your template (e.g., `{SalesSigner}`). This tells TurboSign where to place the field in the generated document. ![Set Anchor Tag](/img/wrike-integration/SigAuto09-ChangeAnchorTag.jpeg) :::caution Anchor Tags Must Match Your Template The anchor tag you set here **must exactly match** the corresponding variable in your document template. If they don't match, TurboSign won't be able to place the signature field. See [How to Add Signature Anchors](./signature-anchors.md) for how to configure these in your template. ::: **Optional: choose the alignment.** Under the anchor tag, **Alignment** sets where the signer's entry sits on the anchor: **Left**, **Center**, or **Right**. Pick **Left** when the anchor follows a label (like "Master's signature:") so every name starts at the same point. If you skip it, the field keeps its standard placement. Checkbox fields don't have this option. ![A signature field mapping card with the Alignment options highlighted](/img/wrike-integration/SigAuto-FieldAlignment.png) On the last step of the wizard, the field mappings table shows your choice in its **Alignment** column (**Default** when you didn't pick one). ### Step 10: Map Additional Document Fields (Optional) Optionally, repeat the process to map more document fields for the signer. ![Map More Fields](/img/wrike-integration/SigAuto10-MapMoreFields.jpeg) ## Post-Signature Settings ### Step 11: Choose Where the Signed Document Gets Attached Select where the signed document should be attached after all signatures are complete. ![Attach Signed Document](/img/wrike-integration/SigAuto11-AttachSignedDoc.jpeg) ### Step 12: Choose a Post-Signature Task Status (Optional) Optionally, choose which status the triggering Wrike task should be changed to after the signature is completed. ![Choose Post-Signature Status](/img/wrike-integration/SigAuto12-ChooseStatus.jpeg) ### Step 13: Select the Completed Status Select the status to apply (e.g., **Completed**) so your Wrike workflow advances automatically after signing. ![Select Completed Status](/img/wrike-integration/SigAuto13-SelectCompleted.jpeg) ### Step 14: Configure Notifications (Optional) Optionally, choose who gets tagged or notified after a signature is completed. ![Configure Notifications](/img/wrike-integration/SigAuto14-Notifications.jpeg) ## Wrike Activity Updates (Optional) Beyond the final **Completed** status you set above, you can have TurboDocx mirror **each step of the signature lifecycle** back into the triggering Wrike item — as a comment, a status change, or both. This keeps signer activity visible in Wrike in near real time, so downstream Wrike automations can react as a document moves through signing. In the **Wrike activity updates** section of the signature action step, you'll see a row for each signature event. For every event you can independently: - **Add a comment** — post a readable note on the Wrike task/folder when the event fires - **Set a status** — change the Wrike item's custom status when the event fires (optional per row) ![Wrike activity updates section showing a comment toggle and status dropdown for each signature event](/img/wrike-integration/SigAuto-WrikeActivityUpdates.png) ### Supported events | Row | Fires when | Notes | |---|---|---| | **Sent** | The document is dispatched to recipients | e.g. `Document sent for signature` | | **Viewed** | A recipient opens the document for the first time | e.g. `Document viewed by John Doe` | | **Signed** | An individual signer completes their signature | Posts per-signer progress like `1 of 2 signed` | | **Progress** | A signer signs but the document is not yet fully complete | Document-level partial-signing progress | | **Finalization failed** | The signed PDF could not be finalized (e.g. a signing error) | The document is **not** marked Completed | | **Voided** | The signature request is voided or cancelled | e.g. `Document voided` | These rows map to the same signature lifecycle events surfaced by [TurboSign webhooks](../../TurboSign/Webhooks.md) — see that page if you also want these events pushed to your own endpoints. :::info The Completed row The table also shows a **Completed** row, but it is not configured here — it reuses the post-signature status you set in **Step 12–13** above and posts automatically once all recipients have signed. Set the "all recipients signed" status there, not in this section. ::: :::tip Comments and statuses are per event Each row is independent. You can, for example, post a comment on **Viewed** and **Signed** but only change the Wrike status on **Voided** — leave any row's comment off and status unset to skip it entirely. ::: ### How it behaves - **Comments are off by default.** A row only posts a comment if you turn it on, so existing automations don't suddenly start commenting when you save. - **Per-recipient progress.** The **Signed** comment includes how many of the total signers have signed (`X of Y signed`) so multi-signer flows read in sequence. - **Non-blocking.** Activity updates never interrupt the signature flow — if a Wrike comment or status change can't be applied, signing still completes and the event is logged. - **Deleted status safety.** If a status you selected is later removed in Wrike, TurboDocx posts a warning comment instead of silently failing. ## Finalize the Automation ### Step 15: Proceed to Final Step Click **Next** to advance to the automation creation step. ![Click Next](/img/wrike-integration/SigAuto15-ClickNext.jpeg) ### Step 16: Create the Automation Click **Create Automation** to save and activate your e-signature automation workflow. ![Create Automation](/img/wrike-integration/SigAuto16-CreateAutomation.jpeg) ## What's Next? - **[How to Add Signature Anchors](./signature-anchors.md)** to your template if you haven't already - **[How to Setup Static Field Mapping](./field-mapping.md)** to template variables for static data - If something isn't working, see [Troubleshooting and FAQ](./troubleshooting.md) --- ## Wrike Example: Generate & Sign a Proposal # End-to-End Example: Generate & Sign a Proposal This walkthrough follows a real scenario from start to finish. A project manager generates a proposal document from a Wrike task and sends it to a client for digital signature — all by changing task statuses. :::tip Want to set this up yourself? This page shows the workflow **in action**. For setup instructions, see the [guides below](#set-this-up-yourself). ::: ## The Scenario Your team uses Wrike to manage client service delivery projects. Each project has deliverables, timelines, and billing milestones tracked as tasks. When a deliverable is ready, you need to generate a branded proposal with a project timeline, pricing, and signature fields — then send it to the client for signing. With TurboDocx, this entire flow is triggered by two status changes in Wrike. ## Step 1: Open the Task Navigate to the task you want to generate a document for. In this example, we're working with **"Deliverable 1"** under a CWM Deployment project in the Service Delivery space. ![Open the task in Wrike](/img/wrike-integration/EndToEnd01-ClickTask.jpeg) ## Step 2: Trigger Document Generation :::info How to set this up See [How to Setup Document Generation Automation](./document-generation-automation.md) to configure the trigger status and template for your automation. ::: Change the task status to **"Generate Document"**. This is the trigger status configured in the TurboDocx automation — as soon as the status changes, TurboDocx picks it up. ![Change status to Generate Document](/img/wrike-integration/EndToEnd02-GenerateDocument.jpeg) ## Step 3: Document is Generated and Attached Within moments, the **TurboDocx Document Bot** generates the document and attaches it directly to the Wrike task. A comment confirms the file was created successfully — in this case, **"Wrike Proposal Estimates.docx"**. ![Document attached to task](/img/wrike-integration/EndToEnd03-DocumentAttached.jpeg) ## Step 4: Review the Generated Document :::info How to set this up See [How to Setup Static Field Mapping](./field-mapping.md) to map Wrike custom fields to template variables, and [How to Setup AI Variable Configuration](./ai-variable.md) to configure AI-generated content. ::: Click the attachment to open and review the proposal. Notice two things: - The **Project Timeline** table was generated by AI based on the project's tasks and dates - The **Projected Deliverable Total Revenue** ($36,000.00) was pulled directly from a Wrike custom field ![Review the generated proposal](/img/wrike-integration/EndToEnd04-ReviewDocument.jpeg) ## Step 5: Check the Signature Anchors Scroll down to the signature section. The document contains **signature anchor placeholders** like `{SalesSignerName}`, `{SalesSigner}`, `{ClientSignerName}`, and `{ClientSignerSignature}`. These aren't visible to the final signer — TurboSign replaces them with interactive signature fields. ![Signature anchors in the document](/img/wrike-integration/EndToEnd05-SignatureAnchors.jpeg) How are these anchors configured? In the TurboDocx template settings, each of these variables is marked as a **Wrike Signature Anchor**. This tells TurboSign to treat them as placement markers for signature fields rather than regular text. ![Anchor configuration in template settings](/img/wrike-integration/EndToEnd06-AnchorConfig.jpeg) For the full setup guide, see [How to Add Signature Anchors](./signature-anchors.md). How do anchor tags map to signature fields? In the e-signature automation configuration, each anchor tag is mapped to a specific signature field type and assigned to a recipient: - `{ClientSigner}` becomes a **Signature** box assigned to the 1st Signer (the customer) ![ClientSigner anchor mapped to signature field](/img/wrike-integration/EndToEnd07-ClientSignerAnchor.jpeg) - `{ClientDate}` becomes a **Date** field, and `{ClientFullName}` becomes a **Full Name** field — both auto-populated when the signer opens the document ![Date and Full Name anchor fields](/img/wrike-integration/EndToEnd08-DateAnchorField.jpeg) For the full setup guide, see [How to Setup E-Signature Automation](./signature-automation.md). Where does the recipient email come from? The signing request is sent to the email address in the **"Customer email"** custom field on the Wrike task. In this example, that's `nicolas@example.com`. This field is mapped in the e-signature automation's recipient configuration. ![Customer email field in Wrike task](/img/wrike-integration/EndToEnd09-RecipientEmail.jpeg) For details on mapping recipient fields, see [How to Setup E-Signature Automation](./signature-automation.md). Here's what it looks like once TurboSign places the interactive signature fields on the final document — the anchors from the template become drag-and-drop fields assigned to each recipient: ![TurboSign signature field placement](/img/wrike-integration/EndToEnd12-ReviewAndSend.jpeg) ## Step 6: Send for Signature :::info How to set this up See [How to Setup E-Signature Automation](./signature-automation.md) to configure the signature trigger, recipients, and anchor tag mapping. ::: Back in Wrike, change the task status to **"Send for Signature"**. This triggers the TurboSign signing workflow. ![Change status to Send for Signature](/img/wrike-integration/EndToEnd10-SendForSignature.jpeg) ## Step 7: Bot Posts Signing Status The TurboDocx Bot comments on the task with the signing status, the signing order (for documents with multiple signers), and a **review link** for you to verify the document before it's sent. ![Bot comment with signing status and review link](/img/wrike-integration/EndToEnd11-BotComment.jpeg) ## Step 8: Review and Send Click the review link to open TurboDocx's signature assignment view. Here you can verify that the signature fields (Full Name, Date, Signature) are correctly placed on the document. When everything looks right, click **"Send Document"** to deliver the signing request to the recipient's email. ![Review signature placements and send](/img/wrike-integration/EndToEnd12-ReviewAndSend.jpeg) ## Set This Up Yourself Ready to configure this workflow for your team? Follow these guides in order: | Step | Guide | What You'll Do | |------|-------|---------------| | 1 | [Setting Up a Wrike Automation](./setting-up-automation.md) | Connect Wrike and create your first automation | | 2 | [How to Setup Static Field Mapping](./field-mapping.md) | Map Wrike custom fields (revenue, dates) to template variables | | 3 | [How to Setup AI Variable Configuration](./ai-variable.md) | Set up AI-generated content like project timelines | | 4 | [How to Add Signature Anchors](./signature-anchors.md) | Mark template variables as signature anchor fields | | 5 | [How to Setup E-Signature Automation](./signature-automation.md) | Configure recipients, anchor tags, and post-signature actions | --- # How to Build a PowerPoint Status Deck from Wrike **Slide automation** turns a single PowerPoint template into a full status deck. When the Wrike automation runs, TurboDocx does two things: - It fills a **summary slide** — one table row for each of your Wrike projects, grouped into tables you define. - It repeats a **looping slide** — one copy for each project, filled with that project's own details. So a two-slide template can produce a fifteen-slide deck, without anyone copying and pasting a slide per project. This page covers the PowerPoint-specific setup. For the basics of connecting Wrike and creating an automation, start with [Setting Up a Wrike Automation](./setting-up-automation.md). ## Prerequisites - A **PowerPoint (.pptx)** template uploaded to TurboDocx - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) - A Wrike folder that contains the projects you want in the deck - Variables on your template mapped to Wrike fields (see [Static Field Mapping](./field-mapping.md)) :::note Slide automation only applies to PowerPoint templates. Word templates use [document generation](./document-generation-automation.md) instead. ::: ## Part 1: Tell TurboDocx which slides do what TurboDocx needs to know which slide is your summary and which slide repeats. You mark this on the template itself, so it is remembered for every run. ### Step 1: Open the slide preview Go to your template's **Details** page. On the right you'll see a page-by-page preview of your PowerPoint file. Each page here is one slide — page 3 in the preview is slide 3 in your deck. Hover over the slide you want to mark, and click the small **tag button** in its top-right corner. ![Template details page with the mark slide button on a slide preview highlighted](/img/wrike_slide_automation/01_mark_slide_button.png) ### Step 2: Choose the slide's role A short menu opens with the two roles you can give a slide. ![The slide role menu open, showing the Summary slide and Looping slide options](/img/wrike_slide_automation/02_slide_role_picker.png) - **Summary slide** — the slide holding the tables you want filled with one row per project. Only one slide can be the summary slide, so if another slide already has this role the menu tells you which one. - **Looping slide** — a slide that gets repeated once for every project. You can mark **several** slides as looping, and each one repeats in place. Click the role you want. Your choice saves straight away, and the slide's preview shows a label so you can see at a glance what is marked. :::tip Mark your looping slide **before** you configure the automation. That way you can check the whole deck's shape in the preview first. ::: ## Part 2: Turn on slide automation With the slides marked, switch on slide automation in the Wrike automation itself. ### Step 3: Open your Wrike configuration On the same **Details** page, click the **"..."** button next to **Edit**, then click **Configure Wrike**. ![The template actions menu with the Configure Wrike option highlighted](/img/wrike_slide_automation/03_configure_wrike.png) ### Step 4: Edit the automation Your configured automations are listed here. Find the one that should build the deck, click its **"..."** button, and choose **Edit**. ![The Wrike Automations dialog showing a configured automation](/img/wrike_slide_automation/04_automation_card.png) Click **Next** until you reach the **Configure action** step. ### Step 5: Switch on slide automation Find the **Slide Automation** panel and turn its switch on. ![The Configure action step with the Slide Automation toggle highlighted](/img/wrike_slide_automation/05_enable_slide_automation.png) Turning this on reveals the settings in the rest of this guide. Leave it off and the automation generates a normal document, ignoring any slide roles you marked. ## Part 3: Choose which projects appear ### Step 6: (Optional) Filter projects with a checkbox field By default, **every project** inside the triggering folder goes into the deck. If you'd rather choose them, pick a Wrike **Checkbox** custom field here — then only projects with that box ticked are included. ![The Project Inclusion Field picker highlighted, with no field selected](/img/wrike_slide_automation/06_project_inclusion_field.png) **This field is optional.** Leave it blank to include everything, which is what most teams want to start with. :::caution If you *do* pick a field, it must be a **Checkbox** field. Picking another type — or picking a field that is later deleted in Wrike — stops the deck being generated, and TurboDocx posts a comment on the Wrike task explaining why. ::: ## Part 4: Set up the summary tables The summary slide can hold several tables — for example one for delivered work and one for work in flight. Each table collects projects by their Wrike status. ### Step 7: Tag a table and choose its statuses Each row here is one table on your summary slide: - **Table tag** — the table's name on the slide. Set this as the table's alt-text (or its shape name) in PowerPoint, so TurboDocx knows which table to fill. - **Statuses** — the Wrike statuses that route a project into this table. ![A summary table row showing the Table tag and Statuses fields](/img/wrike_slide_automation/07_summary_table_row.png) Click **Add table** for each additional table on the slide. ### Step 8: Pick the statuses Click the **Statuses** box to open the list. Statuses are grouped by their Wrike workflow, so you can tell similarly-named ones apart. ![The status dropdown open, with statuses grouped by workflow](/img/wrike_slide_automation/08_status_dropdown.png) The list **stays open** as you pick, so you can select several statuses in one go. Click anywhere outside it when you're finished. :::note A status can belong to **only one** table — otherwise a project could land in two tables at once. If a project's status isn't in any table, it is left out of the summary, and TurboDocx tells you how many were skipped in its Wrike comment. ::: ## Part 5: Colour the cells by value (optional) You can colour a table's cells based on what they say — for example green for completed work and red for cancelled. ### Step 9: Open Color Setup On the table row, click **Color Setup** (it shows a count once you've added rules). ![The Color Setup button on a summary table row highlighted](/img/wrike_slide_automation/09_color_setup_button.png) ### Step 10: Map values to colours Choose the **field** you want to colour by, then map each of its values to a colour. ![The Color Setup dialog mapping status values to fill and text colours](/img/wrike_slide_automation/10_color_setup_dialog.png) For each row: 1. Pick a **value** from the dropdown. It offers the statuses you routed into this table, so the value always matches something real. 2. Click the **colour square** to set the cell's background, or type a hex code such as `#007016`. 3. Optionally set a **text colour** so the writing stays readable on a dark fill. Click **Add Value** for each value you want to colour, then **Save**. ## Step 11: Save the automation Click **Next** through the remaining steps, then **Save Configuration**. Your automation is live. The next time a project moves into the trigger status, TurboDocx builds the deck — summary tables filled, looping slides repeated per project — and attaches it to the Wrike item. ## Troubleshooting **The deck was not generated, and Wrike shows a comment about the inclusion field.** The Checkbox field chosen in Step 6 no longer works — it may have been deleted in Wrike, or changed to another field type. Pick a valid Checkbox field, or clear the field to include every project. **Some projects are missing from the summary.** Their Wrike status isn't listed in any of your tables. Add it to the right table's **Statuses** in Step 8. **A summary table stayed empty.** The **Table tag** doesn't match the table on the slide. Check the table's alt-text (or shape name) in PowerPoint and make it match the tag exactly. **The looping slide didn't repeat.** Check the slide is still marked as a **Looping slide** in Part 1, and that **Slide Automation** is switched on in Step 5. For anything else, see [Wrike Integration Troubleshooting & FAQ](./troubleshooting.md). --- # How to Add a Wrike Table A **Wrike Table** variable turns a single template variable into a table. When the Wrike automation runs, TurboDocx fills that table with the triggering folder or project's **sub-items** (its tasks, and its subfolders or subprojects), one row each. You choose which Wrike fields become the columns, how the rows are laid out, whether nested sub-items are expanded, and an optional filter that limits which sub-items appear. This is different from [Static Field Mapping](./field-mapping.md), which maps one Wrike field to one variable. A Wrike Table maps one variable to many rows. ## Prerequisites - A template uploaded to TurboDocx with at least one variable that is on its own line (a rich-text variable, so the generated table has room to render) - A connected Wrike account (see [Setting Up a Wrike Automation](./setting-up-automation.md)) ## Configure the table ### Step 1: Open the variable menu On your template's **Details** page, find the variable you want to turn into a table. On its input row, click the **three-dot menu** (the "..." button at the right of the row). ![Variable input row with the three-dot menu button highlighted](/img/wrike_table_filtering/01_variable_menu.png) In the menu that opens, click **Wrike Table**. This opens the Wrike Table editor with a live preview. ![Variable menu open with the Wrike Table option highlighted](/img/wrike_table_filtering/02_select_wrike_table.png) ### Step 2: Add fields (columns) Each field is a **value source** plus a **label**: - **Value source**: the Wrike field whose value fills the cells (for example Title, Status, Due Date, Assignee, or any custom field). - **Label**: type your own column heading, or pick a field and use its name as the heading. Add as many fields as you need, and drag the handles to reorder them. To show each row's entity type (Task, Folder, or Project), add the built-in **Item Type** field as a column. ![Wrike Table builder with the + Add Field button highlighted](/img/wrike_table_filtering/06_add_fields.png) ### Step 3: Choose the layout - **Sub-items as rows** (default): each sub-item is a row, each field is a column. - **Sub-items as columns**: the table is transposed. You can also pick an optional **Sub-item header** field (for example Title) to label each sub-item along the other axis, and set **Empty state text** that renders when the trigger has no sub-items. ![Options section with the Layout dropdown highlighted](/img/wrike_table_filtering/07_layout.png) ### Step 4: Expand nested sub-items (optional) Turn on **Expand nested sub-items** to recurse beneath the direct children. **Max depth** controls how many nested levels are expanded (1 to 5). Each nested row is numbered hierarchically (1, 1.1, 1.1.1) and indented so the hierarchy reads cleanly. Nesting is available only in the rows layout. ![Expand nested sub-items toggle and the Max depth control highlighted](/img/wrike_table_filtering/08_nesting.png) ### Step 5: Filter which sub-items appear (optional) Add a **Filter** to include only sub-items that match one or more conditions. Pick a field, then an operator and value. The available operators adapt to the field you pick: - **Item Type**: "is any of" / "is none of", choosing from the built-in Task, Folder, and Project types plus your Wrike custom item types. - **Date fields**: before, after, on, on or before, on or after, between, plus relative options (overdue, in the next N days, in the last N days). - **Numeric fields** (currency, numeric, or percentage): is greater than, is greater than or equal to, is less than, is less than or equal to, is between, plus equals / not equals. - **Other fields**: is set, is not set, is checked, is not checked, equals, not equals. #### Combine multiple conditions Click **+ Add condition** to add another row to the filter (up to 10). When you have two or more conditions, a **Match all (AND) / any (OR)** selector appears: - **all (AND)**: a sub-item must satisfy *every* condition to appear. - **any (OR)**: a sub-item appears if it satisfies *at least one* condition. ![Filter section with the Match all (AND) selector highlighted](/img/wrike_table_filtering/03_match_all_any.png) ![Match selector open showing the all (AND) and any (OR) options](/img/wrike_table_filtering/04_and_or_options.png) Each condition has its own field, operator, and value, and the remove (trash) icon next to a condition deletes just that row. For example, to list only the **milestones due in the next 30 days**, add two conditions combined with **all (AND)**: 1. **Item Type** "is any of" **Milestone** 2. **Due date** "is in the next" **30** days Milestone is a Wrike custom item type, so this condition needs a Wrike plan that includes custom item types (see [Notes and limits](#notes-and-limits) below). To exercise multiple conditions on any plan, use a built-in type instead, such as **Item Type** "is any of" **Folder** combined with a due-date condition. The filter keeps a parent row whenever it, or any sub-item beneath it, matches the conditions. When **Expand nested sub-items** is on, two extra options control how a match pulls in the rows around it: - **Keep parent rows** (on by default): keeps the parent rows above a match, so a deeply nested match still reads in context. Turn this off to show only the matching rows themselves; a match whose parents are hidden moves up to the top level. - **Include sub-items** (off by default): when a row matches, also show everything nested beneath it, even if those sub-items don't match the filter. Use this to filter the top of your hierarchy and then bring each matching branch in whole. ![The Add Condition button with the Keep parent rows and Include sub-items toggles below the conditions](/img/wrike_table_filtering/05_add_condition.png) #### Numeric filter operators When you pick a **numeric** field — a currency, numeric, or percentage field — the condition switches to number comparisons. Pick the field, then choose how to compare it and enter the number. 1. In a condition, open the **field** picker and choose a numeric field (currency, numeric, or percentage). The operator and value controls change to numeric ones automatically. ![A filter condition with a numeric currency field chosen, showing a numeric operator and value box](/img/wrike-table-numeric-filters/01_pick_numeric_field.png) 2. Open the **operator** list and choose one: *is greater than*, *is greater than or equal to*, *is less than*, *is less than or equal to*, *is between*, or *equals* / *does not equal*. ![The filter operator list open showing the numeric operators](/img/wrike-table-numeric-filters/02_numeric_operators.png) 3. Enter the number to compare against. **is between** shows two boxes — the low and high ends of the range. ![The is between operator with a low value and a high value entered](/img/wrike-table-numeric-filters/03_between_values.png) :::tip Type a plain number Enter numeric values as plain numbers — `50000`, not `$50,000.00`. TurboDocx compares the numbers directly, so you don't add the currency symbol, thousands separators, or decimals, and the result isn't affected by how the field is formatted in Wrike. ::: ### Step 6: Save Click **Save** in the table builder to apply your configuration. That is the last step. The template details page saves your variable as you go, so there is no separate save action. ![Wrike Table builder with the Save button highlighted](/img/wrike_table_filtering/09_save.png) ## What happens at generation time The table is built by the Wrike automation when a document is generated, so a Wrike Table variable is **read-only** on the generation page (it shows an informational banner instead of an input). There is nothing to fill in by hand. If you want to enter content manually instead, use **Switch to manual input** on the generation page. ## Notes and limits - **Row cap**: a table renders at most 500 rows. If more sub-items qualify, the table shows the first 500 and notes how many were omitted. - **Plan entitlements**: Wrike custom item types and finance fields (such as Budget or Actual Cost) require a Wrike plan that includes them. If your plan does not, those fields are left blank and a custom item-type filter condition is skipped (the table is never silently emptied). The built-in Task, Folder, and Project types work on any plan. ## Related - [How to Add a Wrike Image](./image-variable.md) - [How to Setup Static Field Mapping](./field-mapping.md) - [Setting Up a Wrike Automation](./setting-up-automation.md) - [Troubleshooting and FAQ](./troubleshooting.md) --- ## Wrike Integration Troubleshooting & FAQ # Troubleshooting and FAQ ## Common Issues ### Document Generation Takes Too Long **Cause:** Complex templates or AI generation can take 1-2 minutes. **Solution:** - Wait at least 2 minutes before assuming failure - Simplify your template or reduce the number of AI-generated variables - Check that your Wrike task has sufficient data for the AI to work with ### Document Not Attached to Wrike Task **Cause:** Generation succeeded but attachment failed. **Solution:** 1. Check that your Wrike API token has permission to add attachments 2. Verify the Wrike folder/task still exists 3. Re-check your automation configuration in TurboDocx ### Status Not Triggering the Automation **Cause:** The task status changed to a status that doesn't match your automation's trigger. **Solution:** - Open TurboDocx and check which trigger statuses are configured on your automation - Ensure the Wrike status name matches exactly what you selected during setup - Verify the task is in a folder that the automation is monitoring ### Folder Permalink Verification Failed **Cause:** The Wrike folder URL is incorrect or inaccessible. **Solution:** 1. In Wrike, right-click the folder and select **Copy Link** to get a fresh permalink 2. Verify your Wrike API token has access to the folder 3. Paste the permalink into the automation configuration and try verifying again ### Signature Workflow Not Working **Cause:** Missing custom fields or document anchors. **Solution:** - Ensure `Seller email` and `Customer email` custom fields exist and are populated on the Wrike task - Verify the document template contains all required signature anchor fields - Check that TurboSign is configured in your organization - See the [End-to-End Example](./signature-workflow.md) for full requirements ## Security Best Practices - **Rotate API keys regularly**: Update your Wrike API token periodically - **Monitor automation activity**: Review your automations in TurboDocx settings - **Use least-privilege access**: Only grant the minimum Wrike permissions needed - **Secure your API tokens**: Never share or commit API keys to version control ## FAQ ### Can I use multiple templates with the same Wrike folder? Yes! You can create multiple automations for the same folder, each with a different template and trigger status. Use different trigger statuses to control which automation fires. ### Can I use this with Wrike tasks or only folders? Both! The automation monitors a specific folder, but the status changes happen on tasks within that folder. You can also monitor entire spaces or projects. ### How do I know if document generation failed? If document generation fails, the document won't appear as an attachment on your Wrike task. Check your automation configuration and ensure the Wrike task has sufficient data for the template. ### Can the AI use data from multiple Wrike tasks? Yes! If you trigger from a folder, the AI can access all tasks within that folder. If you trigger from a task, it can access parent/child tasks as well. ### How do I edit or delete an automation? Open the Wrike integration configuration in TurboDocx (**Settings → Features and integrations**, then **Configure Wrike** on the Wrike Integration card). You can manage all your automations from the configuration panel. ### Can I create automations for different workflows? Yes. Each automation is independent — you can set up different trigger statuses, folders, and templates for each one. For example, one automation for proposals and another for SOWs. :::tip Need Help? Join our [Discord community](https://discord.gg/turbodocx) for support! Our team and community members are ready to help you troubleshoot any issues. ::: --- # Zapier Integration The Zapier integration in TurboDocx enables you to connect your document generation workflow to thousands of apps and services through Zapier's powerful automation platform. This integration allows you to export TurboDocx deliverables and trigger automated workflows across any Zapier-supported application. ## Overview With the Zapier integration, you can seamlessly export your generated documents and data from TurboDocx to any of the 5,000+ apps supported by Zapier. This opens up endless possibilities for automating your document workflows and connecting TurboDocx to your existing business systems. ## Key Features ### Export to Any Zapier-Supported App - **CRM Systems**: Automatically send generated proposals to Salesforce, HubSpot, or Pipedrive - **Project Management**: Create tasks in Asana, Trello, or Monday.com when documents are generated - **Communication**: Send notifications through Slack, Microsoft Teams, or email when deliverables are ready - **Cloud Storage**: Automatically save documents to Google Drive, Dropbox, or OneDrive - **Database Systems**: Store document metadata in Airtable, Google Sheets, or other databases ### Automated Workflows - Trigger actions based on document generation events - Set up multi-step workflows that involve multiple applications - Create conditional logic based on document properties or content - Schedule automated document exports and distributions ## Common Use Cases ### Sales & Marketing - Generate proposals in TurboDocx and automatically send them to prospects via email - Create contracts and sync client data to your CRM system - Generate marketing materials and distribute them across multiple channels ### Operations & Project Management - Create project documentation and automatically create corresponding tasks in project management tools - Generate reports and send them to stakeholders via preferred communication channels - Synchronize document metadata with operational databases ### HR & Administration - Generate employee documents and automatically file them in HR systems - Create onboarding materials and trigger welcome sequences - Generate compliance documents and log them in tracking systems ## Getting Started The Zapier integration is available as part of TurboDocx's premium features. To set up and configure the Zapier integration for your organization: ### Contact Sales To enable the Zapier integration and discuss your specific automation needs: - **Email**: [team@turbodocx.com](mailto:team@turbodocx.com) - **Schedule a Demo**: Book a personalized demonstration to see how Zapier integration can streamline your workflows - **Custom Setup**: Our team can help design custom automation workflows tailored to your business processes ### What Our Team Will Help You With - **Integration Setup**: Complete configuration of the Zapier connection - **Workflow Design**: Custom automation workflows based on your specific needs - **Testing & Optimization**: Ensuring your integrations work seamlessly - **Training**: Comprehensive training for your team on managing Zapier automations - **Ongoing Support**: Dedicated support for troubleshooting and optimization ## Benefits ### Increased Efficiency - Eliminate manual document distribution and filing - Reduce time spent on repetitive tasks - Ensure consistent document workflows across your organization ### Enhanced Collaboration - Automatically notify team members when documents are ready - Sync document data across multiple platforms - Create unified workflows that span multiple applications ### Improved Accuracy - Reduce human error in document handling - Ensure consistent data entry across systems - Maintain audit trails of document lifecycle ## Technical Requirements - TurboDocx Enterprise or Premium plan - Active Zapier account (Basic or Premium recommended) - Administrative access to configure integrations - Access to target applications for integration setup ## Next Steps Ready to supercharge your document workflows with Zapier integration? Contact our sales team today to: 1. **Schedule a consultation** to discuss your specific automation needs 2. **See a live demo** of TurboDocx and Zapier working together 3. **Get a custom quote** based on your integration requirements 4. **Plan your implementation** with our integration specialists Transform your document generation process into a fully automated workflow that connects seamlessly with your existing business tools through the power of Zapier integration. --- ## Zoom Integration # Turn Your Zoom Meetings into Professional Documents & Presentations Transform your meeting recordings into professional documents automatically. TurboDocx's integration eliminates manual note-taking by converting meeting transcripts into polished deliverables, streamlining your post-meeting workflow. ## What You Can Create - **📄 Meeting Summaries**: Convert discussions into formatted meeting minutes and action items - **📊 Client Presentations**: Turn discovery calls into compelling presentation decks - **📋 Project Proposals**: Use requirement gathering sessions to create detailed proposals - **📝 Follow-up Reports**: Generate comprehensive meeting reports with key takeaways - **💼 Sales Materials**: Transform prospect calls into customized sales presentations - **🔄 Automated Workflows**: Connect meeting transcripts directly to your document templates ## Before You Begin To use the Zoom integration, you'll need: - A Zoom account with **cloud recording** enabled - Administrative access to create a Zoom app (or assistance from your IT team) - Meeting recordings stored in Zoom's cloud (local recordings are not accessible) :::tip If you're not sure whether you have cloud recording enabled, check with your Zoom administrator or look for the "Cloud" option when you start recording a meeting. ::: ## Step 1: Creating Your Zoom App The first step is to create a Zoom app that will allow TurboDocx to access your meeting transcripts. Don't worry - this is a one-time setup process that we'll walk you through step by step. ### Navigate to the Zoom Marketplace 1. Open your web browser and go to [https://marketplace.zoom.us](https://marketplace.zoom.us) 2. Click **"Sign In"** in the top right corner ![Zoom Marketplace Sign In](/img/zoom_integration/step1.png) 3. Sign in with your Zoom account credentials 5. You'll be taken to [https://marketplace.zoom.us/user/build](https://marketplace.zoom.us/user/build) ### Create a New App 1. In the top right corner, click the dropdown that says **"Develop"** 2. Select **"Build App"** from the dropdown ![Step 2: Build App selection](/img/zoom_integration/step2.png) 3. In the dialog that appears, select **"General App"** ![Step 3: General App selection](/img/zoom_integration/step3.png) 4. Click **"Create"** 5. **Rename your app to "TurboDocx"** ![Step 4: Rename app to TurboDocx](/img/zoom_integration/Step4RenameTurboDocx.png) :::info Why General App? We use a "General App" because it provides the specific permissions needed to access your cloud recordings safely and securely. ::: ### Configure Basic Information After creating your app, you'll be taken to the app configuration page. Let's set up the basic information first. #### Basic Information Tab 1. Make sure you're on the **"Basic Information"** tab 2. **Leave app as User Managed** - Ensure the app type remains set as "User Managed" ![Basic Information User Managed](/img/zoom_integration/Step5UserManaged.png) 3. Scroll down to **"App Credentials"** section 4. **Note down the Client ID and Client Secret** - You'll need these values when configuring TurboDocx ![App Credentials Section](/img/zoom_integration/Step6ClientIdAndSecret.png) :::tip Keep Your Credentials Safe Your Client Secret is like a password - never share it publicly or include it in emails. TurboDocx will store it securely once you enter it. ::: #### OAuth Information 1. Still on the Basic Information tab, scroll down to **"OAuth Information"** 2. For **"Redirect URL for OAuth"**, enter: ``` https://api.turbodocx.com/oauth/zoom/callback ``` 3. Under **"OAuth Allow List"**, add the following URLs (click "Add" for each one): ``` https://app.turbodocx.com ``` ![OAuth Allow List Configuration](/img/zoom_integration/oauth_allowlist.png) ### Configure Scopes Now we need to set up the permissions (scopes) that allow TurboDocx to access your cloud recordings. #### Scopes Tab 1. In the left bar, click on the **"Scopes"** button and navigate to the add scopes page ![Navigate to Scopes Section](/img/zoom_integration/NavigateToScopesSection.png) 2. Click **"Add Scopes"** button ![Click Add Scopes Button](/img/zoom_integration/clickaddscopesbutton.png) 3. In the search dialog that appears, search for: ``` List all cloud recordings for a user ``` Or look for the scope with this value: ``` cloud_recording:read:list_user_recordings ``` ![Type List Cloud Recordings For User](/img/zoom_integration/TypeListCloudRecordingsForUser.png) 4. **Add the scope** labeled "View your recordings" (with value `cloud_recording:read:list_user_recordings`) ![Select Scope and Press Done](/img/zoom_integration/SelectScopeandPressDone.png) 5. Click **"Done"** to save the configuration ## Step 2: Configuring TurboDocx ### 🔧 How to Configure Zoom in Settings 1. Click your **account avatar** at the bottom of the left sidebar, then click **Settings** ![Account menu with the Settings item highlighted](/img/zoom_integration/GoToSettings.png) 2. In the menu on the left of the Settings page, click **Features and integrations** ![Settings menu with Features and integrations highlighted](/img/zoom_integration/GoToFeaturesAndIntegrations.png) 3. Scroll down to the **Zoom** card 4. Click **Configure Zoom** ![Zoom card with the Configure Zoom button highlighted](/img/zoom_integration/ClickConfigureZoom.png) 5. A Zoom Configuration pop-up will appear 6. Take the **Client ID** and **Client Secret** you obtained earlier, paste them into the appropriate fields, and click **Save Configuration** in the bottom right-hand corner ![Enter Client ID Secret and Press Save Configuration](/img/zoom_integration/enterclientidsecretandpresssaveconfiguration.png) ### Alternative: Inline Configuration Alternatively, you can configure Zoom integration directly when you first access transcript providers. When you navigate to the transcript providers section and select Zoom, you'll be prompted to enter your credentials if the integration hasn't been configured yet. Simply enter your Client ID and Client Secret in the configuration dialog that appears and save your settings. ## Step 3: Using Your Zoom Integration Congratulations! Your Zoom integration is now ready to use. Here's how to access your meeting transcripts and use them in your documents. ### Accessing Meeting Transcripts 1. Click the source dropdown and click **"Change Source"** 2. Go to the **App Library** tab 3. Click **"Transcript Providers"** ![Meeting Providers](/img/zoom_integration/MeetingProviders.png) 4. Click **"Zoom"** and then you should be able to click the transcript 5. You'll see a list of your recent cloud recordings with transcripts ![Transcripts Shown](/img/zoom_integration/TranscriptsShown.png) ## Troubleshooting If you're having trouble with your Zoom integration, here are some common issues and their solutions. ### "No Meetings Found" - **Cause**: No cloud recordings with transcripts available - **Solution**: Ensure your meetings are recorded to the cloud and have transcription enabled ### "Authentication Failed" - **Cause**: Incorrect credentials or expired authentication - **Solution**: - Double-check your Client ID and Client Secret - Try re-authenticating by clicking "Authenticate with Zoom" again ### "Permission Denied" - **Cause**: Required scope not properly configured - **Solution**: Verify that `cloud_recording:read:list_user_recordings` scope is added to your Zoom app :::tip Getting Help If you continue to experience issues: - Verify all steps in this guide have been completed - Check that your Zoom account has cloud recording enabled - Ensure you have meeting recordings stored in Zoom's cloud - Contact your TurboDocx support team for assistance ::: ## Security and Privacy Your data security and privacy are important to us. Here's how your information is protected when using the Zoom integration. ### How Your Data is Protected - **Secure Authentication**: TurboDocx uses OAuth 2.0, the industry standard for secure API access - **Limited Permissions**: The integration only requests access to read your cloud recordings - **Encrypted Transmission**: All data transfers are encrypted using industry-standard protocols ## Best Practices ### For Optimal Results - **Enable Transcription**: Always enable transcription when recording meetings to the cloud - **Use Clear Audio**: Ensure good audio quality for more accurate transcripts - **Descriptive Meeting Names**: Use clear, descriptive names for your meetings to easily identify them later - **Regular Cleanup**: Periodically review and organize your cloud recordings ### Meeting Recording Tips - Start recording at the beginning of important meetings - Speak clearly and avoid overlapping conversations - Use a good quality microphone when possible - Consider recording in a quiet environment ## Finished Congratulations on setting up your Zoom integration! You can now seamlessly import meeting transcripts and use them to create professional documents with TurboDocx. --- # Cloud Connectors (Enterprise) Sometimes the details you need to send a document for signature, such as the signer's name and email, live in a system TurboDocx can't reach directly: an internal database behind your firewall, or an internal API that never faces the public internet. A **Cloud connector** bridges that gap, securely, without ever exposing your systems. :::info Enterprise feature Cloud connectors are an **Enterprise** feature, configured with help from our team. **[Contact us](https://www.turbodocx.com/contact)** to set one up for your organization. ::: ## What a Cloud Connector Does A Cloud connector is a lightweight worker you run **inside your own network**. When a pipeline needs to look up a signer in one of your private systems, the connector handles that lookup locally and returns just the values needed to proceed. Here's the flow in plain language: 1. A document arrives and the pipeline needs to resolve the signer from your internal system. 2. The pipeline **parks the run** and posts the lookup request to TurboDocx. 3. Your connector, running inside your network, **polls TurboDocx** for any pending lookups. 4. It **queries your system locally**, whether that's your database or your internal API, and gets the answer. 5. It **returns the resolved values** to TurboDocx, and signing proceeds with the correct signer. Your sensitive systems are queried where they live. Only the resolved result, meaning the signer's name and email for that document, travels back to TurboDocx. ## Built for Security The connector model is designed so your network stays sealed: - **TurboDocx never reaches into your network.** There is no inbound connection from us to you. - **Your connector makes only outbound HTTPS calls** to TurboDocx, the same kind of secure, outbound web traffic your systems already make. - **No inbound firewall holes.** Because everything is initiated from inside your network, you don't open any ports or expose any internal systems to the internet. - **Authenticated with an org-scoped API key**, so only your connector can pick up and answer your organization's lookups. This keeps your databases and internal APIs exactly where they are, behind your firewall, while still letting pipelines resolve signers from them. :::tip Why this matters Your most sensitive lookups (customer records, internal directories) never have to be copied to the cloud or exposed through a public endpoint. The data stays in your environment; only the answer leaves. ::: ## When to Use a Cloud Connector Reach for a Cloud connector when the signer for a document can't be determined from the document itself or a static value, and instead must be looked up in a system only your network can reach. For example: - The signer is an account owner stored in an **internal database**. - The right recipient comes from an **internal API** or directory service. - Signer assignment depends on business logic that lives in **your own systems**. For simpler cases, such as a single fixed signer or a signer whose email is printed on the document, you can use a static signer or an extracted field instead. See **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)** for those options. ## Getting Set Up Cloud connectors are configured **white-glove** by our team. We'll work with you to stand up a connector inside your network, scope its access, and wire it into your pipeline's signer resolution. ## Contact Sales - **[Get in touch](https://www.turbodocx.com/contact)**: Tell us about your setup and we'll follow up. - **Schedule a Demo**: See how a connector resolves a signer from an internal system end-to-end. - **Custom Setup**: We'll help connect a pipeline to your database or internal API securely. --- # Creating an E-Signature Pipeline This guide walks you through the pipeline wizard one step at a time. By the end, you'll have a hands-free automation that picks up PDFs from a folder, signs them, and files the results. The wizard has five steps, shown in the progress rail on the left: **Sample**, **Source**, **Extract & Route**, **Sign**, and **Deliver**. This guide follows them in that order. As you work, the **If this ran now** panel on the right previews what the pipeline would produce for your sample: the fields it extracted, where the document would land, and its final file name. Any warnings there (for example, "add a routing rule or a fallback folder") are guidance for finishing setup, not errors. :::info Enterprise feature Pipelines are an **Enterprise** feature. If you don't see the option to create one, **[Contact us](https://www.turbodocx.com/contact)** to enable it for your organization. ::: ## Before You Begin You'll need: - **SharePoint connected to TurboDocx *for Pipelines*.** Pipelines run unattended, so they use their own SharePoint connection. An administrator registers an Azure AD app in the Azure portal (app-only and delegated permissions, admin consent, and a client secret), then pastes its credentials into the **Connect SharePoint** dialog reached from the **Pipelines** settings gear. This is a one-time admin task, and you can't create a pipeline until it's done. If you're not an administrator, have IT complete the **[Connect SharePoint for Pipelines setup](./SharePoint-Pipelines-Troubleshooting-and-FAQ#setup-checklist)** first. (This is a *separate* connection from **[Configuring SharePoint or OneDrive](../Advanced-Configuration/Configuring-Sharepoint-or-OneDrive)**, which covers template import and export, not Pipelines.) - A **SharePoint document library** to use as your intake folder. - A **representative sample PDF**, meaning a real example of the kind of document this pipeline will process. - The details of who should sign these documents. ## Step 1: Upload a Sample Start by uploading a representative sample PDF. The wizard uses this sample to preview every later step against real content, so the fields you define and place line up with the documents you'll actually process. Choose a PDF that looks like the documents that will flow through this pipeline, with the same layout and the same kind of data. ![The Sample step with the PDF upload area highlighted](/img/creating-an-e-signature-pipeline/step1-sample.png) :::tip Pick a typical sample Use a document that represents the *common* case. If most of your invoices follow one layout, upload that layout rather than an unusual one-off. ::: ## Step 2: Source Now name the pipeline and connect the source it will watch. **SharePoint** is the source available today, with more connectors on the way. 1. **Name the pipeline** something descriptive, for example "Vendor Invoice Signing" or "NDA Intake." The **Active** toggle beside the name controls whether the pipeline starts watching as soon as you save it. 2. **Pick the SharePoint document library** to watch. This is your **dedicated intake library**, the folder where PDFs are dropped to kick off the pipeline. Clicking **Browse SharePoint** opens a Microsoft sign-in the first time. If the picker has nothing to choose, SharePoint isn't connected yet, see **Before You Begin** above. Think of the intake library as the pipeline's inbox: anything that lands there gets processed automatically. :::caution The source library can't be changed later Once the pipeline is activated, its source library is permanent. To point at a different library, you have to recreate the pipeline, so choose the intake library carefully. ::: ![The Source step with the SharePoint source picker highlighted, below the pipeline name field](/img/creating-an-e-signature-pipeline/step2-source.png) :::tip Use a dedicated library Create a library used *only* for this pipeline's intake. That keeps unrelated files from being picked up and processed by mistake. ::: ### Move Processed Files to a Sent Folder (Optional) Turn on **Move files to a Sent folder once sent for signing** to keep your intake library tidy. As soon as a document is sent for signing, its original moves out of the intake library into a Sent folder you choose. From then on, the intake library only ever shows what still needs sending. ![The Source step with the Move files to a Sent folder toggle highlighted](/img/creating-an-e-signature-pipeline/step2-source-move-to-sent.png) :::caution Keep the Sent folder separate The Sent folder must be a different folder from the intake library. If they were the same, moving a file back into the watched library would trigger the pipeline again. ::: ## Step 3: Extract & Route This step has three sections, matching the wizard: **Route each Document**, **Also extract**, and **Signature Placement**. The first two are optional; signature placement is required. 1. **Route each Document (optional)**: send documents that match certain text to different destination folders. For example, route anything containing "West Region" to one folder and "East Region" to another, each with its own **Pick Folder**. Anything that matches no rule lands in the default destination folder you'll choose in Step 5. Turn on **Required, fail if nothing matches** only if a document that matches no rule should be treated as an error instead. 2. **Also extract (optional)**: pull additional values out of each PDF, such as a customer code, a date, an email, or an amount. Extracted values can drive filenames, signer lookup, and routing. See **[Field Extraction](./Field-Extraction)** for the details and examples. 3. **Signature Placement (required)**: click **Place Fields** to open the placement tool, then pick a field type and click on the sample to drop signature, date, initial, and other fields. You must place **at least one signature field** before you can continue. TurboDocx pins each field to the same spot on every document the pipeline processes. See **[Field Placement](./Field-Placement)** for all the field types and details. ![The Extract & Route step, showing its Route each Document, Also extract, and Signature Placement sections](/img/creating-an-e-signature-pipeline/step3-extract-route.png) :::info Routing and extraction are optional, but signature placement is not If every document should land in the same place, skip routing and everything goes to the default destination folder. You must still place at least one signature field before you can continue. ::: ## Step 4: Signers (the "Sign" step) Tell the pipeline who should sign each document. Choose how the signer is resolved: - **Static signer**: the same person signs every document that flows through this pipeline. - **An extracted field**: use a value the pipeline read from the document itself (for example, an email address found in the PDF) to determine the signer per document. - **A Cloud connector (Enterprise)**: look the signer up in one of your own systems, such as an internal database or API. See **[Cloud Connectors](./Cloud-Connectors)** for how this works. Then set the sender identity recipients will see on the signature email: - **Sender name**: the name that appears as the sender of the signature request. - **Reply-to email**: the address recipients reach if they reply to the signature email. ![The Signers step, showing the three ways to resolve a signer: a fixed email and name, an extracted Document field, or a Cloud connector](/img/creating-an-e-signature-pipeline/step4-signers.png) :::tip Set a recognizable sender Recipients are more likely to trust and act on an email when the sender name and reply-to address clearly identify your organization. ::: ### What Signers See (Optional) Two optional fields control how the signature request appears to each signer: - **Document name shown to signers**: the name signers see for the document in the request email. Leave it blank to use the delivered file name. This is useful when the source file name is a code or an ID that wouldn't mean anything to the signer. - **Message to signers**: a short note shown in the body of each signer's request email. Leave it blank for no message. Both fields accept **insert-field** chips, so you can drop in values like the document name or the pipeline name and have them fill in for each document. ![The Signers step with the Document name shown to signers field highlighted, above the Message to signers field](/img/creating-an-e-signature-pipeline/step4-signer-document-name.png) ## Step 5: Destination & Review (the "Deliver" step) The final step decides where finished documents go and lets you review everything before saving. When everything looks right, click **Create Pipeline** to save and activate it. 1. **Default destination folder**: the fallback folder where signed PDFs are filed. Each Step 2 routing rule can send matches to its own folder; anything that matches no rule lands here. 2. **Filename pattern**: how each signed file is named. You can build the name from extracted values (for example, a customer code or date) so files are easy to find later. 3. **Audit-trail upload**: toggle on to file the signing audit trail (a separate PDF documenting the signing chain of custody) alongside each signed PDF. 4. **Deliver as a single ZIP file**: toggle on to bundle the signed PDF (and the audit trail, if enabled) into one `.zip` in the destination folder, instead of filing them as separate files. 5. **Notifications**: set who is emailed when a document is signed and delivered, and who is alerted when one fails. A failure recipient is required, so problems never go unnoticed. See **[Pipeline Notifications](./Pipeline-Notifications)** for default recipients, per-store overrides (a "store" is one routing rule), and how to customize the completion email. 6. **Review and save**: confirm the source, extraction, signer, and destination settings, then save to activate the pipeline. ![The Destination step, showing the destination picker, the default destination folder, and the filename pattern builder](/img/creating-an-e-signature-pipeline/step5-destination-review.png) To file everything as one archive instead of separate files, turn on **Deliver as a single ZIP file**. ![The Destination step with the Deliver as a single ZIP file toggle highlighted](/img/creating-an-e-signature-pipeline/step5-deliver-as-zip.png) ## You're Live 🎉 Once saved, the pipeline starts watching its intake library. From now on, every PDF dropped there is read, signed, and filed automatically, and you'll be alerted by email if anything needs attention. ## What's Next? - **[Pipeline Notifications](./Pipeline-Notifications)**: choose who is alerted on success and failure, and customize the completion email. - **[Field Extraction](./Field-Extraction)**: pull data out of each document with patterns. - **[Field Placement](./Field-Placement)**: position signature and form fields on the sample. - **[Cloud Connectors](./Cloud-Connectors)**: resolve signers from your own internal systems. --- # Field Extraction Field extraction is how a pipeline reads each document. As every PDF arrives, the pipeline scans its text for the values you've defined and captures them, so they can be used to name files, look up signers, and route documents to the right place. ## What It Is You define **patterns** that describe the data you want. For each incoming document, the pipeline finds the matching text and pulls the value out. Because extraction works on the document's text, you can reliably capture things like: - An invoice or customer code - A date - An email address - A dollar amount These captured values become reusable throughout the rest of the pipeline. ## Two Kinds of Fields ### Pattern Fields A **pattern field** uses a text pattern (a regular expression) to capture a value and store it in a **named variable**. That variable can then be used in: - **Filenames**: name the signed PDF after a customer code or date. - **Signer lookup**: use a captured email to determine who signs. - **Routing**: feed a captured value into a routing decision. ### Routing Fields A **routing field** matches text in the document and sends the document to a specific **destination folder**. Where a pattern field *captures and reuses* a value, a routing field *decides where the document goes*. For example, a routing field might look for the word "Renewal" and file any matching document into a "Renewals" folder, while everything else falls through to the default destination. ## Practical Examples | Goal | What you define | Captures | | --- | --- | --- | | Customer code | A pattern like `Customer\s*#?\s*(\d{6})` | `482194` | | Invoice date | A pattern matching `\d{2}/\d{2}/\d{4}` | `06/14/2026` | | Signer email | A pattern matching an email address | `ap@acme.com` | | Amount due | A pattern matching a currency value | `$4,250.00` | You name each captured value, then reference it later, for instance building a filename like `Invoice-{customerCode}-{invoiceDate}.pdf`. :::tip Test against your sample Because the wizard previews against the sample PDF you uploaded, you can confirm a pattern captures the right value before going live. ::: ## Where Extracted Values Are Used Once captured, values flow into three places: - **Filename builder**: compose meaningful, searchable filenames for signed documents. - **Signer resolution**: use an extracted value (such as an email) to determine the recipient per document. - **Routing**: steer documents to the correct destination folder based on what's inside them. ## A Note on Scanned Documents Extraction is **text-based**, meaning it reads the actual text inside the PDF. If a document is an **image-only scan** (a photo or flat scan with no embedded text), there's no text to read, so extraction can't capture values from it. :::info Signing still works A scanned, image-only PDF can still be signed by the pipeline. The *extraction-dependent* features, namely custom filenames, extracted-field signer lookup, and routing, fall back to their defaults when no text is available. The document still flows through and is filed in the default destination. ::: ## What's Next? - **[Field Placement](./Field-Placement)**: position signature and form fields on the sample. - **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)**: see where extraction fits in the wizard. --- # Field Placement Field placement is where you decide *where* signature and form fields appear on your documents. You position the fields once, on the sample PDF, and the pipeline reprojects that layout onto every live document it processes. ## Placing Fields on the Sample In the wizard's field placer, you work directly on the sample PDF you uploaded: 1. **Pick a field type** from the toolbar. 2. **Click to drop it** onto the page where it belongs. 3. **Position it** by dragging it into place. 4. **Choose the target page**: the first page, the last page, or a specific page number. 5. **Assign the recipient** who will complete that field. You can place as many fields as you need, mixing types freely across the document. ## Supported Field Types The placer offers full **TurboSign parity**, with the same field types you'd use when sending a document manually: | Field | Description | | --- | --- | | **Signature** | The signer draws or applies their signature. | | **Initial** | The signer initials a page or section. | | **Date** | The date the document is signed. | | **Text** | A free-text field the signer fills in. | | **Checkbox** | A box the signer checks to confirm or agree. | | **Full name** | The signer's complete name. | | **First name** | The signer's given name. | | **Last name** | The signer's family name. | | **Title** | The signer's job title. | | **Company** | The signer's organization. | | **Email** | The signer's email address. | **Signer-fillable fields** (signature, initial, date, text, and checkbox) are completed by the signer as they sign. **Identity fields** (full name, first name, last name, title, company, and email) capture details about the signer, which they confirm or complete during signing. :::info All placed fields are required Every field you place must be completed before a document can finish signing. Place only the fields you genuinely need. ::: ## How Placement Travels to Every Document When you position a field, its location is **stored relative to the page** rather than as a fixed pixel spot. That means the layout reprojects cleanly onto each live document at runtime, so a signature you placed at the bottom of the last page of your sample lands at the bottom of the last page of every invoice the pipeline processes, even as the content above it changes. :::tip Place against a representative sample Because placement is relative to the page, the closer your sample matches your real documents' layout, the more precisely fields will land on live runs. ::: ## What's Next? - **[Field Extraction](./Field-Extraction)**: pull data out of each document with patterns. - **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)**: see the full wizard from start to finish. --- # Pipeline Notifications Every pipeline run ends in one of two ways: the document is signed and delivered, or it fails somewhere along the way. This page shows how to control who hears about each outcome, how to send different teams different alerts, and how to customize the "signed and delivered" email your recipients receive. :::info Enterprise feature Pipelines are an **Enterprise** feature. If you don't see the option to create one, **[Contact us](https://www.turbodocx.com/contact)** to enable it for your organization. ::: You set **default notifications** on the **Destination** step (labeled **Deliver** in the wizard's progress rail), and **per-store overrides** on the **Extract & Route** step. If you haven't built a pipeline yet, start with **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)** and come back here to fine-tune the alerts. ## Default Notifications The **Default notifications** section defines two recipient lists that apply to every document the pipeline handles: 1. **Signed & delivered**: who gets an email once a document is signed and filed in its destination. This list is optional. Leave it empty if nobody needs a success confirmation. 2. **Failed**: who gets an alert when a document can't be processed. At least one address is required here, so a failure always reaches someone. Type an address into either box and press **Enter** to add it. Each address becomes a removable chip, so you can build a small distribution list for each outcome. ![The Default notifications section highlighted, with the Signed and delivered and Failed recipient lists](/img/pipeline-notifications/default-notifications.png) :::tip Send failures to a monitored inbox Point the **Failed** list at a shared inbox or ticket queue your team actually watches, not a single person's mailbox. That way an alert is never missed because someone is out of office. ::: ## Customize the Completion Email By default, the "signed and delivered" email uses a standard subject and body. To tailor it, click **Customize the email** under the Signed & delivered list. You can set: - **Subject line**: the subject recipients see. Leave it blank to use the default. - **Message**: a short note shown above the run details. Leave it blank to use the default. - **Attach the signed document and audit trail (ZIP)**: turn this on to attach the signed PDF and its audit trail to the email as a single ZIP file. To drop live values into the subject or message, use the **Insert a field** chips below each box. Available fields include the pipeline name, the signed file name, and the run ID, so a subject like `Your signed agreement is ready: {pipelineName}` fills in automatically for each run. ![The completion email customizer highlighted, showing the subject line, message, insert-field chips, and the attach-as-ZIP toggle](/img/pipeline-notifications/completion-email-customizer.png) :::note Large attachments If the ZIP attachment is too large to email, the completion email is still sent without the attachment so the notice always gets through. The signed document always lands in its destination folder regardless of the email. ::: ## Per-Store Notifications If your pipeline uses routing rules on the **Extract & Route** step, each routing rule is a "store" (for example, one region or one business unit). You can give any store its own recipients instead of the pipeline defaults. Each routing rule has a **Notify** chip. It reads **Default** when the store uses the pipeline's default notifications, and **Custom** once you override it. ![A routing rule with its Notify chip highlighted in the Extract and Route step](/img/pipeline-notifications/route-notify-chip.png) Click the **Notify** chip to open the **Store notifications** panel for that store. For both **Signed & delivered** and **Failed**, choose: - **Use pipeline default**: this store inherits the recipients you set in Default notifications. - **Custom recipients**: only the addresses you enter here are notified for this store. Enter each address and press **Enter**, then click **Save**. If you leave a custom **Failed** list empty, that store falls back to the default failure recipients, so failures always reach someone. ![The Store notifications panel with the Custom recipients option highlighted for the Signed and delivered outcome](/img/pipeline-notifications/store-notifications-drawer.png) :::tip Route alerts to the team that owns the work Per-store notifications are useful when different regions, branches, or departments each manage their own documents. Send each store's alerts to the people who actually act on them, and keep the pipeline defaults for everything else. ::: ## What's Next? - **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)**: build the pipeline these notifications belong to. - **[Field Extraction](./Field-Extraction)**: define the routing fields that create per-store rules. - **[Cloud Connectors](./Cloud-Connectors)**: resolve signers from your own internal systems. --- # SharePoint Pipelines Troubleshooting & FAQ Common issues when connecting a **SharePoint Pipeline** and setting up its bring-your-own (BYO) Azure AD app. ## Connection & sign-in ### "AADSTS500113: No reply address is registered for the application" **Cause:** Your Azure AD app has no **Redirect URI** registered. This trips people up because the Pipelines *runtime* (the folder-watch, file download, and signed-PDF write-back) uses **app-only** auth (client credentials), which needs **no** redirect URI. The **folder picker in the pipeline wizard**, however, signs you in **interactively in the browser**, and that flow **does** require one. If you followed the app-only setup only, the picker sign-in fails with `AADSTS500113`. **Solution:** 1. In the TurboDocx **Connect SharePoint** dialog, copy the **Redirect URI** it displays (it's your TurboDocx app origin, e.g. `https://app.turbodocx.com`). Use the **Copy** button so you get it exactly. 2. Open the [Azure portal](https://portal.azure.com) → **App registrations** → your Pipelines app → **Authentication**. 3. Click **Add a platform** → **Single-page application (SPA)** → paste the redirect URI → **Configure**. 4. Return to TurboDocx and retry. The picker sign-in now succeeds. :::tip Add the URI under **Single-page application (SPA)**, **not** "Web". The picker uses MSAL in the browser (PKCE), which requires the SPA platform type. A "Web" platform will still fail. ::: ### The picker still won't sign you in *after* adding the redirect URI **Cause:** The app is missing the **delegated** permission the interactive sign-in needs. The redirect URI gets you past `AADSTS500113`, but the browser sign-in also requires a delegated identity scope, typically **`User.Read`**, and often the delegated SharePoint read scopes for browsing. **Solution:** Add the delegated permissions in the [permissions list below](#required-api-permissions) (at minimum **`User.Read`** under Microsoft Graph, Delegated), then **Grant admin consent** and retry. ### "Connection failed" or a 403 when saving credentials or clicking Test connection **Cause:** The app is missing a required Microsoft Graph **Application permission**, or **admin consent** hasn't been granted. **Solution:** 1. Azure portal → your Pipelines app → **API permissions**. 2. Add the full set in [Required API permissions](#required-api-permissions) below, in particular the two people most often miss: **`Files.ReadWrite.All`** (Application) and **`User.Read`** (Delegated). 3. Click **Grant admin consent for <your tenant>** and confirm every row shows a green check. 4. Re-run **Test connection** in TurboDocx. ## Required API permissions The Pipelines app needs **both** app-only (Application) permissions for the unattended runtime **and** delegated permissions for the wizard's interactive folder picker. Add all of these, then **Grant admin consent**: | API | Permission | Type | Used by | |-----|-----------|------|---------| | Microsoft Graph | `Sites.ReadWrite.All` *(or `Sites.Selected` + per-site grant)* | **Application** | Runtime: read/write SharePoint items | | Microsoft Graph | `Files.ReadWrite.All` | **Application** | Runtime: download source PDFs, write signed PDFs | | Microsoft Graph | `User.Read` | **Delegated** | Picker: interactive sign-in | | Microsoft Graph | `Sites.ReadWrite.All` | **Delegated** | Picker: browse sites as the signed-in user | | Office 365 SharePoint Online | `AllSites.Read` | **Delegated** | Picker: list site collections | | Office 365 SharePoint Online | `MyFiles.Read` | **Delegated** | Picker: read user files | :::note `Files.ReadWrite.All` (Application) and `User.Read` (Delegated) are the two most commonly missed. Missing `Files.ReadWrite.All` breaks the pipeline run; missing `User.Read` breaks the picker sign-in even after the redirect URI is registered. ::: ### The SharePoint folder picker opens to a blank / gray screen **Cause:** A stale MSAL session cached in the browser. **Solution:** Clear the site's **localStorage and cookies** (or use a fresh incognito window), then reopen the picker and sign in again. ## Setup checklist (avoids the errors above) When registering the BYO Azure AD app for SharePoint Pipelines, make sure you do **all** of these: - **Authentication** → add a **Single-page application (SPA)** platform with the Redirect URI shown in the Connect dialog (prevents `AADSTS500113`). - **API permissions** → add **all** of the [Required API permissions](#required-api-permissions) above, both the **Application** perms (`Sites.ReadWrite.All`, `Files.ReadWrite.All`) and the **Delegated** perms (`User.Read`, plus the SharePoint browse scopes) → **Grant admin consent** (prevents the 403 on save/test *and* the picker sign-in failure). - **Certificates & secrets** → create a client secret and copy the **value** immediately. - Paste **Application (client) ID**, **client secret value**, and **Directory (tenant) ID** into the TurboDocx Connect dialog. --- # TurboDocx Pipelines TurboDocx Pipelines turn a folder into a fully automated signing assembly line. Drop a PDF into a watched location, and the pipeline reads it, places the right signature and form fields, sends it for signature through TurboSign, and files the signed result, all without anyone clicking a button. :::info Enterprise feature Pipelines and Cloud Connectors are **Enterprise** features. **[Contact us](https://www.turbodocx.com/contact)** to enable them for your organization. ::: ## What Is a Pipeline? A **pipeline** is an automation that watches a source location for new documents and processes each one from start to finish without manual steps. For every PDF that arrives, the pipeline: 1. **Reads the document** and extracts the data you care about (an invoice number, a customer code, a date, an email address). 2. **Places signature and form fields** in the right spots, based on a layout you defined once. 3. **Sends it for signature** through TurboSign to the correct signer. 4. **Files the signed PDF**, along with its audit trail, into a destination folder you choose. Once it's set up, you never touch it. New documents are picked up, signed, and filed automatically. ## The Source → Manipulation → Destination Model Every pipeline follows the same three-part shape: ### Source The place the pipeline watches. **SharePoint** is the source available today, a "dedicated intake library" where you (or an upstream system) drop PDFs that need signing. It's the first of several planned connectors, with more sources on the way. ### Manipulation What happens to each document in flight: **field extraction** pulls values out of the PDF text, **field placement** decides where signature and form fields land, **routing** can send different documents to different destinations, and the document is sent for signature through TurboSign. ### Destination Where finished work lands. Signed PDFs and their audit trails are filed into a **SharePoint** destination folder today, with a filename pattern you control. As with sources, SharePoint is the first available connector and more destinations are on the way. ## Who It's For Pipelines are built for teams that handle a steady, repeatable flow of documents that all need the same treatment. Common uses include: - **Invoice signing**: incoming invoices are signed off and filed automatically. - **Contract intake**: agreements dropped into a folder are routed to the right signer and archived once complete. - **Order forms, approvals, and renewals**: any high-volume document that follows a predictable layout. If your team is manually downloading, signing, and re-filing the same kind of document over and over, a pipeline removes that work entirely. ## How It Fits Together | Stage | What you configure | What happens at runtime | | --- | --- | --- | | **Source** | Pick a SharePoint library to watch | New PDFs are picked up automatically | | **Extract & Route** | Define fields to pull + optional routing rules | Values are read from each document; matches steer the destination | | **Signers** | Choose how the signer is resolved | The right recipient is determined per document | | **Field placement** | Drop fields on a sample once | Fields reproject onto each live document | | **Destination** | Pick a folder, filename pattern, alerts | Signed PDF + audit trail are filed away | ## Get Started Ready to set one up? See **[Creating an E-Signature Pipeline](./Creating-an-E-Signature-Pipeline)** for the step-by-step walkthrough. ## Contact Sales Pipelines and Cloud Connectors are part of TurboDocx's Enterprise offering and are configured with help from our team. - **[Get in touch](https://www.turbodocx.com/contact)**: Tell us about your intake process and we'll follow up. - **Schedule a Demo**: See a pipeline process a real document end-to-end. - **Custom Setup**: We'll help map your intake process to a pipeline tailored to your business. --- ## Install with AI Agents (Agent Skills) # Install TurboDocx with AI Agents The TurboDocx Quickstart Skill is an [Agent Skills](https://agentskills.io) plugin that lets your AI coding agent install and wire up the TurboDocx SDK or `@turbodocx/html-to-docx` in a single prompt. It detects your project's language and framework, installs the package, configures environment variables, and generates working integration code that matches your existing codebase patterns. Works with: - **Claude Code** - **GitHub Copilot** (VS Code) - **Cursor** - **OpenCode** - **OpenAI Codex CLI** - **Gemini CLI** - Any tool that supports the [Agent Skills](https://agentskills.io) standard ## Skills | Skill | What it does | | :--- | :--- | | **`turbodocx-sdk`** | Installs the TurboDocx SDK and generates working integration code for **TurboSign** (e-signatures), **Deliverable** (template document generation), **TurboQuote** (quotes & CPQ), **TurboWebhooks** (signature events), and **TurboPartner** (partner/org management). Supports JavaScript/TypeScript, Python, Go, PHP, and Java. | | **`turbodocx-html-to-docx`** | Sets up [`@turbodocx/html-to-docx`](https://www.npmjs.com/package/@turbodocx/html-to-docx) to convert HTML to Microsoft Word documents in Node.js, browser, or hybrid projects. | ## Install The fastest path is the `npx skills` CLI, which auto-detects which agents you have installed and drops the skills into the right config directory. ```bash npx skills add TurboDocx/quickstart ``` ```bash npx skills add TurboDocx/quickstart --skill turbodocx-sdk ``` ```bash npx skills add TurboDocx/quickstart --skill turbodocx-html-to-docx ``` ```bash npx skills add TurboDocx/quickstart -g ``` ## Usage After installing, invoke the skill in your editor or terminal: ```text /turbodocx-sdk ``` The skill will: 1. **Detect** your project language from manifest files (`package.json`, `pyproject.toml`, `go.mod`, `composer.json`, `pom.xml`). 2. **Ask** which product you need. By default it offers **TurboSign** and **Deliverable** (and the generate-then-sign combo); **TurboPartner**, **TurboWebhooks**, and **TurboQuote** are opt-in via their shortcuts (below). 3. **Install** the SDK with your project's package manager. 4. **Configure** environment variables in `.env` and `.env.example`. 5. **Analyze** your codebase structure (Express, FastAPI, Spring Boot, Laravel, Gin, etc.). 6. **Generate** integration code that matches your existing patterns, with route handlers wired into your app. ### Shortcuts Skip the product selection prompt: ```text /turbodocx-sdk turbosign # TurboSign (e-signatures) only /turbodocx-sdk deliverable # Deliverable (template document generation) only /turbodocx-sdk turbosign+deliverable # generate-then-sign workflow /turbodocx-sdk turboquote # TurboQuote (quotes, catalog, price books) only /turbodocx-sdk turbowebhooks # TurboWebhooks (signature events) only /turbodocx-sdk turbopartner # TurboPartner (org provisioning) only /turbodocx-sdk both # TurboSign + Deliverable ``` For HTML-to-DOCX: ```text /turbodocx-html-to-docx ``` ## What gets generated ### TurboSign - Client configuration with environment variable loading - `sendSignature()` — send documents for e-signature - `getStatus()` — check the document-level status - `getRecipients()` — per-signer status, email history, and who sent it - `download()` — retrieve the signed PDF - Optional helpers when you need them: `createSignatureReviewLink()` (preview before sending), `void()`, `resend()`, and `getAuditTrail()` - A route handler wired into your existing app ### Deliverable - `generateDeliverable()` — render a document from a template with variable substitution - `getDeliverableDetails()` — fetch a generated deliverable by ID - `downloadPDF()` / `downloadSourceFile()` — download the rendered output - If you also pick TurboSign, the generate-then-sign workflow (render a template, then route it for signature) ### TurboQuote - `createQuote()`, `addLineItems()`, `sendQuote()`, `downloadQuotePdf()` — build, send, and export quotes and proposals - Optional catalog scaffolding — `createProduct()`, `createBundle()`, `createPriceBook()`, `applyPriceBook()` - Optional payments path — `getPaymentConnectionStatus()` (gate on the seller being connected), `createPaymentLink()`, and `getPaymentStatus()`, plus a verified `quote.payment.succeeded` webhook (adds `TURBODOCX_WEBHOOK_SECRET`) ### TurboWebhooks - A verified webhook endpoint that subscribes to signature events (for example `signature.document.completed`) - `X-TurboDocx-Signature` verification so you can trust incoming payloads - Requires an **administrator-role** API key (non-admin keys are rejected); the endpoint is registered under the fixed name `signature` ### TurboPartner - Partner client configuration - `createOrganization()` — provision customer organizations - `listOrganizations()` — list managed orgs - `updateOrganizationEntitlements()` — set an organization's features and entitlements - A route handler wired into your existing app ### `@turbodocx/html-to-docx` - Helper module appropriate for your framework (Express, Next.js API route, Fastify, NestJS controller, etc.) - A working endpoint that returns a `.docx` response - Browser-bundle setup if you're running in a static HTML / no-bundler project ## Prerequisites - **API credentials** from the [TurboDocx dashboard](https://app.turbodocx.com). Which ones depend on the product (the skill writes them into `.env` and `.env.example` for you): - Most products use `TURBODOCX_API_KEY` + `TURBODOCX_ORG_ID`. - **TurboSign** also needs a verified `TURBODOCX_SENDER_EMAIL` (plus optional `TURBODOCX_SENDER_NAME`) — `sendSignature()` fails without it. - **TurboWebhooks** needs an **administrator-role** API key. - **TurboPartner** uses separate `TURBODOCX_PARTNER_API_KEY` + `TURBODOCX_PARTNER_ID`. - **TurboQuote payments** add `TURBODOCX_WEBHOOK_SECRET`. - Node.js to run the `npx skills` installer. ## Source and feedback The skill is open source at **[github.com/TurboDocx/quickstart](https://github.com/TurboDocx/quickstart)** — issues, PRs, and feature requests welcome. ## Related - [SDKs Overview](./index.md) - [TurboSign JavaScript SDK](/docs/SDKs/turbosign?language=js) - [TurboSign Python SDK](/docs/SDKs/turbosign?language=python) - [TurboPartner JavaScript SDK](./partner-javascript.md) --- # Deliverable SDK The official TurboDocx Deliverable SDK for JavaScript and TypeScript applications. Generate documents from templates with dynamic variable injection, download source files and PDFs, and manage deliverables programmatically. Available on npm as `@turbodocx/sdk`. The official TurboDocx Deliverable SDK for Python applications. Generate documents from templates with dynamic variable injection, download source files and PDFs, and manage deliverables programmatically with async/await patterns and comprehensive error handling. Available on PyPI as `turbodocx-sdk`. The official TurboDocx Deliverable SDK for PHP applications. Generate documents from templates with dynamic variable injection, download source files and PDFs, and manage deliverables programmatically. Available on Packagist as `turbodocx/sdk`. The official TurboDocx Deliverable SDK for Go applications. Generate documents from templates with dynamic variable injection, download source files and PDFs, and manage deliverables programmatically with idiomatic Go patterns, context support, and comprehensive error handling. Available as `github.com/TurboDocx/SDK/packages/go-sdk`. The official TurboDocx Deliverable SDK for Java applications. Generate documents from templates with dynamic variable injection, download source files and PDFs, and manage deliverables programmatically with the Builder pattern, comprehensive error handling, and type-safe APIs. Available on Maven Central as `com.turbodocx:turbodocx-sdk`. ## Installation {#installation} ```bash npm install @turbodocx/sdk ``` ```bash yarn add @turbodocx/sdk ``` ```bash pnpm add @turbodocx/sdk ``` ```bash pip install turbodocx-sdk ``` ```bash poetry add turbodocx-sdk ``` ```bash pipenv install turbodocx-sdk ``` ```bash composer require turbodocx/sdk ``` ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ## Requirements {#requirements} - Node.js 18+ or modern browser - TypeScript 4.7+ (optional, for type checking) - Python 3.9+ - `httpx` (installed automatically) - PHP 8.1 or higher - Composer - ext-json - ext-fileinfo :::tip Modern PHP Features This SDK leverages PHP 8.1+ features including enums, named parameters, readonly classes, and match expressions for a superior developer experience. ::: - Go 1.21+ - Java 11+ - OkHttp 4.x (included) - Gson 2.x (included) --- ## Configuration {#configuration} ```javascript const { Deliverable } = require("@turbodocx/sdk"); // Configure globally (recommended for server-side) Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY, // Required: Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID, // Required: Your organization ID // Optional: OAuth access token instead of an API key // accessToken: process.env.TURBODOCX_ACCESS_TOKEN, // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` ```typescript // Configure globally (recommended for server-side) Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", // Required: Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID || "", // Required: Your organization ID // Optional: OAuth access token instead of an API key // accessToken: process.env.TURBODOCX_ACCESS_TOKEN, // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` **`DeliverableConfig` fields:** | Property | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------------ | | `apiKey` | `string` | Yes\* | Your TurboDocx API key | | `accessToken` | `string` | Yes\* | OAuth access token, alternative to `apiKey` | | `orgId` | `string` | Yes | Your organization ID | | `baseUrl` | `string` | No | API base URL (defaults to `https://api.turbodocx.com`) | \*Supply either `apiKey` or `accessToken`. When both are set, `accessToken` wins. :::tip No Sender Email Required Unlike TurboSign, the Deliverable module only requires a credential and `orgId`: no sender email or name is needed. ::: ```python from turbodocx_sdk import Deliverable # Configure globally (recommended) Deliverable.configure( api_key=os.environ["TURBODOCX_API_KEY"], # Required: Your TurboDocx API key org_id=os.environ["TURBODOCX_ORG_ID"], # Required: Your organization ID # base_url="https://api.turbodocx.com" # Optional: Override base URL ) ``` :::tip No Sender Email Required Unlike TurboSign, the Deliverable module only requires `api_key` and `org_id`: no sender email or name is needed. ::: ```php ```php :::tip No senderEmail Required Unlike TurboSign, the Deliverable module only requires `apiKey` and `orgId`: no sender email or name is needed. ::: ```go package main "log" "os" sdk "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // Option 1: Standalone deliverable client (no SenderEmail needed) deliverable, err := sdk.NewDeliverableClientOnly(sdk.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } // Option 2: Full client (includes TurboSign + Deliverable) client, err := sdk.NewClientWithConfig(sdk.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), SenderEmail: "sender@example.com", BaseURL: "https://api.turbodocx.com", // Optional custom base URL }) if err != nil { log.Fatal(err) } deliverable = client.Deliverable } ``` :::tip No SenderEmail Required Use `NewDeliverableClientOnly()` when you only need document generation: it skips the `SenderEmail` validation required by TurboSign. ::: ```java public class Main { public static void main(String[] args) { // Option 1: Standalone deliverable client (no senderEmail needed) DeliverableClient deliverable = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildDeliverableClient(); // Option 2: Full client (includes TurboSign + Deliverable) TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail("sender@example.com") .build(); DeliverableClient deliverable = client.deliverable(); } } ``` :::tip No senderEmail Required Use `buildDeliverableClient()` when you only need document generation: it skips the `senderEmail` validation required by TurboSign. ::: ### Environment Variables {#environment-variables} ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here ``` ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here ``` :::caution API Credentials Required Both `api_key` and `org_id` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here ``` :::caution API Credentials Required An `apiKey` (or `accessToken` as an alternative) is **required** for all API requests; `orgId` is optional but recommended for organization-scoped operations. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here ``` ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here ``` :::caution API Credentials Required Both `apiKey` and `orgId` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ## Quick Start {#quick-start} ### Generate a document from a template {#generate-a-document-from-a-template} ```javascript const { Deliverable } = require("@turbodocx/sdk"); Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, }); // Generate a document from a template with variables const result = await Deliverable.generateDeliverable({ name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corporation", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ], description: "Quarterly business report", tags: ["reports", "quarterly"], }); console.log(JSON.stringify(result, null, 2)); ``` ```typescript Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", orgId: process.env.TURBODOCX_ORG_ID || "", }); // Generate a document from a template with variables const request: CreateDeliverableRequest = { name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corporation", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ], description: "Quarterly business report", tags: ["reports", "quarterly"], }; const result = await Deliverable.generateDeliverable(request); console.log(JSON.stringify(result, null, 2)); ``` ```python from turbodocx_sdk import Deliverable Deliverable.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"] ) async def generate_report(): # Generate a document from a template with variables result = await Deliverable.generate_deliverable( name="Q1 Report", template_id="your-template-id", variables=[ {"placeholder": "{CompanyName}", "text": "Acme Corporation", "mimeType": "text"}, {"placeholder": "{Date}", "text": "2026-03-12", "mimeType": "text"}, ], description="Quarterly business report", tags=["reports", "quarterly"], ) print("Result:", json.dumps(result, indent=2)) asyncio.run(generate_report()) ``` ```php 'Q1 Report', 'templateId' => 'your-template-id', 'variables' => [ ['placeholder' => '{CompanyName}', 'text' => 'Acme Corporation', 'mimeType' => 'text'], ['placeholder' => '{Date}', 'text' => '2026-03-12', 'mimeType' => 'text'], ], 'description' => 'Quarterly business report', 'tags' => ['reports', 'quarterly'], ]); echo "Deliverable ID: {$result['results']['deliverable']['id']}\n"; ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, wrap all Deliverable calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: ```go package main "context" "encoding/json" "fmt" "log" "os" sdk "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { deliverable, err := sdk.NewDeliverableClientOnly(sdk.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } ctx := context.Background() result, err := deliverable.GenerateDeliverable(ctx, &sdk.CreateDeliverableRequest{ Name: "Q1 Report", TemplateID: "your-template-id", Variables: []sdk.DeliverableVariable{ {Placeholder: "{CompanyName}", Text: "Acme Corporation", MimeType: "text"}, {Placeholder: "{Date}", Text: "2026-03-12", MimeType: "text"}, }, Description: "Quarterly business report", Tags: []string{"reports", "quarterly"}, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) } ``` ```java public class Main { public static void main(String[] args) throws Exception { DeliverableClient deliverable = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildDeliverableClient(); Gson gson = new GsonBuilder().setPrettyPrinting().create(); DeliverableVariable var1 = new DeliverableVariable(); var1.setPlaceholder("{CompanyName}"); var1.setText("Acme Corporation"); var1.setMimeType("text"); DeliverableVariable var2 = new DeliverableVariable(); var2.setPlaceholder("{Date}"); var2.setText("2026-03-12"); var2.setMimeType("text"); CreateDeliverableRequest request = new CreateDeliverableRequest(); request.setName("Q1 Report"); request.setTemplateId("your-template-id"); request.setVariables(List.of(var1, var2)); request.setDescription("Quarterly business report"); request.setTags(List.of("reports", "quarterly")); CreateDeliverableResponse result = deliverable.generateDeliverable(request); System.out.println("Result: " + gson.toJson(result)); } } ``` ### Download and manage deliverables {#download-and-manage-deliverables} ```javascript const { Deliverable } = require("@turbodocx/sdk"); const { writeFileSync } = require("fs"); // List deliverables with pagination const list = await Deliverable.listDeliverables({ limit: 10, showTags: true }); console.log(`Total: ${list.totalRecords}`); // Get deliverable details const details = await Deliverable.getDeliverableDetails("deliverable-uuid"); console.log(`Name: ${details.name}`); // Download source file (DOCX/PPTX) const buffer = await Deliverable.downloadSourceFile("deliverable-uuid"); writeFileSync("report.docx", Buffer.from(buffer)); // Download PDF const pdfBuffer = await Deliverable.downloadPDF("deliverable-uuid"); writeFileSync("report.pdf", Buffer.from(pdfBuffer)); // Update deliverable await Deliverable.updateDeliverableInfo("deliverable-uuid", { name: "Q1 Report - Final", description: "Final quarterly business report", }); // Delete deliverable await Deliverable.deleteDeliverable("deliverable-uuid"); ``` ```typescript // List deliverables with pagination const list = await Deliverable.listDeliverables({ limit: 10, showTags: true }); console.log(`Total: ${list.totalRecords}`); // Get deliverable details const details = await Deliverable.getDeliverableDetails("deliverable-uuid"); console.log(`Name: ${details.name}`); // Download source file (DOCX/PPTX) const buffer = await Deliverable.downloadSourceFile("deliverable-uuid"); writeFileSync("report.docx", Buffer.from(buffer)); // Download PDF const pdfBuffer = await Deliverable.downloadPDF("deliverable-uuid"); writeFileSync("report.pdf", Buffer.from(pdfBuffer)); // Update deliverable await Deliverable.updateDeliverableInfo("deliverable-uuid", { name: "Q1 Report - Final", description: "Final quarterly business report", }); // Delete deliverable await Deliverable.deleteDeliverable("deliverable-uuid"); ``` ```python from turbodocx_sdk import Deliverable Deliverable.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"] ) async def manage_deliverables(): # List deliverables with pagination items = await Deliverable.list_deliverables(limit=10, show_tags=True) print(f"Total: {items['totalRecords']}") # Get deliverable details details = await Deliverable.get_deliverable_details("deliverable-uuid") print(f"Name: {details['name']}") # Download source file (DOCX/PPTX) source_bytes = await Deliverable.download_source_file("deliverable-uuid") with open("report.docx", "wb") as f: f.write(source_bytes) # Download PDF pdf_bytes = await Deliverable.download_pdf("deliverable-uuid") with open("report.pdf", "wb") as f: f.write(pdf_bytes) # Update deliverable await Deliverable.update_deliverable_info( "deliverable-uuid", name="Q1 Report - Final", description="Final quarterly business report", ) # Delete deliverable await Deliverable.delete_deliverable("deliverable-uuid") asyncio.run(manage_deliverables()) ``` ```php 10, 'showTags' => true]); echo "Total: {$list['totalRecords']}\n"; // Get deliverable details $details = Deliverable::getDeliverableDetails('deliverable-uuid', showTags: true); echo "Name: {$details['name']}\n"; // Download source file (DOCX/PPTX) $sourceFile = Deliverable::downloadSourceFile('deliverable-uuid'); file_put_contents('report.docx', $sourceFile); // Download PDF $pdfFile = Deliverable::downloadPDF('deliverable-uuid'); file_put_contents('report.pdf', $pdfFile); // Update deliverable $updated = Deliverable::updateDeliverableInfo('deliverable-uuid', [ 'name' => 'Q1 Report - Final', 'description' => 'Final quarterly business report', ]); // Delete deliverable $deleted = Deliverable::deleteDeliverable('deliverable-uuid'); ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, wrap all Deliverable calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: ```go package main "context" "encoding/json" "fmt" "log" "os" sdk "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { deliverable, err := sdk.NewDeliverableClientOnly(sdk.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } ctx := context.Background() // List deliverables with pagination list, err := deliverable.ListDeliverables(ctx, &sdk.ListDeliverablesOptions{ Limit: 10, ShowTags: true, }) if err != nil { log.Fatal(err) } fmt.Printf("Total: %d\n", list.TotalRecords) // Get deliverable details details, err := deliverable.GetDeliverableDetails(ctx, "deliverable-uuid", &sdk.GetDeliverableOptions{ ShowTags: true, }) if err != nil { log.Fatal(err) } fmt.Printf("Name: %s\n", details.Name) // Download source file (DOCX/PPTX) sourceData, err := deliverable.DownloadSourceFile(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } os.WriteFile("report.docx", sourceData, 0644) // Download PDF pdfData, err := deliverable.DownloadPDF(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } os.WriteFile("report.pdf", pdfData, 0644) // Update deliverable updateResult, err := deliverable.UpdateDeliverableInfo(ctx, "deliverable-uuid", &sdk.UpdateDeliverableRequest{ Name: "Q1 Report - Final", Description: "Final quarterly business report", Tags: &[]string{"reports", "final"}, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(updateResult, "", " "); fmt.Println("Result:", string(b)) // Delete deliverable deleteResult, err := deliverable.DeleteDeliverable(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } b, _ = json.MarshalIndent(deleteResult, "", " "); fmt.Println("Result:", string(b)) } ``` ```java public class Main { public static void main(String[] args) throws Exception { DeliverableClient deliverable = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildDeliverableClient(); Gson gson = new GsonBuilder().setPrettyPrinting().create(); // List deliverables with pagination ListDeliverablesRequest listRequest = new ListDeliverablesRequest(); listRequest.setLimit(10); listRequest.setShowTags(true); DeliverableListResponse list = deliverable.listDeliverables(listRequest); System.out.println("Total: " + list.getTotalRecords()); // Get deliverable details DeliverableRecord details = deliverable.getDeliverableDetails("deliverable-uuid"); System.out.println("Name: " + details.getName()); // Download source file (DOCX/PPTX) byte[] sourceFile = deliverable.downloadSourceFile("deliverable-uuid"); Files.write(Paths.get("report.docx"), sourceFile); // Download PDF byte[] pdfFile = deliverable.downloadPDF("deliverable-uuid"); Files.write(Paths.get("report.pdf"), pdfFile); // Update deliverable UpdateDeliverableRequest updateRequest = new UpdateDeliverableRequest(); updateRequest.setName("Q1 Report - Final"); updateRequest.setDescription("Final quarterly business report"); updateRequest.setTags(List.of("reports", "final")); deliverable.updateDeliverableInfo("deliverable-uuid", updateRequest); // Delete deliverable deliverable.deleteDeliverable("deliverable-uuid"); } } ``` --- ## Variable Types {#variable-types} The Deliverable module supports four variable types for template injection: ### 1. Text Variables {#1-text-variables} Inject plain text values into template placeholders: ```javascript const variables = [ { placeholder: "{CompanyName}", text: "Acme Corporation", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ]; ``` ```typescript const variables: DeliverableVariable[] = [ { placeholder: "{CompanyName}", text: "Acme Corporation", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ]; ``` ```python variables = [ {"placeholder": "{CompanyName}", "text": "Acme Corporation", "mimeType": "text"}, {"placeholder": "{Date}", "text": "2026-03-12", "mimeType": "text"}, ] ``` ```php $variables = [ ['placeholder' => '{CompanyName}', 'text' => 'Acme Corporation', 'mimeType' => 'text'], ['placeholder' => '{Date}', 'text' => '2026-03-12', 'mimeType' => 'text'], ]; ``` ```go variables := []sdk.DeliverableVariable{ {Placeholder: "{CompanyName}", Text: "Acme Corporation", MimeType: "text"}, {Placeholder: "{Date}", Text: "2026-03-12", MimeType: "text"}, } ``` ```java DeliverableVariable var = new DeliverableVariable(); var.setPlaceholder("{CompanyName}"); var.setText("Acme Corporation"); var.setMimeType("text"); ``` ### 2. HTML Variables {#2-html-variables} Inject rich HTML content with formatting: ```javascript const variables = [ { placeholder: "{Summary}", text: "This is a formatted summary with rich text.", mimeType: "html", }, ]; ``` ```python variables = [ { "placeholder": "{Summary}", "text": "This is a formatted summary with rich text.", "mimeType": "html", }, ] ``` ```php $variables = [ [ 'placeholder' => '{Summary}', 'text' => 'This is a formatted summary with rich text.', 'mimeType' => 'html', ], ]; ``` ```go variables := []sdk.DeliverableVariable{ { Placeholder: "{Summary}", Text: "This is a formatted summary with rich text.", MimeType: "html", }, } ``` ```java DeliverableVariable var = new DeliverableVariable(); var.setPlaceholder("{Summary}"); var.setText("This is a formatted summary with rich text."); var.setMimeType("html"); ``` ### 3. Image Variables {#3-image-variables} Inject images by providing a URL or base64-encoded content: ```javascript const variables = [ { placeholder: "{Logo}", text: "https://example.com/logo.png", mimeType: "image", }, ]; ``` ```python variables = [ { "placeholder": "{Logo}", "text": "https://example.com/logo.png", "mimeType": "image", }, ] ``` ```php $variables = [ [ 'placeholder' => '{Logo}', 'text' => 'https://example.com/logo.png', 'mimeType' => 'image', ], ]; ``` ```go variables := []sdk.DeliverableVariable{ { Placeholder: "{Logo}", Text: "https://example.com/logo.png", MimeType: "image", }, } ``` ```java DeliverableVariable var = new DeliverableVariable(); var.setPlaceholder("{Logo}"); var.setText("https://example.com/logo.png"); var.setMimeType("image"); ``` ### 4. Markdown Variables {#4-markdown-variables} Inject markdown content that gets converted to formatted text: ```javascript const variables = [ { placeholder: "{Notes}", text: "## Key Points\n- First item\n- Second item\n\n**Important:** Review before submission.", mimeType: "markdown", }, ]; ``` :::info Variable Stack For repeating content (e.g., table rows), use `variableStack` instead of `text` to provide multiple values for the same placeholder. See the [Types section](#createdeliverablerequest) for details. ::: ```python variables = [ { "placeholder": "{Notes}", "text": "## Key Points\n- First item\n- Second item\n\n**Important:** Review before submission.", "mimeType": "markdown", }, ] ``` :::info Variable Stack For repeating content (e.g., table rows), use `variableStack` instead of `text` to provide multiple values for the same placeholder. See the [Types section](#createdeliverablerequest) for details. ::: ```php $variables = [ [ 'placeholder' => '{Notes}', 'text' => "## Key Points\n- First item\n- Second item\n\n**Important:** Review before submission.", 'mimeType' => 'markdown', ], ]; ``` :::info Variable Stack For repeating content (e.g., table rows), use `variableStack` instead of `text` to provide multiple values for the same placeholder. See the [PHP Types section](#php-types) for details. ::: ```go variables := []sdk.DeliverableVariable{ { Placeholder: "{Notes}", Text: "## Key Points\n- First item\n- Second item\n\n**Important:** Review before submission.", MimeType: "markdown", }, } ``` :::info Variable Stack For repeating content (e.g., table rows), use `VariableStack` instead of `Text` to provide multiple values for the same placeholder. See the [Types section](#createdeliverablerequest) for details. ::: ```java DeliverableVariable var = new DeliverableVariable(); var.setPlaceholder("{Notes}"); var.setText("## Key Points\n- First item\n- Second item\n\n**Important:** Review before submission."); var.setMimeType("markdown"); ``` :::info Variable Stack For repeating content (e.g., table rows), use `setVariableStack()` instead of `setText()` to provide multiple values for the same placeholder. See the [Types section](#createdeliverablerequest) for details. ::: --- ## API Reference {#api-reference} :::note Async snippets The snippets below are partial and run inside an `async` function. They assume you have already called `Deliverable.configure(...)` and that `asyncio` and `json` are imported (`import asyncio, json`). For a complete runnable script, wrap the calls in `async def main(): ...` and run with `asyncio.run(main())`, as shown in [Quick Start](#quick-start). ::: ### Configure {#configure} Configure the SDK with your API credentials and organization settings. **Example:** ```javascript const { Deliverable } = require("@turbodocx/sdk"); Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, // Optional: override for testing // baseUrl: 'https://api.turbodocx.com' }); ``` ```typescript Deliverable.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", orgId: process.env.TURBODOCX_ORG_ID || "", // Optional: override for testing // baseUrl: 'https://api.turbodocx.com' }); ``` :::caution API Credentials Required Both `apiKey` and `orgId` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: Configure the SDK with your API credentials and organization settings. ```python Deliverable.configure( api_key: Optional[str] = None, # API key (or use access_token) access_token: Optional[str] = None, # OAuth2 access token (alternative to api_key) base_url: str = "https://api.turbodocx.com", # Optional: API base URL org_id: Optional[str] = None, # Required: Your organization ID ) ``` :::caution API Credentials Required All parameters are optional and keyword-only. Either `api_key` or `access_token` must be provided for authentication, and `org_id` is **required** for all Deliverable operations (enforced at runtime). To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: Configure the SDK with your API credentials and organization settings. ```php use TurboDocx\Deliverable; use TurboDocx\Config\DeliverableConfig; // Manual configuration Deliverable::configure(new DeliverableConfig( apiKey: 'your-api-key', orgId: 'your-org-id' )); // Or from environment Deliverable::configure(DeliverableConfig::fromEnvironment()); ``` Create a new TurboDocx Deliverable client. ```go // Standalone deliverable client (no SenderEmail needed) deliverable, err := sdk.NewDeliverableClientOnly(sdk.ClientConfig{ APIKey: "your-api-key", OrgID: "your-org-id", }) // Full client (includes TurboSign + Deliverable) client, err := sdk.NewClientWithConfig(sdk.ClientConfig{ APIKey: "your-api-key", OrgID: "your-org-id", SenderEmail: "sender@example.com", BaseURL: "https://api.turbodocx.com", // Optional }) deliverable := client.Deliverable ``` :::caution API Credentials Required Both `APIKey` and `OrgID` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: Create a new Deliverable client using the Builder pattern. ```java // Standalone deliverable client DeliverableClient deliverable = new TurboDocxClient.Builder() .apiKey("your-api-key") // Required .orgId("your-org-id") // Required .buildDeliverableClient(); // Or from the full client TurboDocxClient client = new TurboDocxClient.Builder() .apiKey("your-api-key") // Required .orgId("your-org-id") // Required .senderEmail("sender@co.com") // Required for TurboSign .build(); DeliverableClient deliverable = client.deliverable(); ``` The builder authenticates with either `apiKey(...)` or `accessToken(...)` (a bearer token), and exposes three build targets: | Builder method | Returns | Use for | | -------------------------- | ------------------- | --------------------------------------------------- | | `build()` | `TurboDocxClient` | Full client, `turboSign()` and `deliverable()` | | `buildDeliverableClient()` | `DeliverableClient` | Document generation only (no `senderEmail` needed) | | `buildWebhooksClient()` | `TurboWebhooks` | Signature webhook subscriptions, see [TurboWebhooks Java SDK](/docs/SDKs/webhooks?language=java) | ```java // Authenticate with a bearer access token instead of an API key DeliverableClient deliverable = new TurboDocxClient.Builder() .accessToken(System.getenv("TURBODOCX_ACCESS_TOKEN")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildDeliverableClient(); ``` :::info Sharing one HTTP client `DeliverableClient` also has a public constructor, `new DeliverableClient(HttpClient)`, if you already hold a configured `HttpClient` and want the deliverable module to reuse it. Most applications should use the builder instead. ::: ### Generate deliverable {#generate-deliverable} Generate a new document from a template with variable substitution. ```javascript const result = await Deliverable.generateDeliverable({ name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corp", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ], description: "Quarterly business report", tags: ["reports", "quarterly"], }); console.log(JSON.stringify(result, null, 2)); ``` ```typescript const result = await Deliverable.generateDeliverable({ name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corp", mimeType: "text" }, { placeholder: "{Date}", text: "2026-03-12", mimeType: "text" }, ], description: "Quarterly business report", tags: ["reports", "quarterly"], }); console.log(JSON.stringify(result, null, 2)); ``` ```python result = await Deliverable.generate_deliverable( name="Q1 Report", template_id="your-template-id", variables=[ {"placeholder": "{CompanyName}", "text": "Acme Corp", "mimeType": "text"}, {"placeholder": "{Date}", "text": "2026-03-12", "mimeType": "text"}, ], description="Quarterly business report", tags=["reports", "quarterly"], ) print("Result:", json.dumps(result, indent=2)) ``` ```php $result = Deliverable::generateDeliverable([ 'name' => 'Q1 Report', 'templateId' => 'your-template-id', 'variables' => [ ['placeholder' => '{CompanyName}', 'text' => 'Acme Corp', 'mimeType' => 'text'], ['placeholder' => '{Date}', 'text' => '2026-03-12', 'mimeType' => 'text'], ], 'description' => 'Quarterly business report', 'tags' => ['reports', 'quarterly'], ]); echo "Deliverable ID: {$result['results']['deliverable']['id']}\n"; ``` ```go result, err := deliverable.GenerateDeliverable(ctx, &sdk.CreateDeliverableRequest{ Name: "Q1 Report", TemplateID: "your-template-id", Variables: []sdk.DeliverableVariable{ {Placeholder: "{CompanyName}", Text: "Acme Corp", MimeType: "text"}, {Placeholder: "{Date}", Text: "2026-03-12", MimeType: "text"}, }, Description: "Quarterly business report", Tags: []string{"reports", "quarterly"}, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) ``` ```java DeliverableVariable var1 = new DeliverableVariable(); var1.setPlaceholder("{CompanyName}"); var1.setText("Acme Corp"); var1.setMimeType("text"); CreateDeliverableRequest request = new CreateDeliverableRequest(); request.setName("Q1 Report"); request.setTemplateId("your-template-id"); request.setVariables(List.of(var1)); request.setDescription("Quarterly business report"); request.setTags(List.of("reports", "quarterly")); CreateDeliverableResponse result = deliverable.generateDeliverable(request); System.out.println("Result: " + gson.toJson(result)); ``` ### List deliverables {#list-deliverables} List deliverables with pagination, search, and filtering. ```javascript const list = await Deliverable.listDeliverables({ limit: 10, offset: 0, query: "report", showTags: true, }); console.log(JSON.stringify(list, null, 2)); ``` ```typescript const list = await Deliverable.listDeliverables({ limit: 10, offset: 0, query: "report", showTags: true, }); console.log(JSON.stringify(list, null, 2)); ``` ```python items = await Deliverable.list_deliverables( limit=10, offset=0, query="report", show_tags=True, ) print("Result:", json.dumps(items, indent=2)) ``` ```php $list = Deliverable::listDeliverables([ 'limit' => 10, 'offset' => 0, 'query' => 'report', 'showTags' => true, ]); echo "Total records: {$list['totalRecords']}\n"; foreach ($list['results'] as $deliverable) { echo " {$deliverable['name']}\n"; } ``` ```go list, err := deliverable.ListDeliverables(ctx, &sdk.ListDeliverablesOptions{ Limit: 10, Offset: 0, Query: "report", ShowTags: true, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(list, "", " "); fmt.Println("Result:", string(b)) ``` ```java ListDeliverablesRequest request = new ListDeliverablesRequest(); request.setLimit(10); request.setOffset(0); request.setQuery("report"); request.setShowTags(true); DeliverableListResponse list = deliverable.listDeliverables(request); System.out.println("Result: " + gson.toJson(list)); ``` ### Get deliverable details {#get-deliverable-details} Retrieve the full details of a single deliverable, including variables and fonts. ```javascript const details = await Deliverable.getDeliverableDetails("deliverable-uuid", { showTags: true, }); console.log(JSON.stringify(details, null, 2)); ``` ```typescript const details = await Deliverable.getDeliverableDetails("deliverable-uuid", { showTags: true, }); console.log(JSON.stringify(details, null, 2)); ``` ```python details = await Deliverable.get_deliverable_details("deliverable-uuid", show_tags=True) print("Result:", json.dumps(details, indent=2)) ``` ```php $details = Deliverable::getDeliverableDetails('deliverable-uuid', showTags: true); echo "Name: {$details['name']}\n"; echo "Template: {$details['templateName']}\n"; echo "Created: {$details['createdOn']}\n"; ``` ```go details, err := deliverable.GetDeliverableDetails(ctx, "deliverable-uuid", &sdk.GetDeliverableOptions{ ShowTags: true, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(details, "", " "); fmt.Println("Result:", string(b)) ``` ```java DeliverableRecord details = deliverable.getDeliverableDetails("deliverable-uuid", true); System.out.println("Result: " + gson.toJson(details)); ``` ### Update deliverable info {#update-deliverable-info} Update a deliverable's name, description, or tags. ```javascript const result = await Deliverable.updateDeliverableInfo("deliverable-uuid", { name: "Q1 Report - Final", description: "Final quarterly business report", tags: ["reports", "final"], }); console.log(JSON.stringify(result, null, 2)); ``` ```typescript const result = await Deliverable.updateDeliverableInfo("deliverable-uuid", { name: "Q1 Report - Final", description: "Final quarterly business report", tags: ["reports", "final"], }); console.log(JSON.stringify(result, null, 2)); ``` ```python result = await Deliverable.update_deliverable_info( "deliverable-uuid", name="Q1 Report - Final", description="Final quarterly business report", tags=["reports", "final"], ) print("Result:", json.dumps(result, indent=2)) ``` ```php $result = Deliverable::updateDeliverableInfo('deliverable-uuid', [ 'name' => 'Q1 Report - Final', 'description' => 'Final quarterly business report', 'tags' => ['reports', 'final'], ]); echo "Updated: {$result['deliverableId']}\n"; ``` ```go result, err := deliverable.UpdateDeliverableInfo(ctx, "deliverable-uuid", &sdk.UpdateDeliverableRequest{ Name: "Q1 Report - Final", Description: "Final quarterly business report", Tags: &[]string{"reports", "final"}, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) ``` ```java UpdateDeliverableRequest request = new UpdateDeliverableRequest(); request.setName("Q1 Report - Final"); request.setDescription("Final quarterly business report"); request.setTags(List.of("reports", "final")); UpdateDeliverableResponse result = deliverable.updateDeliverableInfo("deliverable-uuid", request); System.out.println("Result: " + gson.toJson(result)); ``` ### Delete deliverable {#delete-deliverable} Soft-delete a deliverable. ```javascript const result = await Deliverable.deleteDeliverable("deliverable-uuid"); console.log(JSON.stringify(result, null, 2)); ``` ```typescript const result = await Deliverable.deleteDeliverable("deliverable-uuid"); console.log(JSON.stringify(result, null, 2)); ``` ```python result = await Deliverable.delete_deliverable("deliverable-uuid") print("Result:", json.dumps(result, indent=2)) ``` ```php $result = Deliverable::deleteDeliverable('deliverable-uuid'); echo "Deleted: {$result['deliverableId']}\n"; echo "Message: {$result['message']}\n"; ``` ```go result, err := deliverable.DeleteDeliverable(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) ``` ```java DeleteDeliverableResponse result = deliverable.deleteDeliverable("deliverable-uuid"); System.out.println("Result: " + gson.toJson(result)); ``` ### Download source file {#download-source-file} Download the original source file (DOCX or PPTX). ```javascript const buffer = await Deliverable.downloadSourceFile("deliverable-uuid"); // Node.js: Save to file const { writeFileSync } = require("fs"); writeFileSync("report.docx", Buffer.from(buffer)); ``` ```typescript const buffer = await Deliverable.downloadSourceFile("deliverable-uuid"); // Node.js: Save to file writeFileSync("report.docx", Buffer.from(buffer)); ``` ```python source_bytes = await Deliverable.download_source_file("deliverable-uuid") # Save to file with open("report.docx", "wb") as f: f.write(source_bytes) ``` ```php $sourceFile = Deliverable::downloadSourceFile('deliverable-uuid'); // Save to file file_put_contents('report.docx', $sourceFile); // Or send as HTTP response header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); header('Content-Disposition: attachment; filename="report.docx"'); echo $sourceFile; ``` ```go sourceData, err := deliverable.DownloadSourceFile(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } // Save to file err = os.WriteFile("report.docx", sourceData, 0644) if err != nil { log.Fatal(err) } ``` ```java byte[] sourceData = deliverable.downloadSourceFile("deliverable-uuid"); // Save to file Files.write(Paths.get("report.docx"), sourceData); ``` ### Download PDF {#download-pdf} Download the PDF version of a deliverable. ```javascript const buffer = await Deliverable.downloadPDF("deliverable-uuid"); // Node.js: Save to file const { writeFileSync } = require("fs"); writeFileSync("report.pdf", Buffer.from(buffer)); ``` ```typescript const buffer = await Deliverable.downloadPDF("deliverable-uuid"); // Node.js: Save to file writeFileSync("report.pdf", Buffer.from(buffer)); ``` ```python pdf_bytes = await Deliverable.download_pdf("deliverable-uuid") # Save to file with open("report.pdf", "wb") as f: f.write(pdf_bytes) ``` ```php $pdfFile = Deliverable::downloadPDF('deliverable-uuid'); // Save to file file_put_contents('report.pdf', $pdfFile); // Or send as HTTP response header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="report.pdf"'); echo $pdfFile; ``` ```go pdfData, err := deliverable.DownloadPDF(ctx, "deliverable-uuid") if err != nil { log.Fatal(err) } // Save to file err = os.WriteFile("report.pdf", pdfData, 0644) if err != nil { log.Fatal(err) } ``` ```java byte[] pdfData = deliverable.downloadPDF("deliverable-uuid"); // Save to file Files.write(Paths.get("report.pdf"), pdfData); ``` --- ## Error Handling {#error-handling} `Deliverable.generateDeliverable()` rejects with `NotFoundError` when `templateId` doesn't match a template in the org, and `ValidationError` when an entry in `variables` is missing a required field. Both extend the base `TurboDocxError` class: `Deliverable.generate_deliverable()` raises `NotFoundError` when `template_id` doesn't match a template in the org, and `ValidationError` for invalid request parameters, most commonly a variable dict missing `text` (required unless it sets `variableStack` or `isDisabled: True`) or specifying an unsupported `mimeType`. Both extend the base `TurboDocxError`: `Deliverable::generateDeliverable()` throws `NotFoundException` when `templateId` doesn't match a template in the org, and `ValidationException` when a variable in the `variables` array is missing a required field: `GenerateDeliverable` returns `NotFoundError` when `TemplateID` doesn't match a template in the org, and `ValidationError` for invalid request parameters, most commonly a `DeliverableVariable` missing `Text` (required unless it sets `VariableStack` or `IsDisabled: true`) or specifying an unsupported `MimeType`. Match on the concrete type with `errors.As`, same as every other Go SDK call: `deliverable.generateDeliverable()` throws `TurboDocxException.NotFoundException` when `templateId` doesn't match a template in the org, and `TurboDocxException.ValidationException` when a variable in the request is missing a required field: ### Handling Errors {#handling-errors} ```javascript const { Deliverable, TurboDocxError, AuthenticationError, ValidationError, NotFoundError, RateLimitError, NetworkError, } = require("@turbodocx/sdk"); try { const result = await Deliverable.generateDeliverable({ name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corp", mimeType: "text" }, ], }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Authentication failed:", error.message); // Check your API key and org ID } else if (error instanceof ValidationError) { console.error("Validation error:", error.message); // Check request parameters } else if (error instanceof NotFoundError) { console.error("Resource not found:", error.message); // Template or deliverable doesn't exist } else if (error instanceof RateLimitError) { console.error("Rate limited:", error.message); // Wait and retry } else if (error instanceof NetworkError) { console.error("Network error:", error.message); // Check connectivity } else if (error instanceof TurboDocxError) { console.error("SDK error:", error.message, error.statusCode, error.code); } } ``` ```typescript Deliverable, TurboDocxError, AuthenticationError, ValidationError, NotFoundError, RateLimitError, NetworkError, } from "@turbodocx/sdk"; try { const result = await Deliverable.generateDeliverable({ name: "Q1 Report", templateId: "your-template-id", variables: [ { placeholder: "{CompanyName}", text: "Acme Corp", mimeType: "text" }, ], }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Authentication failed:", error.message); // Check your API key and org ID } else if (error instanceof ValidationError) { console.error("Validation error:", error.message); // Check request parameters } else if (error instanceof NotFoundError) { console.error("Resource not found:", error.message); // Template or deliverable doesn't exist } else if (error instanceof RateLimitError) { console.error("Rate limited:", error.message); // Wait and retry } else if (error instanceof NetworkError) { console.error("Network error:", error.message); // Check connectivity } else if (error instanceof TurboDocxError) { console.error("SDK error:", error.message, error.statusCode, error.code); } } ``` The full typed-error table (`AuthenticationError`, `AuthorizationError`, `ConflictError`, `RateLimitError`, `NetworkError`, HTTP status and code mapping) and the `message`/`statusCode`/`code` properties shared by every error are documented once in the [JavaScript / TypeScript SDK's Error Handling reference](/docs/SDKs/turbosign?language=js#error-handling). ```python from turbodocx_sdk import ( Deliverable, TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, ConflictError, RateLimitError, NetworkError, ) async def main(): try: result = await Deliverable.generate_deliverable( name="Q1 Report", template_id="your-template-id", variables=[ {"placeholder": "{CompanyName}", "text": "Acme Corp", "mimeType": "text"}, ], ) except AuthenticationError as e: print(f"Authentication failed: {e}") # Check your API key and org ID except AuthorizationError as e: print(f"Not authorized: {e}") # Authenticated, but lacks permission for this operation except ValidationError as e: print(f"Validation error: {e}") # Check request parameters except NotFoundError as e: print(f"Resource not found: {e}") # Template or deliverable doesn't exist except ConflictError as e: print(f"Conflict: {e}") # Request conflicts with the current resource state except RateLimitError as e: print(f"Rate limited: {e}") # Wait and retry except NetworkError as e: print(f"Network error: {e}") # Check connectivity except TurboDocxError as e: print(f"SDK error: {e}, status_code={e.status_code}, code={e.code}") asyncio.run(main()) ``` The full typed-error table (`AuthenticationError`, `AuthorizationError`, `ConflictError`, `RateLimitError`, `NetworkError`, HTTP status mapping) and the `message`/`status_code`/`code` attributes shared by every error are documented once in the [Python SDK's Error Handling reference](/docs/SDKs/turbosign?language=python#error-handling). ```php 'Q1 Report', 'templateId' => 'your-template-id', 'variables' => [ ['placeholder' => '{CompanyName}', 'text' => 'Acme Corp', 'mimeType' => 'text'], ], ]); } catch (AuthenticationException $e) { // 401 - Invalid API key or access token echo "Authentication failed: {$e->getMessage()}\n"; } catch (AuthorizationException $e) { // 403 - API key lacks required permissions echo "Authorization error: {$e->getMessage()}\n"; } catch (ValidationException $e) { // 400 - Invalid request data echo "Validation error: {$e->getMessage()}\n"; } catch (NotFoundException $e) { // 404 - Deliverable or template not found echo "Not found: {$e->getMessage()}\n"; } catch (ConflictException $e) { // 409 - Resource conflict echo "Conflict: {$e->getMessage()}\n"; } catch (RateLimitException $e) { // 429 - Rate limit exceeded echo "Rate limit: {$e->getMessage()}\n"; } catch (NetworkException $e) { // Network/connection error echo "Network error: {$e->getMessage()}\n"; } ``` The full typed-exception table (`AuthenticationException`, `AuthorizationException`, `ConflictException`, `RateLimitException`, `NetworkException`, HTTP status mapping) and the `getMessage()`/`statusCode`/`errorCode` properties shared by every exception are documented once in the [PHP SDK's Error Handling reference](/docs/SDKs/turbosign?language=php#error-handling). ```go "errors" "log" sdk "github.com/TurboDocx/SDK/packages/go-sdk" ) result, err := deliverable.GenerateDeliverable(ctx, request) if err != nil { // Check for specific error types var authErr *sdk.AuthenticationError var validationErr *sdk.ValidationError var notFoundErr *sdk.NotFoundError var rateLimitErr *sdk.RateLimitError var networkErr *sdk.NetworkError switch { case errors.As(err, &authErr): log.Printf("Authentication failed: %s", authErr.Message) case errors.As(err, &validationErr): log.Printf("Validation error: %s", validationErr.Message) case errors.As(err, ¬FoundErr): log.Printf("Not found: %s", notFoundErr.Message) case errors.As(err, &rateLimitErr): log.Printf("Rate limited: %s", rateLimitErr.Message) case errors.As(err, &networkErr): log.Printf("Network error: %s", networkErr.Message) default: // Base TurboDocxError or unexpected error var turboErr *sdk.TurboDocxError if errors.As(err, &turboErr) { log.Printf("API error [%d]: %s", turboErr.StatusCode, turboErr.Message) } else { log.Fatal(err) } } } ``` The full typed-error table (`AuthenticationError`, `AuthorizationError`, `ConflictError`, `RateLimitError`, `NetworkError`, HTTP status mapping) and the `Message`/`StatusCode`/`Code` fields on every error are documented once in the [Go SDK's Error Handling reference](/docs/SDKs/turbosign?language=go#error-handling). ```java try { CreateDeliverableResponse result = deliverable.generateDeliverable(request); } catch (TurboDocxException.AuthenticationException e) { System.err.println("Authentication failed: " + e.getMessage()); // Check your API key and org ID } catch (TurboDocxException.ValidationException e) { System.err.println("Validation error: " + e.getMessage()); // Check request parameters } catch (TurboDocxException.NotFoundException e) { System.err.println("Not found: " + e.getMessage()); // Template or deliverable doesn't exist } catch (TurboDocxException.RateLimitException e) { System.err.println("Rate limited: " + e.getMessage()); // Wait and retry } catch (TurboDocxException.NetworkException e) { System.err.println("Network error: " + e.getMessage()); // Check connectivity } catch (TurboDocxException e) { // Base exception for other API errors System.err.println("API error [" + e.getStatusCode() + "]: " + e.getMessage()); } ``` The full typed-exception table (`AuthenticationException`, `AuthorizationException`, `ConflictException`, `RateLimitException`, `NetworkException`, HTTP status mapping) and the `getMessage()`/`getStatusCode()`/`getCode()` methods shared by every exception are documented once in the [Java SDK's Error Handling reference](/docs/SDKs/turbosign?language=java#error-handling). --- ## TypeScript Types / Python Types / PHP Types / Types {#typescript-types} The SDK exports TypeScript types for full type safety. Import them directly from the package. The SDK uses Python type hints with `Dict[str, Any]` for flexible JSON-like structures. ### Importing Types {#importing-types} ```typescript // Variable types DeliverableVariable, VariableMimeType, // Request types DeliverableConfig, CreateDeliverableRequest, UpdateDeliverableRequest, ListDeliverablesOptions, // Response types CreateDeliverableResponse, UpdateDeliverableResponse, DeleteDeliverableResponse, DeliverableListResponse, // Record types DeliverableRecord, Tag, Font, } from "@turbodocx/sdk"; ``` ```python from typing import Dict, List, Any, Optional ``` ### VariableMimeType {#variablemimetype} Union type for variable content types: ```typescript type VariableMimeType = "text" | "html" | "image" | "markdown"; ``` String values for variable content types: | Value | Description | | ------------ | ----------------------------- | | `"text"` | Plain text injection | | `"html"` | Rich HTML content | | `"image"` | Image URL or base64 content | | `"markdown"` | Markdown converted to text | ### DeliverableVariable / Variable Array Structure {#deliverablevariable} Variable configuration for template injection: | Property | Type | Required | Description | | ------------------------ | ----------------------- | -------- | ---------------------------------------------------- | | `placeholder` | `string` | Yes | Template placeholder (e.g., `{CompanyName}`) | | `text` | `string` | No\* | Value to inject | | `mimeType` | `VariableMimeType` | Yes | `"text"`, `"html"`, `"image"`, or `"markdown"` | | `isDisabled` | `boolean` | No | Skip this variable during generation | | `subvariables` | `DeliverableVariable[]` | No | Nested sub-variables for HTML content | | `variableStack` | `object \| array` | No | Multiple instances for repeating content | | `aiPrompt` | `string` | No | AI prompt for content generation (max 16,000 chars) | | `allowRichTextInjection` | `boolean` | No | Whether to allow rich text injection | \*Required unless `variableStack` is provided or `isDisabled` is true. Variable configuration for template injection:   | Property | Type | Required | Description | | ------------------------ | ----------------- | -------- | ---------------------------------------------------- | | `placeholder` | `str` | Yes | Template placeholder (e.g., `{CompanyName}`) | | `text` | `str` | No\* | Value to inject | | `mimeType` | `str` | Yes | `"text"`, `"html"`, `"image"`, or `"markdown"` | | `isDisabled` | `bool` | No | Skip this variable during generation | | `subvariables` | `list[dict]` | No | Nested sub-variables for HTML content | | `variableStack` | `dict \| list` | No | Multiple instances for repeating content | | `aiPrompt` | `str` | No | AI prompt for content generation (max 16,000 chars) | \*Required unless `variableStack` is provided or `isDisabled` is true. Variables are passed as associative arrays with the following keys: | Key | Type | Required | Description | | ------------------------ | -------------- | -------- | ---------------------------------------------------- | | `placeholder` | `string` | Yes | Template placeholder (e.g., `{CompanyName}`) | | `text` | `string` | No\* | Value to inject | | `mimeType` | `string` | Yes | `"text"`, `"html"`, `"image"`, or `"markdown"` | | `isDisabled` | `bool` | No | Skip this variable during generation | | `allowRichTextInjection` | `bool` | No | Whether to allow rich text injection | | `subvariables` | `array` | No | Nested sub-variables for HTML content | | `variableStack` | `array` | No | Multiple instances for repeating content | | `aiPrompt` | `string` | No | AI prompt for content generation (max 16,000 chars) | \*Required unless `variableStack` is provided or `isDisabled` is true. Variable configuration for template injection: | Property | Type | Required | Description | | ------------------------ | ----------------------- | -------- | ---------------------------------------------------- | | `Placeholder` | `string` | Yes | Template placeholder (e.g., `{CompanyName}`) | | `Text` | `string` | No\* | Value to inject | | `MimeType` | `string` | Yes | `"text"`, `"html"`, `"image"`, or `"markdown"` | | `IsDisabled` | `FlexBool` | No | Skip this variable during generation | | `Subvariables` | `[]DeliverableVariable` | No | Nested sub-variables for HTML content | | `VariableStack` | `interface{}` | No | Multiple instances for repeating content | | `AIPrompt` | `string` | No | AI prompt for content generation (max 16,000 chars) | | `AllowRichTextInjection` | `FlexBool` | No | Whether to allow rich text injection | \*Required unless `VariableStack` is provided or `IsDisabled` is true. :::note `FlexBool`, not `bool` Boolean fields on the deliverable types are the SDK's `FlexBool`, a named `bool`. Untyped literals work as-is (`IsDisabled: true`), but assigning to or from an existing `bool` variable needs an explicit conversion: `var b bool = bool(rec.IsActive)`, `IsDisabled: turbodocx.FlexBool(myBool)`. ::: Variable configuration for template injection: | Property | Type | Required | Description | | ------------------------ | ----------------------------- | -------- | ---------------------------------------------------- | | `placeholder` | `String` | Yes | Template placeholder (e.g., `{CompanyName}`) | | `text` | `String` | No\* | Value to inject | | `mimeType` | `String` | Yes | `"text"`, `"html"`, `"image"`, or `"markdown"` | | `isDisabled` | `Boolean` | No | Skip this variable during generation | | `subvariables` | `List` | No | Nested sub-variables for HTML content | | `variableStack` | `Object` | No | Multiple instances for repeating content | | `aiPrompt` | `String` | No | AI prompt for content generation (max 16,000 chars) | | `allowRichTextInjection` | `Boolean` | No | Allow rich text (HTML) to be injected for this variable | \*Required unless `variableStack` is provided or `isDisabled` is true. ### CreateDeliverableRequest / Generate Deliverable Request {#createdeliverablerequest} Request configuration for `generateDeliverable`: | Property | Type | Required | Description | | -------------- | ----------------------- | -------- | ------------------------------------------ | | `name` | `string` | Yes | Deliverable name (3-255 characters) | | `templateId` | `string` | Yes | Template ID to generate from | | `variables` | `DeliverableVariable[]` | Yes | Variables for template substitution | | `description` | `string` | No | Description (up to 65,535 characters) | | `tags` | `string[]` | No | Tag strings to associate | Request configuration for `generate_deliverable`:   | Property | Type | Required | Description | | --------------- | ------------ | -------- | ------------------------------------------ | | `name` | `str` | Yes | Deliverable name (3-255 characters) | | `template_id` | `str` | Yes | Template ID to generate from | | `variables` | `list[dict]` | Yes | Variables for template substitution | | `description` | `str` | No | Description (up to 65,535 characters) | | `tags` | `list[str]` | No | Tag strings to associate | Request array for `generateDeliverable`: | Key | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------------ | | `name` | `string` | Yes | Deliverable name (3-255 characters) | | `templateId` | `string` | Yes | Template ID to generate from | | `variables` | `array` | Yes | Variables for template substitution | | `description` | `string` | No | Description (up to 65,535 characters) | | `tags` | `array` | No | Tag strings to associate | Request configuration for `GenerateDeliverable`: | Property | Type | Required | Description | | -------------- | ----------------------- | -------- | ------------------------------------------ | | `Name` | `string` | Yes | Deliverable name (3-255 characters) | | `TemplateID` | `string` | Yes | Template ID to generate from | | `Variables` | `[]DeliverableVariable` | Yes | Variables for template substitution | | `Description` | `string` | No | Description (up to 65,535 characters) | | `Tags` | `[]string` | No | Tag strings to associate | Request configuration for `generateDeliverable`: | Property | Type | Required | Description | | -------------- | ----------------------------- | -------- | ------------------------------------------ | | `name` | `String` | Yes | Deliverable name (3-255 characters) | | `templateId` | `String` | Yes | Template ID to generate from | | `variables` | `List` | Yes | Variables for template substitution | | `description` | `String` | No | Description (up to 65,535 characters) | | `tags` | `List` | No | Tag strings to associate | ### UpdateDeliverableRequest / Update Deliverable Request {#updatedeliverablerequest} Request configuration for `updateDeliverableInfo`: | Property | Type | Required | Description | | ------------- | ---------- | -------- | ---------------------------------------- | | `name` | `string` | No | Updated name (3-255 characters) | | `description` | `string` | No | Updated description | | `tags` | `string[]` | No | Replace all tags (empty array to remove) | Request configuration for `update_deliverable_info`:   | Property | Type | Required | Description | | ------------- | ----------- | -------- | ---------------------------------------- | | `name` | `str` | No | Updated name (3-255 characters) | | `description` | `str` | No | Updated description | | `tags` | `list[str]` | No | Replace all tags (empty list to remove) | Request array for `updateDeliverableInfo`: | Key | Type | Required | Description | | ------------- | -------- | -------- | ---------------------------------------- | | `name` | `string` | No | Updated name (3-255 characters) | | `description` | `string` | No | Updated description | | `tags` | `array` | No | Replace all tags (empty array to remove) | Request configuration for `UpdateDeliverableInfo`: | Property | Type | Required | Description | | ------------- | ----------- | -------- | ---------------------------------------------------------------- | | `Name` | `string` | No | Updated name (3-255 characters) | | `Description` | `string` | No | Updated description | | `Tags` | `*[]string` | No | Replace all tags (`nil` = no change, `&[]string{}` = remove all) | Request configuration for `updateDeliverableInfo`: | Property | Type | Required | Description | | ------------- | -------------- | -------- | ---------------------------------------- | | `name` | `String` | No | Updated name (3-255 characters) | | `description` | `String` | No | Updated description | | `tags` | `List` | No | Replace all tags (empty list to remove) | ### ListDeliverablesOptions / List Deliverables Options / ListDeliverablesRequest {#listdeliverablesoptions} Options for `listDeliverables`:   | Property | Type | Required | Description | | -------------- | ---------- | -------- | ------------------------------------ | | `limit` | `number` | No | Results per page (1-100, default 6) | | `offset` | `number` | No | Results to skip (default 0) | | `query` | `string` | No | Search query to filter by name | | `showTags` | `boolean` | No | Include tags in the response | Options for `list_deliverables`:   | Property | Type | Required | Description | | --------------- | ------- | -------- | ------------------------------------ | | `limit` | `int` | No | Results per page (1-100, default 6) | | `offset` | `int` | No | Results to skip (default 0) | | `query` | `str` | No | Search query to filter by name | | `show_tags` | `bool` | No | Include tags in the response | Options array for `listDeliverables`: | Key | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------ | | `limit` | `int` | No | Results per page (1-100, default 6) | | `offset` | `int` | No | Results to skip (default 0) | | `query` | `string` | No | Search query to filter by name | | `showTags` | `bool` | No | Include tags in the response | Options for `ListDeliverables`: | Property | Type | Required | Description | | -------------- | ---------- | -------- | ------------------------------------ | | `Limit` | `int` | No | Results per page (1-100, default 6) | | `Offset` | `int` | No | Results to skip (default 0) | | `Query` | `string` | No | Search query to filter by name | | `ShowTags` | `bool` | No | Include tags in the response | Options for `listDeliverables`: | Property | Type | Required | Description | | -------------- | ---------- | -------- | ------------------------------------ | | `limit` | `Integer` | No | Results per page (1-100, default 6) | | `offset` | `Integer` | No | Results to skip (default 0) | | `query` | `String` | No | Search query to filter by name | | `showTags` | `Boolean` | No | Include tags in the response | ### DeliverableRecord / Deliverable Record {#deliverablerecord} The deliverable object returned by both `listDeliverables` and `getDeliverableDetails`. Fields marked _details only_ are populated only by `getDeliverableDetails`:   | Property | Type | Description | | -------------------- | ----------------------- | ---------------------------------------------------- | | `id` | `string` | Unique deliverable ID (UUID) | | `name` | `string` | Deliverable name | | `description` | `string` | Description text | | `templateId` | `string` | Source template ID | | `templateName` | `string` | Source template name | | `templateNotDeleted` | `boolean` | Whether the source template still exists | | `createdBy` | `string` | User ID of the creator | | `email` | `string` | Creator's email address | | `fileSize` | `number` | File size in bytes | | `fileType` | `string` | MIME type of the generated file | | `defaultFont` | `string` | Default font used | | `fonts` | `Font[]` | Fonts used in the document | | `isActive` | `boolean` | Whether the deliverable is active | | `createdOn` | `string` | ISO 8601 creation timestamp | | `updatedOn` | `string` | ISO 8601 last update timestamp | | `variables` | `DeliverableVariable[]` | Parsed variable objects with values (_details only_) | | `tags` | `Tag[]` | Associated tags (when `showTags=true`) | The deliverable object returned by `list_deliverables`:   | Property | Type | Description | | ----------------- | -------- | ------------------------------------- | | `id` | `str` | Unique deliverable ID (UUID) | | `name` | `str` | Deliverable name | | `description` | `str` | Description text | | `templateId` | `str` | Source template ID | | `createdBy` | `str` | User ID of the creator | | `email` | `str` | Creator's email address | | `fileSize` | `int` | File size in bytes | | `fileType` | `str` | MIME type of the generated file | | `defaultFont` | `str` | Default font used | | `fonts` | `list` | Fonts used in the document | | `isActive` | `bool` | Whether the deliverable is active | | `createdOn` | `str` | ISO 8601 creation timestamp | | `updatedOn` | `str` | ISO 8601 last update timestamp | | `tags` | `list` | Associated tags (when `show_tags=True`)| The deliverable record returned by `listDeliverables`: | Key | Type | Description | | ---------------- | -------- | ------------------------------------- | | `id` | `string` | Unique deliverable ID (UUID) | | `name` | `string` | Deliverable name | | `description` | `string` | Description text | | `templateId` | `string` | Source template ID | | `createdBy` | `string` | User ID of the creator | | `email` | `string` | Creator's email address | | `fileSize` | `int` | File size in bytes | | `fileType` | `string` | MIME type of the generated file | | `defaultFont` | `string` | Default font used | | `fonts` | `array` | Fonts used in the document | | `isActive` | `bool` | Whether the deliverable is active | | `createdOn` | `string` | ISO 8601 creation timestamp | | `updatedOn` | `string` | ISO 8601 last update timestamp | | `tags` | `array` | Associated tags (when `showTags=true`)| The deliverable object returned by both `ListDeliverables` and `GetDeliverableDetails`: | Property | Type | Description | | -------------------- | ----------------------- | ---------------------------------------- | | `ID` | `string` | Unique deliverable ID (UUID) | | `Name` | `string` | Deliverable name | | `Description` | `string` | Description text | | `TemplateID` | `string` | Source template ID | | `TemplateName` | `string` | Source template name | | `TemplateNotDeleted` | `*FlexBool` | Whether the source template still exists | | `CreatedBy` | `string` | User ID of the creator | | `Email` | `string` | Creator's email address | | `FileSize` | `int64` | File size in bytes | | `FileType` | `string` | MIME type of the generated file | | `DefaultFont` | `string` | Default font used | | `Fonts` | `[]Font` | Fonts used in the document | | `IsActive` | `FlexBool` | Whether the deliverable is active | | `CreatedOn` | `string` | ISO 8601 creation timestamp | | `UpdatedOn` | `string` | ISO 8601 last update timestamp | | `Variables` | `[]DeliverableVariable` | Parsed variable objects with values | | `Tags` | `[]Tag` | Associated tags (when `ShowTags=true`) | The deliverable object returned by both `listDeliverables` and `getDeliverableDetails`. Both methods return the same type. | Property | Type | Description | | -------------------- | ---------------------------- | ---------------------------------------- | | `id` | `String` | Unique deliverable ID (UUID) | | `name` | `String` | Deliverable name | | `description` | `String` | Description text | | `templateId` | `String` | Source template ID | | `templateName` | `String` | Source template name | | `templateNotDeleted` | `boolean` | Whether the source template still exists | | `createdBy` | `String` | User ID of the creator | | `email` | `String` | Creator's email address | | `fileSize` | `Long` | File size in bytes | | `fileType` | `String` | MIME type of the generated file | | `defaultFont` | `String` | Default font used | | `isActive` | `boolean` | Whether the deliverable is active | | `createdOn` | `String` | ISO 8601 creation timestamp | | `updatedOn` | `String` | ISO 8601 last update timestamp | | `variables` | `List` | Parsed variable objects with values | | `tags` | `List` | Associated tags (when `showTags=true`) | ### DeliverableDetailRecord / Deliverable Detail Record {#deliverabledetailrecord} The deliverable object returned by `get_deliverable_details`. Includes all fields from [DeliverableRecord](#deliverablerecord) **except `fileSize`**, plus:   | Property | Type | Description | | -------------------- | ------------ | ---------------------------------------- | | `templateName` | `str` | Source template name | | `templateNotDeleted` | `bool` | Whether the source template still exists | | `variables` | `list[dict]` | Parsed variable objects with values | The deliverable record returned by `getDeliverableDetails`. Includes all fields from [Deliverable Record](#deliverable-record) **except `fileSize`**, plus: | Key | Type | Description | | -------------------- | -------- | ---------------------------------------- | | `templateName` | `string` | Source template name | | `templateNotDeleted` | `bool` | Whether the source template still exists | | `variables` | `array` | Parsed variable objects with values | ### Tag {#tag} Tag object included when `showTags` is enabled:   | Property | Type | Description | | ----------- | --------- | ------------------------------------ | | `id` | `string` | Tag unique identifier (UUID) | | `label` | `string` | Tag display name | | `isActive` | `boolean` | Whether the tag is active | | `updatedOn` | `string` | ISO 8601 last update timestamp | | `createdOn` | `string` | ISO 8601 creation timestamp | | `createdBy` | `string` | User ID of the tag creator | | `orgId` | `string` | Organization ID | Tag object included when `show_tags` is enabled. Each tag is a `dict` with: | Key | Type | Description | | ----------- | ------ | ------------------------------------ | | `id` | `str` | Tag unique identifier (UUID) | | `label` | `str` | Tag display name | | `isActive` | `bool` | Whether the tag is active | | `updatedOn` | `str` | ISO 8601 last update timestamp | | `createdOn` | `str` | ISO 8601 creation timestamp | | `createdBy` | `str` | User ID of the tag creator | | `orgId` | `str` | Organization ID | Tag object included when `showTags` is enabled. Each tag is an associative array with: | Key | Type | Description | | ----------- | -------- | ------------------------------------ | | `id` | `string` | Tag unique identifier (UUID) | | `label` | `string` | Tag display name | | `isActive` | `bool` | Whether the tag is active | | `updatedOn` | `string` | ISO 8601 last update timestamp | | `createdOn` | `string` | ISO 8601 creation timestamp | | `createdBy` | `string` | User ID of the tag creator | | `orgId` | `string` | Organization ID | Tag object included when `ShowTags` is enabled: | Property | Type | Description | | ----------- | -------- | ------------------------------------ | | `ID` | `string` | Tag unique identifier (UUID) | | `Label` | `string` | Tag display name | | `IsActive` | `FlexBool` | Whether the tag is active | | `UpdatedOn` | `string` | ISO 8601 last update timestamp | | `CreatedOn` | `string` | ISO 8601 creation timestamp | | `CreatedBy` | `string` | User ID of the tag creator | | `OrgID` | `string` | Organization ID | Tag object included when `showTags` is enabled: | Property | Type | Description | | ----------- | --------- | ------------------------------------ | | `id` | `String` | Tag unique identifier (UUID) | | `label` | `String` | Tag display name | | `isActive` | `boolean` | Whether the tag is active | | `updatedOn` | `String` | ISO 8601 last update timestamp | | `createdOn` | `String` | ISO 8601 creation timestamp | | `createdBy` | `String` | User ID of the tag creator | | `orgId` | `String` | Organization ID | --- ## Additional Documentation {#additional-documentation} For detailed information about advanced configuration and API concepts, see: ### Core API References {#core-api-references} - **[TurboDocx Templating](/docs/TurboDocx-Templating/How-to-Create-a-Template)** - How to create and configure document templates - **[Variable Reference](/docs/API/Deliverable-API#variable-object-structure)** - Complete guide to variable types, formatting, and advanced injection options - **[API Reference](/docs/API/Deliverable-API)** - Full REST API documentation for Deliverable endpoints --- ## Resources {#resources} - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [npm Package](https://www.npmjs.com/package/@turbodocx/sdk) - [API Reference](/docs/API/Deliverable-API) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [PyPI Package](https://pypi.org/project/turbodocx-sdk/) - [API Reference](/docs/API/Deliverable-API) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [Packagist Package](https://packagist.org/packages/turbodocx/sdk) - [API Reference](/docs/API/Deliverable-API) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [API Reference](/docs/API/Deliverable-API) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) - [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) - [API Reference](/docs/API/Deliverable-API) --- # How to Get Ready-to-Run Code with Developer Mode Developer Mode adds a **Code** button to pages across TurboDocx. Click it and you get the exact code that does what that page does, in the programming language you choose, ready to copy into your project. For example, on a template's page the code already contains that template's ID and its real variable names, so you only fill in the values. :::tip What you need - A TurboDocx account. Turning on Developer Mode only changes what **you** see; it doesn't affect anyone else in your organization. - An API key to run the code. You can create one from inside the code panel (see [Step 7](#step-7-create-an-api-key-if-you-dont-have-one)). ::: ## Step 1: Turn on Developer Mode 1. Go to the **Home** page. 2. In the top-right corner, click the **Dev Mode** switch to turn it on. ![Home page with the Dev Mode switch highlighted in the top-right corner](/img/developer_mode/dev_mode_switch.png) Developer Mode stays on until you turn it off. It is saved in this browser only, so you need to turn it on again on another computer or browser. ## Step 2: Open the code panel 1. Go to the page for what you want to do in code. In this guide we use a template: open **Templates** and click a template to open its generate page. 2. At the top of the page, click the **Code** button. ![Template generate page with the Code button highlighted next to Generate Deliverable](/img/developer_mode/code_button.png) A panel opens on the right side of the screen. :::note Where else you'll find the Code button The **Code** button also appears on deliverables, e-signature documents, quotes, products, bundles, price books, companies, webhook settings, and the partner portal. Each one shows the code for the page you are on. ::: ## Step 3: Choose what you want to do Some pages can do more than one thing. On those pages, the panel has an **Operation** dropdown. 1. Click the **Operation** dropdown. 2. Choose what you want the code to do. On a template's generate page you can choose **Generate document** or **Send for signature**. ![Code panel with the Operation dropdown highlighted](/img/developer_mode/operation_dropdown.png) ## Step 4: Add reminders and expiration (optional) When you choose **Send for signature**, you can add reminder emails and an expiry date to the request. 1. Click the **Include reminders & expiration** switch to turn it on. ![Code panel with the Include reminders and expiration switch highlighted](/img/developer_mode/reminders_toggle.png) The code updates to include the reminder and expiration settings. Leave the switch off to use your organization's default settings. ## Step 5: Pick your programming language 1. Click the tab for your language: **TypeScript**, **Python**, **Go**, **PHP**, **Java**, or **cURL**. ![Code panel with the language tabs highlighted](/img/developer_mode/language_tabs.png) Only the languages that support the chosen operation are shown. For example, **Go** is hidden while reminders and expiration are switched on. ## Step 6: Copy the code 1. Click **Copy** at the top-right of the code. ![Code panel with the Copy button on the code highlighted](/img/developer_mode/copy_snippet.png) 2. Paste the code into your project. Your organization ID is already written into the code. Your API key is **not**: the code reads it from an environment variable called `TURBODOCX_API_KEY`, so it never ends up in your source code. Set that variable before you run the code. Code that sends documents for signature also reads your sender email from `TURBODOCX_SENDER_EMAIL`. :::tip New to the SDK? The panel also shows a one-line command at the top (`npx skills add TurboDocx/quickstart`). Run it in your project and your AI coding agent installs and sets up the TurboDocx SDK for you. See [Install with AI Agents](./agent-skills). ::: ## Step 7: Create an API key if you don't have one 1. At the top of the panel, click **API Keys**. ![Code panel with the API Keys button highlighted](/img/developer_mode/api_keys_button.png) 2. In the window that opens, click **New** to create a key, then copy it into your `TURBODOCX_API_KEY` environment variable. Only administrators can create API keys. If the button is grayed out, ask an administrator in your organization to create one for you. ## Step 8: Read the full documentation (optional) 1. At the bottom of the panel, click **Explore the … docs**. The link opens the SDK documentation for the product you are working with (for example, TurboSign). ![Code panel with the Explore the TurboSign docs link highlighted](/img/developer_mode/docs_link.png) ## Turn off Developer Mode When you no longer want to see the **Code** buttons: 1. Open any code panel. 2. At the top of the panel, click **Turn Off Dev Mode**. ![Code panel with the Turn Off Dev Mode button highlighted](/img/developer_mode/turn_off_dev_mode.png) The panel closes and the **Code** buttons disappear. You can turn Developer Mode back on at any time from the **Dev Mode** switch on the **Home** page. :::note The panel remembers your choices The next time you open a code panel, it opens in the language you used last, and each page reopens on the operation and switch settings you left it on. These choices are saved in this browser only. ::: --- # TurboDocx Go SDK The official TurboDocx SDK for Go applications. Build document generation and digital signature workflows with idiomatic Go patterns, context support, and comprehensive error handling. Available as `github.com/TurboDocx/SDK/packages/go-sdk`. ## Installation ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` ## Requirements - Go 1.21+ --- ## Configuration ```go package main "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // Create a new client (reads SenderEmail from TURBODOCX_SENDER_EMAIL) client, err := turbodocx.NewClient( os.Getenv("TURBODOCX_API_KEY"), os.Getenv("TURBODOCX_ORG_ID"), ) if err != nil { log.Fatal(err) } _ = client // Or with custom configuration client, err = turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), SenderEmail: os.Getenv("TURBODOCX_SENDER_EMAIL"), // Required for TurboSign BaseURL: "https://api.turbodocx.com", // Optional custom base URL }) if err != nil { log.Fatal(err) } _ = client } ``` ### Environment Variables ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here export TURBODOCX_SENDER_EMAIL=you@example.com # Required for TurboSign (reply-to address) ``` --- ## Product guides Each product guide opens on the Go tab: - [TurboSign](/docs/SDKs/turbosign?language=go): send documents for signature, track status, download signed PDFs and audit trails - [Deliverable](/docs/SDKs/deliverable?language=go): generate documents from templates - [TurboWebhooks](/docs/SDKs/webhooks?language=go): receive real-time signature events - [TurboQuote](/docs/SDKs/quote?language=go): create, send and download quotes ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- ## SDKs Overview # TurboDocx SDKs Official client libraries for the TurboDocx API. Build document generation, digital signature, and quoting workflows in your language of choice. ## Choose Your Product All five modules ship in the **same package** for each language: pick the one that matches what you're building: | Product | Use it when you need to… | | :------------- | :----------------------------------------------------------------------------------------- | | **TurboSign** | Send documents for legally-binding e-signature; track status; download signed PDFs | | **Deliverable** | Generate documents from templates with variable injection (DOCX / PPTX / PDF output) | | **TurboQuote** | Build sales quotes & proposals (CPQ): line items, a product/bundle catalog, price books | | **TurboWebhooks** | Receive real-time signature events instead of polling, and verify inbound deliveries | TurboSign, Deliverable, TurboQuote, and TurboWebhooks all use the same `TURBODOCX_API_KEY` + `TURBODOCX_ORG_ID`. See [credential requirements](#which-credentials-does-each-product-need) below. :::tip Install with one prompt Skip the boilerplate: use the [TurboDocx Agent Skill](./agent-skills.md) to install the SDK, configure environment variables, and generate working integration code via Claude Code, GitHub Copilot, Cursor, OpenCode, Codex CLI, or Gemini CLI: ```bash npx skills add TurboDocx/quickstart ``` ::: ## TurboSign SDKs Send documents for legally-binding eSignatures with full audit trails. | Language | Package | Install Command | Links | | :------------------------ | :------------------------- | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------- | | **JavaScript/TypeScript** | `@turbodocx/sdk` | `npm install @turbodocx/sdk` | [Docs](/docs/SDKs/turbosign?language=js) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) | | **Python** | `turbodocx-sdk` | `pip install turbodocx-sdk` | [Docs](/docs/SDKs/turbosign?language=python) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) | | **PHP** | `turbodocx/sdk` | `composer require turbodocx/sdk` | [Docs](/docs/SDKs/turbosign?language=php) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) | | **Go** | `github.com/TurboDocx/SDK/packages/go-sdk` | `go get github.com/TurboDocx/SDK/packages/go-sdk` | [Docs](/docs/SDKs/turbosign?language=go) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) | | **Java** | `com.turbodocx:turbodocx-sdk` | [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) | [Docs](/docs/SDKs/turbosign?language=java) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) | ## TurboWebhooks SDKs Subscribe to all 7 TurboSign signature events (`sent`, `viewed`, `recipient_signed`, `signed`, `completed`, `finalization_failed`, `voided`) and verify inbound signatures with HMAC-SHA256. Each SDK exports the full set as constants, so you never hand-write the wire strings. | Language | Package | Install Command | Links | | :------------------------ | :-------------- | :---------------------------- | :----------------------------------------------------------------------------------------------------- | | **JavaScript / TypeScript** | `@turbodocx/sdk` | `npm install @turbodocx/sdk` | [Docs](/docs/SDKs/webhooks?language=js) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) | | **PHP** | `turbodocx/sdk` | `composer require turbodocx/sdk` | [Docs](/docs/SDKs/webhooks?language=php) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) | | **Python** | `turbodocx-sdk` | `pip install turbodocx-sdk` | [Docs](/docs/SDKs/webhooks?language=python) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) | | **Go** | `github.com/TurboDocx/SDK/packages/go-sdk` | `go get github.com/TurboDocx/SDK/packages/go-sdk` | [Docs](/docs/SDKs/webhooks?language=go) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) | | **Java** | `com.turbodocx:turbodocx-sdk` | `mvn` / `gradle` (see [docs](/docs/SDKs/webhooks?language=java)) | [Docs](/docs/SDKs/webhooks?language=java) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) | For the conceptual overview (delivery retries, payload schema, dashboard configuration), see [TurboSign → Webhooks](/docs/TurboSign/Webhooks). ## Deliverable SDKs Generate documents from templates with dynamic variable injection, download source files and PDFs. | Language | Package | Install Command | Links | | :------------------------ | :------------------------- | :------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | | **JavaScript/TypeScript** | `@turbodocx/sdk` | `npm install @turbodocx/sdk` | [Docs](/docs/SDKs/deliverable?language=js) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) | | **Python** | `turbodocx-sdk` | `pip install turbodocx-sdk` | [Docs](/docs/SDKs/deliverable?language=python) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) | | **PHP** | `turbodocx/sdk` | `composer require turbodocx/sdk` | [Docs](/docs/SDKs/deliverable?language=php) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) | | **Go** | `github.com/TurboDocx/SDK/packages/go-sdk` | `go get github.com/TurboDocx/SDK/packages/go-sdk` | [Docs](/docs/SDKs/deliverable?language=go) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) | | **Java** | `com.turbodocx:turbodocx-sdk` | [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) | [Docs](/docs/SDKs/deliverable?language=java) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) | ## TurboQuote SDKs Build sales quotes and proposals programmatically: quotes and line items, a product/bundle catalog, price books, companies/contacts, and quote templates. | Language | Package | Install Command | Links | | :------------------------ | :------------------------- | :------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | | **JavaScript/TypeScript** | `@turbodocx/sdk` | `npm install @turbodocx/sdk` | [Docs](/docs/SDKs/quote?language=js) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) | | **Python** | `turbodocx-sdk` | `pip install turbodocx-sdk` | [Docs](/docs/SDKs/quote?language=python) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) | | **PHP** | `turbodocx/sdk` | `composer require turbodocx/sdk` | [Docs](/docs/SDKs/quote?language=php) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) | | **Go** | `github.com/TurboDocx/SDK/packages/go-sdk` | `go get github.com/TurboDocx/SDK/packages/go-sdk` | [Docs](/docs/SDKs/quote?language=go) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) | | **Java** | `com.turbodocx:turbodocx-sdk` | [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) | [Docs](/docs/SDKs/quote?language=java) [GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) | :::tip Low-code or No-code? Check out our [n8n community node](https://www.npmjs.com/package/@turbodocx/n8n-nodes-turbodocx) for workflow automation, or get [TurboDocx Writer](https://appsource.microsoft.com/en-us/product/office/WA200007397) for Microsoft Word. ::: --- ## Quick Start Get up and running in under 2 minutes. ### 1. Get Your Credentials Before you begin, you'll need two things from your TurboDocx account: - **API Access Token**: Your authentication key - **Organization ID**: Your unique organization identifier :::note senderEmail for TurboSign TurboSign accepts a `senderEmail` (used as the reply-to address for signature request emails). The **JS/TS SDK enforces it client-side**: `TurboSign.configure()` throws a `ValidationError` (generic `VALIDATION_ERROR` code, not an API error code) if no `senderEmail` is supplied in configuration or via the `TURBODOCX_SENDER_EMAIL` environment variable; this check runs once at configure time, not per request. Other SDKs may differ; check each SDK's README. The **backend API itself does not require `senderEmail`** for TurboSign or TurboQuote; a request or org template with no sender falls back to a generic TurboDocx no-reply address and name and is never rejected. Deliverable and TurboWebhooks do not use `senderEmail` at all. **TurboQuote:** there is **no `senderEmail` field on a quote request**. A sender is resolved from your organization's **quote template** (Quote Settings) when set; if none is configured, the quote falls back to a generic TurboDocx sender rather than failing. See [Prepared By & Sender Identity](/docs/TurboQuote/Prepared-By-and-Sender-Identity). ::: #### Which credentials does each product need? | Product | API key | Org ID | Also needs | | :------------- | :----------------------------- | :------------------------- | :-------------------------------------------------------------- | | **TurboSign** | `TURBODOCX_API_KEY` | `TURBODOCX_ORG_ID` | `TURBODOCX_SENDER_EMAIL` (required by the JS/TS SDK at configure time, reply-to for signer emails; the API itself falls back to a generic sender if omitted) | | **Deliverable** | `TURBODOCX_API_KEY` | `TURBODOCX_ORG_ID` | None | | **TurboQuote** | `TURBODOCX_API_KEY` | `TURBODOCX_ORG_ID` | a **Sender Email + Sender Name on the org quote template** recommended (no per-request sender field exists; falls back to a generic TurboDocx sender if not configured) | | **TurboWebhooks** | `TURBODOCX_API_KEY` (**administrator** role, non-admin keys get 403) | `TURBODOCX_ORG_ID` | the webhook secret returned by `createWebhook`, to verify inbound events | #### How to Get Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or copy your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ![TurboSign API Key](/img/turbosign/api/api-key.png) ![TurboSign Organization ID](/img/turbosign/api/org-id.png) :::tip Keep Your Credentials Secure - Store your API key and Organization ID as environment variables - Never commit credentials to version control - Rotate your API keys regularly for security ::: ### 2. Install the SDK ```bash npm install @turbodocx/sdk # or yarn add @turbodocx/sdk # or pnpm add @turbodocx/sdk ``` ```bash npm install @turbodocx/sdk # or yarn add @turbodocx/sdk # or pnpm add @turbodocx/sdk # TypeScript types are included in the package ``` ```bash pip install turbodocx-sdk # or poetry add turbodocx-sdk ``` ```bash composer require turbodocx/sdk ``` ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ### 3. Send Your First Document for Signature ```javascript const { TurboSign } = require("@turbodocx/sdk"); // or with ES modules: // import { TurboSign } from '@turbodocx/sdk'; // Configure with your API key TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // required for TurboSign }); (async () => { // Send a document for signature const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); console.log(`Document sent! ID: ${result.documentId}`); })(); ``` ```typescript // Configure with your API key TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", orgId: process.env.TURBODOCX_ORG_ID || "", senderEmail: process.env.TURBODOCX_SENDER_EMAIL || "", // required for TurboSign }); // Send a document for signature const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); console.log(`Document sent! ID: ${result.documentId}`); ``` ```python from turbodocx_sdk import TurboSign # Configure with your API key TurboSign.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], sender_email=os.environ["TURBODOCX_SENDER_EMAIL"] # required for TurboSign ) async def main(): # Send a document for signature result = await TurboSign.send_signature( file_link="https://www.turbodocx.com/examples/turbodocx.pdf", recipients=[ {"name": "John Doe", "email": "john@example.com", "signingOrder": 1} ], fields=[ {"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "john@example.com"} ] ) print(f"Document sent! ID: {result['documentId']}") asyncio.run(main()) ``` ```php documentId}\n"; ``` ```go package main "context" "fmt" "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // Configure with your API key client, err := turbodocx.NewClient( os.Getenv("TURBODOCX_API_KEY"), os.Getenv("TURBODOCX_ORG_ID"), ) if err != nil { log.Fatal(err) } // Send a document for signature result, err := client.TurboSign.SendSignature(context.Background(), &turbodocx.SendSignatureRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", SenderEmail: "contracts@acme.com", // required for TurboSign Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) if err != nil { log.Fatal(err) } fmt.Printf("Document sent! ID: %s\n", result.DocumentID) } ``` ```java public class Main { public static void main(String[] args) throws Exception { // Configure with your API key TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) // required for TurboSign .build(); // Send a document for signature SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); System.out.println("Document sent! ID: " + result.getDocumentId()); } } ``` --- ## Core Features All TurboDocx SDKs provide access to: ### TurboSign: Digital Signatures Send documents for legally-binding eSignatures with full audit trails. | Method | Description | | :---------------------------- | :------------------------------------------------------ | | `createSignatureReviewLink()` | Upload document for preview without sending emails | | `sendSignature()` | Upload and immediately send signature requests | | `getStatus()` | Check the document-level status | | `getRecipients()` | Per-signer status, email history, and who sent it | | `download()` | Download the completed signed document | | `void()` | Cancel/void a signature request | | `resend()` | Resend signature request emails | | `getAuditTrail()` | Get complete audit trail with all events and timestamps | [Learn more about TurboSign →](/docs/TurboSign/Setting-up-TurboSign) ### Deliverable: Document Generation Generate documents from templates with dynamic variable injection, download source files and PDFs. | Method | Description | | :-------------------------- | :--------------------------------------------------------- | | `generateDeliverable()` | Generate a document from a template with variable injection | | `listDeliverables()` | List deliverables with pagination, search, and filtering | | `getDeliverableDetails()` | Get full details of a deliverable including variables | | `updateDeliverableInfo()` | Update a deliverable's name, description, or tags | | `deleteDeliverable()` | Soft-delete a deliverable | | `downloadSourceFile()` | Download the original DOCX/PPTX source file | | `downloadPDF()` | Download the PDF version | [Learn more about Deliverable SDKs →](/docs/SDKs/deliverable?language=js) ### TurboQuote: Sales Quoting & CPQ Build quotes and proposals: line items, a product/bundle catalog, price books, companies, and contacts. | Method | Description | | :--------------------------- | :---------------------------------------------------------------- | | `createQuote()` | Create a draft quote for a company and contact | | `addLineItems()` | Add product or bundle line items to a quote | | `sendQuote()` | Send a quote to the customer for review | | `sendQuoteWithDeliverable()` | Merge a TurboDocx Deliverable with the quote and send for e-signature | | `downloadQuotePdf()` | Download the rendered quote PDF | | `createProduct()` / `createBundle()` / `createPriceBook()` | Manage the product catalog and pricing | | `createAndSend()` | Create, add line items, and send in a single call | [Learn more about TurboQuote SDKs →](/docs/SDKs/quote?language=js) ### TurboWebhooks: Signature Events Subscribe a per-org endpoint to TurboSign events and verify inbound deliveries with HMAC-SHA256. **Requires an administrator API key.** | Method | Description | | :--------------------------- | :--------------------------------------------------------------- | | `createWebhook()` | Subscribe the org's `signature` webhook (returns the secret once) | | `getWebhook()` | Get the webhook plus delivery stats | | `updateWebhook()` | Update URLs, events, or active state | | `testWebhook()` | Fire a synthetic delivery to all configured URLs | | `regenerateWebhookSecret()` | Rotate the HMAC secret | | `listWebhookDeliveries()` / `replayWebhookDelivery()` | Inspect and retry past deliveries | | `verifyWebhookSignature()` | Free function, verify the `X-TurboDocx-Signature` header on a received event | [Learn more about TurboWebhooks SDKs →](/docs/SDKs/webhooks?language=js) --- ## Field Positioning TurboSign supports two methods for placing signature fields on your documents: | Method | Best For | | :------------------- | :----------------------------------------------------------------------- | | **Coordinate-Based** | PDFs with fixed layouts where you know exact pixel positions | | **Template-Based** | Documents where content may shift, using text anchors like `{SIGNATURE}` | :::info Field Positioning Reference For detailed information about both positioning methods, including anchor configuration, placement options, and best practices, see the **[Field Positioning Methods](/docs/TurboSign/API-Signatures#field-positioning-methods)** guide. ::: :::info Complete Field Types Reference For a comprehensive list of all available field types (signature, initials, text, date, checkbox, full_name, email, title, company) and their detailed usage, see the [Field Types section in the API Signatures guide](/docs/TurboSign/API-Signatures#field-types-reference). ::: --- ## Error Handling All SDKs provide structured error handling with detailed error codes: ```javascript const { TurboSign, TurboDocxError } = require("@turbodocx/sdk"); (async () => { try { const result = await TurboSign.sendSignature({ /* ... */ }); } catch (error) { if (error instanceof TurboDocxError) { console.error(`Error ${error.code}: ${error.message}`); // Handle specific error codes if (error.code === "VALIDATION_ERROR") { // Handle validation error } } } })(); ``` ```typescript try { const result = await TurboSign.sendSignature({ /* ... */ }); } catch (error) { if (error instanceof TurboDocxError) { console.error(`Error ${error.code}: ${error.message}`); // Handle specific error codes if (error.code === "VALIDATION_ERROR") { // Handle validation error } } } ``` ```python from turbodocx_sdk import TurboSign, TurboDocxError async def main(): try: result = await TurboSign.send_signature(...) except TurboDocxError as e: # TurboDocxError doesn't set a .message attribute; str(e) is the message print(f"Error {e.code}: {e}") if e.code == "VALIDATION_ERROR": # Handle validation error pass asyncio.run(main()) ``` ```php getMessage()}\n"; // Handle validation error } catch (TurboDocxException $e) { echo "Error {$e->errorCode}: {$e->getMessage()}\n"; echo "Status code: {$e->statusCode}\n"; } ``` ```go result, err := client.TurboSign.SendSignature(ctx, request) if err != nil { // errors.As must target the specific type: a *TurboDocxError target does not match // *ValidationError, *AuthenticationError, etc., even though each embeds TurboDocxError. // See the Go SDK's own Error Handling reference for the full set of named types. var validationErr *sdk.ValidationError if errors.As(err, &validationErr) { fmt.Printf("Validation error [%s]: %s\n", validationErr.Code, validationErr.Message) } } ``` ```java try { SendSignatureResponse result = client.turboSign().sendSignature(/* ... */); } catch (AuthenticationException e) { System.err.println("Invalid API key: " + e.getMessage()); } catch (ValidationException e) { System.err.println("Validation error: " + e.getMessage()); System.err.println("Error code: " + e.getCode()); } catch (RateLimitException e) { System.err.println("Rate limited: " + e.getMessage()); } catch (TurboDocxException e) { System.err.println("Error " + e.getCode() + ": " + e.getMessage()); System.err.println("Status code: " + e.getStatusCode()); } ``` ### Common Error Codes | Code | HTTP Status | Description | | :--------------------- | :---------- | :------------------------------------ | | `AUTHENTICATION_ERROR` | 401 | Invalid or expired API key | | `AUTHORIZATION_ERROR` | 403 | Authenticated, but lacks permission | | `VALIDATION_ERROR` | 400 | Invalid request data or parameters | | `NOT_FOUND` | 404 | Document or resource not found | | `CONFLICT` | 409 | Conflicts with the resource's state | | `RATE_LIMIT_EXCEEDED` | 429 | Too many requests, retry with backoff | | `NETWORK_ERROR` | N/A | Network connection or timeout error | When the API returns a specific code the SDK surfaces it verbatim; otherwise, for one of the 7 named categories above, it falls back to that class's default, so `code` is populated for those without a null check. An error for a status code outside this table (an unexpected 5xx, for example) is not guaranteed a `code`. ### TurboQuote / TurboSign specific codes These are returned by the API and passed through unchanged. They are more precise than the generic codes above; prefer them when handling a specific failure. | Code | HTTP Status | Meaning | | :------------------------- | :---------- | :------------------------------------------------------------------------------------------ | | `QuoteHasNoLineItems` | 400 | The quote has no line items. Add at least one product, bundle, or custom line item. | | `QuoteExpired` | 400 | The quote is past its `validUntil` date. Update the date before sending. | | `QuoteValidUntilRequired` | 400 | The quote has no `validUntil` date set. | | `QuoteNotSendable` | 400 | The quote's current status does not allow sending (only drafts can be sent). | | `QuoteContactRequired` | 400 | The quote's contact is missing a name or email. | | `QuoteCustomerInactive` | 400 | The quote's company or contact was deleted or deactivated. | | `QUOTE_NOT_FOUND` | 404 | No quote with that ID in this organization. | ### Error messages carry the actionable reason The API reports validation failures in several envelopes. The SDKs unwrap all of them, so `error.message` is the specific field-level reason, not a generic `"There was an issue validating the body"`. Multiple field errors are joined with `"; "`: ``` "name" is not allowed to be empty; "companyId" must be a valid GUID ``` --- ## Audit Trail & Client Context Every action you take through an SDK is recorded in the TurboDocx audit trail. All six SDKs automatically attach **client-context headers** to **every** request, including TurboSign, Deliverable, TurboQuote, TurboWebhooks, and TurboPartner, so the audit trail records real environment details instead of blanks: | Recorded column | What the SDK sends | | :--- | :--- | | **Device** | The host machine / runtime the call came from | | **Operating system** | The OS the SDK is running on | | **Timezone** | The machine's IANA timezone (e.g. `America/New_York`) | | **Language** | The machine's locale (e.g. `en-US`) | | **Application** | `TurboDocx SDK ` | You do not configure any of this: it is collected and sent for you. ### SDK / n8n calls vs. raw API calls The audit trail distinguishes how a request reached TurboDocx: | Caller | How it appears in the audit trail | | :--- | :--- | | A language SDK | `TurboDocx SDK `, with real device, OS, timezone, and language | | The TurboDocx n8n node | `TurboDocx n8n Node `, with real device, OS, timezone, and language | | A raw HTTP/API call | The **name of the HTTP library** that made the call, the action `API Request`, and `N/A` for the environment fields it cannot know | Raw API calls show `N/A` (not `Unknown`) for the fields no client context was supplied for. If you want fully attributed audit entries, call through an SDK or the n8n node rather than hand-rolled HTTP. --- ## Next Steps { e.currentTarget.style.transform = 'translateY(-8px)'; e.currentTarget.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)'; }} > API Signatures Complete guide to TurboSign API integration with detailed examples. { e.currentTarget.style.transform = 'scale(1.02)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.25)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary-dark) 0%, var(--ifm-color-primary-darker) 100%)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)'; }} > View Guide → { e.currentTarget.style.transform = 'translateY(-8px)'; e.currentTarget.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)'; }} > Webhooks Receive real-time notifications when documents are signed. { e.currentTarget.style.transform = 'scale(1.02)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.25)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary-dark) 0%, var(--ifm-color-primary-darker) 100%)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)'; }} > View Webhooks → { e.currentTarget.style.transform = 'translateY(-8px)'; e.currentTarget.style.boxShadow = '0 12px 24px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.06)'; }} > GitHub Repository View source code, report issues, and contribute to the SDKs. { e.currentTarget.style.transform = 'scale(1.02)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.25)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary-dark) 0%, var(--ifm-color-primary-darker) 100%)'; }} onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; e.currentTarget.style.background = 'linear-gradient(135deg, var(--ifm-color-primary) 0%, var(--ifm-color-primary-dark) 100%)'; }} > View on GitHub → --- ## Support Need help with the SDKs? - **Discord Community**: [Join our Discord](https://discord.gg/NYKwz4BcpX) for real-time support - **GitHub Issues**: [Report bugs or request features](https://github.com/TurboDocx/SDK/issues) - **Documentation**: Browse the language-specific guides in the sidebar --- # TurboDocx Java SDK The official TurboDocx SDK for Java applications. Build document generation and digital signature workflows with the Builder pattern, comprehensive error handling, and type-safe APIs. Available on Maven Central as `com.turbodocx:turbodocx-sdk`. ## Installation ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ## Requirements - Java 11+ - OkHttp 4.x (included) - Gson 2.x (included) --- ## Configuration ```java public class Main { public static void main(String[] args) { // Create client with Builder pattern TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .build(); // Or with custom base URL TurboDocxClient customClient = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .baseUrl("https://api.turbodocx.com") .build(); } } ``` ### Builder Options | Method | Type | Required | Default | Description | | ------------------------------- | -------- | -------- | ----------------------------- | -------------------------------------------------- | | `apiKey(String)` | `String` | Yes\* | - | Organization API key | | `accessToken(String)` | `String` | Yes\* | - | Bearer access token (alternative to `apiKey`) | | `orgId(String)` | `String` | Yes | - | Organization ID | | `senderEmail(String)` | `String` | Yes | - | Reply-to address for signature request emails | | `senderName(String)` | `String` | No | - | Display name used on signature request emails | | `baseUrl(String)` | `String` | No | `https://api.turbodocx.com` | API base URL | | `connectTimeoutSeconds(int)` | `int` | No | `60` | Connection timeout | | `readTimeoutSeconds(int)` | `int` | No | `120` | Read timeout, raise it for large document uploads | | `writeTimeoutSeconds(int)` | `int` | No | `60` | Write timeout, raise it for large document uploads | \*Provide either `apiKey` or `accessToken`. ```java // Tune the timeouts for large documents TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .connectTimeoutSeconds(30) .readTimeoutSeconds(300) .writeTimeoutSeconds(300) .build(); ``` ### Closing the Client `TurboDocxClient` implements `AutoCloseable`. Calling `close()` shuts down the underlying OkHttp dispatcher and connection pool, so long-running JVM services should close clients they no longer need. Use try-with-resources for short-lived clients: ```java try (TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .build()) { SendSignatureResponse result = client.turboSign().sendSignature(request); } ``` :::tip Reuse a single client Creating a client per request leaks OkHttp threads until they are closed. Prefer one long-lived client for the life of your application and call `client.close()` during shutdown. ::: ### Environment Variables ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here export TURBODOCX_SENDER_EMAIL=sender@yourcompany.com ``` :::warning API Credentials Required Three parameters are **required** for TurboSign operations: `apiKey` (or `accessToken`), `orgId`, and `senderEmail`. The `senderEmail` is used as the reply-to address for signature request emails. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ## Product guides Each product guide opens on the Java tab: - [TurboSign](/docs/SDKs/turbosign?language=java): send documents for signature, track status, download signed PDFs and audit trails - [Deliverable](/docs/SDKs/deliverable?language=java): generate documents from templates - [TurboWebhooks](/docs/SDKs/webhooks?language=java): receive real-time signature events - [TurboQuote](/docs/SDKs/quote?language=java): create, send and download quotes ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) - [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- # TurboDocx JavaScript / TypeScript SDK The official TurboDocx SDK for JavaScript and TypeScript applications. Build document generation and digital signature workflows with full TypeScript support, async/await patterns, and comprehensive error handling. Available on npm as `@turbodocx/sdk`. ## Installation ```bash npm install @turbodocx/sdk ``` ```bash yarn add @turbodocx/sdk ``` ```bash pnpm add @turbodocx/sdk ``` ## Requirements - Node.js 18+ or modern browser - TypeScript 4.7+ (optional, for type checking) --- ## Configuration ```javascript const { TurboSign } = require("@turbodocx/sdk"); // Configure globally (recommended for server-side) TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY, // Required : Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID, // Required: Your organization ID senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // Required: reply-to address for signature request emails senderName: "Your Company Name", // Optional but recommended: appears as the sender name // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` ```typescript // Configure globally (recommended for server-side) TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", // Required : Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID || "", // Required: Your organization ID senderEmail: process.env.TURBODOCX_SENDER_EMAIL || "", // Required: reply-to address for signature request emails senderName: "Your Company Name", // Optional but recommended: appears as the sender name // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` :::tip Authentication Authenticate using `apiKey`. API keys are recommended for server-side applications. ::: ### Environment Variables ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here ``` --- ## Product guides Each product guide opens on the JavaScript / TypeScript tab: - [TurboSign](/docs/SDKs/turbosign?language=js): send documents for signature, track status, download signed PDFs and audit trails - [Deliverable](/docs/SDKs/deliverable?language=js): generate documents from templates - [TurboWebhooks](/docs/SDKs/webhooks?language=js): receive real-time signature events - [TurboQuote](/docs/SDKs/quote?language=js): create, send and download quotes ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [npm Package](https://www.npmjs.com/package/@turbodocx/sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- # TurboPartner Go SDK :::tip Interested in TurboPartner? TurboPartner is available for integrators and partners. [Contact us](https://www.turbodocx.com/demo) to get started. ::: The official TurboDocx Partner SDK for Go applications. Build multi-tenant SaaS applications with programmatic organization management, user provisioning, API key management, and entitlement control. Zero dependencies: standard library only. :::info What is TurboPartner? TurboPartner is the partner management API for TurboDocx. It allows you to programmatically create and manage organizations, users, API keys, and feature entitlements, perfect for building white-label or multi-tenant applications on top of TurboDocx. ::: ## TLDR ```go package main "context" "fmt" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // 1. Configure partner, _ := turbodocx.NewPartnerClient(turbodocx.PartnerConfig{ PartnerAPIKey: os.Getenv("TURBODOCX_PARTNER_API_KEY"), PartnerID: os.Getenv("TURBODOCX_PARTNER_ID"), }) ctx := context.Background() // 2. Create an organization with entitlements org, _ := partner.CreateOrganization(ctx, &turbodocx.CreateOrganizationRequest{ Name: "Acme Corporation", Features: &turbodocx.Features{ MaxUsers: turbodocx.IntPtr(25), // Max users allowed MaxStorage: turbodocx.Int64Ptr(5 * 1024 * 1024 * 1024), // 5GB in bytes HasTDAI: turbodocx.BoolPtr(true), // Enable TurboDocx AI }, }) orgID := org.Data.ID // 3. Add a user user, _ := partner.AddUserToOrganization(ctx, orgID, &turbodocx.AddOrgUserRequest{ Email: "admin@acme.com", Role: "admin", }) fmt.Printf("User: %s\n", user.Data.Email) // 4. Create an API key key, _ := partner.CreateOrganizationAPIKey(ctx, orgID, &turbodocx.CreateOrgAPIKeyRequest{ Name: "Production Key", Role: "admin", }) fmt.Printf("API Key: %s\n", key.Data.Key) // Save this, only shown once! } ``` --- ## Installation ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` ## Requirements - Go 1.21 or higher - No external dependencies (standard library only) :::tip Zero Dependencies The Go SDK uses only the standard library: no third-party packages required. This makes it easy to integrate into any Go project. ::: --- ## Configuration ```go // Configure with your partner credentials partner, err := turbodocx.NewPartnerClient(turbodocx.PartnerConfig{ PartnerAPIKey: os.Getenv("TURBODOCX_PARTNER_API_KEY"), // Required: Must start with TDXP- PartnerID: os.Getenv("TURBODOCX_PARTNER_ID"), // Required: Your partner UUID }) if err != nil { log.Fatal(err) } ``` ```go // Auto-configure from environment variables // Leave fields empty to read from TURBODOCX_PARTNER_API_KEY and TURBODOCX_PARTNER_ID partner, err := turbodocx.NewPartnerClient(turbodocx.PartnerConfig{}) if err != nil { log.Fatal(err) } ``` :::caution Partner API Key Required Partner API keys start with `TDXP-` prefix. These are different from regular organization API keys and provide access to partner-level operations across all your organizations. ::: ### Environment Variables ```bash # .env or shell environment export TURBODOCX_PARTNER_API_KEY=TDXP-your-partner-api-key export TURBODOCX_PARTNER_ID=your-partner-uuid ``` --- ## Quick Start ### Create Your First Organization ```go partner, err := turbodocx.NewPartnerClient(turbodocx.PartnerConfig{}) if err != nil { log.Fatal(err) } ctx := context.Background() // Create a new organization result, err := partner.CreateOrganization(ctx, &turbodocx.CreateOrganizationRequest{ Name: "Acme Corporation", }) if err != nil { log.Fatal(err) } fmt.Printf("Organization created!\n") fmt.Printf(" ID: %s\n", result.Data.ID) fmt.Printf(" Name: %s\n", result.Data.Name) ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, always check the `err` return value. See [Error Handling](#error-handling) for complete patterns. ::: --- ## Organization Management ### `CreateOrganization()` Create a new organization under your partner account. ```go result, err := partner.CreateOrganization(ctx, &turbodocx.CreateOrganizationRequest{ Name: "Acme Corporation", Features: &turbodocx.Features{ MaxUsers: turbodocx.IntPtr(50), // Optional entitlements override }, }) fmt.Printf("Organization ID: %s\n", result.Data.ID) ``` ### `ListOrganizations()` List all organizations with pagination and search. ```go result, err := partner.ListOrganizations(ctx, &turbodocx.ListOrganizationsRequest{ Limit: turbodocx.IntPtr(25), Offset: turbodocx.IntPtr(0), Search: "Acme", // Optional search by name }) fmt.Printf("Total: %d\n", result.Data.TotalRecords) for _, org := range result.Data.Results { fmt.Printf("- %s (ID: %s)\n", org.Name, org.ID) } ``` ### `GetOrganizationDetails()` Get full details including features and tracking for an organization. ```go result, err := partner.GetOrganizationDetails(ctx, "org-uuid-here") fmt.Printf("Name: %s\n", result.Data.Name) fmt.Printf("Active: %t\n", result.Data.IsActive) if result.Data.Features != nil { if result.Data.Features.MaxUsers != nil { fmt.Printf("Max Users: %d\n", *result.Data.Features.MaxUsers) } if result.Data.Features.MaxStorage != nil { fmt.Printf("Max Storage: %d bytes\n", *result.Data.Features.MaxStorage) } } if result.Data.Tracking != nil { fmt.Printf("Current Users: %d\n", result.Data.Tracking.NumUsers) fmt.Printf("Storage Used: %d bytes\n", result.Data.Tracking.StorageUsed) } ``` ### `UpdateOrganizationInfo()` Update an organization's name. ```go result, err := partner.UpdateOrganizationInfo(ctx, "org-uuid-here", &turbodocx.UpdateOrganizationRequest{ Name: "Acme Corp (Updated)", }, ) ``` ### `UpdateOrganizationEntitlements()` Update an organization's feature limits and capabilities. ```go result, err := partner.UpdateOrganizationEntitlements(ctx, "org-uuid-here", &turbodocx.UpdateEntitlementsRequest{ Features: &turbodocx.Features{ MaxUsers: turbodocx.IntPtr(100), MaxStorage: turbodocx.Int64Ptr(10 * 1024 * 1024 * 1024), // 10GB MaxSignatures: turbodocx.IntPtr(500), HasTDAI: turbodocx.BoolPtr(true), HasFileDownload: turbodocx.BoolPtr(true), HasBetaFeatures: turbodocx.BoolPtr(false), }, }, ) fmt.Println("Entitlements updated!") ``` You can also set the usage counters on the same call: ```go _, err = partner.UpdateOrganizationEntitlements(ctx, "org-uuid-here", &turbodocx.UpdateEntitlementsRequest{ Tracking: &turbodocx.Tracking{ NumUsers: 5, StorageUsed: 1048576, CurrentAICredits: -1, // -1 = unlimited }, }, ) ``` :::info Features vs Tracking **Features** are the limits and capabilities you grant (MaxUsers, HasTDAI, …). **Tracking** is the current consumption against those limits (NumUsers, StorageUsed, …). It is normally maintained by TurboDocx, but this endpoint **does accept a `Tracking` object**, so you can seed or reconcile counters during a migration. See [Entitlements Reference](#entitlements-reference) for all available fields. ::: ### `DeleteOrganization()` Delete an organization (soft delete). ```go result, err := partner.DeleteOrganization(ctx, "org-uuid-here") fmt.Printf("Success: %t\n", result.Success) ``` :::danger Use With Caution Deleting an organization is a destructive operation. All organization data, users, and API keys will be affected. ::: ### `GetOrganizationPreferences()` Read an organization's TurboSign display preferences. Returns only the partner-settable keys, with defaults applied for any the org never set. ```go resp, err := partner.GetOrganizationPreferences(ctx, "org-uuid-here") if err != nil { log.Fatal(err) } fmt.Println(resp.Data.Preferences.HideSignatureOutline) // bool fmt.Println(resp.Data.Preferences.HideSignatureHash) // bool fmt.Println(resp.Data.Preferences.LockedFieldsBackground) // bool fmt.Println(resp.Data.Preferences.AllowDownloadBeforeSigning) // bool ``` ### `UpdateOrganizationPreferences()` Update one or more of an organization's TurboSign display preferences. Only the non-nil keys are sent; every other org setting is preserved. Use a `*bool` so a `false` value is transmitted. The response echoes the full, merged preferences. ```go disabled := false enabled := true resp, err := partner.UpdateOrganizationPreferences(ctx, "org-uuid-here", &turbodocx.UpdateOrgPreferencesRequest{ LockedFieldsBackground: &disabled, // render locked fields as plain text, not a grey box AllowDownloadBeforeSigning: &enabled, // let signers download the unsigned PDF before they sign }) if err != nil { log.Fatal(err) } fmt.Println(resp.Data.Preferences.LockedFieldsBackground) // false fmt.Println(resp.Data.Preferences.AllowDownloadBeforeSigning) // true ``` See [Preferences Reference](#preferences-reference) for every settable key and its meaning. --- ## Organization User Management ### `AddUserToOrganization()` Add a user to an organization with a specific role. ```go result, err := partner.AddUserToOrganization(ctx, "org-uuid-here", &turbodocx.AddOrgUserRequest{ Email: "user@example.com", Role: "admin", // admin, contributor, user, or viewer }, ) fmt.Printf("User ID: %s\n", result.Data.ID) fmt.Printf("Invitation sent to: %s\n", result.Data.Email) ``` ### `ListOrganizationUsers()` List all users in an organization. ```go result, err := partner.ListOrganizationUsers(ctx, "org-uuid-here", &turbodocx.ListOrgUsersRequest{ Limit: turbodocx.IntPtr(50), Offset: turbodocx.IntPtr(0), }, ) fmt.Printf("Total Users: %d\n", result.Data.TotalRecords) for _, user := range result.Data.Results { fmt.Printf("- %s (%s)\n", user.Email, user.Role) } ``` ### `UpdateOrganizationUserRole()` Change a user's role within an organization. ```go result, err := partner.UpdateOrganizationUserRole(ctx, "org-uuid-here", "user-uuid-here", &turbodocx.UpdateOrgUserRequest{Role: "contributor"}, ) ``` ### `ResendOrganizationInvitationToUser()` Resend the invitation email to a pending user. ```go result, err := partner.ResendOrganizationInvitationToUser(ctx, "org-uuid-here", "user-uuid-here", ) ``` ### `RemoveUserFromOrganization()` Remove a user from an organization. ```go result, err := partner.RemoveUserFromOrganization(ctx, "org-uuid-here", "user-uuid-here", ) ``` --- ## Organization API Key Management ### `CreateOrganizationAPIKey()` Create an API key for an organization. ```go result, err := partner.CreateOrganizationAPIKey(ctx, "org-uuid-here", &turbodocx.CreateOrgAPIKeyRequest{ Name: "Production API Key", Role: "admin", // ORG role enum: admin, contributor, user, or viewer }, ) fmt.Printf("Key ID: %s\n", result.Data.ID) fmt.Printf("Full Key: %s\n", result.Data.Key) // Only shown once! ``` :::caution Save Your API Key The full API key is only returned once during creation. Store it securely: you won't be able to retrieve it again. ::: ### `ListOrganizationAPIKeys()` List all API keys for an organization. ```go result, err := partner.ListOrganizationAPIKeys(ctx, "org-uuid-here", &turbodocx.ListOrgAPIKeysRequest{Limit: turbodocx.IntPtr(50)}, ) for _, key := range result.Data.Results { fmt.Printf("- %s (Role: %s)\n", key.Name, key.Role) } ``` ### `UpdateOrganizationAPIKey()` Update an organization API key's name or role. ```go result, err := partner.UpdateOrganizationAPIKey(ctx, "org-uuid-here", "api-key-uuid-here", &turbodocx.UpdateOrgAPIKeyRequest{ Name: "Updated Key Name", Role: "contributor", }, ) ``` ### `RevokeOrganizationAPIKey()` Revoke (delete) an organization API key. ```go result, err := partner.RevokeOrganizationAPIKey(ctx, "org-uuid-here", "api-key-uuid-here", ) ``` --- ## Partner API Key Management ### `CreatePartnerAPIKey()` Create a new partner-level API key with specific scopes. ```go result, err := partner.CreatePartnerAPIKey(ctx, &turbodocx.CreatePartnerAPIKeyRequest{ Name: "Integration API Key", Scopes: []string{ turbodocx.ScopeOrgCreate, turbodocx.ScopeOrgRead, turbodocx.ScopeOrgUpdate, turbodocx.ScopeEntitlementsUpdate, turbodocx.ScopeAuditRead, }, Description: "For third-party integration", }, ) fmt.Printf("Key ID: %s\n", result.Data.ID) fmt.Printf("Full Key: %s\n", result.Data.Key) // Only shown once! ``` ### `ListPartnerAPIKeys()` List all partner API keys. ```go result, err := partner.ListPartnerAPIKeys(ctx, &turbodocx.ListPartnerAPIKeysRequest{Limit: turbodocx.IntPtr(50)}, ) for _, key := range result.Data.Results { fmt.Printf("- %s\n", key.Name) fmt.Printf(" Scopes: %v\n", key.Scopes) } ``` ### `UpdatePartnerAPIKey()` Update a partner API key. ```go result, err := partner.UpdatePartnerAPIKey(ctx, "partner-key-uuid-here", &turbodocx.UpdatePartnerAPIKeyRequest{ Name: "Updated Integration Key", Description: "Updated description", }, ) ``` ### `RevokePartnerAPIKey()` Revoke a partner API key. ```go result, err := partner.RevokePartnerAPIKey(ctx, "partner-key-uuid-here") ``` --- ## Partner User Management :::danger Partner users use a different role enum Partner portal users take `admin`, `member`, or `viewer`. **Organization** users and organization API keys take `admin`, `contributor`, `user`, or `viewer`. The two enums do not overlap beyond `admin`/`viewer`: `"member"` is rejected on an org call, and `"contributor"`/`"user"` are rejected on a partner call. See [Role Enums](#organization-user-roles). ::: :::caution `Permissions` is all-or-nothing The `Permissions` object itself is optional, but if you send it, **all seven fields are required**. There is no partial permissions update: the API rejects an incomplete object with `*ValidationError` (400). Because `PartnerPermissions` is a struct of plain `bool`s, any field you leave out silently serializes as `false` rather than "unchanged": read the current values first and re-send them with your change applied. ::: ### `AddUserToPartnerPortal()` Add a user to the partner portal with specific permissions. ```go result, err := partner.AddUserToPartnerPortal(ctx, &turbodocx.AddPartnerUserRequest{ Email: "admin@partner.com", Role: "admin", // PARTNER role enum: admin, member, or viewer // All 7 fields required whenever Permissions is present. Permissions: turbodocx.PartnerPermissions{ CanManageOrgs: true, CanManageOrgUsers: true, CanManagePartnerUsers: false, CanManageOrgAPIKeys: true, CanManagePartnerAPIKeys: false, CanUpdateEntitlements: true, CanViewAuditLogs: true, }, }, ) fmt.Printf("Partner User ID: %s\n", result.Data.ID) ``` ### `ListPartnerPortalUsers()` List all partner portal users. ```go result, err := partner.ListPartnerPortalUsers(ctx, &turbodocx.ListPartnerUsersRequest{Limit: turbodocx.IntPtr(50)}, ) for _, user := range result.Data.Results { fmt.Printf("- %s (Role: %s)\n", user.Email, user.Role) } ``` ### `UpdatePartnerUserPermissions()` Update a partner user's role and permissions. If you set `Permissions`, populate **all seven fields**: a partial object is a 400, and unset bools default to `false`. ```go result, err := partner.UpdatePartnerUserPermissions(ctx, "partner-user-uuid-here", &turbodocx.UpdatePartnerUserRequest{ Role: "admin", // Not a patch: every field below must be set explicitly. Permissions: &turbodocx.PartnerPermissions{ CanManageOrgs: true, CanManageOrgUsers: true, CanManagePartnerUsers: true, CanManageOrgAPIKeys: true, CanManagePartnerAPIKeys: true, CanUpdateEntitlements: true, CanViewAuditLogs: true, }, }, ) ``` ### `ResendPartnerPortalInvitationToUser()` Resend the invitation email to a pending partner user. ```go result, err := partner.ResendPartnerPortalInvitationToUser(ctx, "partner-user-uuid-here") ``` ### `RemoveUserFromPartnerPortal()` Remove a user from the partner portal. ```go result, err := partner.RemoveUserFromPartnerPortal(ctx, "partner-user-uuid-here") ``` --- ## Audit Logs ### `GetPartnerAuditLogs()` Get audit logs for all partner activities with filtering. ```go result, err := partner.GetPartnerAuditLogs(ctx, &turbodocx.ListAuditLogsRequest{ Limit: turbodocx.IntPtr(50), Offset: turbodocx.IntPtr(0), Action: "ORG_CREATED", // Optional filter by action ResourceType: "organization", // Optional filter by resource type Success: turbodocx.BoolPtr(true), // Optional filter by success/failure StartDate: "2024-01-01", // Optional date range start EndDate: "2024-12-31", // Optional date range end }, ) for _, entry := range result.Data.Results { fmt.Printf("%s - %s", entry.CreatedOn, entry.Action) if entry.ResourceType != "" { fmt.Printf(" (%s)", entry.ResourceType) } if entry.Success { fmt.Println(" - Success") } else { fmt.Println(" - Failed") } } ``` --- ## Entitlements Reference ### Features (Settable Limits) These are limits and capabilities you can configure for each organization: | Field | Type | Description | |-------|------|-------------| | `MaxUsers` | `*int` | Maximum users allowed (-1 = unlimited) | | `MaxProjectspaces` | `*int` | Maximum projectspaces | | `MaxTemplates` | `*int` | Maximum templates | | `MaxStorage` | `*int64` | Maximum storage in bytes | | `MaxGeneratedDeliverables` | `*int` | Maximum generated documents | | `MaxSignatures` | `*int` | Maximum e-signatures | | `MaxAICredits` | `*int` | Maximum AI credits | | `RdWatermark` | `*bool` | Enable RapidDocx watermark | | `HasFileDownload` | `*bool` | Enable file download | | `HasAdvancedDateFormats` | `*bool` | Enable advanced date formats | | `HasGDrive` | `*bool` | Enable Google Drive integration | | `HasSharepoint` | `*bool` | Enable SharePoint integration | | `HasSharepointOnly` | `*bool` | SharePoint-only mode | | `HasTDAI` | `*bool` | Enable TurboDocx AI features | | `HasPptx` | `*bool` | Enable PowerPoint support | | `HasTDWriter` | `*bool` | Enable TurboDocx Writer | | `HasSalesforce` | `*bool` | Enable Salesforce integration | | `HasWrike` | `*bool` | Enable Wrike integration | | `HasVariableStack` | `*bool` | Enable variable stack | | `HasSubvariables` | `*bool` | Enable subvariables | | `HasZapier` | `*bool` | Enable Zapier integration | | `HasBYOM` | `*bool` | Enable Bring Your Own Model | | `HasBYOVS` | `*bool` | Enable Bring Your Own Vector Store | | `HasBetaFeatures` | `*bool` | Enable beta features | | `EnableBulkSending` | `*bool` | Enable bulk document sending | :::tip Pointer Helpers Use the provided helper functions for setting optional fields: - `turbodocx.IntPtr(25)`, for `*int` fields - `turbodocx.Int64Ptr(5368709120)`, for `*int64` fields (storage) - `turbodocx.BoolPtr(true)`, for `*bool` fields ::: ### Tracking (Usage Counters) Current consumption against the limits above. TurboDocx maintains these automatically, but `UpdateOrganizationEntitlements()` **accepts a `Tracking` object**, useful for seeding counters when migrating an existing customer: | Field | Type | Description | |-------|------|-------------| | `NumUsers` | `int` | Current number of users | | `NumProjectspaces` | `int` | Current number of projectspaces | | `NumTemplates` | `int` | Current number of templates | | `StorageUsed` | `int64` | Current storage used in bytes | | `NumGeneratedDeliverables` | `int` | Total documents generated | | `NumSignaturesUsed` | `int` | Total signatures used | | `NumQuotesSent` | `int` | Total quotes sent | | `CurrentAICredits` | `int` | Remaining AI credits (-1 = unlimited) | Every counter except `CurrentAICredits` floors at `0`. Only `CurrentAICredits` accepts `-1`, meaning unlimited. :::note Zero values are omitted `Tracking` fields are plain values tagged `omitempty`, so a field you set to `0` is dropped from the request body rather than sent as `0`. To reset a counter to zero, use the REST API directly. ::: --- ## Preferences Reference TurboSign display preferences you can read and set per organization. Every key is a boolean and is validated strictly: the strings `"true"` / `"false"` are rejected with a 400, so pass real booleans. The API returns only these keys and never any of the organization's other settings. | Field | Type | Default | Description | |-------|------|---------|-------------| | `hideSignatureOutline` | boolean | `false` | Hide the outline/label around signed fields in the finished PDF | | `hideSignatureHash` | boolean | `false` | Hide the verification hash printed on signed fields | | `lockedFieldsBackground` | boolean | `true` | Render locked fields with a grey box background (`true`) or as plain text (`false`) | | `allowDownloadBeforeSigning` | boolean | `false` | When enabled, a signer can download the unsigned PDF from the signing page before they sign it (for example, to review it with their legal team). Defaults to off. | `GetOrganizationPreferences()` returns every key with its effective value (the default is applied for any key the org never set). `UpdateOrganizationPreferences()` changes only the keys you pass and preserves all other organization settings. --- ## Scope Constants ### Partner Scopes (22 Scopes) ```go // Organization CRUD turbodocx.ScopeOrgCreate // "org:create" turbodocx.ScopeOrgRead // "org:read" turbodocx.ScopeOrgUpdate // "org:update" turbodocx.ScopeOrgDelete // "org:delete" // Entitlements turbodocx.ScopeEntitlementsUpdate // "entitlements:update" // Organization Users turbodocx.ScopeOrgUsersCreate // "org-users:create" turbodocx.ScopeOrgUsersRead // "org-users:read" turbodocx.ScopeOrgUsersUpdate // "org-users:update" turbodocx.ScopeOrgUsersDelete // "org-users:delete" // Partner Users turbodocx.ScopePartnerUsersCreate // "partner-users:create" turbodocx.ScopePartnerUsersRead // "partner-users:read" turbodocx.ScopePartnerUsersUpdate // "partner-users:update" turbodocx.ScopePartnerUsersDelete // "partner-users:delete" // Organization API Keys turbodocx.ScopeOrgAPIKeysCreate // "org-apikeys:create" turbodocx.ScopeOrgAPIKeysRead // "org-apikeys:read" turbodocx.ScopeOrgAPIKeysUpdate // "org-apikeys:update" turbodocx.ScopeOrgAPIKeysDelete // "org-apikeys:delete" // Partner API Keys turbodocx.ScopePartnerAPIKeysCreate // "partner-apikeys:create" turbodocx.ScopePartnerAPIKeysRead // "partner-apikeys:read" turbodocx.ScopePartnerAPIKeysUpdate // "partner-apikeys:update" turbodocx.ScopePartnerAPIKeysDelete // "partner-apikeys:delete" // Audit turbodocx.ScopeAuditRead // "audit:read" ``` :::note Renamed since v0.3.0 The eight API-key scopes were spelled `ScopeOrgApikeysCreate`, `ScopePartnerApikeysCreate`, and so on in v0.3.0. Those identifiers still compile as deprecated aliases with identical string values, but they will be removed in a future major release. Use the `APIKeys` spelling above. ::: ### Organization User Roles Used by `AddUserToOrganization()`, `UpdateOrganizationUserRole()`, `CreateOrganizationAPIKey()`, and `UpdateOrganizationAPIKey()`. | Role | Description | |------|-------------| | `"admin"` | Full organization access | | `"contributor"` | Can create and edit content | | `"user"` | Standard user access | | `"viewer"` | Read-only access | ### Partner User Roles Used by `AddUserToPartnerPortal()` and `UpdatePartnerUserPermissions()` only. | Role | Description | |------|-------------| | `"admin"` | Full partner portal access | | `"member"` | Standard partner access (respects permissions) | | `"viewer"` | Read-only access to partner portal | :::danger Do not mix the two enums `"member"` exists **only** in the partner role enum; sending it to an organization endpoint is a 400. `"contributor"` and `"user"` exist **only** in the organization role enum; sending either to a partner endpoint is a 400. ::: ### PartnerPermissions All seven fields are required whenever a `PartnerPermissions` object is sent. Partial objects are rejected with a 400. ```go permissions := turbodocx.PartnerPermissions{ CanManageOrgs: true, // Create, update, delete organizations CanManageOrgUsers: true, // Manage users within organizations CanManagePartnerUsers: false, // Manage other partner portal users CanManageOrgAPIKeys: true, // Manage organization API keys CanManagePartnerAPIKeys: false, // Manage partner API keys CanUpdateEntitlements: true, // Update organization entitlements CanViewAuditLogs: true, // View audit logs } ``` --- ## Error Handling `partner.CreateOrganization` and the other partner calls return `AuthorizationError` when the partner API key lacks the scope for the route, since partner keys are scoped separately from organization keys: ```go result, err := partner.CreateOrganization(ctx, request) if err != nil { var authErr *turbodocx.AuthenticationError var authzErr *turbodocx.AuthorizationError var validErr *turbodocx.ValidationError var notFoundErr *turbodocx.NotFoundError var conflictErr *turbodocx.ConflictError var rateLimitErr *turbodocx.RateLimitError var networkErr *turbodocx.NetworkError switch { case errors.As(err, &authErr): // 401 - Invalid API key or partner ID fmt.Printf("Authentication failed: %s\n", authErr.Message) case errors.As(err, &authzErr): // 403 - Key is valid but lacks the scope this route requires fmt.Printf("Authorization failed: %s\n", authzErr.Message) case errors.As(err, &validErr): // 400 - Invalid request data fmt.Printf("Validation error: %s\n", validErr.Message) case errors.As(err, ¬FoundErr): // 404 - Organization or resource not found fmt.Printf("Not found: %s\n", notFoundErr.Message) case errors.As(err, &conflictErr): // 409 - Resource conflict (e.g. AddUserToPartnerPortal on an existing user) fmt.Printf("Conflict: %s\n", conflictErr.Message) case errors.As(err, &rateLimitErr): // 429 - Rate limit exceeded fmt.Printf("Rate limit: %s\n", rateLimitErr.Message) case errors.As(err, &networkErr): // Network/connection error fmt.Printf("Network error: %s\n", networkErr.Message) default: fmt.Printf("Error: %v\n", err) } } ``` The full typed-error table and HTTP status mapping is documented once in the [Go SDK's Error Handling reference](./go.md#error-handling); partner calls use the same `AuthenticationError`/`AuthorizationError`/`ValidationError`/`NotFoundError`/`ConflictError`/`RateLimitError`/`NetworkError` types (for example, `AddUserToPartnerPortal()` returns a `*ConflictError` (409) when the target user already has partner-portal access). --- ## Complete Example Here's a complete example showing a typical partner workflow: ```go package main "context" "fmt" "log" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // Configure partner, err := turbodocx.NewPartnerClient(turbodocx.PartnerConfig{}) if err != nil { log.Fatal(err) } ctx := context.Background() // 1. Create an organization for a new customer org, err := partner.CreateOrganization(ctx, &turbodocx.CreateOrganizationRequest{ Name: "New Customer Inc", }) if err != nil { log.Fatal(err) } fmt.Printf("Created organization: %s\n", org.Data.ID) // 2. Set up their entitlements based on their plan _, err = partner.UpdateOrganizationEntitlements(ctx, org.Data.ID, &turbodocx.UpdateEntitlementsRequest{ Features: &turbodocx.Features{ MaxUsers: turbodocx.IntPtr(25), MaxStorage: turbodocx.Int64Ptr(5 * 1024 * 1024 * 1024), // 5GB HasTDAI: turbodocx.BoolPtr(true), HasFileDownload: turbodocx.BoolPtr(true), }, }, ) if err != nil { log.Fatal(err) } fmt.Println("Configured entitlements") // 3. Add their admin user user, err := partner.AddUserToOrganization(ctx, org.Data.ID, &turbodocx.AddOrgUserRequest{ Email: "admin@newcustomer.com", Role: "admin", }, ) if err != nil { log.Fatal(err) } fmt.Printf("Added admin user: %s\n", user.Data.Email) // 4. Create an API key for their integration apiKey, err := partner.CreateOrganizationAPIKey(ctx, org.Data.ID, &turbodocx.CreateOrgAPIKeyRequest{ Name: "Production API Key", Role: "admin", }, ) if err != nil { log.Fatal(err) } fmt.Printf("Created API key: %s\n", apiKey.Data.Key) fmt.Println("\nCustomer setup complete!") } ``` --- ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [Go Package Reference](https://pkg.go.dev/github.com/TurboDocx/SDK/packages/go-sdk) - [TurboSign Go SDK](/docs/SDKs/go): for digital signature operations --- # TurboPartner Java SDK :::tip Interested in TurboPartner? TurboPartner is available for integrators and partners. [Contact us](https://www.turbodocx.com/demo) to get started. ::: The official TurboDocx Partner SDK for Java applications. Build multi-tenant SaaS applications with programmatic organization management, user provisioning, API key management, and entitlement control. Available on Maven Central as `com.turbodocx:turbodocx-sdk`. :::info What is TurboPartner? TurboPartner is the partner management API for TurboDocx. It allows you to programmatically create and manage organizations, users, API keys, and feature entitlements, perfect for building white-label or multi-tenant applications on top of TurboDocx. ::: ## TLDR ```java public class Main { public static void main(String[] args) throws Exception { // 1. Configure TurboPartnerClient client = new TurboPartnerClient.Builder() .partnerApiKey(System.getenv("TURBODOCX_PARTNER_API_KEY")) .partnerId(System.getenv("TURBODOCX_PARTNER_ID")) .build(); // 2. Create an organization with entitlements Map features = new LinkedHashMap(); features.put("maxUsers", 25); // Max users allowed features.put("maxStorage", 5368709120L); // 5GB in bytes features.put("hasTDAI", true); // Enable TurboDocx AI JsonObject org = client.turboPartner().createOrganization("Acme Corporation", null, features); String orgId = org.getAsJsonObject("data").get("id").getAsString(); // 3. Add a user client.turboPartner().addUserToOrganization(orgId, "admin@acme.com", "admin"); // 4. Create an API key JsonObject key = client.turboPartner().createOrganizationApiKey(orgId, "Production Key", "admin"); System.out.println("API Key: " + key.getAsJsonObject("data").get("key").getAsString()); // Save this, only shown once! } } ``` --- ## Installation ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ## Requirements - Java 11+ - OkHttp 4.x (included) - Gson 2.x (included) --- ## Configuration ```java public class Main { public static void main(String[] args) { // Create the partner client with the Builder pattern TurboPartnerClient client = new TurboPartnerClient.Builder() .partnerApiKey(System.getenv("TURBODOCX_PARTNER_API_KEY")) // Required: must start with TDXP- .partnerId(System.getenv("TURBODOCX_PARTNER_ID")) // Required: your partner UUID .build(); // Or with a custom base URL TurboPartnerClient custom = new TurboPartnerClient.Builder() .partnerApiKey(System.getenv("TURBODOCX_PARTNER_API_KEY")) .partnerId(System.getenv("TURBODOCX_PARTNER_ID")) .baseUrl("https://api.turbodocx.com") .build(); } } ``` ### Builder Options | Method | Type | Required | Default | Description | | ----------------------- | -------- | -------- | --------------------------- | --------------------------------- | | `partnerApiKey(String)` | `String` | Yes | - | Partner API key (starts with `TDXP-`) | | `partnerId(String)` | `String` | Yes | - | Your partner UUID | | `baseUrl(String)` | `String` | No | `https://api.turbodocx.com` | API base URL | `build()` throws `TurboDocxException.AuthenticationException` if `partnerApiKey` or `partnerId` is missing. All partner operations live on `client.turboPartner()`. :::caution Partner API Key Required Partner API keys start with the `TDXP-` prefix. These are different from regular organization API keys and provide access to partner-level operations across all your organizations. ::: ### Environment Variables ```bash export TURBODOCX_PARTNER_API_KEY=TDXP-your-partner-api-key export TURBODOCX_PARTNER_ID=your-partner-uuid ``` :::info Responses are raw `JsonObject` Every `TurboPartner` method returns a Gson `JsonObject` containing the raw API response: `success`, `data`, and sometimes `message`. Unlike the TurboSign and Deliverable modules, partner responses are **not** unwrapped into typed models, so read them with `getAsJsonObject("data")`, `getAsJsonArray("results")`, `getAsString()`, and friends. Iterating a results array needs `com.google.gson.JsonElement` alongside `com.google.gson.JsonObject`. Every method throws `IOException` on transport failure. ::: --- ## Quick Start ### Create Your First Organization ```java public class Main { public static void main(String[] args) throws Exception { TurboPartnerClient client = new TurboPartnerClient.Builder() .partnerApiKey(System.getenv("TURBODOCX_PARTNER_API_KEY")) .partnerId(System.getenv("TURBODOCX_PARTNER_ID")) .build(); JsonObject result = client.turboPartner().createOrganization("Acme Corporation"); JsonObject data = result.getAsJsonObject("data"); System.out.println("Organization created!"); System.out.println(" ID: " + data.get("id").getAsString()); System.out.println(" Name: " + data.get("name").getAsString()); } } ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, wrap all TurboPartner calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: --- ## Organization Management ### `createOrganization()` Create a new organization under your partner account. There are two overloads: name only, or name plus optional `metadata` and `features` maps (either may be `null`). ```java // Name only JsonObject simple = client.turboPartner().createOrganization("Acme Corporation"); // With metadata and entitlements overrides Map metadata = new LinkedHashMap(); metadata.put("crmAccountId", "ACC-1024"); Map features = new LinkedHashMap(); features.put("maxUsers", 50); JsonObject result = client.turboPartner().createOrganization("Acme Corporation", metadata, features); System.out.println("Organization ID: " + result.getAsJsonObject("data").get("id").getAsString()); ``` ### `listOrganizations()` List all organizations with pagination and search. Pass `null` for any filter you don't need. ```java JsonObject result = client.turboPartner().listOrganizations(25, 0, "Acme"); JsonObject data = result.getAsJsonObject("data"); System.out.println("Total: " + data.get("totalRecords").getAsInt()); for (JsonElement element : data.getAsJsonArray("results")) { JsonObject org = element.getAsJsonObject(); System.out.println("- " + org.get("name").getAsString() + " (ID: " + org.get("id").getAsString() + ")"); } ``` ### `getOrganizationDetails()` Get full details including features and tracking for an organization. ```java JsonObject result = client.turboPartner().getOrganizationDetails("org-uuid-here"); JsonObject data = result.getAsJsonObject("data"); System.out.println("Name: " + data.get("name").getAsString()); System.out.println("Active: " + (data.get("isActive").getAsBoolean() ? "Yes" : "No")); if (data.has("features") && data.get("features").isJsonObject()) { JsonObject features = data.getAsJsonObject("features"); System.out.println("Max Users: " + features.get("maxUsers").getAsInt()); System.out.println("Max Storage: " + features.get("maxStorage").getAsLong() + " bytes"); } if (data.has("tracking") && data.get("tracking").isJsonObject()) { JsonObject tracking = data.getAsJsonObject("tracking"); System.out.println("Current Users: " + tracking.get("numUsers").getAsInt()); System.out.println("Storage Used: " + tracking.get("storageUsed").getAsLong() + " bytes"); } ``` ### `updateOrganizationInfo()` Update an organization's name. ```java JsonObject result = client.turboPartner().updateOrganizationInfo( "org-uuid-here", "Acme Corp (Updated)" ); ``` ### `updateOrganizationEntitlements()` Update an organization's feature limits and capabilities. Both `features` and `tracking` are optional: pass `null` for the one you are not changing. ```java Map features = new LinkedHashMap(); features.put("maxUsers", 100); features.put("maxStorage", 10737418240L); // 10GB in bytes features.put("maxSignatures", 500); features.put("hasTDAI", true); features.put("hasFileDownload", true); features.put("hasBetaFeatures", false); JsonObject result = client.turboPartner().updateOrganizationEntitlements( "org-uuid-here", features, null ); System.out.println("Entitlements updated!"); ``` You can also set the usage counters on the same call: ```java Map tracking = new LinkedHashMap(); tracking.put("numUsers", 5); tracking.put("storageUsed", 1048576L); tracking.put("currentAICredits", -1); // -1 = unlimited client.turboPartner().updateOrganizationEntitlements("org-uuid-here", null, tracking); ``` :::info Features vs Tracking **Features** are the limits and capabilities you grant (maxUsers, hasTDAI, …). **Tracking** is the current consumption against those limits (numUsers, storageUsed, …). It is normally maintained by TurboDocx, but this endpoint **does accept a `tracking` map**, so you can seed or reconcile counters during a migration. See [Entitlements Reference](#entitlements-reference) for all available fields. ::: ### `deleteOrganization()` Delete an organization (soft delete). ```java JsonObject result = client.turboPartner().deleteOrganization("org-uuid-here"); System.out.println("Success: " + (result.get("success").getAsBoolean() ? "Yes" : "No")); ``` :::danger Use With Caution Deleting an organization is a destructive operation. All organization data, users, and API keys will be affected. ::: ### `getOrganizationPreferences()` Read an organization's TurboSign display preferences. Returns only the partner-settable keys, with defaults applied for any the org never set. ```java PartnerOrgPreferencesResponse result = client.turboPartner() .getOrganizationPreferences("org-uuid-here"); System.out.println(result.getData().getPreferences().getHideSignatureOutline()); // Boolean System.out.println(result.getData().getPreferences().getHideSignatureHash()); // Boolean System.out.println(result.getData().getPreferences().getLockedFieldsBackground()); // Boolean System.out.println(result.getData().getPreferences().getAllowDownloadBeforeSigning()); // Boolean ``` ### `updateOrganizationPreferences()` Update one or more of an organization's TurboSign display preferences. Only the keys you set are changed; every other org setting is preserved. The response echoes the full, merged preferences. ```java PartnerOrgPreferencesResponse result = client.turboPartner() .updateOrganizationPreferences( "org-uuid-here", new PartnerOrgPreferences() .setLockedFieldsBackground(false) // plain text, not a grey box .setAllowDownloadBeforeSigning(true) // let signers download the unsigned PDF before they sign ); System.out.println(result.getData().getPreferences().getLockedFieldsBackground()); // false System.out.println(result.getData().getPreferences().getAllowDownloadBeforeSigning()); // true ``` See [Preferences Reference](#preferences-reference) for every settable key and its meaning. --- ## Organization User Management ### `addUserToOrganization()` Add a user to an organization with a specific role. ```java JsonObject result = client.turboPartner().addUserToOrganization( "org-uuid-here", "user@example.com", "admin" // "admin", "contributor", "user", or "viewer" ); JsonObject data = result.getAsJsonObject("data"); System.out.println("User ID: " + data.get("id").getAsString()); System.out.println("Invitation sent to: " + data.get("email").getAsString()); ``` ### `listOrganizationUsers()` List all users in an organization. `limit`, `offset`, and `search` may each be `null`. ```java JsonObject result = client.turboPartner().listOrganizationUsers("org-uuid-here", 50, 0, null); JsonObject data = result.getAsJsonObject("data"); System.out.println("Total Users: " + data.get("totalRecords").getAsInt()); for (JsonElement element : data.getAsJsonArray("results")) { JsonObject user = element.getAsJsonObject(); System.out.println("- " + user.get("email").getAsString() + " (" + user.get("role").getAsString() + ")"); } ``` ### `updateOrganizationUserRole()` Change a user's role within an organization. ```java JsonObject result = client.turboPartner().updateOrganizationUserRole( "org-uuid-here", "user-uuid-here", "contributor" ); ``` ### `resendOrganizationInvitationToUser()` Resend the invitation email to a pending user. ```java JsonObject result = client.turboPartner().resendOrganizationInvitationToUser( "org-uuid-here", "user-uuid-here" ); ``` ### `removeUserFromOrganization()` Remove a user from an organization. ```java JsonObject result = client.turboPartner().removeUserFromOrganization( "org-uuid-here", "user-uuid-here" ); ``` --- ## Organization API Key Management ### `createOrganizationApiKey()` Create an API key for an organization. ```java JsonObject result = client.turboPartner().createOrganizationApiKey( "org-uuid-here", "Production API Key", "admin" // ORG role: "admin", "contributor", "user", or "viewer" ); JsonObject data = result.getAsJsonObject("data"); System.out.println("Key ID: " + data.get("id").getAsString()); System.out.println("Full Key: " + data.get("key").getAsString()); // Only shown once! ``` :::caution Save Your API Key The full API key is only returned once during creation. Store it securely: you won't be able to retrieve it again. ::: ### `listOrganizationApiKeys()` List all API keys for an organization. ```java JsonObject result = client.turboPartner().listOrganizationApiKeys("org-uuid-here", 50, null, null); for (JsonElement element : result.getAsJsonObject("data").getAsJsonArray("results")) { JsonObject key = element.getAsJsonObject(); System.out.println("- " + key.get("name").getAsString() + " (Role: " + key.get("role").getAsString() + ")"); } ``` ### `updateOrganizationApiKey()` Update an organization API key's name or role. Pass `null` for a field to keep its current value. ```java JsonObject result = client.turboPartner().updateOrganizationApiKey( "org-uuid-here", "api-key-uuid-here", "Updated Key Name", "contributor" ); ``` The updated key is returned on the `apiKey` property rather than `data`: ```java System.out.println(result.getAsJsonObject("apiKey").get("name").getAsString()); ``` ### `revokeOrganizationApiKey()` Revoke (delete) an organization API key. ```java JsonObject result = client.turboPartner().revokeOrganizationApiKey( "org-uuid-here", "api-key-uuid-here" ); ``` --- ## Partner API Key Management ### `createPartnerApiKey()` Create a new partner-level API key with specific scopes. `description` is optional (`null` to omit). ```java JsonObject result = client.turboPartner().createPartnerApiKey( "Integration API Key", Arrays.asList( PartnerScope.ORG_CREATE, PartnerScope.ORG_READ, PartnerScope.ORG_UPDATE, PartnerScope.ENTITLEMENTS_UPDATE, PartnerScope.AUDIT_READ ), "For third-party integration" ); JsonObject data = result.getAsJsonObject("data"); System.out.println("Key ID: " + data.get("id").getAsString()); System.out.println("Full Key: " + data.get("key").getAsString()); // Only shown once! ``` ### `listPartnerApiKeys()` List all partner API keys. ```java JsonObject result = client.turboPartner().listPartnerApiKeys(50, null, null); for (JsonElement element : result.getAsJsonObject("data").getAsJsonArray("results")) { JsonObject key = element.getAsJsonObject(); System.out.println("- " + key.get("name").getAsString()); System.out.println(" Scopes: " + key.getAsJsonArray("scopes")); } ``` ### `updatePartnerApiKey()` Update a partner API key. The argument order is `keyId, name, description, scopes`: pass `null` for anything you want to leave unchanged. ```java JsonObject result = client.turboPartner().updatePartnerApiKey( "partner-key-uuid-here", "Updated Integration Key", "Updated description", null // keep the existing scopes ); ``` ### `revokePartnerApiKey()` Revoke a partner API key. ```java JsonObject result = client.turboPartner().revokePartnerApiKey("partner-key-uuid-here"); ``` --- ## Partner User Management :::danger Partner users use different role values Partner portal users take `"admin"`, `"member"`, or `"viewer"`. **Organization** users and organization API keys take `"admin"`, `"contributor"`, `"user"`, or `"viewer"`. The two sets do not overlap beyond `admin`/`viewer`: `"member"` is rejected on an org call, and `"contributor"`/`"user"` are rejected on a partner call. See [Role Values](#role-values). ::: :::caution `permissions` is all-or-nothing `addUserToPartnerPortal()` **requires** a permissions map containing all seven keys. On `updatePartnerUserPermissions()` the map is optional (`null` keeps the current values), but if you send it, **all seven keys are required**. There is no partial permissions update: the API rejects an incomplete map with `TurboDocxException.ValidationException` (400). Read the current values first and re-send them with your change applied. ::: ### `addUserToPartnerPortal()` Add a user to the partner portal with specific permissions. ```java // Required on add: all 7 keys must be present. Map permissions = new LinkedHashMap(); permissions.put("canManageOrgs", true); // Create, update, delete organizations permissions.put("canManageOrgUsers", true); // Manage users within organizations permissions.put("canManagePartnerUsers", false); // Manage other partner portal users permissions.put("canManageOrgAPIKeys", true); // Manage organization API keys permissions.put("canManagePartnerAPIKeys", false); // Manage partner API keys permissions.put("canUpdateEntitlements", true); // Update organization entitlements permissions.put("canViewAuditLogs", true); // View audit logs JsonObject result = client.turboPartner().addUserToPartnerPortal( "admin@partner.com", "admin", // PARTNER role: "admin", "member", or "viewer" permissions ); System.out.println("Partner User ID: " + result.getAsJsonObject("data").get("id").getAsString()); ``` ### `listPartnerPortalUsers()` List all partner portal users. ```java JsonObject result = client.turboPartner().listPartnerPortalUsers(50, null, null); for (JsonElement element : result.getAsJsonObject("data").getAsJsonArray("results")) { JsonObject user = element.getAsJsonObject(); System.out.println("- " + user.get("email").getAsString() + " (Role: " + user.get("role").getAsString() + ")"); } ``` ### `updatePartnerUserPermissions()` Update a partner user's role and/or permissions. Pass `null` for `role` or `permissions` to keep the current value, but if you pass `permissions`, supply **all seven keys**; a partial map is a 400. ```java Map permissions = new LinkedHashMap(); permissions.put("canManageOrgs", true); permissions.put("canManageOrgUsers", true); permissions.put("canManagePartnerUsers", true); permissions.put("canManageOrgAPIKeys", true); permissions.put("canManagePartnerAPIKeys", true); permissions.put("canUpdateEntitlements", true); permissions.put("canViewAuditLogs", true); JsonObject result = client.turboPartner().updatePartnerUserPermissions( "partner-user-uuid-here", "admin", permissions ); ``` ### `resendPartnerPortalInvitationToUser()` Resend the invitation email to a pending partner user. ```java JsonObject result = client.turboPartner().resendPartnerPortalInvitationToUser("partner-user-uuid-here"); ``` ### `removeUserFromPartnerPortal()` Remove a user from the partner portal. ```java JsonObject result = client.turboPartner().removeUserFromPartnerPortal("partner-user-uuid-here"); ``` --- ## Audit Logs ### `getPartnerAuditLogs()` Get audit logs for all partner activities with filtering. All nine arguments are positional: pass `null` for any filter you don't want. ```java JsonObject result = client.turboPartner().getPartnerAuditLogs( 50, // limit 0, // offset null, // search "ORG_CREATED", // action "organization", // resourceType null, // resourceId true, // success "2024-01-01", // startDate (ISO 8601) "2024-12-31" // endDate (ISO 8601) ); for (JsonElement element : result.getAsJsonObject("data").getAsJsonArray("results")) { JsonObject entry = element.getAsJsonObject(); System.out.print(entry.get("createdOn").getAsString() + " - " + entry.get("action").getAsString()); if (entry.has("resourceType") && !entry.get("resourceType").isJsonNull()) { System.out.print(" (" + entry.get("resourceType").getAsString() + ")"); } System.out.println(" - " + (entry.get("success").getAsBoolean() ? "Success" : "Failed")); } ``` | Argument | Type | Description | | -------------- | --------- | ------------------------------------ | | `limit` | `Integer` | Maximum number of results | | `offset` | `Integer` | Pagination offset | | `search` | `String` | Search query string | | `action` | `String` | Filter by action type | | `resourceType` | `String` | Filter by resource type | | `resourceId` | `String` | Filter by resource ID | | `success` | `Boolean` | Filter by success status | | `startDate` | `String` | Filter from date (ISO 8601) | | `endDate` | `String` | Filter to date (ISO 8601) | --- ## Entitlements Reference ### Features (Settable Limits) These are limits and capabilities you can configure for each organization: | Field | Type | Description | |-------|------|-------------| | `maxUsers` | int | Maximum users allowed (-1 = unlimited) | | `maxProjectspaces` | int | Maximum projectspaces | | `maxTemplates` | int | Maximum templates | | `maxStorage` | long | Maximum storage in bytes | | `maxGeneratedDeliverables` | int | Maximum generated documents | | `maxSignatures` | int | Maximum e-signatures | | `maxAICredits` | int | Maximum AI credits | | `rdWatermark` | boolean | Enable RapidDocx watermark | | `hasFileDownload` | boolean | Enable file download | | `hasAdvancedDateFormats` | boolean | Enable advanced date formats | | `hasGDrive` | boolean | Enable Google Drive integration | | `hasSharepoint` | boolean | Enable SharePoint integration | | `hasSharepointOnly` | boolean | SharePoint-only mode | | `hasTDAI` | boolean | Enable TurboDocx AI features | | `hasPptx` | boolean | Enable PowerPoint support | | `hasTDWriter` | boolean | Enable TurboDocx Writer | | `hasSalesforce` | boolean | Enable Salesforce integration | | `hasWrike` | boolean | Enable Wrike integration | | `hasVariableStack` | boolean | Enable variable stack | | `hasSubvariables` | boolean | Enable subvariables | | `hasZapier` | boolean | Enable Zapier integration | | `hasBYOM` | boolean | Enable Bring Your Own Model | | `hasBYOVS` | boolean | Enable Bring Your Own Vector Store | | `hasBetaFeatures` | boolean | Enable beta features | | `enableBulkSending` | boolean | Enable bulk document sending | :::info Map keys stay camelCase The `features`, `tracking`, and `permissions` maps are serialized straight into the JSON request body, so the keys must match exactly as written above (`maxUsers`, `hasTDAI`, `canManageOrgAPIKeys`): Java naming conventions do not apply to request-body keys. ::: ### Tracking (Usage Counters) Current consumption against the limits above. TurboDocx maintains these automatically, but `updateOrganizationEntitlements()` **accepts a `tracking` map**, useful for seeding counters when migrating an existing customer: | Field | Type | Description | |-------|------|-------------| | `numUsers` | int | Current number of users | | `numProjectspaces` | int | Current number of projectspaces | | `numTemplates` | int | Current number of templates | | `storageUsed` | long | Current storage used in bytes | | `numGeneratedDeliverables` | int | Total documents generated | | `numSignaturesUsed` | int | Total signatures used | | `numQuotesSent` | int | Total quotes sent | | `currentAICredits` | int | Remaining AI credits (-1 = unlimited) | Every counter except `currentAICredits` floors at `0`. Only `currentAICredits` accepts `-1`, meaning unlimited. --- ## Preferences Reference TurboSign display preferences you can read and set per organization. Every key is a boolean and is validated strictly: the strings `"true"` / `"false"` are rejected with a 400, so pass real booleans. The API returns only these keys and never any of the organization's other settings. | Field | Type | Default | Description | |-------|------|---------|-------------| | `hideSignatureOutline` | boolean | `false` | Hide the outline/label around signed fields in the finished PDF | | `hideSignatureHash` | boolean | `false` | Hide the verification hash printed on signed fields | | `lockedFieldsBackground` | boolean | `true` | Render locked fields with a grey box background (`true`) or as plain text (`false`) | | `allowDownloadBeforeSigning` | boolean | `false` | When enabled, a signer can download the unsigned PDF from the signing page before they sign it (for example, to review it with their legal team). Defaults to off. | `getOrganizationPreferences()` returns every key with its effective value (the default is applied for any key the org never set). `updateOrganizationPreferences()` changes only the keys you pass and preserves all other organization settings. --- ## Constants and Types ### PartnerScope (22 Scopes) `com.turbodocx.PartnerScope` is a constants class of `String` values: there is no scope enum. Pass them as a `List` to `createPartnerApiKey()` and `updatePartnerApiKey()`. ```java // Organization CRUD PartnerScope.ORG_CREATE // "org:create" PartnerScope.ORG_READ // "org:read" PartnerScope.ORG_UPDATE // "org:update" PartnerScope.ORG_DELETE // "org:delete" // Entitlements PartnerScope.ENTITLEMENTS_UPDATE // "entitlements:update" // Organization Users PartnerScope.ORG_USERS_CREATE // "org-users:create" PartnerScope.ORG_USERS_READ // "org-users:read" PartnerScope.ORG_USERS_UPDATE // "org-users:update" PartnerScope.ORG_USERS_DELETE // "org-users:delete" // Partner Users PartnerScope.PARTNER_USERS_CREATE // "partner-users:create" PartnerScope.PARTNER_USERS_READ // "partner-users:read" PartnerScope.PARTNER_USERS_UPDATE // "partner-users:update" PartnerScope.PARTNER_USERS_DELETE // "partner-users:delete" // Organization API Keys PartnerScope.ORG_APIKEYS_CREATE // "org-apikeys:create" PartnerScope.ORG_APIKEYS_READ // "org-apikeys:read" PartnerScope.ORG_APIKEYS_UPDATE // "org-apikeys:update" PartnerScope.ORG_APIKEYS_DELETE // "org-apikeys:delete" // Partner API Keys PartnerScope.PARTNER_APIKEYS_CREATE // "partner-apikeys:create" PartnerScope.PARTNER_APIKEYS_READ // "partner-apikeys:read" PartnerScope.PARTNER_APIKEYS_UPDATE // "partner-apikeys:update" PartnerScope.PARTNER_APIKEYS_DELETE // "partner-apikeys:delete" // Audit PartnerScope.AUDIT_READ // "audit:read" ``` ### Role Values Roles are plain `String` values in the Java SDK: there is no role enum. **Organization users and organization API keys**, used by `addUserToOrganization()`, `updateOrganizationUserRole()`, `createOrganizationApiKey()`, and `updateOrganizationApiKey()`: | Value | Description | | --------------- | ---------------------------- | | `"admin"` | Full organization access | | `"contributor"` | Can create and edit content | | `"user"` | Standard user access | | `"viewer"` | Read-only access | **Partner portal users**, used by `addUserToPartnerPortal()` and `updatePartnerUserPermissions()` only: | Value | Description | | ---------- | -------------------------------------------- | | `"admin"` | Full partner portal access | | `"member"` | Standard partner access (respects permissions) | | `"viewer"` | Read-only access to partner portal | :::danger Do not mix the two sets `"member"` is valid **only** for partner portal users; sending it to an organization endpoint is a 400. `"contributor"` and `"user"` are valid **only** for organization users; sending either to a partner endpoint is a 400. ::: ### Partner Permissions All seven keys are required whenever a permissions map is sent. Partial maps are rejected with a 400. | Key | Type | Description | | ------------------------- | --------- | -------------------------------------- | | `canManageOrgs` | `Boolean` | Create, update, delete organizations | | `canManageOrgUsers` | `Boolean` | Manage users within organizations | | `canManagePartnerUsers` | `Boolean` | Manage other partner portal users | | `canManageOrgAPIKeys` | `Boolean` | Manage organization API keys | | `canManagePartnerAPIKeys` | `Boolean` | Manage partner API keys | | `canUpdateEntitlements` | `Boolean` | Update organization entitlements | | `canViewAuditLogs` | `Boolean` | View audit logs | --- ## Error Handling Partner calls throw `TurboDocxException.AuthenticationException` when the partner API key or partner ID is wrong, since partner credentials are validated separately from organization API keys. Every typed exception extends `TurboDocxException`, a `RuntimeException`, so catch it after any checked `IOException` handling: ```java try { JsonObject result = client.turboPartner().createOrganization("Acme Corp"); } catch (TurboDocxException.AuthenticationException e) { // 401 - Invalid partner API key or partner ID System.err.println("Authentication failed: " + e.getMessage()); } catch (TurboDocxException.AuthorizationException e) { // 403 - Partner API key lacks the required scope System.err.println("Forbidden: " + e.getMessage()); } catch (TurboDocxException.ValidationException e) { // 400 - Invalid request data System.err.println("Validation error: " + e.getMessage()); } catch (TurboDocxException.NotFoundException e) { // 404 - Organization or resource not found System.err.println("Not found: " + e.getMessage()); } catch (TurboDocxException.RateLimitException e) { // 429 - Rate limit exceeded System.err.println("Rate limit: " + e.getMessage()); } catch (TurboDocxException e) { // Any other API error, including 409 conflicts System.err.println("API error [" + e.getStatusCode() + "]: " + e.getMessage()); } catch (IOException e) { // Network/connection error System.err.println("Network error: " + e.getMessage()); } ``` The full typed-exception table and HTTP status mapping is documented once in the [Java SDK's Error Handling reference](./java.md#error-handling); partner calls use the same `AuthenticationException`/`ValidationException`/`AuthorizationException`/`NotFoundException`/`RateLimitException` types. Transport failures are **not** wrapped: the partner client propagates OkHttp's checked `IOException` directly, so catch `IOException` for connectivity problems rather than `TurboDocxException.NetworkException`. :::tip 409 Conflicts `TurboDocxException.ConflictException` is raised for 409 responses on partner calls too (for example, a user that already exists), the same way as `AuthenticationException`, `ValidationException`, `AuthorizationException`, `NotFoundException`, and `RateLimitException`. Add a `catch (TurboDocxException.ConflictException e)` block if you want to handle conflicts separately from the base `TurboDocxException` catch-all. ::: --- ## Complete Example Here's a complete example showing a typical partner workflow: ```java public class PartnerOnboarding { public static void main(String[] args) { TurboPartnerClient client = new TurboPartnerClient.Builder() .partnerApiKey(System.getenv("TURBODOCX_PARTNER_API_KEY")) .partnerId(System.getenv("TURBODOCX_PARTNER_ID")) .build(); try { // 1. Create an organization for a new customer JsonObject org = client.turboPartner().createOrganization("New Customer Inc"); String orgId = org.getAsJsonObject("data").get("id").getAsString(); System.out.println("Created organization: " + orgId); // 2. Set up their entitlements based on their plan Map features = new LinkedHashMap(); features.put("maxUsers", 25); features.put("maxStorage", 5368709120L); // 5GB features.put("hasTDAI", true); features.put("hasFileDownload", true); client.turboPartner().updateOrganizationEntitlements(orgId, features, null); System.out.println("Configured entitlements"); // 3. Add their admin user JsonObject user = client.turboPartner().addUserToOrganization( orgId, "admin@newcustomer.com", "admin"); System.out.println("Added admin user: " + user.getAsJsonObject("data").get("email").getAsString()); // 4. Create an API key for their integration JsonObject apiKey = client.turboPartner().createOrganizationApiKey( orgId, "Production API Key", "admin"); System.out.println("Created API key: " + apiKey.getAsJsonObject("data").get("key").getAsString()); System.out.println("\nCustomer setup complete!"); } catch (TurboDocxException e) { System.err.println("Error [" + e.getStatusCode() + "]: " + e.getMessage()); System.exit(1); } catch (Exception e) { System.err.println("Error: " + e.getMessage()); System.exit(1); } } } ``` --- ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) - [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) - [TurboSign Java SDK](/docs/SDKs/java): for digital signature operations --- # TurboPartner JavaScript SDK :::tip Interested in TurboPartner? TurboPartner is available for integrators and partners. [Contact us](https://www.turbodocx.com/demo) to get started. ::: The official TurboDocx Partner SDK for JavaScript and TypeScript applications. Build multi-tenant SaaS applications with programmatic organization management, user provisioning, API key management, and entitlement control. Available on npm as `@turbodocx/sdk`. :::info What is TurboPartner? TurboPartner is the partner management API for TurboDocx. It allows you to programmatically create and manage organizations, users, API keys, and feature entitlements, perfect for building white-label or multi-tenant applications on top of TurboDocx. ::: ## TLDR ```typescript // 1. Configure TurboPartner.configure({ partnerApiKey: process.env.TURBODOCX_PARTNER_API_KEY!, partnerId: process.env.TURBODOCX_PARTNER_ID!, }); // 2. Create an organization with entitlements const org = await TurboPartner.createOrganization({ name: 'Acme Corporation', features: { maxUsers: 25, // Max users allowed maxStorage: 5368709120, // 5GB in bytes hasTDAI: true, // Enable TurboDocx AI }, }); const orgId = org.data.id; // 3. Add a user const user = await TurboPartner.addUserToOrganization(orgId, { email: 'admin@acme.com', role: 'admin', }); // 4. Create an API key const key = await TurboPartner.createOrganizationApiKey(orgId, { name: 'Production Key', role: 'admin', }); console.log(`API Key: ${key.data.key}`); // Save this, only shown once! ``` --- ## Installation ```bash npm install @turbodocx/sdk # or yarn add @turbodocx/sdk # or pnpm add @turbodocx/sdk ``` ## Requirements - Node.js 18 or higher - TypeScript 4.7+ (optional, types included) :::tip Full TypeScript Support This SDK includes complete TypeScript type definitions for all request/response types, enums, and configuration options: no additional `@types` packages needed. ::: --- ## Configuration ```typescript // Configure with your partner credentials TurboPartner.configure({ partnerApiKey: process.env.TURBODOCX_PARTNER_API_KEY, // Required: Must start with TDXP- partnerId: process.env.TURBODOCX_PARTNER_ID, // Required: Your partner UUID }); ``` ```typescript // Auto-configure from environment variables // Reads from: TURBODOCX_PARTNER_API_KEY, TURBODOCX_PARTNER_ID TurboPartner.configure({ partnerApiKey: process.env.TURBODOCX_PARTNER_API_KEY!, partnerId: process.env.TURBODOCX_PARTNER_ID!, }); ``` :::caution Partner API Key Required Partner API keys start with `TDXP-` prefix. These are different from regular organization API keys and provide access to partner-level operations across all your organizations. ::: ### Environment Variables ```bash # .env TURBODOCX_PARTNER_API_KEY=TDXP-your-partner-api-key TURBODOCX_PARTNER_ID=your-partner-uuid ``` --- ## Quick Start ### Create Your First Organization ```typescript // Configure with your partner credentials TurboPartner.configure({ partnerApiKey: process.env.TURBODOCX_PARTNER_API_KEY!, partnerId: process.env.TURBODOCX_PARTNER_ID!, }); // Create a new organization const result = await TurboPartner.createOrganization({ name: 'Acme Corporation', }); console.log('Organization created!'); console.log(` ID: ${result.data.id}`); console.log(` Name: ${result.data.name}`); ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, wrap all TurboPartner calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: --- ## Organization Management ### `createOrganization()` Create a new organization under your partner account. ```typescript const result = await TurboPartner.createOrganization({ name: 'Acme Corporation', metadata: { crmAccountId: 'ACC-4471', tier: 'gold' }, // Optional arbitrary tenant metadata features: { maxUsers: 50 }, // Optional entitlements override }); console.log(`Organization ID: ${result.data.id}`); ``` ### `listOrganizations()` List all organizations with pagination and search. ```typescript const result = await TurboPartner.listOrganizations({ limit: 25, offset: 0, search: 'Acme', // Optional search by name }); console.log(`Total: ${result.data.totalRecords}`); for (const org of result.data.results) { console.log(`- ${org.name} (ID: ${org.id})`); } ``` ### `getOrganizationDetails()` Get full details including features and tracking for an organization. ```typescript const result = await TurboPartner.getOrganizationDetails('org-uuid-here'); console.log(`Name: ${result.data.name}`); console.log(`Active: ${result.data.isActive ? 'Yes' : 'No'}`); if (result.data.features) { console.log(`Max Users: ${result.data.features.maxUsers}`); console.log(`Max Storage: ${result.data.features.maxStorage} bytes`); } if (result.data.tracking) { console.log(`Current Users: ${result.data.tracking.numUsers}`); console.log(`Storage Used: ${result.data.tracking.storageUsed} bytes`); } ``` ### `updateOrganizationInfo()` Update an organization's name. ```typescript const result = await TurboPartner.updateOrganizationInfo( 'org-uuid-here', { name: 'Acme Corp (Updated)' } ); ``` ### `updateOrganizationEntitlements()` Update an organization's feature limits and capabilities. ```typescript const result = await TurboPartner.updateOrganizationEntitlements( 'org-uuid-here', { features: { maxUsers: 100, maxStorage: 10737418240, // 10GB in bytes maxSignatures: 500, hasTDAI: true, hasFileDownload: true, hasBetaFeatures: false, }, } ); console.log('Entitlements updated!'); ``` You can also set the usage counters on the same call: ```typescript await TurboPartner.updateOrganizationEntitlements('org-uuid-here', { tracking: { numUsers: 5, storageUsed: 1048576, currentAICredits: -1, // -1 = unlimited }, }); ``` :::info Features vs Tracking **Features** are the limits and capabilities you grant (`maxUsers`, `hasTDAI`, …). **Tracking** is the current consumption against those limits (`numUsers`, `storageUsed`, …). It is normally maintained by TurboDocx, but this endpoint **does accept a `tracking` object**, so you can seed or reconcile counters during a migration. See [Entitlements Reference](#entitlements-reference) for all available fields. ::: ### `deleteOrganization()` Delete an organization (soft delete). ```typescript const result = await TurboPartner.deleteOrganization('org-uuid-here'); console.log(`Success: ${result.success}`); ``` :::danger Use With Caution Deleting an organization is a destructive operation. All organization data, users, and API keys will be affected. ::: ### `getOrganizationPreferences()` Read an organization's TurboSign display preferences. Returns only the partner-settable keys, with defaults applied for any the org never set. ```typescript const { data } = await TurboPartner.getOrganizationPreferences('org-uuid-here'); console.log(data.preferences.hideSignatureOutline); // boolean console.log(data.preferences.hideSignatureHash); // boolean console.log(data.preferences.lockedFieldsBackground); // boolean console.log(data.preferences.allowDownloadBeforeSigning); // boolean ``` ### `updateOrganizationPreferences()` Update one or more of an organization's TurboSign display preferences. Only the keys you pass are changed; every other org setting is preserved. The response echoes the full, merged preferences. ```typescript const { data } = await TurboPartner.updateOrganizationPreferences( 'org-uuid-here', { lockedFieldsBackground: false, // render locked fields as plain text, not a grey box allowDownloadBeforeSigning: true, // let signers download the unsigned PDF before they sign } ); console.log(data.preferences.lockedFieldsBackground); // false console.log(data.preferences.allowDownloadBeforeSigning); // true ``` See [Preferences Reference](#preferences-reference) for every settable key and its meaning. --- ## Organization User Management ### `addUserToOrganization()` Add a user to an organization with a specific role. ```typescript const result = await TurboPartner.addUserToOrganization( 'org-uuid-here', { email: 'user@example.com', role: 'admin', // 'admin' | 'contributor' | 'user' | 'viewer' } ); console.log(`User ID: ${result.data.id}`); console.log(`Invitation sent to: ${result.data.email}`); ``` ### `listOrganizationUsers()` List all users in an organization. ```typescript const result = await TurboPartner.listOrganizationUsers( 'org-uuid-here', { limit: 50, offset: 0 } ); console.log(`Total Users: ${result.data.totalRecords}`); for (const user of result.data.results) { console.log(`- ${user.email} (${user.role})`); } ``` ### `updateOrganizationUserRole()` Change a user's role within an organization. ```typescript const result = await TurboPartner.updateOrganizationUserRole( 'org-uuid-here', 'user-uuid-here', { role: 'contributor' } ); ``` ### `resendOrganizationInvitationToUser()` Resend the invitation email to a pending user. ```typescript const result = await TurboPartner.resendOrganizationInvitationToUser( 'org-uuid-here', 'user-uuid-here' ); ``` ### `removeUserFromOrganization()` Remove a user from an organization. ```typescript const result = await TurboPartner.removeUserFromOrganization( 'org-uuid-here', 'user-uuid-here' ); ``` --- ## Organization API Key Management ### `createOrganizationApiKey()` Create an API key for an organization. ```typescript const result = await TurboPartner.createOrganizationApiKey( 'org-uuid-here', { name: 'Production API Key', role: 'admin', // 'admin' | 'contributor' | 'user' | 'viewer' (the ORG role enum) } ); console.log(`Key ID: ${result.data.id}`); console.log(`Full Key: ${result.data.key}`); // Only shown once! ``` :::caution Save Your API Key The full API key is only returned once during creation. Store it securely: you won't be able to retrieve it again. ::: ### `listOrganizationApiKeys()` List all API keys for an organization. ```typescript const result = await TurboPartner.listOrganizationApiKeys( 'org-uuid-here', { limit: 50 } ); for (const key of result.data.results) { console.log(`- ${key.name} (Role: ${key.role})`); } ``` ### `updateOrganizationApiKey()` Update an organization API key's name or role. ```typescript const result = await TurboPartner.updateOrganizationApiKey( 'org-uuid-here', 'api-key-uuid-here', { name: 'Updated Key Name', role: 'contributor', } ); ``` ### `revokeOrganizationApiKey()` Revoke (delete) an organization API key. ```typescript const result = await TurboPartner.revokeOrganizationApiKey( 'org-uuid-here', 'api-key-uuid-here' ); ``` --- ## Partner API Key Management ### `createPartnerApiKey()` Create a new partner-level API key with specific scopes. ```typescript const result = await TurboPartner.createPartnerApiKey({ name: 'Integration API Key', scopes: [ 'org:create', 'org:read', 'org:update', 'entitlements:update', 'audit:read', ], description: 'For third-party integration', }); console.log(`Key ID: ${result.data.id}`); console.log(`Full Key: ${result.data.key}`); // Only shown once! ``` ### `listPartnerApiKeys()` List all partner API keys. ```typescript const result = await TurboPartner.listPartnerApiKeys({ limit: 50 }); for (const key of result.data.results) { console.log(`- ${key.name}`); console.log(` Scopes: ${key.scopes?.join(', ')}`); } ``` ### `updatePartnerApiKey()` Update a partner API key. ```typescript const result = await TurboPartner.updatePartnerApiKey( 'partner-key-uuid-here', { name: 'Updated Integration Key', description: 'Updated description', } ); ``` ### `revokePartnerApiKey()` Revoke a partner API key. ```typescript const result = await TurboPartner.revokePartnerApiKey('partner-key-uuid-here'); ``` --- ## Partner User Management :::danger Partner users use a different role enum Partner portal users take `'admin' | 'member' | 'viewer'`. **Organization** users and organization API keys take `'admin' | 'contributor' | 'user' | 'viewer'`. The two enums do not overlap beyond `admin`/`viewer`: `'member'` is rejected on an org call, and `'contributor'`/`'user'` are rejected on a partner call. See [Role Enums](#orguserrole-organization-users-and-org-api-keys). ::: :::caution `permissions` is all-or-nothing On `addUserToPartnerPortal()` the `permissions` object is **required**. On `updatePartnerUserPermissions()` it is optional, but if you send it, **all seven keys are required**. Either way there is no partial permissions update: omitting even one key is a `ValidationError` (400). Always send the complete object; read the current values first and re-send them with your change applied. ::: ### `addUserToPartnerPortal()` Add a user to the partner portal with specific permissions. ```typescript const result = await TurboPartner.addUserToPartnerPortal({ email: 'admin@partner.com', role: 'admin', // 'admin' | 'member' | 'viewer' (the PARTNER role enum) // Required on this method, and all 7 keys must be present. permissions: { canManageOrgs: true, canManageOrgUsers: true, canManagePartnerUsers: false, canManageOrgAPIKeys: true, canManagePartnerAPIKeys: false, canUpdateEntitlements: true, canViewAuditLogs: true, }, }); console.log(`Partner User ID: ${result.data.id}`); ``` ### `listPartnerPortalUsers()` List all partner portal users. ```typescript const result = await TurboPartner.listPartnerPortalUsers({ limit: 50 }); for (const user of result.data.results) { console.log(`- ${user.email} (Role: ${user.role})`); } ``` ### `updatePartnerUserPermissions()` Update a partner user's role and permissions. If you include `permissions`, send **all seven keys**: a partial object is a 400. ```typescript const result = await TurboPartner.updatePartnerUserPermissions( 'partner-user-uuid-here', { role: 'admin', // Not a patch: every key below must be present. permissions: { canManageOrgs: true, canManageOrgUsers: true, canManagePartnerUsers: true, canManageOrgAPIKeys: true, canManagePartnerAPIKeys: true, canUpdateEntitlements: true, canViewAuditLogs: true, }, } ); ``` ### `resendPartnerPortalInvitationToUser()` Resend the invitation email to a pending partner user. ```typescript const result = await TurboPartner.resendPartnerPortalInvitationToUser( 'partner-user-uuid-here' ); ``` ### `removeUserFromPartnerPortal()` Remove a user from the partner portal. ```typescript const result = await TurboPartner.removeUserFromPartnerPortal( 'partner-user-uuid-here' ); ``` --- ## Audit Logs ### `getPartnerAuditLogs()` Get audit logs for all partner activities with filtering. ```typescript const result = await TurboPartner.getPartnerAuditLogs({ limit: 50, offset: 0, action: 'org.created', // Optional filter by action resourceType: 'organization', // Optional filter by resource type success: true, // Optional filter by success/failure startDate: '2024-01-01', // Optional date range start endDate: '2024-12-31', // Optional date range end }); for (const entry of result.data.results) { let line = `${entry.createdOn} - ${entry.action}`; if (entry.resourceType) { line += ` (${entry.resourceType})`; } line += ` - ${entry.success ? 'Success' : 'Failed'}`; console.log(line); } ``` --- ## Entitlements Reference ### Features (Settable Limits) These are limits and capabilities you can configure for each organization: | Field | Type | Description | |-------|------|-------------| | `maxUsers` | number | Maximum users allowed (-1 = unlimited) | | `maxProjectspaces` | number | Maximum projectspaces | | `maxTemplates` | number | Maximum templates | | `maxStorage` | number | Maximum storage in bytes | | `maxGeneratedDeliverables` | number | Maximum generated documents | | `maxSignatures` | number | Maximum e-signatures | | `maxAICredits` | number | Maximum AI credits | | `rdWatermark` | boolean | Enable RapidDocx watermark | | `hasFileDownload` | boolean | Enable file download | | `hasAdvancedDateFormats` | boolean | Enable advanced date formats | | `hasGDrive` | boolean | Enable Google Drive integration | | `hasSharepoint` | boolean | Enable SharePoint integration | | `hasSharepointOnly` | boolean | SharePoint-only mode | | `hasTDAI` | boolean | Enable TurboDocx AI features | | `hasPptx` | boolean | Enable PowerPoint support | | `hasTDWriter` | boolean | Enable TurboDocx Writer | | `hasSalesforce` | boolean | Enable Salesforce integration | | `hasWrike` | boolean | Enable Wrike integration | | `hasVariableStack` | boolean | Enable variable stack | | `hasSubvariables` | boolean | Enable subvariables | | `hasZapier` | boolean | Enable Zapier integration | | `hasBYOM` | boolean | Enable Bring Your Own Model | | `hasBYOVS` | boolean | Enable Bring Your Own Vector Store | | `hasBetaFeatures` | boolean | Enable beta features | | `enableBulkSending` | boolean | Enable bulk document sending | ### Tracking (Usage Counters) Current consumption against the limits above. TurboDocx maintains these automatically, but `updateOrganizationEntitlements()` **accepts a `tracking` object**, useful for seeding counters when migrating an existing customer: | Field | Type | Description | |-------|------|-------------| | `numUsers` | number | Current number of users | | `numProjectspaces` | number | Current number of projectspaces | | `numTemplates` | number | Current number of templates | | `storageUsed` | number | Current storage used in bytes | | `numGeneratedDeliverables` | number | Total documents generated | | `numSignaturesUsed` | number | Total signatures used | | `numQuotesSent` | number | Total quotes sent | | `currentAICredits` | number | Remaining AI credits (-1 = unlimited) | Every counter except `currentAICredits` floors at `0`. Only `currentAICredits` accepts `-1`, meaning unlimited. --- ## Preferences Reference TurboSign display preferences you can read and set per organization. Every key is a boolean and is validated strictly: the strings `"true"` / `"false"` are rejected with a 400, so pass real booleans. The API returns only these keys and never any of the organization's other settings. | Field | Type | Default | Description | |-------|------|---------|-------------| | `hideSignatureOutline` | boolean | `false` | Hide the outline/label around signed fields in the finished PDF | | `hideSignatureHash` | boolean | `false` | Hide the verification hash printed on signed fields | | `lockedFieldsBackground` | boolean | `true` | Render locked fields with a grey box background (`true`) or as plain text (`false`) | | `allowDownloadBeforeSigning` | boolean | `false` | When enabled, a signer can download the unsigned PDF from the signing page before they sign it (for example, to review it with their legal team). Defaults to off. | `getOrganizationPreferences()` returns every key with its effective value (the default is applied for any key the org never set). `updateOrganizationPreferences()` changes only the keys you pass and preserves all other organization settings. --- ## Types and Interfaces ### PartnerScope (22 Scopes) ```typescript // Organization CRUD 'org:create' 'org:read' 'org:update' 'org:delete' // Entitlements 'entitlements:update' // Organization Users 'org-users:create' 'org-users:read' 'org-users:update' 'org-users:delete' // Partner Users 'partner-users:create' 'partner-users:read' 'partner-users:update' 'partner-users:delete' // Organization API Keys 'org-apikeys:create' 'org-apikeys:read' 'org-apikeys:update' 'org-apikeys:delete' // Partner API Keys 'partner-apikeys:create' 'partner-apikeys:read' 'partner-apikeys:update' 'partner-apikeys:delete' // Audit 'audit:read' ``` ### OrgUserRole (Organization Users and Org API Keys) Used by `addUserToOrganization()`, `updateOrganizationUserRole()`, `createOrganizationApiKey()`, and `updateOrganizationApiKey()`. ```typescript type OrgUserRole = 'admin' | 'contributor' | 'user' | 'viewer'; ``` | Role | Description | |------|-------------| | `'admin'` | Full organization access | | `'contributor'` | Can create and edit content | | `'user'` | Standard user access | | `'viewer'` | Read-only access | ### PartnerUserRole (Partner Portal Users) Used by `addUserToPartnerPortal()` and `updatePartnerUserPermissions()` only. ```typescript type PartnerUserRole = 'admin' | 'member' | 'viewer'; ``` | Role | Description | |------|-------------| | `'admin'` | Full partner portal access | | `'member'` | Standard partner access (respects permissions) | | `'viewer'` | Read-only access to partner portal | :::danger Do not mix the two enums `'member'` exists **only** in `PartnerUserRole`; sending it to an organization endpoint is a 400. `'contributor'` and `'user'` exist **only** in `OrgUserRole`; sending either to a partner endpoint is a 400. ::: ### PartnerPermissions All seven keys are required whenever a `permissions` object is sent. Partial objects are rejected with a 400. ```typescript interface PartnerPermissions { canManageOrgs: boolean; // Create, update, delete organizations canManageOrgUsers: boolean; // Manage users within organizations canManagePartnerUsers: boolean; // Manage other partner portal users canManageOrgAPIKeys: boolean; // Manage organization API keys canManagePartnerAPIKeys: boolean; // Manage partner API keys canUpdateEntitlements: boolean; // Update organization entitlements canViewAuditLogs: boolean; // View audit logs } ``` --- ## Error Handling `TurboPartner.createOrganization()` and the other partner calls reject with `AuthorizationError` when the partner API key lacks the scope for the route, since partner keys are scoped separately from organization keys: ```typescript TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, ConflictError, RateLimitError, NetworkError, } from '@turbodocx/sdk'; try { const result = await TurboPartner.createOrganization({ name: 'Acme Corp' }); } catch (error) { if (error instanceof AuthenticationError) { // 401 - Invalid API key or partner ID console.error(`Authentication failed: ${error.message}`); } else if (error instanceof AuthorizationError) { // 403 - Partner API key lacks the required scope console.error(`Forbidden: ${error.message}`); } else if (error instanceof ValidationError) { // 400 - Invalid request data console.error(`Validation error: ${error.message}`); } else if (error instanceof NotFoundError) { // 404 - Organization or resource not found console.error(`Not found: ${error.message}`); } else if (error instanceof ConflictError) { // 409 - Resource conflict (e.g. user already exists) console.error(`Conflict: ${error.message}`); } else if (error instanceof RateLimitError) { // 429 - Rate limit exceeded console.error(`Rate limit: ${error.message}`); } else if (error instanceof NetworkError) { // Network/connection error console.error(`Network error: ${error.message}`); } else if (error instanceof TurboDocxError) { // Other API error console.error(`Error ${error.code}: ${error.message}`); } } ``` The full typed-error table and HTTP status mapping is documented once in the [JavaScript / TypeScript SDK's Error Handling reference](./javascript.md#error-handling); partner calls use the same `AuthenticationError`/`AuthorizationError`/`ValidationError`/`NotFoundError`/`ConflictError`/`RateLimitError`/`NetworkError` types. --- ## Complete Example Here's a complete example showing a typical partner workflow: ```typescript // Configure TurboPartner.configure({ partnerApiKey: process.env.TURBODOCX_PARTNER_API_KEY!, partnerId: process.env.TURBODOCX_PARTNER_ID!, }); try { // 1. Create an organization for a new customer const org = await TurboPartner.createOrganization({ name: 'New Customer Inc', }); console.log(`Created organization: ${org.data.id}`); // 2. Set up their entitlements based on their plan await TurboPartner.updateOrganizationEntitlements( org.data.id, { features: { maxUsers: 25, maxStorage: 5368709120, // 5GB hasTDAI: true, hasFileDownload: true, }, } ); console.log('Configured entitlements'); // 3. Add their admin user const user = await TurboPartner.addUserToOrganization( org.data.id, { email: 'admin@newcustomer.com', role: 'admin', } ); console.log(`Added admin user: ${user.data.email}`); // 4. Create an API key for their integration const apiKey = await TurboPartner.createOrganizationApiKey( org.data.id, { name: 'Production API Key', role: 'admin', } ); console.log(`Created API key: ${apiKey.data.key}`); console.log('\nCustomer setup complete!'); } catch (error) { if (error instanceof TurboDocxError) { console.error(`Error: ${error.message}`); process.exit(1); } throw error; } ``` --- ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [npm Package](https://www.npmjs.com/package/@turbodocx/sdk) - [TurboSign JavaScript SDK](/docs/SDKs/javascript): for digital signature operations --- # TurboPartner PHP SDK :::tip Interested in TurboPartner? TurboPartner is available for integrators and partners. [Contact us](https://www.turbodocx.com/demo) to get started. ::: The official TurboDocx Partner SDK for PHP applications. Build multi-tenant SaaS applications with programmatic organization management, user provisioning, API key management, and entitlement control. Available on Packagist as `turbodocx/sdk`. :::info What is TurboPartner? TurboPartner is the partner management API for TurboDocx. It allows you to programmatically create and manage organizations, users, API keys, and feature entitlements, perfect for building white-label or multi-tenant applications on top of TurboDocx. ::: ## TLDR ```php 25, // Max users allowed 'maxStorage' => 5368709120, // 5GB in bytes 'hasTDAI' => true, // Enable TurboDocx AI ] ) ); $orgId = $org->data->id; // 3. Add a user $user = TurboPartner::addUserToOrganization($orgId, new AddOrgUserRequest(email: 'admin@acme.com', role: OrgUserRole::ADMIN) ); // 4. Create an API key $key = TurboPartner::createOrganizationApiKey($orgId, new CreateOrgApiKeyRequest(name: 'Production Key', role: 'admin') ); echo "API Key: {$key->data->key}\n"; // Save this, only shown once! ``` --- ## Installation ```bash composer require turbodocx/sdk ``` ## Requirements - PHP 8.1 or higher - Composer - ext-json - ext-curl :::tip Modern PHP Features This SDK leverages PHP 8.1+ features including enums, named parameters, readonly classes, and match expressions for a superior developer experience. ::: --- ## Configuration ```php ```php :::caution Partner API Key Required Partner API keys start with `TDXP-` prefix. These are different from regular organization API keys and provide access to partner-level operations across all your organizations. ::: ### Environment Variables ```bash # .env TURBODOCX_PARTNER_API_KEY=TDXP-your-partner-api-key TURBODOCX_PARTNER_ID=your-partner-uuid ``` --- ## Quick Start ### Create Your First Organization ```php data->id}\n"; echo " Name: {$result->data->name}\n"; ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, wrap all TurboPartner calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: --- ## Organization Management ### `createOrganization()` Create a new organization under your partner account. ```php use TurboDocx\Types\Requests\Partner\CreateOrganizationRequest; $result = TurboPartner::createOrganization( new CreateOrganizationRequest( name: 'Acme Corporation', features: ['maxUsers' => 50] // Optional entitlements override ) ); echo "Organization ID: {$result->data->id}\n"; ``` ### `listOrganizations()` List all organizations with pagination and search. ```php use TurboDocx\Types\Requests\Partner\ListOrganizationsRequest; $result = TurboPartner::listOrganizations( new ListOrganizationsRequest( limit: 25, offset: 0, search: 'Acme' // Optional search by name ) ); echo "Total: {$result->totalRecords}\n"; foreach ($result->results as $org) { echo "- {$org->name} (ID: {$org->id})\n"; } ``` ### `getOrganizationDetails()` Get full details including features and tracking for an organization. ```php $result = TurboPartner::getOrganizationDetails('org-uuid-here'); echo "Name: {$result->organization->name}\n"; echo "Active: " . ($result->organization->isActive ? 'Yes' : 'No') . "\n"; if ($result->features) { echo "Max Users: {$result->features->maxUsers}\n"; echo "Max Storage: {$result->features->maxStorage} bytes\n"; } if ($result->tracking) { echo "Current Users: {$result->tracking->numUsers}\n"; echo "Storage Used: {$result->tracking->storageUsed} bytes\n"; } ``` ### `updateOrganizationInfo()` Update an organization's name. ```php use TurboDocx\Types\Requests\Partner\UpdateOrganizationRequest; $result = TurboPartner::updateOrganizationInfo( 'org-uuid-here', new UpdateOrganizationRequest( name: 'Acme Corp (Updated)' ) ); ``` ### `updateOrganizationEntitlements()` Update an organization's feature limits and capabilities. ```php use TurboDocx\Types\Requests\Partner\UpdateEntitlementsRequest; $result = TurboPartner::updateOrganizationEntitlements( 'org-uuid-here', new UpdateEntitlementsRequest( features: [ 'maxUsers' => 100, 'maxStorage' => 10737418240, // 10GB in bytes 'maxSignatures' => 500, 'hasTDAI' => true, 'hasFileDownload' => true, 'hasBetaFeatures' => false, ] ) ); echo "Entitlements updated!\n"; ``` You can also set the usage counters on the same call: ```php TurboPartner::updateOrganizationEntitlements( 'org-uuid-here', new UpdateEntitlementsRequest( tracking: [ 'numUsers' => 5, 'storageUsed' => 1048576, 'currentAICredits' => -1, // -1 = unlimited ] ) ); ``` :::info Features vs Tracking **Features** are the limits and capabilities you grant (maxUsers, hasTDAI, …). **Tracking** is the current consumption against those limits (numUsers, storageUsed, …). It is normally maintained by TurboDocx, but this endpoint **does accept a `tracking` array**, so you can seed or reconcile counters during a migration. See [Entitlements Reference](#entitlements-reference) for all available fields. ::: ### `deleteOrganization()` Delete an organization (soft delete). ```php $result = TurboPartner::deleteOrganization('org-uuid-here'); echo "Success: " . ($result->success ? 'Yes' : 'No') . "\n"; ``` :::danger Use With Caution Deleting an organization is a destructive operation. All organization data, users, and API keys will be affected. ::: ### `getOrganizationPreferences()` Read an organization's TurboSign display preferences. Returns only the partner-settable keys, with defaults applied for any the org never set. ```php $result = TurboPartner::getOrganizationPreferences('org-uuid-here'); echo $result->data->preferences->hideSignatureOutline; // bool echo $result->data->preferences->hideSignatureHash; // bool echo $result->data->preferences->lockedFieldsBackground; // bool echo $result->data->preferences->allowDownloadBeforeSigning; // bool ``` ### `updateOrganizationPreferences()` Update one or more of an organization's TurboSign display preferences. Only the keys you pass are changed; every other org setting is preserved. Keys stay camelCase. The response echoes the full, merged preferences. ```php $result = TurboPartner::updateOrganizationPreferences('org-uuid-here', [ 'lockedFieldsBackground' => false, // render locked fields as plain text, not a grey box 'allowDownloadBeforeSigning' => true, // let signers download the unsigned PDF before they sign ]); echo $result->data->preferences->lockedFieldsBackground; // false echo $result->data->preferences->allowDownloadBeforeSigning; // true ``` See [Preferences Reference](#preferences-reference) for every settable key and its meaning. --- ## Organization User Management ### `addUserToOrganization()` Add a user to an organization with a specific role. ```php use TurboDocx\Types\Requests\Partner\AddOrgUserRequest; use TurboDocx\Types\Enums\OrgUserRole; $result = TurboPartner::addUserToOrganization( 'org-uuid-here', new AddOrgUserRequest( email: 'user@example.com', role: OrgUserRole::ADMIN // ADMIN, CONTRIBUTOR, USER, or VIEWER ) ); echo "User ID: {$result->data->id}\n"; echo "Invitation sent to: {$result->data->email}\n"; ``` ### `listOrganizationUsers()` List all users in an organization. ```php use TurboDocx\Types\Requests\Partner\ListOrgUsersRequest; $result = TurboPartner::listOrganizationUsers( 'org-uuid-here', new ListOrgUsersRequest(limit: 50, offset: 0) ); echo "Total Users: {$result->totalRecords}\n"; foreach ($result->results as $user) { echo "- {$user->email} ({$user->role})\n"; } ``` ### `updateOrganizationUserRole()` Change a user's role within an organization. ```php use TurboDocx\Types\Requests\Partner\UpdateOrgUserRequest; use TurboDocx\Types\Enums\OrgUserRole; $result = TurboPartner::updateOrganizationUserRole( 'org-uuid-here', 'user-uuid-here', new UpdateOrgUserRequest(role: OrgUserRole::CONTRIBUTOR) ); ``` ### `resendOrganizationInvitationToUser()` Resend the invitation email to a pending user. ```php $result = TurboPartner::resendOrganizationInvitationToUser( 'org-uuid-here', 'user-uuid-here' ); ``` ### `removeUserFromOrganization()` Remove a user from an organization. ```php $result = TurboPartner::removeUserFromOrganization( 'org-uuid-here', 'user-uuid-here' ); ``` --- ## Organization API Key Management ### `createOrganizationApiKey()` Create an API key for an organization. ```php use TurboDocx\Types\Requests\Partner\CreateOrgApiKeyRequest; $result = TurboPartner::createOrganizationApiKey( 'org-uuid-here', new CreateOrgApiKeyRequest( name: 'Production API Key', role: 'admin' // ORG role enum: admin, contributor, user, or viewer ) ); echo "Key ID: {$result->data->id}\n"; echo "Full Key: {$result->data->key}\n"; // Only shown once! ``` :::caution Save Your API Key The full API key is only returned once during creation. Store it securely: you won't be able to retrieve it again. ::: ### `listOrganizationApiKeys()` List all API keys for an organization. ```php use TurboDocx\Types\Requests\Partner\ListOrgApiKeysRequest; $result = TurboPartner::listOrganizationApiKeys( 'org-uuid-here', new ListOrgApiKeysRequest(limit: 50) ); foreach ($result->results as $key) { echo "- {$key->name} (Role: {$key->role})\n"; } ``` ### `updateOrganizationApiKey()` Update an organization API key's name or role. ```php use TurboDocx\Types\Requests\Partner\UpdateOrgApiKeyRequest; $result = TurboPartner::updateOrganizationApiKey( 'org-uuid-here', 'api-key-uuid-here', new UpdateOrgApiKeyRequest( name: 'Updated Key Name', role: 'contributor' ) ); ``` ### `revokeOrganizationApiKey()` Revoke (delete) an organization API key. ```php $result = TurboPartner::revokeOrganizationApiKey( 'org-uuid-here', 'api-key-uuid-here' ); ``` --- ## Partner API Key Management ### `createPartnerApiKey()` Create a new partner-level API key with specific scopes. ```php use TurboDocx\Types\Requests\Partner\CreatePartnerApiKeyRequest; use TurboDocx\Types\Enums\PartnerScope; $result = TurboPartner::createPartnerApiKey( new CreatePartnerApiKeyRequest( name: 'Integration API Key', scopes: [ PartnerScope::ORG_CREATE, PartnerScope::ORG_READ, PartnerScope::ORG_UPDATE, PartnerScope::ENTITLEMENTS_UPDATE, PartnerScope::AUDIT_READ, ], description: 'For third-party integration' ) ); echo "Key ID: {$result->data->id}\n"; echo "Full Key: {$result->data->key}\n"; // Only shown once! ``` ### `listPartnerApiKeys()` List all partner API keys. ```php use TurboDocx\Types\Requests\Partner\ListPartnerApiKeysRequest; $result = TurboPartner::listPartnerApiKeys( new ListPartnerApiKeysRequest(limit: 50) ); foreach ($result->results as $key) { echo "- {$key->name}\n"; echo " Scopes: " . implode(', ', $key->scopes ?? []) . "\n"; } ``` ### `updatePartnerApiKey()` Update a partner API key. ```php use TurboDocx\Types\Requests\Partner\UpdatePartnerApiKeyRequest; $result = TurboPartner::updatePartnerApiKey( 'partner-key-uuid-here', new UpdatePartnerApiKeyRequest( name: 'Updated Integration Key', description: 'Updated description' ) ); ``` ### `revokePartnerApiKey()` Revoke a partner API key. ```php $result = TurboPartner::revokePartnerApiKey('partner-key-uuid-here'); ``` --- ## Partner User Management :::danger Partner users use a different role enum Partner portal users take `admin`, `member`, or `viewer` (`PartnerUserRole`). **Organization** users and organization API keys take `admin`, `contributor`, `user`, or `viewer` (`OrgUserRole`). The two enums do not overlap beyond `admin`/`viewer`: `'member'` is rejected on an org call, and `'contributor'`/`'user'` are rejected on a partner call. See [Role Enums](#orguserrole-organization-users-and-org-api-keys). ::: :::caution `permissions` is all-or-nothing `AddPartnerUserRequest` **requires** `permissions`: omitting it is an `ArgumentCountError`. On `UpdatePartnerUserRequest` it is optional, but if you send it, **all seven arguments are required**. There is no partial permissions update: the API rejects an incomplete object with `ValidationException` (400). Read the current values first and re-send them with your change applied. ::: ### `addUserToPartnerPortal()` Add a user to the partner portal with specific permissions. ```php use TurboDocx\Types\Requests\Partner\AddPartnerUserRequest; use TurboDocx\Types\Partner\PartnerPermissions; $result = TurboPartner::addUserToPartnerPortal( new AddPartnerUserRequest( email: 'admin@partner.com', role: 'admin', // PARTNER role enum: admin, member, or viewer // Required on add: all 7 arguments must be supplied. permissions: new PartnerPermissions( canManageOrgs: true, canManageOrgUsers: true, canManagePartnerUsers: false, canManageOrgAPIKeys: true, canManagePartnerAPIKeys: false, canUpdateEntitlements: true, canViewAuditLogs: true ) ) ); echo "Partner User ID: {$result->data->id}\n"; ``` ### `listPartnerPortalUsers()` List all partner portal users. ```php use TurboDocx\Types\Requests\Partner\ListPartnerUsersRequest; $result = TurboPartner::listPartnerPortalUsers( new ListPartnerUsersRequest(limit: 50) ); foreach ($result->results as $user) { echo "- {$user->email} (Role: {$user->role})\n"; } ``` ### `updatePartnerUserPermissions()` Update a partner user's role and permissions. If you pass `permissions`, supply **all seven arguments**: a partial object is a 400. ```php use TurboDocx\Types\Requests\Partner\UpdatePartnerUserRequest; use TurboDocx\Types\Partner\PartnerPermissions; $result = TurboPartner::updatePartnerUserPermissions( 'partner-user-uuid-here', new UpdatePartnerUserRequest( role: 'admin', // Not a patch: every argument below must be supplied. permissions: new PartnerPermissions( canManageOrgs: true, canManageOrgUsers: true, canManagePartnerUsers: true, canManageOrgAPIKeys: true, canManagePartnerAPIKeys: true, canUpdateEntitlements: true, canViewAuditLogs: true ) ) ); ``` ### `resendPartnerPortalInvitationToUser()` Resend the invitation email to a pending partner user. ```php $result = TurboPartner::resendPartnerPortalInvitationToUser('partner-user-uuid-here'); ``` ### `removeUserFromPartnerPortal()` Remove a user from the partner portal. ```php $result = TurboPartner::removeUserFromPartnerPortal('partner-user-uuid-here'); ``` --- ## Audit Logs ### `getPartnerAuditLogs()` Get audit logs for all partner activities with filtering. ```php use TurboDocx\Types\Requests\Partner\ListAuditLogsRequest; $result = TurboPartner::getPartnerAuditLogs( new ListAuditLogsRequest( limit: 50, offset: 0, action: 'ORG_CREATED', // Optional filter by action resourceType: 'organization', // Optional filter by resource type success: true, // Optional filter by success/failure startDate: '2024-01-01', // Optional date range start endDate: '2024-12-31' // Optional date range end ) ); foreach ($result->results as $entry) { echo "{$entry->createdOn} - {$entry->action}"; if ($entry->resourceType) { echo " ({$entry->resourceType})"; } echo " - " . ($entry->success ? 'Success' : 'Failed') . "\n"; } ``` --- ## Entitlements Reference ### Features (Settable Limits) These are limits and capabilities you can configure for each organization: | Field | Type | Description | |-------|------|-------------| | `maxUsers` | int | Maximum users allowed (-1 = unlimited) | | `maxProjectspaces` | int | Maximum projectspaces | | `maxTemplates` | int | Maximum templates | | `maxStorage` | int | Maximum storage in bytes | | `maxGeneratedDeliverables` | int | Maximum generated documents | | `maxSignatures` | int | Maximum e-signatures | | `maxAICredits` | int | Maximum AI credits | | `rdWatermark` | bool | Enable RapidDocx watermark | | `hasFileDownload` | bool | Enable file download | | `hasAdvancedDateFormats` | bool | Enable advanced date formats | | `hasGDrive` | bool | Enable Google Drive integration | | `hasSharepoint` | bool | Enable SharePoint integration | | `hasSharepointOnly` | bool | SharePoint-only mode | | `hasTDAI` | bool | Enable TurboDocx AI features | | `hasPptx` | bool | Enable PowerPoint support | | `hasTDWriter` | bool | Enable TurboDocx Writer | | `hasSalesforce` | bool | Enable Salesforce integration | | `hasWrike` | bool | Enable Wrike integration | | `hasVariableStack` | bool | Enable variable stack | | `hasSubvariables` | bool | Enable subvariables | | `hasZapier` | bool | Enable Zapier integration | | `hasBYOM` | bool | Enable Bring Your Own Model | | `hasBYOVS` | bool | Enable Bring Your Own Vector Store | | `hasBetaFeatures` | bool | Enable beta features | | `enableBulkSending` | bool | Enable bulk document sending | ### Tracking (Usage Counters) Current consumption against the limits above. TurboDocx maintains these automatically, but `updateOrganizationEntitlements()` **accepts a `tracking` array**, useful for seeding counters when migrating an existing customer: | Field | Type | Description | |-------|------|-------------| | `numUsers` | int | Current number of users | | `numProjectspaces` | int | Current number of projectspaces | | `numTemplates` | int | Current number of templates | | `storageUsed` | int | Current storage used in bytes | | `numGeneratedDeliverables` | int | Total documents generated | | `numSignaturesUsed` | int | Total signatures used | | `numQuotesSent` | int | Total quotes sent | | `currentAICredits` | int | Remaining AI credits (-1 = unlimited) | Every counter except `currentAICredits` floors at `0`. Only `currentAICredits` accepts `-1`, meaning unlimited. --- ## Preferences Reference TurboSign display preferences you can read and set per organization. Every key is a boolean and is validated strictly: the strings `"true"` / `"false"` are rejected with a 400, so pass real booleans. The API returns only these keys and never any of the organization's other settings. | Field | Type | Default | Description | |-------|------|---------|-------------| | `hideSignatureOutline` | boolean | `false` | Hide the outline/label around signed fields in the finished PDF | | `hideSignatureHash` | boolean | `false` | Hide the verification hash printed on signed fields | | `lockedFieldsBackground` | boolean | `true` | Render locked fields with a grey box background (`true`) or as plain text (`false`) | | `allowDownloadBeforeSigning` | boolean | `false` | When enabled, a signer can download the unsigned PDF from the signing page before they sign it (for example, to review it with their legal team). Defaults to off. | `getOrganizationPreferences()` returns every key with its effective value (the default is applied for any key the org never set). `updateOrganizationPreferences()` changes only the keys you pass and preserves all other organization settings. --- ## Enums and Types ### PartnerScope (22 Scopes) ```php use TurboDocx\Types\Enums\PartnerScope; // Organization CRUD PartnerScope::ORG_CREATE // 'org:create' PartnerScope::ORG_READ // 'org:read' PartnerScope::ORG_UPDATE // 'org:update' PartnerScope::ORG_DELETE // 'org:delete' // Entitlements PartnerScope::ENTITLEMENTS_UPDATE // 'entitlements:update' // Organization Users PartnerScope::ORG_USERS_CREATE // 'org-users:create' PartnerScope::ORG_USERS_READ // 'org-users:read' PartnerScope::ORG_USERS_UPDATE // 'org-users:update' PartnerScope::ORG_USERS_DELETE // 'org-users:delete' // Partner Users PartnerScope::PARTNER_USERS_CREATE // 'partner-users:create' PartnerScope::PARTNER_USERS_READ // 'partner-users:read' PartnerScope::PARTNER_USERS_UPDATE // 'partner-users:update' PartnerScope::PARTNER_USERS_DELETE // 'partner-users:delete' // Organization API Keys PartnerScope::ORG_APIKEYS_CREATE // 'org-apikeys:create' PartnerScope::ORG_APIKEYS_READ // 'org-apikeys:read' PartnerScope::ORG_APIKEYS_UPDATE // 'org-apikeys:update' PartnerScope::ORG_APIKEYS_DELETE // 'org-apikeys:delete' // Partner API Keys PartnerScope::PARTNER_APIKEYS_CREATE // 'partner-apikeys:create' PartnerScope::PARTNER_APIKEYS_READ // 'partner-apikeys:read' PartnerScope::PARTNER_APIKEYS_UPDATE // 'partner-apikeys:update' PartnerScope::PARTNER_APIKEYS_DELETE // 'partner-apikeys:delete' // Audit PartnerScope::AUDIT_READ // 'audit:read' ``` ### OrgUserRole (Organization Users and Org API Keys) Used by `addUserToOrganization()`, `updateOrganizationUserRole()`, `createOrganizationApiKey()`, and `updateOrganizationApiKey()`. ```php use TurboDocx\Types\Enums\OrgUserRole; OrgUserRole::ADMIN // Full organization access OrgUserRole::CONTRIBUTOR // Can create and edit content OrgUserRole::USER // Standard user access OrgUserRole::VIEWER // Read-only access ``` ### PartnerUserRole (Partner Portal Users) Used by `addUserToPartnerPortal()` and `updatePartnerUserPermissions()` only. ```php use TurboDocx\Types\Enums\PartnerUserRole; PartnerUserRole::ADMIN // Full partner portal access PartnerUserRole::MEMBER // Standard partner access (respects permissions) PartnerUserRole::VIEWER // Read-only access to partner portal ``` :::danger Do not mix the two enums `MEMBER` exists **only** on `PartnerUserRole`; sending `'member'` to an organization endpoint is a 400. `CONTRIBUTOR` and `USER` exist **only** on `OrgUserRole`; sending either to a partner endpoint is a 400. ::: ### PartnerPermissions All seven arguments are required whenever a `PartnerPermissions` object is sent. Partial objects are rejected with a 400. ```php use TurboDocx\Types\Partner\PartnerPermissions; $permissions = new PartnerPermissions( canManageOrgs: true, // Create, update, delete organizations canManageOrgUsers: true, // Manage users within organizations canManagePartnerUsers: false, // Manage other partner portal users canManageOrgAPIKeys: true, // Manage organization API keys canManagePartnerAPIKeys: false, // Manage partner API keys canUpdateEntitlements: true, // Update organization entitlements canViewAuditLogs: true // View audit logs ); ``` --- ## Error Handling `TurboPartner::createOrganization()` and the other partner calls throw `AuthenticationException` when the partner API key is invalid, missing, or the partner account is inactive, and `NotFoundException` when the `partnerId` doesn't match the key's own partner, since partner credentials are validated separately from organization API keys: ```php use TurboDocx\Exceptions\AuthenticationException; use TurboDocx\Exceptions\ValidationException; use TurboDocx\Exceptions\NotFoundException; use TurboDocx\Exceptions\RateLimitException; use TurboDocx\Exceptions\NetworkException; try { $result = TurboPartner::createOrganization(/* ... */); } catch (AuthenticationException $e) { // 401 - Invalid or missing partner API key echo "Authentication failed: {$e->getMessage()}\n"; } catch (ValidationException $e) { // 400 - Invalid request data echo "Validation error: {$e->getMessage()}\n"; } catch (NotFoundException $e) { // 404 - Organization/resource not found, or partnerId doesn't match the key echo "Not found: {$e->getMessage()}\n"; } catch (RateLimitException $e) { // 429 - Rate limit exceeded echo "Rate limit: {$e->getMessage()}\n"; } catch (NetworkException $e) { // Network/connection error echo "Network error: {$e->getMessage()}\n"; } ``` The full typed-exception table and HTTP status mapping is documented once in the [PHP SDK's Error Handling reference](./php.md#error-handling). --- ## Complete Example Here's a complete example showing a typical partner workflow: ```php data->id}\n"; // 2. Set up their entitlements based on their plan TurboPartner::updateOrganizationEntitlements( $org->data->id, new UpdateEntitlementsRequest( features: [ 'maxUsers' => 25, 'maxStorage' => 5368709120, // 5GB 'hasTDAI' => true, 'hasFileDownload' => true, ] ) ); echo "Configured entitlements\n"; // 3. Add their admin user $user = TurboPartner::addUserToOrganization( $org->data->id, new AddOrgUserRequest( email: 'admin@newcustomer.com', role: OrgUserRole::ADMIN ) ); echo "Added admin user: {$user->data->email}\n"; // 4. Create an API key for their integration $apiKey = TurboPartner::createOrganizationApiKey( $org->data->id, new CreateOrgApiKeyRequest( name: 'Production API Key', role: 'admin' ) ); echo "Created API key: {$apiKey->data->key}\n"; echo "\nCustomer setup complete!\n"; } catch (TurboDocxException $e) { echo "Error: {$e->getMessage()}\n"; exit(1); } ``` --- ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [Packagist Package](https://packagist.org/packages/turbodocx/sdk) - [TurboSign PHP SDK](/docs/SDKs/php): for digital signature operations --- # TurboPartner Python SDK :::tip Interested in TurboPartner? TurboPartner is available for integrators and partners. [Contact us](https://www.turbodocx.com/demo) to get started. ::: The official TurboDocx Partner SDK for Python applications. Build multi-tenant SaaS applications with programmatic organization management, user provisioning, API key management, and entitlement control. Fully async with `httpx`. :::info What is TurboPartner? TurboPartner is the partner management API for TurboDocx. It allows you to programmatically create and manage organizations, users, API keys, and feature entitlements, perfect for building white-label or multi-tenant applications on top of TurboDocx. ::: ## TLDR ```python from turbodocx_sdk import TurboPartner async def main(): # 1. Configure TurboPartner.configure( partner_api_key=os.environ["TURBODOCX_PARTNER_API_KEY"], partner_id=os.environ["TURBODOCX_PARTNER_ID"], ) # 2. Create an organization with entitlements org = await TurboPartner.create_organization( "Acme Corporation", features={ "maxUsers": 25, # Max users allowed "maxStorage": 5 * 1024 * 1024 * 1024, # 5GB in bytes "hasTDAI": True, # Enable TurboDocx AI }, ) org_id = org["data"]["id"] # 3. Add a user user = await TurboPartner.add_user_to_organization( org_id, email="admin@acme.com", role="admin" ) print(f"User: {user['data']['email']}") # 4. Create an API key key = await TurboPartner.create_organization_api_key( org_id, name="Production Key", role="admin" ) print(f"API Key: {key['data']['key']}") # Save this, only shown once! asyncio.run(main()) ``` --- ## Installation ```bash pip install turbodocx-sdk # or poetry add turbodocx-sdk ``` ## Requirements - Python 3.9 or higher - `httpx` (installed automatically) --- ## Configuration ```python from turbodocx_sdk import TurboPartner # Configure with your partner credentials TurboPartner.configure( partner_api_key=os.environ["TURBODOCX_PARTNER_API_KEY"], # Required: Must start with TDXP- partner_id=os.environ["TURBODOCX_PARTNER_ID"], # Required: Your partner UUID ) ``` ```python from turbodocx_sdk import TurboPartner # Auto-configure from environment variables # Reads from TURBODOCX_PARTNER_API_KEY and TURBODOCX_PARTNER_ID TurboPartner.configure() ``` :::caution Partner API Key Required Partner API keys start with `TDXP-` prefix. These are different from regular organization API keys and provide access to partner-level operations across all your organizations. ::: ### Environment Variables ```bash # .env or shell environment export TURBODOCX_PARTNER_API_KEY=TDXP-your-partner-api-key export TURBODOCX_PARTNER_ID=your-partner-uuid ``` --- ## Quick Start ### Create Your First Organization ```python from turbodocx_sdk import TurboPartner TurboPartner.configure( partner_api_key=os.environ["TURBODOCX_PARTNER_API_KEY"], partner_id=os.environ["TURBODOCX_PARTNER_ID"], ) # Create a new organization result = await TurboPartner.create_organization("Acme Corporation") print(f"Organization created!") print(f" ID: {result['data']['id']}") print(f" Name: {result['data']['name']}") ``` :::caution Always Handle Errors The above examples omit error handling for brevity. In production, always wrap calls in try/except. See [Error Handling](#error-handling) for complete patterns. ::: --- ## Organization Management ### `create_organization()` Create a new organization under your partner account. ```python result = await TurboPartner.create_organization( "Acme Corporation", features={"maxUsers": 50}, # Optional entitlements override metadata={"plan": "enterprise"}, # Optional metadata key-value pairs ) print(f"Organization ID: {result['data']['id']}") ``` ### `list_organizations()` List all organizations with pagination and search. ```python result = await TurboPartner.list_organizations( limit=25, offset=0, search="Acme", # Optional search by name ) print(f"Total: {result['data']['totalRecords']}") for org in result["data"]["results"]: print(f"- {org['name']} (ID: {org['id']})") ``` ### `get_organization_details()` Get full details including features and tracking for an organization. ```python result = await TurboPartner.get_organization_details("org-uuid-here") print(f"Name: {result['data']['name']}") print(f"Active: {result['data']['isActive']}") features = result["data"].get("features", {}) if features.get("maxUsers") is not None: print(f"Max Users: {features['maxUsers']}") if features.get("maxStorage") is not None: print(f"Max Storage: {features['maxStorage']} bytes") tracking = result["data"].get("tracking", {}) print(f"Current Users: {tracking.get('numUsers', 0)}") print(f"Storage Used: {tracking.get('storageUsed', 0)} bytes") ``` ### `update_organization_info()` Update an organization's name. ```python result = await TurboPartner.update_organization_info( "org-uuid-here", name="Acme Corp (Updated)", ) ``` ### `update_organization_entitlements()` Update an organization's feature limits and capabilities. ```python result = await TurboPartner.update_organization_entitlements( "org-uuid-here", features={ "maxUsers": 100, "maxStorage": 10 * 1024 * 1024 * 1024, # 10GB "maxSignatures": 500, "hasTDAI": True, "hasFileDownload": True, "hasBetaFeatures": False, }, tracking={ # Optional: set usage counters "numUsers": 5, "storageUsed": 1048576, "currentAICredits": -1, # -1 = unlimited }, ) print("Entitlements updated!") ``` :::info Features vs Tracking **Features** are the limits and capabilities you grant (maxUsers, hasTDAI, …). **Tracking** is the current consumption against those limits (numUsers, storageUsed, …). It is normally maintained by TurboDocx, but this endpoint **does accept a `tracking` object**, so you can seed or reconcile counters during a migration. See [Entitlements Reference](#entitlements-reference) for all available fields. ::: ### `delete_organization()` Delete an organization (soft delete). ```python result = await TurboPartner.delete_organization("org-uuid-here") print(f"Success: {result['success']}") ``` :::danger Use With Caution Deleting an organization is a destructive operation. All organization data, users, and API keys will be affected. ::: ### `get_organization_preferences()` Read an organization's TurboSign display preferences. Returns only the partner-settable keys, with defaults applied for any the org never set. ```python result = await TurboPartner.get_organization_preferences("org-uuid-here") prefs = result["data"]["preferences"] print(prefs["hideSignatureOutline"]) # bool print(prefs["hideSignatureHash"]) # bool print(prefs["lockedFieldsBackground"]) # bool print(prefs["allowDownloadBeforeSigning"]) # bool ``` ### `update_organization_preferences()` Update one or more of an organization's TurboSign display preferences. Only the keys you pass are changed; every other org setting is preserved. Keys stay camelCase. The response echoes the full, merged preferences. ```python result = await TurboPartner.update_organization_preferences( "org-uuid-here", { "lockedFieldsBackground": False, # render locked fields as plain text, not a grey box "allowDownloadBeforeSigning": True, # let signers download the unsigned PDF before they sign }, ) print(result["data"]["preferences"]["lockedFieldsBackground"]) # False print(result["data"]["preferences"]["allowDownloadBeforeSigning"]) # True ``` See [Preferences Reference](#preferences-reference) for every settable key and its meaning. --- ## Organization User Management ### `add_user_to_organization()` Add a user to an organization with a specific role. ```python result = await TurboPartner.add_user_to_organization( "org-uuid-here", email="user@example.com", role="admin", # admin, contributor, user, or viewer ) print(f"User ID: {result['data']['id']}") print(f"Invitation sent to: {result['data']['email']}") ``` ### `list_organization_users()` List all users in an organization. ```python result = await TurboPartner.list_organization_users( "org-uuid-here", limit=50, offset=0, search="admin", # Optional search by email or name ) print(f"Total Users: {result['data']['totalRecords']}") for user in result["data"]["results"]: print(f"- {user['email']} ({user['role']})") ``` ### `update_organization_user_role()` Change a user's role within an organization. ```python result = await TurboPartner.update_organization_user_role( "org-uuid-here", "user-uuid-here", role="contributor", ) ``` ### `resend_organization_invitation_to_user()` Resend the invitation email to a pending user. ```python result = await TurboPartner.resend_organization_invitation_to_user( "org-uuid-here", "user-uuid-here", ) ``` ### `remove_user_from_organization()` Remove a user from an organization. ```python result = await TurboPartner.remove_user_from_organization( "org-uuid-here", "user-uuid-here", ) ``` --- ## Organization API Key Management ### `create_organization_api_key()` Create an API key for an organization. ```python result = await TurboPartner.create_organization_api_key( "org-uuid-here", name="Production API Key", role="admin", # ORG role enum: admin, contributor, user, or viewer ) print(f"Key ID: {result['data']['id']}") print(f"Full Key: {result['data']['key']}") # Only shown once! ``` :::caution Save Your API Key The full API key is only returned once during creation. Store it securely: you won't be able to retrieve it again. ::: ### `list_organization_api_keys()` List all API keys for an organization. ```python result = await TurboPartner.list_organization_api_keys( "org-uuid-here", limit=50, offset=0, # Optional pagination offset search="prod", # Optional search by name ) for key in result["data"]["results"]: print(f"- {key['name']} (Role: {key['role']})") ``` ### `update_organization_api_key()` Update an organization API key's name or role. ```python result = await TurboPartner.update_organization_api_key( "org-uuid-here", "api-key-uuid-here", name="Updated Key Name", role="contributor", ) ``` ### `revoke_organization_api_key()` Revoke (delete) an organization API key. ```python result = await TurboPartner.revoke_organization_api_key( "org-uuid-here", "api-key-uuid-here", ) ``` --- ## Partner API Key Management ### `create_partner_api_key()` Create a new partner-level API key with specific scopes. ```python from turbodocx_sdk import ( SCOPE_ORG_CREATE, SCOPE_ORG_READ, SCOPE_ORG_UPDATE, SCOPE_ENTITLEMENTS_UPDATE, SCOPE_AUDIT_READ, ) result = await TurboPartner.create_partner_api_key( name="Integration API Key", scopes=[ SCOPE_ORG_CREATE, SCOPE_ORG_READ, SCOPE_ORG_UPDATE, SCOPE_ENTITLEMENTS_UPDATE, SCOPE_AUDIT_READ, ], description="For third-party integration", ) print(f"Key ID: {result['data']['id']}") print(f"Full Key: {result['data']['key']}") # Only shown once! ``` ### `list_partner_api_keys()` List all partner API keys. ```python result = await TurboPartner.list_partner_api_keys( limit=50, offset=0, # Optional pagination offset search="integ", # Optional search by name ) for key in result["data"]["results"]: print(f"- {key['name']}") print(f" Scopes: {key['scopes']}") ``` ### `update_partner_api_key()` Update a partner API key. ```python result = await TurboPartner.update_partner_api_key( "partner-key-uuid-here", name="Updated Integration Key", description="Updated description", scopes=[SCOPE_ORG_READ, SCOPE_AUDIT_READ], # Optional: update scopes ) ``` ### `revoke_partner_api_key()` Revoke a partner API key. ```python result = await TurboPartner.revoke_partner_api_key("partner-key-uuid-here") ``` --- ## Partner User Management :::danger Partner users use a different role enum Partner portal users take `admin`, `member`, or `viewer`. **Organization** users and organization API keys take `admin`, `contributor`, `user`, or `viewer`. The two enums do not overlap beyond `admin`/`viewer`: `"member"` is rejected on an org call, and `"contributor"`/`"user"` are rejected on a partner call. See [Role Enums](#organization-user-roles). ::: :::caution `permissions` is all-or-nothing On `add_user_to_partner_portal()`, `permissions` is a **required** keyword argument: omitting it raises a Python `TypeError` before any request is sent. On `update_partner_user_permissions()`, the `permissions` dict itself is optional. Either way, if you send `permissions`, **all seven keys are required**. There is no partial permissions update: omitting even one key raises `ValidationError` (400). Always send the complete dict; read the current values first and re-send them with your change applied. ::: ### `add_user_to_partner_portal()` Add a user to the partner portal with specific permissions. ```python result = await TurboPartner.add_user_to_partner_portal( email="admin@partner.com", role="admin", # PARTNER role enum: admin, member, or viewer # `permissions` is required here, and all 7 keys must be present. permissions={ "canManageOrgs": True, "canManageOrgUsers": True, "canManagePartnerUsers": False, "canManageOrgAPIKeys": True, "canManagePartnerAPIKeys": False, "canUpdateEntitlements": True, "canViewAuditLogs": True, }, ) print(f"Partner User ID: {result['data']['id']}") ``` ### `list_partner_portal_users()` List all partner portal users. ```python result = await TurboPartner.list_partner_portal_users( limit=50, offset=0, # Optional pagination offset search="admin", # Optional search by email or name ) for user in result["data"]["results"]: print(f"- {user['email']} (Role: {user['role']})") ``` ### `update_partner_user_permissions()` Update a partner user's role and permissions. If you pass `permissions`, send **all seven keys**: a partial dict is a 400. ```python result = await TurboPartner.update_partner_user_permissions( "partner-user-uuid-here", role="admin", # Not a patch: every key below must be present. permissions={ "canManageOrgs": True, "canManageOrgUsers": True, "canManagePartnerUsers": True, "canManageOrgAPIKeys": True, "canManagePartnerAPIKeys": True, "canUpdateEntitlements": True, "canViewAuditLogs": True, }, ) ``` ### `resend_partner_portal_invitation_to_user()` Resend the invitation email to a pending partner user. ```python result = await TurboPartner.resend_partner_portal_invitation_to_user( "partner-user-uuid-here" ) ``` ### `remove_user_from_partner_portal()` Remove a user from the partner portal. ```python result = await TurboPartner.remove_user_from_partner_portal( "partner-user-uuid-here" ) ``` --- ## Audit Logs ### `get_partner_audit_logs()` Get audit logs for all partner activities with filtering. ```python result = await TurboPartner.get_partner_audit_logs( limit=50, offset=0, search="acme", # Optional search query action="ORG_CREATED", # Optional filter by action resource_type="organization", # Optional filter by resource type resource_id="org-uuid-here", # Optional filter by resource ID success=True, # Optional filter by success/failure start_date="2024-01-01", # Optional date range start end_date="2024-12-31", # Optional date range end ) for entry in result["data"]["results"]: status = "Success" if entry["success"] else "Failed" resource = f" ({entry['resourceType']})" if entry.get("resourceType") else "" print(f"{entry['createdOn']} - {entry['action']}{resource} - {status}") ``` --- ## Entitlements Reference ### Features (Settable Limits) These are limits and capabilities you can configure for each organization: | Field | Type | Description | |-------|------|-------------| | `maxUsers` | `int` | Maximum users allowed (-1 = unlimited) | | `maxProjectspaces` | `int` | Maximum projectspaces | | `maxTemplates` | `int` | Maximum templates | | `maxStorage` | `int` | Maximum storage in bytes | | `maxGeneratedDeliverables` | `int` | Maximum generated documents | | `maxSignatures` | `int` | Maximum e-signatures | | `maxAICredits` | `int` | Maximum AI credits | | `rdWatermark` | `bool` | Enable RapidDocx watermark | | `hasFileDownload` | `bool` | Enable file download | | `hasAdvancedDateFormats` | `bool` | Enable advanced date formats | | `hasGDrive` | `bool` | Enable Google Drive integration | | `hasSharepoint` | `bool` | Enable SharePoint integration | | `hasSharepointOnly` | `bool` | SharePoint-only mode | | `hasTDAI` | `bool` | Enable TurboDocx AI features | | `hasPptx` | `bool` | Enable PowerPoint support | | `hasTDWriter` | `bool` | Enable TurboDocx Writer | | `hasSalesforce` | `bool` | Enable Salesforce integration | | `hasWrike` | `bool` | Enable Wrike integration | | `hasVariableStack` | `bool` | Enable variable stack | | `hasSubvariables` | `bool` | Enable subvariables | | `hasZapier` | `bool` | Enable Zapier integration | | `hasBYOM` | `bool` | Enable Bring Your Own Model | | `hasBYOVS` | `bool` | Enable Bring Your Own Vector Store | | `hasBetaFeatures` | `bool` | Enable beta features | | `enableBulkSending` | `bool` | Enable bulk document sending | :::tip Dictionary-Based Configuration Pass features as a plain dictionary with camelCase keys. The SDK sends them directly to the API: ```python features={"maxUsers": 25, "hasTDAI": True} ``` ::: ### Tracking (Usage Counters) Current consumption against the limits above. TurboDocx maintains these automatically, but `update_organization_entitlements()` **accepts a `tracking` dict**, useful for seeding counters when migrating an existing customer: | Field | Type | Description | |-------|------|-------------| | `numUsers` | `int` | Current number of users | | `numProjectspaces` | `int` | Current number of projectspaces | | `numTemplates` | `int` | Current number of templates | | `storageUsed` | `int` | Current storage used in bytes | | `numGeneratedDeliverables` | `int` | Total documents generated | | `numSignaturesUsed` | `int` | Total signatures used | | `currentAICredits` | `int` | Remaining AI credits (-1 = unlimited) | Every counter except `currentAICredits` floors at `0`. Only `currentAICredits` accepts `-1`, meaning unlimited. --- ## Preferences Reference TurboSign display preferences you can read and set per organization. Every key is a boolean and is validated strictly: the strings `"true"` / `"false"` are rejected with a 400, so pass real booleans. The API returns only these keys and never any of the organization's other settings. | Field | Type | Default | Description | |-------|------|---------|-------------| | `hideSignatureOutline` | boolean | `false` | Hide the outline/label around signed fields in the finished PDF | | `hideSignatureHash` | boolean | `false` | Hide the verification hash printed on signed fields | | `lockedFieldsBackground` | boolean | `true` | Render locked fields with a grey box background (`true`) or as plain text (`false`) | | `allowDownloadBeforeSigning` | boolean | `false` | When enabled, a signer can download the unsigned PDF from the signing page before they sign it (for example, to review it with their legal team). Defaults to off. | `get_organization_preferences()` returns every key with its effective value (the default is applied for any key the org never set). `update_organization_preferences()` changes only the keys you pass and preserves all other organization settings. --- ## Scope Constants ### Partner Scopes (22 Scopes) ```python from turbodocx_sdk import ( # Organization CRUD SCOPE_ORG_CREATE, # "org:create" SCOPE_ORG_READ, # "org:read" SCOPE_ORG_UPDATE, # "org:update" SCOPE_ORG_DELETE, # "org:delete" # Entitlements SCOPE_ENTITLEMENTS_UPDATE, # "entitlements:update" # Organization Users SCOPE_ORG_USERS_CREATE, # "org-users:create" SCOPE_ORG_USERS_READ, # "org-users:read" SCOPE_ORG_USERS_UPDATE, # "org-users:update" SCOPE_ORG_USERS_DELETE, # "org-users:delete" # Partner Users SCOPE_PARTNER_USERS_CREATE, # "partner-users:create" SCOPE_PARTNER_USERS_READ, # "partner-users:read" SCOPE_PARTNER_USERS_UPDATE, # "partner-users:update" SCOPE_PARTNER_USERS_DELETE, # "partner-users:delete" # Organization API Keys SCOPE_ORG_APIKEYS_CREATE, # "org-apikeys:create" SCOPE_ORG_APIKEYS_READ, # "org-apikeys:read" SCOPE_ORG_APIKEYS_UPDATE, # "org-apikeys:update" SCOPE_ORG_APIKEYS_DELETE, # "org-apikeys:delete" # Partner API Keys SCOPE_PARTNER_APIKEYS_CREATE, # "partner-apikeys:create" SCOPE_PARTNER_APIKEYS_READ, # "partner-apikeys:read" SCOPE_PARTNER_APIKEYS_UPDATE, # "partner-apikeys:update" SCOPE_PARTNER_APIKEYS_DELETE, # "partner-apikeys:delete" # Audit SCOPE_AUDIT_READ, # "audit:read" ) ``` ### Organization User Roles Used by `add_user_to_organization()`, `update_organization_user_role()`, `create_organization_api_key()`, and `update_organization_api_key()`. | Role | Description | |------|-------------| | `"admin"` | Full organization access | | `"contributor"` | Can create and edit content | | `"user"` | Standard user access | | `"viewer"` | Read-only access | ### Partner User Roles Used by `add_user_to_partner_portal()` and `update_partner_user_permissions()` only. | Role | Description | |------|-------------| | `"admin"` | Full partner portal access | | `"member"` | Standard partner access (respects permissions) | | `"viewer"` | Read-only access to partner portal | :::danger Do not mix the two enums `"member"` exists **only** in the partner role enum; sending it to an organization endpoint is a 400. `"contributor"` and `"user"` exist **only** in the organization role enum; sending either to a partner endpoint is a 400. ::: ### Partner Permissions All seven keys are required whenever a `permissions` dict is sent. Partial dicts are rejected with a 400. ```python permissions = { "canManageOrgs": True, # Create, update, delete organizations "canManageOrgUsers": True, # Manage users within organizations "canManagePartnerUsers": False, # Manage other partner portal users "canManageOrgAPIKeys": True, # Manage organization API keys "canManagePartnerAPIKeys": False, # Manage partner API keys "canUpdateEntitlements": True, # Update organization entitlements "canViewAuditLogs": True, # View audit logs } ``` --- ## Error Handling `TurboPartner.create_organization()` and the other partner calls raise `AuthorizationError` when the partner API key lacks the scope for the route, since partner keys are scoped separately from organization keys: ```python from turbodocx_sdk import ( TurboPartner, TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, ConflictError, RateLimitError, NetworkError, ) try: result = await TurboPartner.create_organization("Acme Corp") except AuthenticationError as e: # 401 - Invalid API key or partner ID print(f"Authentication failed: {e}") print(f" HTTP Status: {e.status_code}") except AuthorizationError as e: # 403 - Authenticated, but the key lacks the required partner scope print(f"Not authorized: {e}") print(f" HTTP Status: {e.status_code}") except ValidationError as e: # 400 - Invalid request data print(f"Validation error: {e}") print(f" HTTP Status: {e.status_code}") except NotFoundError as e: # 404 - Resource not found print(f"Not found: {e}") except ConflictError as e: # 409 - Conflicts with the current resource state print(f"Conflict: {e}") except RateLimitError as e: # 429 - Too many requests print(f"Rate limited: {e}") except NetworkError as e: # Network connectivity issues print(f"Network error: {e}") except TurboDocxError as e: # Any other API error print(f"API error: {e}") if hasattr(e, "status_code"): print(f" HTTP Status: {e.status_code}") if hasattr(e, "code") and e.code: print(f" Error Code: {e.code}") ``` The full typed-error table and HTTP status mapping is documented once in the [Python SDK's Error Handling reference](./python.md#error-handling); partner calls use the same `AuthenticationError`/`AuthorizationError`/`ValidationError`/`NotFoundError`/`ConflictError`/`RateLimitError`/`NetworkError` types. --- ## Complete Example Here's a complete example showing a typical partner workflow: ```python from turbodocx_sdk import TurboPartner, TurboDocxError async def main(): # Configure TurboPartner.configure( partner_api_key=os.environ["TURBODOCX_PARTNER_API_KEY"], partner_id=os.environ["TURBODOCX_PARTNER_ID"], ) # 1. Create an organization for a new customer org = await TurboPartner.create_organization("New Customer Inc") org_id = org["data"]["id"] print(f"Created organization: {org_id}") # 2. Set up their entitlements based on their plan await TurboPartner.update_organization_entitlements( org_id, features={ "maxUsers": 25, "maxStorage": 5 * 1024 * 1024 * 1024, # 5GB "hasTDAI": True, "hasFileDownload": True, }, ) print("Configured entitlements") # 3. Add their admin user user = await TurboPartner.add_user_to_organization( org_id, email="admin@newcustomer.com", role="admin" ) print(f"Added admin user: {user['data']['email']}") # 4. Create an API key for their integration api_key = await TurboPartner.create_organization_api_key( org_id, name="Production API Key", role="admin" ) print(f"Created API key: {api_key['data']['key']}") print("\nCustomer setup complete!") asyncio.run(main()) ``` --- ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [PyPI Package](https://pypi.org/project/turbodocx-sdk/) - [TurboSign Python SDK](/docs/SDKs/python): for digital signature operations - [SDKs Overview](/docs/SDKs/): all TurboDocx SDKs --- # TurboDocx PHP SDK The official TurboDocx SDK for PHP applications. Build document generation and digital signature workflows with modern PHP 8.1+ features, strong typing, and comprehensive error handling. Available on Packagist as `turbodocx/sdk`. ## Installation ```bash composer require turbodocx/sdk ``` ## Requirements - PHP 8.1 or higher - Composer - ext-json - ext-fileinfo :::tip Modern PHP Features This SDK leverages PHP 8.1+ features including enums, named parameters, readonly classes, and match expressions for a superior developer experience. ::: --- ## Configuration ```php ```php :::warning Sender Email Required The `senderEmail` parameter is **required** for TurboSign. This email appears as the reply-to address in signature request emails. If you omit it, `configure()` throws a `ValidationException` before any request is sent (unless `skipSenderValidation` is enabled, which TurboSign does not use). ::: ### Environment Variables ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here TURBODOCX_SENDER_EMAIL=you@company.com TURBODOCX_SENDER_NAME=Your Company Name ``` :::warning API Credentials Required Both `apiKey` and `orgId` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ## Product guides Each product guide opens on the PHP tab: - [TurboSign](/docs/SDKs/turbosign?language=php): send documents for signature, track status, download signed PDFs and audit trails - [Deliverable](/docs/SDKs/deliverable?language=php): generate documents from templates - [TurboWebhooks](/docs/SDKs/webhooks?language=php): receive real-time signature events - [TurboQuote](/docs/SDKs/quote?language=php): create, send and download quotes ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [Packagist Package](https://packagist.org/packages/turbodocx/sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- # TurboDocx Python SDK The official TurboDocx SDK for Python applications. Build document generation and digital signature workflows with async/await patterns and comprehensive error handling. Available on PyPI as `turbodocx-sdk`. ## Installation ```bash pip install turbodocx-sdk ``` ```bash poetry add turbodocx-sdk ``` ```bash pipenv install turbodocx-sdk ``` ## Requirements - Python 3.9+ - `httpx` (installed automatically) --- ## Configuration ```python from turbodocx_sdk import TurboSign # Configure globally (recommended) TurboSign.configure( api_key=os.environ["TURBODOCX_API_KEY"], # Required: Your TurboDocx API key org_id=os.environ["TURBODOCX_ORG_ID"], # Required: Your organization ID sender_email="contracts@yourcompany.com", # Required: Reply-to address for signature emails sender_name="Your Company", # Recommended: Sender name shown in emails # base_url="https://api.turbodocx.com" # Optional: Override base URL ) ``` :::tip Authentication Authenticate using `api_key`. API keys are recommended for server-side applications. ::: ### Environment Variables ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here TURBODOCX_SENDER_EMAIL=contracts@yourcompany.com TURBODOCX_SENDER_NAME=Your Company ``` :::warning API Credentials Required `api_key` and `org_id` are **required** for all API requests. TurboSign additionally **requires `sender_email`** (set it on `configure()`, per call, or via the `TURBODOCX_SENDER_EMAIL` environment variable): `configure()` raises a `ValidationError` without it. `sender_name` is optional but strongly recommended. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ## Product guides Each product guide opens on the Python tab: - [TurboSign](/docs/SDKs/turbosign?language=python): send documents for signature, track status, download signed PDFs and audit trails - [Deliverable](/docs/SDKs/deliverable?language=python): generate documents from templates - [TurboWebhooks](/docs/SDKs/webhooks?language=python): receive real-time signature events - [TurboQuote](/docs/SDKs/quote?language=python): create, send and download quotes ## Resources - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [PyPI Package](https://pypi.org/project/turbodocx-sdk/) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- # TurboQuote SDK The official TurboDocx TurboQuote SDK for Node.js and browser applications. Build quoting and CPQ (configure-price-quote) workflows: create and send quotes, manage line items, maintain a product and bundle catalog, apply price books, and handle the full quote lifecycle — all with zero runtime dependencies and complete TypeScript types. Available on npm as `@turbodocx/sdk` (same package as TurboSign and TurboWebhooks). :::info What is TurboQuote? TurboQuote is TurboDocx's quoting and CPQ module. Quotes progress through a lifecycle: `draft` → `pending_approval` → `sent` → `accepted` / `declined` / `voided`. A `draft` can also be marked `declined` directly, for a deal that dies before the quote is ever sent. Each quote belongs to a company and contact, carries line items (individual products or bundles), and can optionally have a price book applied. Accepted quotes can be merged with a TurboDocx Deliverable (e.g. a contract generated from a template) and sent for e-signature through TurboSign via `sendQuoteWithDeliverable`. ::: The official TurboDocx TurboQuote SDK for Python applications. Build full CPQ (configure, price, quote) workflows: create quotes, add product and bundle line items, apply price books, send proposals to contacts, and download PDF exports — all from async Python 3.9+. Distributed on PyPI as `turbodocx-sdk` (same package as TurboSign, TurboWebhooks, and Deliverable). :::info What is TurboQuote? TurboQuote is TurboDocx's quoting and proposal engine. It covers the full quote lifecycle — draft, send, accept/decline/void — with a product catalog, bundle groupings, price books, company/contact CRM, and customizable quote templates. A draft can also be marked declined directly, for a deal that dies before the quote is ever sent. Quotes can be sent with an attached Deliverable document for a branded proposal experience. ::: The official TurboDocx TurboQuote SDK for PHP applications. Build full CPQ (configure, price, quote) workflows: create quotes, add product and bundle line items, apply price books, send proposals to contacts, and download PDF exports — all from PHP 8.1+. Available on Packagist as `turbodocx/sdk` (same package as TurboSign, TurboWebhooks, and Deliverable). :::info What is TurboQuote? TurboQuote is TurboDocx's quoting and proposal engine. It covers the full quote lifecycle — draft, send, accept/decline/void — with a product catalog, bundle groupings, price books, company/contact CRM, and customizable quote templates. A draft can also be marked declined directly, for a deal that dies before the quote is ever sent. Quotes can be sent with an attached Deliverable document for a branded proposal experience. ::: The official TurboDocx TurboQuote SDK for Go applications. Create quotes, attach line items and bundles, send proposals to customers, download PDFs, and manage your full product catalog — products, bundles, price books, companies, contacts, and quote templates — all with idiomatic Go patterns, context support, and typed errors. Available as `github.com/TurboDocx/SDK/packages/go-sdk`. :::info What is TurboQuote? TurboQuote is TurboDocx's CPQ (Configure, Price, Quote) module. It lets your application generate professional, branded quote documents and send them to customers for acceptance or rejection, with full lifecycle management (draft, sent, accepted, declined, voided). A draft can also be marked declined directly, for a deal that dies before the quote is ever sent. For the dashboard UI, quote template configuration, and sending behavior, see the TurboQuote product documentation. ::: The official TurboDocx TurboQuote SDK for Java applications. Create and send sales quotes, manage line items, products, bundles, and price books — all from Java 11+. Distributed as `com.turbodocx:turbodocx-sdk` on Maven Central (same artifact as TurboSign and TurboWebhooks). :::info What is TurboQuote? TurboQuote is TurboDocx's CPQ (Configure, Price, Quote) module. Build a product catalog, assemble quotes with line items, apply price book discounts, and send branded proposals to contacts — with optional TurboSign e-signature delivery via `sendQuoteWithDeliverable`. A `draft` can also be marked `declined` directly, for a deal that dies before the quote is ever sent. The client config takes no `senderEmail`; the quote's **"Prepared by"** sender comes from your org quote template (see the note below `createQuote`). ::: ## Installation {#installation} ```bash npm install @turbodocx/sdk ``` ```bash pnpm add @turbodocx/sdk ``` ```bash yarn add @turbodocx/sdk ``` ```bash pip install turbodocx-sdk ``` ```bash poetry add turbodocx-sdk ``` ```bash pipenv install turbodocx-sdk ``` ```bash composer require turbodocx/sdk ``` ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` Then import: ```go ``` ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` Then import: ```java ``` ## Requirements {#requirements} - Node.js 18 or higher (native `fetch`) - TypeScript 4.7+ (optional, for type checking — declaration files are included) - Zero runtime dependencies — the SDK uses only Node built-ins - Python 3.9 or higher - `httpx` (installed automatically as a dependency) - A TurboDocx API key (`TDX-` prefix) — generate one in **Settings → API Keys** - All SDK methods are `async` — call them from an `async def` (or wrap with `asyncio.run(...)` in synchronous contexts) - PHP 8.1 or higher - Composer 2.x - ext-json - A TurboDocx API key (`TDX-` prefix) — generate one in **Settings → API Keys** - Go 1.21 or higher - A TurboDocx API key (`TDX-` prefix) - All methods accept a `context.Context` — pass `context.Background()` for one-offs or your request context inside handlers - Java 11 or higher - OkHttp 4.x (included transitively) - Gson 2.x (included transitively) - A TurboDocx API key (`TDX-` prefix) — no administrator role required ## Configuration {#configuration} ```typescript TurboQuote.configure({ apiKey: process.env.TURBODOCX_API_KEY!, orgId: process.env.TURBODOCX_ORG_ID, // optional — falls back to env var // accessToken: process.env.TURBODOCX_ACCESS_TOKEN, // optional — OAuth token instead of apiKey }); ``` ```javascript const { TurboQuote } = require('@turbodocx/sdk'); TurboQuote.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, // accessToken: process.env.TURBODOCX_ACCESS_TOKEN, // optional — OAuth token instead of apiKey }); ``` :::tip No senderEmail on the client — but set one on your quote template Unlike TurboSign, `TurboQuote.configure()` does **not** require `senderEmail` or `senderName` — quotes are not sent as signature emails. Only a credential is required — either `apiKey` or an OAuth `accessToken` (`accessToken` wins when both are set); `orgId` is recommended but falls back to `TURBODOCX_ORG_ID`. If you skip `configure()` entirely, the SDK auto-initialises from environment variables on the first method call. The quote's **"Prepared by"** sender comes from your **org quote template** instead. Because an API key has no mailbox of its own, if the org's quote template has no sender email set, `createQuote`, `duplicateQuote`, `sendQuote` / `sendQuoteWithDeliverable`, and `handleExpiredQuote` still succeed: they fall back to a generic TurboDocx sender (`no-reply@turbodocx.com`) rather than rejecting the call. Configure both **Sender Name** and **Sender Email** once (`const tmpl = await TurboQuote.getTemplate(); await TurboQuote.updateTemplate(tmpl.id, { senderEmail, senderName });`) so quotes show your own sender identity instead of the generic fallback. ::: ```python from turbodocx_sdk import TurboQuote TurboQuote.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], # optional — auto-reads env var ) ``` :::tip No sender email on the client — but set one on your quote template `TurboQuote.configure()` does **not** take `sender_email` or `sender_name`. Quotes are not signature emails. Only `api_key` and, optionally, `org_id` are needed. If you skip the explicit call, the SDK lazily auto-configures from `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID` on first use. The quote's **"Prepared by"** sender comes from your **org quote template** instead. Because an API key has no mailbox of its own, every sender-resolving call — `create_quote`, `duplicate_quote`, `send_quote` / `send_quote_with_deliverable`, and `handle_expired_quote` — fails with a `ValidationError` (`400 SenderEmailRequired`) when the org's quote template has no sender email set. A companion `400 SenderNameRequired` is returned when no sender **name** resolves. Configure both **Sender Name** and **Sender Email** once (`TurboQuote.update_template(id, {"senderEmail": ..., "senderName": ...})`) and all of them resolve cleanly. ::: ```php ```php :::tip No senderEmail on the client — but set one on your quote template `TurboQuote` does **not** take `senderEmail` or `senderName` — quotes are not signature emails; only `apiKey` and, optionally, `orgId` are needed. The quote's **"Prepared by"** sender comes from your **org quote template** instead. Because an API key has no mailbox of its own, every sender-resolving call — `createQuote()`, `duplicateQuote()`, `sendQuote()` / `sendQuoteWithDeliverable()`, and `handleExpiredQuote()` — fails with a `ValidationException` (`400 SenderEmailRequired`) when the org's quote template has no sender email set. A companion `400 SenderNameRequired` is returned when no sender **name** resolves. Configure both **Sender Name** and **Sender Email** once (`TurboQuote::updateTemplate(...)`) and all of them resolve cleanly. ::: ```go "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) qc, err := turbodocx.NewQuoteClient(turbodocx.QuoteClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } ``` `NewQuoteClient` does **not** take `SenderEmail` — a quote has no per-request sender field. Sender validation is **not** skipped, though: it moves to your org quote template (see below). `OrgID` is required; both `APIKey` and `OrgID` fall back to `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID` when not set in config. The quote's **"Prepared by"** sender comes from your **org quote template** instead. Because an API key has no mailbox of its own, every sender-resolving call — `CreateQuote`, `DuplicateQuote`, `SendQuote` / `SendQuoteWithDeliverable`, and `HandleExpiredQuote` — fails with a `ValidationError` (`400 SenderEmailRequired`) when the org's quote template has no sender email set. A companion `400 SenderNameRequired` is returned when no sender **name** resolves. Configure both **Sender Name** and **Sender Email** once (`UpdateTemplate`) and all of them resolve cleanly. ```java TurboQuoteClient client = new TurboQuoteClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .build(); TurboQuote tq = client.turboQuote(); ``` `TurboQuoteClient.Builder` does **not** take `senderEmail` — a quote has no per-request sender field. Sender validation is **not** skipped, though: it moves to your org quote template (see below). `orgId` is required. Construct `TurboQuoteClient` once and reuse `tq`. The quote's **"Prepared by"** sender comes from your **org quote template** instead. Because an API key has no mailbox of its own, every sender-resolving call — `createQuote`, `duplicateQuote`, `sendQuote` / `sendQuoteWithDeliverable`, and `handleExpiredQuote` — fails with a `ValidationException` (`400 SenderEmailRequired`) when the org's quote template has no sender email set. A companion `400 SenderNameRequired` is returned when no sender **name** resolves. Configure both **Sender Name** and **Sender Email** once (`updateTemplate`) and all of them resolve cleanly. ### Environment Variables {#environment-variables} ```bash TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com ``` ```bash TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com ``` ```bash TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com ``` :::caution API Credentials Required Both `apiKey` and `orgId` are needed for most API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ```bash TURBODOCX_API_KEY=your_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com ``` :::caution API Credentials Required Both `APIKey` and `OrgID` are required. To get your credentials, follow the [Get Your Credentials](/docs/SDKs#1-get-your-credentials) steps from the SDKs main page. ::: ```bash TURBODOCX_API_KEY=your_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com ``` :::caution API Credentials Required Both `apiKey` and `orgId` are required. To get your credentials, follow the [Get Your Credentials](/docs/SDKs#1-get-your-credentials) steps from the SDKs main page. ::: ## Quick Start {#quick-start} The most common flow: create a quote for a company and contact, add a product line item, then send it. ```typescript TurboQuote.configure({ apiKey: process.env.TURBODOCX_API_KEY!, orgId: process.env.TURBODOCX_ORG_ID, }); // 1. Create a draft quote const quote = await TurboQuote.createQuote({ name: 'Acme Corp — Enterprise Plan', companyId: 'company-uuid', contactId: 'contact-uuid', currency: 'USD', termDays: 30, }); // 2. Add a product line item await TurboQuote.addLineItems(quote.id, { productId: 'product-uuid', productName: 'Enterprise Licence', unitPrice: 1200, billingFrequency: 'annual', quantity: 5, }); // 3. Send the quote const { quote: sentQuote, message } = await TurboQuote.sendQuote(quote.id, { validUntil: '2026-07-31', }); console.log(message); // "Quote sent successfully" console.log(sentQuote.status); // "sent" // 4. Download the PDF const pdf = await TurboQuote.downloadQuotePdf(sentQuote.id); writeFileSync('quote.pdf', Buffer.from(pdf)); ``` ```javascript const { TurboQuote } = require('@turbodocx/sdk'); const { writeFileSync } = require('fs'); TurboQuote.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, }); // 1. Create a draft quote const quote = await TurboQuote.createQuote({ name: 'Acme Corp — Enterprise Plan', companyId: 'company-uuid', contactId: 'contact-uuid', currency: 'USD', termDays: 30, }); // 2. Add a product line item await TurboQuote.addLineItems(quote.id, { productId: 'product-uuid', productName: 'Enterprise Licence', unitPrice: 1200, billingFrequency: 'annual', quantity: 5, }); // 3. Send the quote const { quote: sentQuote, message } = await TurboQuote.sendQuote(quote.id, { validUntil: '2026-07-31', }); console.log(message); // "Quote sent successfully" console.log(sentQuote.status); // "sent" // 4. Download the PDF const pdf = await TurboQuote.downloadQuotePdf(sentQuote.id); writeFileSync('quote.pdf', Buffer.from(pdf)); ``` ### 1. Create a company, quote, and add line items {#1-create-a-company-quote-and-add-line-items} ```java public class QuoteLifecycle { public static void main(String[] args) throws Exception { TurboQuoteClient client = new TurboQuoteClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .build(); TurboQuote tq = client.turboQuote(); // Step 1: Create a company with an initial contact CreateCompanyContactInput contact = new CreateCompanyContactInput(); contact.setName("Alice Buyer"); contact.setEmail("alice@example.com"); CreateCompanyRequest companyReq = new CreateCompanyRequest(); companyReq.setName("Acme Corp"); companyReq.setContacts(Arrays.asList(contact)); Company company = tq.createCompany(companyReq); ContactListResponse contacts = tq.listCompanyContacts(company.getId()); String contactId = contacts.getResults().get(0).getId(); // Step 2: Create a quote CreateQuoteRequest quoteReq = new CreateQuoteRequest(); quoteReq.setName("Q1 Software License"); quoteReq.setCompanyId(company.getId()); quoteReq.setContactId(contactId); quoteReq.setTermDays(30); quoteReq.setCurrency(Currency.USD); Quote quote = tq.createQuote(quoteReq); System.out.println("Quote: " + quote.getId() + " status=" + quote.getStatus()); // Step 3: Add a line item. // productId, productName, unitPrice and billingFrequency are all required. // productId may be null (custom line item), but must always be set. AddLineItemRequest item = new AddLineItemRequest(); item.setProductId("product-uuid"); item.setProductName("Enterprise Software License"); item.setUnitPrice(1200.00); item.setQuantity(3.0); item.setBillingFrequency("annual"); item.setDiscountType(DiscountType.PERCENT); item.setDiscountPercent(10.0); List lineItems = tq.addLineItems(quote.getId(), item); System.out.println("Added " + lineItems.size() + " line item(s)"); // Step 4: Send the quote SendQuoteResponse sent = tq.sendQuote(quote.getId()); System.out.println("Sent. Status: " + sent.getQuote().getStatus()); // Step 5: Download the PDF byte[] pdf = tq.downloadQuotePdf(quote.getId()); Files.write(Paths.get("quote.pdf"), pdf); System.out.println("PDF saved (" + pdf.length + " bytes)"); } } ``` ### 2. Create and send in one call {#2-create-and-send-in-one-call} `createAndSend` is a convenience method that creates the quote, adds line items and bundle items, and sends it atomically. ```java CreateAndSendRequest req = new CreateAndSendRequest(); req.setName("Partner Proposal"); req.setCompanyId(companyId); req.setContactId(contactId); AddLineItemRequest item = new AddLineItemRequest(); item.setProductId("product-uuid"); // required — null for a custom line item item.setProductName("Starter Plan"); item.setUnitPrice(499.00); item.setQuantity(1.0); item.setBillingFrequency("monthly"); // required req.setItems(Arrays.asList(item)); // req.setSend(...) to configure send options, or omit to use defaults CreateAndSendResponse result = tq.createAndSend(req); System.out.println("Quote created and sent: " + result.getQuote().getId()); ``` ### 3. Apply a price book, then send with a deliverable {#3-apply-a-price-book-then-send-with-a-deliverable} ```java // Apply a price book to recalculate line item prices ApplyPriceBookResponse applied = tq.applyPriceBook(quoteId, priceBookId); System.out.println("Updated: " + applied.getUpdatedCount() + ", Skipped: " + applied.getSkippedCount()); // Send with a TurboDocx deliverable attached SendQuoteWithDeliverableRequest sendReq = new SendQuoteWithDeliverableRequest(); sendReq.setDeliverableId("your-deliverable-id"); sendReq.setMergePosition("end"); SendQuoteWithDeliverableResponse sendResp = tq.sendQuoteWithDeliverable(quoteId, sendReq); System.out.println("Document ID: " + sendResp.getDocumentId()); ``` ### Create a quote, add line items, send, and download the PDF / Full quote lifecycle: create → add items → send → download PDF / Full lifecycle: create → add items → send → download PDF {#create-a-quote-add-line-items-send-and-download-the-pdf} ```python from turbodocx_sdk import TurboQuote TurboQuote.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], ) async def full_quote_lifecycle(): # 1. Create a draft quote quote = await TurboQuote.create_quote({ "name": "Acme Corp — Annual Plan Q3", "companyId": "company-uuid", "contactId": "contact-uuid", "validUntil": "2026-09-30", "currency": "USD", }) quote_id = quote["id"] print(f"Created quote {quote_id}") # 2. Add product line items items = await TurboQuote.add_line_items(quote_id, [ { "productId": "product-uuid-1", "productName": "Platform License", "quantity": 5, "unitPrice": "199.00", "billingFrequency": "annual", }, { "productId": "product-uuid-2", "productName": "Onboarding Package", "quantity": 1, "unitPrice": "499.00", "billingFrequency": "one-time", }, ]) print(f"Added {len(items)} line items") # 3. Apply a price book (optional) result = await TurboQuote.apply_price_book(quote_id, "pricebook-uuid") print(f"Price book applied: {result['updatedCount']} items updated") # 4. Send the quote sent = await TurboQuote.send_quote(quote_id, { "ccEmails": ["manager@acme.com"], "validUntil": "2026-09-30", }) print(f"Sent: {sent['message']}") # 5. Download the PDF pdf_bytes = await TurboQuote.download_quote_pdf(quote_id) with open("acme-quote.pdf", "wb") as f: f.write(pdf_bytes) print(f"PDF saved ({len(pdf_bytes)} bytes)") asyncio.run(full_quote_lifecycle()) ``` ```php id}\n"; // 2. Add a product line item $items = TurboQuote::addLineItems($quote->id, new AddLineItemRequest( productId: 'product-uuid', productName: 'Enterprise Seat', unitPrice: 199.00, billingFrequency: 'monthly', quantity: 5, )); echo 'Added ' . count($items) . " item(s)\n"; // 3. Send the quote $sent = TurboQuote::sendQuote($quote->id); echo "Status: {$sent->quote->status}\n"; // 'sent' // 4. Download the PDF $pdfBytes = TurboQuote::downloadQuotePdf($quote->id); file_put_contents('proposal.pdf', $pdfBytes); echo "PDF saved.\n"; ``` :::caution Always Handle Errors The above example omits error handling for brevity. In production, wrap all TurboQuote calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: ```go package main "context" "fmt" "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { ctx := context.Background() qc, err := turbodocx.NewQuoteClient(turbodocx.QuoteClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } // 1. Create a draft quote currency := "USD" termDays := 30 quote, err := qc.CreateQuote(ctx, &turbodocx.CreateQuoteRequest{ Name: "Acme Corp — Q3 Proposal", CompanyID: "company-uuid", ContactID: "contact-uuid", CurrencyCode: ¤cy, TermDays: &termDays, }) if err != nil { log.Fatal(err) } fmt.Printf("Created quote %s (%s)\n", quote.QuoteNumber, quote.ID) // 2. Add a product line item qty := 5 lineItems, err := qc.AddLineItems(ctx, quote.ID, turbodocx.AddLineItemRequest{ ProductID: &[]string{"product-uuid"}[0], ProductName: "Enterprise License", UnitPrice: 500.00, BillingFrequency: "annual", Quantity: &qty, }) if err != nil { log.Fatal(err) } fmt.Printf("Added %d line item(s)\n", len(lineItems)) // 3. Send the quote to the customer sent, err := qc.SendQuote(ctx, quote.ID, &turbodocx.SendQuoteRequest{ CCEmails: []string{"manager@acmecorp.com"}, }) if err != nil { log.Fatal(err) } fmt.Printf("Sent: %s\n", sent.Message) // 4. Download PDF pdfBytes, err := qc.DownloadQuotePdf(ctx, quote.ID) if err != nil { log.Fatal(err) } os.WriteFile("quote.pdf", pdfBytes, 0644) fmt.Printf("PDF saved (%d bytes)\n", len(pdfBytes)) } ``` ### Convenience: createAndSend / Convenience: create, add items, and send in one call / Convenience: CreateAndSend {#convenience-createandsend} `createAndSend` combines quote creation, line item addition, and sending into a single call. ```typescript const { quote } = await TurboQuote.createAndSend({ name: 'Acme Corp — Starter', companyId: 'company-uuid', contactId: 'contact-uuid', currency: 'USD', termDays: 30, items: [ { productId: null, productName: 'Setup Fee', unitPrice: 500, billingFrequency: 'one-time' }, { productId: 'product-uuid', productName: 'Monthly Subscription', unitPrice: 99, billingFrequency: 'monthly', quantity: 10 }, ], send: { validUntil: '2026-07-31' }, }); console.log(quote.status); // "sent" ``` --- ```python result = await TurboQuote.create_and_send({ "name": "Acme Corp — Quick Proposal", "companyId": "company-uuid", "contactId": "contact-uuid", "currency": "USD", "items": [ {"productId": "product-uuid-1", "productName": "Platform License", "quantity": 3, "unitPrice": "299.00", "billingFrequency": "monthly"}, ], "bundleItems": [ {"bundleId": "bundle-uuid-1", "bundleName": "Starter Pack", "quantity": 1}, ], "send": { "ccEmails": ["cc@example.com"], "validUntil": "2026-09-30", }, }) print(f"Quote sent: {result['quote']['id']}") ``` --- ```php quote->id}\n"; ``` --- `CreateAndSend` performs the create + add items + send flow in a single call (3-4 sequential API requests under the hood): ```go currency := "USD" productID := "product-uuid" resp, err := qc.CreateAndSend(ctx, &turbodocx.CreateAndSendRequest{ Name: "Acme Corp — Quick Proposal", CompanyID: "company-uuid", ContactID: "contact-uuid", CurrencyCode: ¤cy, Items: []turbodocx.AddLineItemRequest{ { // ProductID, ProductName, UnitPrice and BillingFrequency are all required. // ProductID may be nil (custom line item), but the key is always sent. ProductID: &productID, ProductName: "Enterprise License", UnitPrice: 500.00, BillingFrequency: "annual", }, }, Send: &turbodocx.SendQuoteRequest{}, }) if err != nil { log.Fatal(err) } fmt.Printf("Quote %s sent\n", resp.Quote.QuoteNumber) ``` ## Method Reference {#method-reference} All methods are static on the `TurboQuote` class. Configure once, then call on the class directly. All methods are `@classmethod`s on `TurboQuote`; configure once, then call on the class. All methods are static. Configure once with `TurboQuote::configure(...)`, then call on the class directly. All methods are instance methods on `*turbodocx.QuoteClient`. Construct once, then reuse across goroutines — the client is safe for concurrent use. --- All methods are instance methods on `com.turbodocx.TurboQuote`. Obtain the instance via `client.turboQuote()` from a constructed `TurboQuoteClient`. All methods throw `IOException` and `TurboDocxException` subclasses. --- ### Quotes / Quotes — CRUD {#quotes} #### `listQuotes` / `list_quotes` / ListQuotes {#listquotes} List quotes with optional pagination and filters. Returns totals and pipeline stats alongside results. ```typescript const { results, totalRecords, stats } = await TurboQuote.listQuotes({ limit: 20, offset: 0, statuses: ['draft', 'sent'], // string or string[] companyId: 'company-uuid', currency: 'USD', }); // stats.total, stats.winRate, stats.monthlyRecurringRevenue, ... for (const q of results) { console.log(q.name, q.status); } ``` ```python page = await TurboQuote.list_quotes({ "limit": 20, "offset": 0, "query": "acme", "statuses": ["draft", "sent"], # repeated-key filter }) # page["results"], page["totalRecords"], page["stats"] for q in page["results"]: print(q["name"], q["status"]) ``` ```php use TurboDocx\Types\Requests\Quote\ListQuotesRequest; $page = TurboQuote::listQuotes(new ListQuotesRequest( limit: 10, offset: 0, query: 'Enterprise', )); echo "Total: {$page->totalRecords}\n"; foreach ($page->results as $q) { echo " [{$q->status}] {$q->name}\n"; } ``` Retrieve a paginated list of quotes with optional filters. Returns `*QuoteListResponse` which includes `Results`, `TotalRecords`, and aggregate `Stats` (pipeline totals, win rate, MRR, etc.). ```go limit := 10 statuses := []string{"draft", "sent"} list, err := qc.ListQuotes(ctx, &turbodocx.ListQuotesOptions{ Limit: &limit, Statuses: statuses, }) // list.Results []Quote // list.TotalRecords int // list.Stats.WinRate float64 for _, q := range list.Results { fmt.Println(q.Name, q.Status) } ``` | Field | Type | Description | |---|---|---| | `Limit` | `*int` | Results per page | | `Offset` | `*int` | Results to skip | | `Query` | `*string` | Search by name or quote number | | `Statuses` | `[]string` | Filter by status (e.g., `"draft"`, `"sent"`, `"accepted"`) | | `CompanyID` | `*string` | Filter by company | | `ContactID` | `*string` | Filter by contact | | `CurrencyCode` | `*string` | Filter by currency | ```java QuoteListResponse listQuotes() QuoteListResponse listQuotes(ListQuotesOptions options) ``` List quotes with optional pagination and filters. Returns a paginated response including stats (totals, counts by status). ```java ListQuotesOptions opts = new ListQuotesOptions(); opts.setLimit(20); opts.setOffset(0); QuoteListResponse list = tq.listQuotes(opts); System.out.println("Total: " + list.getTotalRecords()); list.getResults().forEach(q -> System.out.println(q.getId() + " " + q.getStatus())); ``` #### `createQuote` / `create_quote` / CreateQuote {#createquote} Create a new quote in `draft` status. ```typescript // Fixed-term quote — termDays 0–3650, no renewalPeriod (0 = one-time, -1 = auto-renewal) const quote = await TurboQuote.createQuote({ name: 'Q3 Renewal', // required companyId: 'company-uuid', // required contactId: 'contact-uuid', // required currency: 'USD', // 'USD'|'EUR'|'GBP'|'CAD'|'AUD'|'INR' termDays: 30, // fixed term in days — omit to get the default of 60 validUntil: '2026-09-30', taxRate: 8.5, priceBookId: 'pb-uuid', }); // Auto-renewal quote — termDays: -1 REQUIRES renewalPeriod, and renewalPeriod is ONLY valid // when termDays is -1. Pairing renewalPeriod with a fixed term (e.g. termDays: 30) returns a 400. const subscription = await TurboQuote.createQuote({ name: 'Annual Subscription', companyId: 'company-uuid', contactId: 'contact-uuid', currency: 'USD', termDays: -1, // -1 = auto-renewal renewalPeriod: 'annually', // 'weekly'|'monthly'|'quarterly'|'annually' }); ``` :::caution termDays and renewalPeriod are coupled `termDays` defaults to **60** when omitted. Valid values are `-1` (auto-renewal) or `0`–`3650` (`0` = one-time). `renewalPeriod` is **required** when `termDays` is `-1`, and must be **null or absent** for every other `termDays` value — sending it alongside a fixed term returns a `400`. The same rule applies on `updateQuote`. ::: ```python # Fixed-term quote — termDays is -1 or 0–3650; omit it to get the default of 60. quote = await TurboQuote.create_quote({ "name": "Q3 Proposal", "companyId": "company-uuid", # required "contactId": "contact-uuid", # required "currency": "USD", "termDays": 30, # fixed term — do NOT send renewalPeriod with this "validUntil": "2026-09-30", "notes": "Includes implementation services", "taxRate": "8.5", }) # returns Quote dict # Auto-renewal quote — termDays -1 REQUIRES renewalPeriod. subscription = await TurboQuote.create_quote({ "name": "Annual Subscription", "companyId": "company-uuid", "contactId": "contact-uuid", "currency": "USD", "termDays": -1, # -1 = auto-renewal "renewalPeriod": "annually", # "weekly" | "monthly" | "quarterly" | "annually" }) ``` :::caution `termDays` and `renewalPeriod` are coupled `termDays` defaults to **60** when omitted. Valid values are `-1` (auto-renewal) or `0`–`3650` (`0` = one-time). `renewalPeriod` is **required** when `termDays` is `-1`, and must be **`None` or absent** for every other `termDays` value — sending it alongside a fixed term returns a `400`. The same rule applies on `update_quote`. ::: ```php use TurboDocx\Types\Requests\Quote\CreateQuoteRequest; // Fixed-term quote — termDays is -1 or 0–3650; omit it to get the default of 60. $quote = TurboQuote::createQuote(new CreateQuoteRequest( name: 'Q3 Proposal', companyId: 'company-uuid', contactId: 'contact-uuid', validUntil: '2026-09-30', currency: 'USD', termDays: 30, // fixed term — do NOT pass renewalPeriod with this )); // Auto-renewal quote — termDays -1 REQUIRES renewalPeriod. $subscription = TurboQuote::createQuote(new CreateQuoteRequest( name: 'Annual Subscription', companyId: 'company-uuid', contactId: 'contact-uuid', currency: 'USD', termDays: -1, // -1 = auto-renewal renewalPeriod: 'annually', // 'weekly' | 'monthly' | 'quarterly' | 'annually' )); ``` :::caution `termDays` and `renewalPeriod` are coupled `termDays` defaults to **60** when omitted. Valid values are `-1` (auto-renewal) or `0`–`3650` (`0` = one-time). `renewalPeriod` is **required** when `termDays` is `-1`, and must be **null or absent** for every other `termDays` value — sending it alongside a fixed term returns a `400`. The same rule applies on `updateQuote`. ::: ```go // Fixed-term quote — TermDays is -1 or 0–3650; omit it to get the default of 60. termDays := 30 quote, err := qc.CreateQuote(ctx, &turbodocx.CreateQuoteRequest{ Name: "New Proposal", CompanyID: "company-uuid", ContactID: "contact-uuid", TermDays: &termDays, // fixed term — do NOT set RenewalPeriod alongside this }) // Auto-renewal quote — TermDays -1 REQUIRES RenewalPeriod. autoRenew := -1 renewalPeriod := "annually" // "weekly" | "monthly" | "quarterly" | "annually" subscription, err := qc.CreateQuote(ctx, &turbodocx.CreateQuoteRequest{ Name: "Annual Subscription", CompanyID: "company-uuid", ContactID: "contact-uuid", TermDays: &autoRenew, RenewalPeriod: &renewalPeriod, }) ``` Required: `Name`, `CompanyID`, `ContactID`. Returns `*Quote`. :::caution TermDays and RenewalPeriod are coupled `TermDays` defaults to **60** when omitted. Valid values are `-1` (auto-renewal) or `0`–`3650` (`0` = one-time). `RenewalPeriod` is **required** when `TermDays` is `-1`, and must be **nil/absent** for every other `TermDays` value — sending it alongside a fixed term returns a `400`. The same rule applies on `UpdateQuote`; use `ClearRenewalPeriod()` when moving a quote off auto-renewal. ::: ```java Quote createQuote(CreateQuoteRequest request) ``` Create a new quote. Returns the created `Quote`. ```java // Fixed-term quote — termDays is -1 or 0–3650; leave it unset to get the default of 60. CreateQuoteRequest req = new CreateQuoteRequest(); req.setName("Enterprise Proposal"); req.setCompanyId(companyId); req.setContactId(contactId); req.setCurrency(Currency.USD); req.setTermDays(30); // fixed term — do NOT set renewalPeriod alongside this Quote quote = tq.createQuote(req); // Auto-renewal quote — termDays -1 REQUIRES renewalPeriod. CreateQuoteRequest subReq = new CreateQuoteRequest(); subReq.setName("Annual Subscription"); subReq.setCompanyId(companyId); subReq.setContactId(contactId); subReq.setCurrency(Currency.USD); subReq.setTermDays(-1); // -1 = auto-renewal subReq.setRenewalPeriod(RenewalPeriod.ANNUALLY); // WEEKLY | MONTHLY | QUARTERLY | ANNUALLY Quote subscription = tq.createQuote(subReq); ``` :::caution `termDays` and `renewalPeriod` are coupled `termDays` defaults to **60** when left unset. Valid values are `-1` (auto-renewal) or `0`–`3650` (`0` = one-time). `renewalPeriod` is **required** when `termDays` is `-1`, and must be **null or unset** for every other `termDays` value — sending it alongside a fixed term returns a `400`. The same rule applies on `updateQuote`. ::: #### `getQuote` / `get_quote` / GetQuote {#getquote} Fetch a single quote. The returned object includes a `statusInfo` field with transition flags (`canSend`, `canAccept`, `canDecline`, `canVoid`) and a `preparedBy` object — the resolved "Prepared by" identity shown on the quote PDF. ```typescript const quote = await TurboQuote.getQuote('quote-uuid'); console.log(quote.statusInfo?.canSend); // true when status is 'draft' console.log(quote.statusInfo?.canDecline); // also true when status is 'draft' console.log(quote.preparedBy?.name); // e.g. "Acme Billing Integration" or the template sender console.log(quote.preparedBy?.email); // may be undefined for an API-created quote — render a placeholder ``` `preparedBy` is resolved server-side (org template first, then the quote's creator). **Prefer it over `creator`** for any customer-facing display — `creator` may be the internal API service account. For an API-created quote the resolved name is the **API key's name** (never a generic "API Service User"), and the email comes from the org quote template. `preparedBy` is returned by the **single-quote fetch only** — it is not present on create, duplicate, or list responses. Returns the quote with `statusInfo` and `preparedBy` merged in when present. `preparedBy` is the resolved "Prepared by" identity shown on the quote PDF. ```python quote = await TurboQuote.get_quote("quote-uuid") # quote["status"], quote["grandTotal"], quote.get("statusInfo") prepared = quote.get("preparedBy") or {} print(prepared.get("name")) # e.g. "Acme Billing Integration" or the template sender print(prepared.get("email")) # may be absent for an API-created quote — render a placeholder ``` `preparedBy` is resolved server-side (org template first, then the quote's creator). **Prefer it over `creator`** for any customer-facing display — `creator` may be the internal API service account. For an API-created quote the resolved name is the **API key's name** (never a generic "API Service User"), and the email comes from the org quote template. `preparedBy` is returned by the **single-quote fetch only** — it is not present on create, duplicate, or list responses. ```php $quote = TurboQuote::getQuote('quote-uuid'); // statusInfo and preparedBy are merged onto the returned Quote object when present echo $quote->status; $prepared = $quote->preparedBy ?? []; echo $prepared['name'] ?? ''; // e.g. "Acme Billing Integration" or the template sender echo $prepared['email'] ?? ''; // may be absent for an API-created quote — render a placeholder ``` `preparedBy` is the resolved "Prepared by" identity (org template first, then the quote's creator). **Prefer it over `creator`** for any customer-facing display — `creator` may be the internal API service account. For an API-created quote the resolved name is the **API key's name** (never a generic "API Service User"), and the email comes from the org quote template. `preparedBy` is returned by the **single-quote fetch only** — it is not present on create, duplicate, or list responses. Fetches a quote by ID. The `StatusInfo` field (merged onto the returned `Quote`) describes what transitions are available: `CanSend`, `CanAccept`, `CanDecline`, `CanVoid`, `IsTerminal`. The `PreparedBy` field carries the resolved "Prepared by" identity shown on the quote PDF. ```go quote, err := qc.GetQuote(ctx, "quote-uuid") if quote.StatusInfo != nil { fmt.Printf("Can send: %v\n", quote.StatusInfo.CanSend) } if quote.PreparedBy != nil && quote.PreparedBy.Name != nil { fmt.Println(*quote.PreparedBy.Name) // e.g. "Acme Billing Integration"; Email may be nil } ``` `PreparedBy` is resolved server-side (org template first, then the quote's creator). **Prefer it over `Creator`** for any customer-facing display — `Creator` may be the internal API service account. For an API-created quote the resolved name is the **API key's name** (never a generic "API Service User"), and the email comes from the org quote template. `preparedBy` is returned by the **single-quote fetch only** — it is not present on create, duplicate, or list responses. ```java Quote getQuote(String id) ``` Get a quote by ID. Returns the `Quote` with `statusInfo` (expiry dates, status transitions) and `preparedBy` (the resolved "Prepared by" identity shown on the quote PDF) merged in. ```java Quote quote = tq.getQuote(quoteId); System.out.println("Status: " + quote.getStatus()); // quote.getStatusInfo() — expiry/transition metadata if (quote.getPreparedBy() != null) { System.out.println(quote.getPreparedBy().getName()); // e.g. "Acme Billing Integration" System.out.println(quote.getPreparedBy().getEmail()); // may be null — render a placeholder } ``` `preparedBy` is resolved server-side (org template first, then the quote's creator). **Prefer it over `getCreator()`** for any customer-facing display — the creator may be the internal API service account. For an API-created quote the resolved name is the **API key's name** (never a generic "API Service User"), and the email comes from the org quote template. `preparedBy` is returned by the **single-quote fetch only** — it is not present on create, duplicate, or list responses. #### `updateQuote` / `update_quote` / UpdateQuote {#updatequote} Patch any combination of quote fields. Pass `null` to clear nullable fields (`renewalPeriod`, `validUntil`, `taxRate`, `priceBookId`). ```typescript const updated = await TurboQuote.updateQuote('quote-uuid', { name: 'Q3 Renewal — Revised', taxRate: null, // clears the tax rate }); ``` :::info `name` is trimmed, and renaming is draft-only `name` is trimmed on both `createQuote` and `updateQuote` — `' Acme '` is stored as `'Acme'` — and a name that is empty once trimmed returns a `400`. A quote can only be renamed while it is a **draft**; on any other status the update is rejected with `Cannot update quote that is not in draft status` (`templateId` is the only field exempt from that rule). Rename before you send, because **sending snapshots the quote's current name onto the TurboSign document** — that is the name signers see in the request email and on the signed PDF. ::: All fields are optional — pass only what changes. Send an explicit `None` to clear a nullable field. ```python updated = await TurboQuote.update_quote("quote-uuid", { "name": "Q3 Proposal — Revised", "validUntil": "2026-10-15", "taxRate": None, # clears the field }) ``` :::info `name` is trimmed, and renaming is draft-only `name` is trimmed on both `create_quote` and `update_quote` — `" Acme "` is stored as `"Acme"` — and a name that is empty once trimmed returns a `400`. A quote can only be renamed while it is a **draft**; on any other status the update is rejected with `Cannot update quote that is not in draft status` (`templateId` is the only field exempt from that rule). Rename before you send, because **sending snapshots the quote's current name onto the TurboSign document** — that is the name signers see in the request email and on the signed PDF. ::: ```php use TurboDocx\Types\Requests\Quote\UpdateQuoteRequest; $quote = TurboQuote::updateQuote('quote-uuid', new UpdateQuoteRequest( name: 'Q3 Proposal — Revised', validUntil: '2026-10-15', )); ``` :::info `name` is trimmed, and renaming is draft-only `name` is trimmed on both `createQuote` and `updateQuote` — `' Acme '` is stored as `'Acme'` — and a name that is empty once trimmed returns a `400`. A quote can only be renamed while it is a **draft**; on any other status the update is rejected with `Cannot update quote that is not in draft status` (`templateId` is the only field exempt from that rule). Rename before you send, because **sending snapshots the quote's current name onto the TurboSign document** — that is the name signers see in the request email and on the signed PDF. ::: PATCH semantics — only provided fields are sent. Use the `Clear*` helpers to explicitly null a field: ```go req := &turbodocx.UpdateQuoteRequest{} req.ClearPriceBookID() // sends "priceBookId": null req.ClearValidUntil() // sends "validUntil": null quote, err := qc.UpdateQuote(ctx, "quote-uuid", req) ``` Available null-clear helpers: `ClearPriceBookID`, `ClearValidUntil`, `ClearTaxRate`, `ClearRenewalPeriod`. :::info `Name` is trimmed, and renaming is draft-only `Name` is trimmed on both `CreateQuote` and `UpdateQuote` — `" Acme "` is stored as `"Acme"` — and a name that is empty once trimmed returns a `400`. A quote can only be renamed while it is a **draft**; on any other status the update is rejected with `Cannot update quote that is not in draft status` (`TemplateID` is the only field exempt from that rule). Rename before you send, because **sending snapshots the quote's current name onto the TurboSign document** — that is the name signers see in the request email and on the signed PDF. ::: ```java Quote updateQuote(String id, UpdateQuoteRequest request) ``` Update an existing quote. Only fields explicitly set on `UpdateQuoteRequest` are patched; unset fields are omitted from the request body. Fields explicitly set to `null` are cleared on the server (e.g., `setValidUntil(null)` clears the expiry date). ```java UpdateQuoteRequest req = new UpdateQuoteRequest(); req.setName("Revised Proposal — Q2"); req.setTermDays(60); Quote updated = tq.updateQuote(quoteId, req); ``` :::info `name` is trimmed, and renaming is draft-only `name` is trimmed on both `createQuote` and `updateQuote` — `" Acme "` is stored as `"Acme"` — and a name that is empty once trimmed returns a `400`. A quote can only be renamed while it is a **draft**; on any other status the update is rejected with `Cannot update quote that is not in draft status` (`templateId` is the only field exempt from that rule). Rename before you send, because **sending snapshots the quote's current name onto the TurboSign document** — that is the name signers see in the request email and on the signed PDF. ::: #### `deleteQuote` / `delete_quote` / DeleteQuote {#deletequote} Soft-delete a quote. ```typescript const { message } = await TurboQuote.deleteQuote('quote-uuid'); ``` ```python result = await TurboQuote.delete_quote("quote-uuid") # result["message"] ``` ```php $result = TurboQuote::deleteQuote('quote-uuid'); echo $result->message; ``` ```go result, err := qc.DeleteQuote(ctx, "quote-uuid") // result.Message string ``` ```java SuccessResponse deleteQuote(String id) ``` Delete a quote. ```java SuccessResponse resp = tq.deleteQuote(quoteId); System.out.println(resp.getMessage()); ``` #### `duplicateQuote` / `duplicate_quote` / DuplicateQuote {#duplicatequote} Copy a quote (and its line items) into a new draft. ```typescript const copy = await TurboQuote.duplicateQuote('quote-uuid'); ``` The copy is named **`Copy of `**, truncated to the name column's 255-character limit. Rename it with `updateQuote` before sending if that is not what you want signers to see. ```python new_quote = await TurboQuote.duplicate_quote("quote-uuid") # returns new Quote in draft status ``` The copy is named **`Copy of `**, truncated to the name column's 255-character limit. Rename it with `update_quote` before sending if that is not what you want signers to see. ```php $copy = TurboQuote::duplicateQuote('quote-uuid'); echo "Copy id: {$copy->id}"; ``` The copy is named **`Copy of `**, truncated to the name column's 255-character limit. Rename it with `updateQuote` before sending if that is not what you want signers to see. Creates a new draft quote as a copy of the specified quote. ```go copy, err := qc.DuplicateQuote(ctx, "quote-uuid") ``` The copy is named **`Copy of `**, truncated to the name column's 255-character limit. Rename it with `UpdateQuote` before sending if that is not what you want signers to see. ```java Quote duplicateQuote(String id) ``` Duplicate a quote (creates a draft copy). ```java Quote copy = tq.duplicateQuote(quoteId); System.out.println("New quote: " + copy.getId()); ``` The copy is named **`Copy of `**, truncated to the name column's 255-character limit. Rename it with `updateQuote` before sending if that is not what you want signers to see. The copy is attributed to **whoever ran the duplicate**, not to the original quote's creator — duplicating with an API key produces a quote whose "Prepared by" resolves through that API key and your org quote template. #### `applyPriceBook` / `apply_price_book` / ApplyPriceBook {#applypricebook} Apply a price book to all line items on a quote. Returns the updated quote plus counts of how many items were updated vs skipped. ```typescript const { quote, updatedCount, skippedCount, message } = await TurboQuote.applyPriceBook( 'quote-uuid', 'pricebook-uuid', ); ``` Apply a price book to an existing quote; updates matching line item prices. ```python result = await TurboQuote.apply_price_book("quote-uuid", "pricebook-uuid") # result["quote"] — updated Quote # result["updatedCount"] — int, items that were re-priced # result["skippedCount"] — int, items that had no pricebook match # result["message"] ``` Apply a price book to a quote, updating matching line item prices. ```php $result = TurboQuote::applyPriceBook('quote-uuid', 'pricebook-uuid'); // $result->quote // $result->message // $result->updatedCount — number of items re-priced // $result->skippedCount — items with no matching pricebook entry ``` Applies a price book to a quote, adjusting line item prices to match book pricing. ```go resp, err := qc.ApplyPriceBook(ctx, "quote-uuid", "pricebook-uuid") // resp.UpdatedCount int — items updated // resp.SkippedCount int — items not in price book // resp.QuoteResult Quote ``` ```java ApplyPriceBookResponse applyPriceBook(String quoteId, String priceBookId) ``` Apply a price book to a quote, recalculating line item prices. Returns `{quote, message, updatedCount, skippedCount}`. ```java ApplyPriceBookResponse resp = tq.applyPriceBook(quoteId, priceBookId); System.out.println("Updated " + resp.getUpdatedCount() + " items."); ``` #### `removePriceBook` / `remove_price_book` / RemovePriceBook {#removepricebook} Detach the price book from a quote (line item prices are not reverted). ```typescript const quote = await TurboQuote.removePriceBook('quote-uuid'); ``` ```python quote = await TurboQuote.remove_price_book("quote-uuid") ``` --- ```php $quote = TurboQuote::removePriceBook('quote-uuid'); ``` Removes the applied price book, reverting line items to catalog prices. ```go quote, err := qc.RemovePriceBook(ctx, "quote-uuid") ``` --- ```java Quote removePriceBook(String quoteId) ``` Remove the applied price book from a quote, restoring original line item pricing. ```java Quote quote = tq.removePriceBook(quoteId); ``` #### `downloadQuotePdf` / `download_quote_pdf` / DownloadQuotePdf {#downloadquotepdf} Download the quote as a PDF. Returns raw bytes as an `ArrayBuffer`. ```typescript const pdf = await TurboQuote.downloadQuotePdf('quote-uuid'); writeFileSync('quote.pdf', Buffer.from(pdf)); ``` --- Returns raw PDF bytes. ```python pdf_bytes = await TurboQuote.download_quote_pdf("quote-uuid") with open("quote.pdf", "wb") as f: f.write(pdf_bytes) ``` --- Returns raw PDF bytes. Save to disk or stream to the browser. ```php $pdfBytes = TurboQuote::downloadQuotePdf('quote-uuid'); file_put_contents('quote.pdf', $pdfBytes); // Or stream as HTTP response header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="quote.pdf"'); echo $pdfBytes; ``` Returns the raw PDF bytes. Write directly to a file or stream to a response. ```go pdfBytes, err := qc.DownloadQuotePdf(ctx, "quote-uuid") if err != nil { log.Fatal(err) } os.WriteFile("proposal.pdf", pdfBytes, 0644) ``` --- ```java byte[] downloadQuotePdf(String id) ``` Download the quote as a PDF. Returns raw bytes. ```java byte[] pdf = tq.downloadQuotePdf(quoteId); Files.write(Paths.get("quote.pdf"), pdf); ``` --- ### Quote Numbering Configuration {#quote-numbering-configuration} Customize the per-org quote number format: prefix, year/month tokens, separator, zero-padding, suffix, starting number, and reset cadence. Both methods are **admin only**; a non-admin API key receives a `403`. #### `getQuoteNumberConfig` / `get_quote_number_config` / GetQuoteNumberConfig {#getquotenumberconfig} Fetch the org's current quote numbering format and the current per-period issued floor. ```typescript const config = await TurboQuote.getQuoteNumberConfig(); console.log(config.format.prefix); // e.g. "Q-" console.log(config.currentFloor); // the current per-period issued floor ``` Fetch the org's current quote numbering format and the current per-period issued floor. ```python config = await TurboQuote.get_quote_number_config() print(config["format"]["prefix"]) # e.g. "Q-" print(config["currentFloor"]) # the current per-period issued floor ``` Fetch the org's current quote numbering format and the current per-period issued floor. ```php $config = TurboQuote::getQuoteNumberConfig(); echo $config->format->prefix; // e.g. "Q-" echo $config->currentFloor; // the current per-period issued floor ``` Fetches the org's current quote numbering format and the current per-period issued floor. ```go config, err := qc.GetQuoteNumberConfig(ctx) if err != nil { log.Fatal(err) } fmt.Println(config.Format.Prefix) // e.g. "Q-" fmt.Println(config.CurrentFloor) // the current per-period issued floor ``` ```java QuoteNumberConfig getQuoteNumberConfig() ``` Fetch the org's current quote numbering format and the current per-period issued floor. ```java QuoteNumberConfig config = tq.getQuoteNumberConfig(); System.out.println(config.getFormat().getPrefix()); // e.g. "Q-" System.out.println(config.getCurrentFloor()); // the current per-period issued floor ``` #### `updateQuoteNumberConfig` / `update_quote_number_config` / UpdateQuoteNumberConfig {#updatequotenumberconfig} Update the numbering format. Pass the full format object; all eight fields are required. ```typescript const config = await TurboQuote.updateQuoteNumberConfig({ prefix: 'INV', yearToken: 'none', // 'none' | 'two' | 'four' monthToken: 'off', // 'off' | 'two' separator: '-', padWidth: 4, // 0–12 suffix: '', startNumber: 1000, // >= 0 resetCadence: 'never', // 'never' | 'yearly' | 'monthly' }); console.log(config.format.startNumber); // 1000 ``` Update the numbering format. Pass the full format object; all eight fields are required. Keys stay camelCase. ```python config = await TurboQuote.update_quote_number_config({ "prefix": "INV", "yearToken": "none", # "none" | "two" | "four" "monthToken": "off", # "off" | "two" "separator": "-", "padWidth": 4, # 0–12 "suffix": "", "startNumber": 1000, # >= 0 "resetCadence": "never", # "never" | "yearly" | "monthly" }) print(config["format"]["startNumber"]) # 1000 ``` Update the numbering format. All eight fields are sent. ```php use TurboDocx\Types\Quote\QuoteNumberFormat; $config = TurboQuote::updateQuoteNumberConfig(new QuoteNumberFormat( prefix: 'INV', yearToken: 'none', // 'none' | 'two' | 'four' monthToken: 'off', // 'off' | 'two' separator: '-', padWidth: 4, // 0–12 suffix: '', startNumber: 1000, // >= 0 resetCadence: 'never', // 'never' | 'yearly' | 'monthly' )); echo $config->format->startNumber; // 1000 ``` Updates the numbering format. Pass the full format; all eight fields are sent. ```go config, err := qc.UpdateQuoteNumberConfig(ctx, &turbodocx.QuoteNumberFormat{ Prefix: "INV", YearToken: "none", // "none" | "two" | "four" MonthToken: "off", // "off" | "two" Separator: "-", PadWidth: 4, // 0–12 Suffix: "", StartNumber: 1000, // >= 0 ResetCadence: "never", // "never" | "yearly" | "monthly" }) if err != nil { log.Fatal(err) } fmt.Println(config.Format.StartNumber) // 1000 ``` ```java QuoteNumberConfig updateQuoteNumberConfig(QuoteNumberFormat format) ``` Update the numbering format. All eight fields are sent. ```java QuoteNumberFormat format = new QuoteNumberFormat(); format.setPrefix("INV"); format.setYearToken(QuoteNumberYearToken.NONE); // NONE | TWO | FOUR format.setMonthToken(QuoteNumberMonthToken.OFF); // OFF | TWO format.setSeparator("-"); format.setPadWidth(4); // 0–12 format.setSuffix(""); format.setStartNumber(1000); // >= 0 format.setResetCadence(QuoteNumberResetCadence.NEVER); // NEVER | YEARLY | MONTHLY QuoteNumberConfig config = tq.updateQuoteNumberConfig(format); System.out.println(config.getFormat().getStartNumber()); // 1000 ``` #### Field reference, defaults & validation {#field-reference-defaults--validation} All eight `format` fields are sent on every update. The API enforces these caps and allowed values — a violation returns `400`: | Field | Type | Allowed / range | Default | |-------|------|-----------------|---------| | `prefix` | string | ≤ 12 characters | `"Q"` | | `yearToken` | enum | `none` \| `two` \| `four` | `four` | | `monthToken` | enum | `off` \| `two` | `off` | | `separator` | string | ≤ 4 characters | `"-"` | | `padWidth` | integer | `0`–`12` (`0` = no padding) | `5` | | `suffix` | string | ≤ 12 characters | `""` | | `startNumber` | integer | `0`–`1000000000` | `1` | | `resetCadence` | enum | `never` \| `yearly` \| `monthly` | `yearly` | An org that has never configured numbering uses the **default format** above, which renders like `Q-2026-00001`. Beyond the per-field caps, the API rejects self-inconsistent formats with a `400`: - `resetCadence: "yearly"` requires a year token (`yearToken` other than `none`) — otherwise numbers repeat across years. - `resetCadence: "monthly"` requires **both** a year token and a month token (`monthToken: "two"`). - The rendered quote number must be ≤ 256 characters. `currentFloor` (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update. --- All eight `format` keys are sent on every update. The API enforces these caps and allowed values — a violation returns `400`: | Field | Type | Allowed / range | Default | |-------|------|-----------------|---------| | `prefix` | string | ≤ 12 characters | `"Q"` | | `yearToken` | enum | `none` \| `two` \| `four` | `four` | | `monthToken` | enum | `off` \| `two` | `off` | | `separator` | string | ≤ 4 characters | `"-"` | | `padWidth` | integer | `0`–`12` (`0` = no padding) | `5` | | `suffix` | string | ≤ 12 characters | `""` | | `startNumber` | integer | `0`–`1000000000` | `1` | | `resetCadence` | enum | `never` \| `yearly` \| `monthly` | `yearly` | An org that has never configured numbering uses the **default format** above, which renders like `Q-2026-00001`. Beyond the per-field caps, the API rejects self-inconsistent formats with a `400`: - `resetCadence: "yearly"` requires a year token (`yearToken` other than `none`) — otherwise numbers repeat across years. - `resetCadence: "monthly"` requires **both** a year token and a month token (`monthToken: "two"`). - The rendered quote number must be ≤ 256 characters. `currentFloor` (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update. --- All eight `QuoteNumberFormat` fields are sent on every update. The API enforces these caps and allowed values — a violation returns `400`: | Field | Type | Allowed / range | Default | |-------|------|-----------------|---------| | `prefix` | string | ≤ 12 characters | `"Q"` | | `yearToken` | enum | `none` \| `two` \| `four` | `four` | | `monthToken` | enum | `off` \| `two` | `off` | | `separator` | string | ≤ 4 characters | `"-"` | | `padWidth` | int | `0`–`12` (`0` = no padding) | `5` | | `suffix` | string | ≤ 12 characters | `""` | | `startNumber` | int | `0`–`1000000000` | `1` | | `resetCadence` | enum | `never` \| `yearly` \| `monthly` | `yearly` | The token sets also ship as native enums (`TurboDocx\Types\Enums\QuoteNumberYearToken`, `QuoteNumberMonthToken`, `QuoteNumberResetCadence`) — pass `QuoteNumberYearToken::FOUR->value` if you prefer named cases over raw strings. An org that has never configured numbering uses the **default format** above, which renders like `Q-2026-00001`. Beyond the per-field caps, the API rejects self-inconsistent formats with a `400`: - `resetCadence: 'yearly'` requires a year token (`yearToken` other than `none`) — otherwise numbers repeat across years. - `resetCadence: 'monthly'` requires **both** a year token and a month token (`monthToken: 'two'`). - The rendered quote number must be ≤ 256 characters. `currentFloor` (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update. All eight `QuoteNumberFormat` fields are sent on every update. The API enforces these caps and allowed values — a violation returns `400`: | Field | Type | Allowed / range | Default | |-------|------|-----------------|---------| | `Prefix` | string | ≤ 12 characters | `"Q"` | | `YearToken` | enum | `none` \| `two` \| `four` | `four` | | `MonthToken` | enum | `off` \| `two` | `off` | | `Separator` | string | ≤ 4 characters | `"-"` | | `PadWidth` | int | `0`–`12` (`0` = no padding) | `5` | | `Suffix` | string | ≤ 12 characters | `""` | | `StartNumber` | int | `0`–`1000000000` | `1` | | `ResetCadence` | enum | `never` \| `yearly` \| `monthly` | `yearly` | An org that has never configured numbering uses the **default format** above, which renders like `Q-2026-00001`. The token values are also available as typed constants (`turbodocx.QuoteNumberYearTokenFour`, etc.). Beyond the per-field caps, the API rejects self-inconsistent formats with a `400`: - `ResetCadence: "yearly"` requires a year token (`YearToken` other than `none`) — otherwise numbers repeat across years. - `ResetCadence: "monthly"` requires **both** a year token and a month token (`MonthToken: "two"`). - The rendered quote number must be ≤ 256 characters. `CurrentFloor` (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update. --- All eight `QuoteNumberFormat` fields are sent on every update. The API enforces these caps and allowed values — a violation returns `400`: | Field | Type | Allowed / range | Default | |-------|------|-----------------|---------| | `prefix` | String | ≤ 12 characters | `"Q"` | | `yearToken` | enum | `NONE` \| `TWO` \| `FOUR` | `FOUR` | | `monthToken` | enum | `OFF` \| `TWO` | `OFF` | | `separator` | String | ≤ 4 characters | `"-"` | | `padWidth` | int | `0`–`12` (`0` = no padding) | `5` | | `suffix` | String | ≤ 12 characters | `""` | | `startNumber` | int | `0`–`1000000000` | `1` | | `resetCadence` | enum | `NEVER` \| `YEARLY` \| `MONTHLY` | `YEARLY` | Tokens are the `QuoteNumberYearToken` / `QuoteNumberMonthToken` / `QuoteNumberResetCadence` enums (their wire values are the lowercase strings `none`/`two`/`four`, `off`/`two`, `never`/`yearly`/`monthly`). An org that has never configured numbering uses the **default format** above, which renders like `Q-2026-00001`. Beyond the per-field caps, the API rejects self-inconsistent formats with a `400`: - `ResetCadence.YEARLY` requires a year token (`yearToken` other than `NONE`) — otherwise numbers repeat across years. - `ResetCadence.MONTHLY` requires **both** a year token and a month token (`MonthToken.TWO`). - The rendered quote number must be ≤ 256 characters. `currentFloor` (returned by both methods) is read-only — the sequence the next quote will use for the current period — and is never sent on update. --- ### Quote Status Transitions / Quotes — Status Transitions {#quote-status-transitions} :::caution Send preconditions Both send methods share the same server-side checks. Each is rejected with **HTTP 400** and a specific error `code` before anything is created or emailed: | Condition | Code | | :--- | :--- | | Quote is not a draft | `QuoteNotSendable` | | No `validUntil` date set | `QuoteValidUntilRequired` | | `validUntil` is in the past | `QuoteExpired` | | No line items | `QuoteHasNoLineItems` | | Contact missing a name or email | `QuoteContactRequired` | | Company or contact deleted/deactivated | `QuoteCustomerInactive` | A quote with **no line items cannot be sent** — add at least one product, bundle, or custom line item first. Likewise an **expired quote is rejected**; update `validUntil`, or use the handle-expired flow to void it and create a fresh draft. ::: :::caution Send preconditions Both send methods share the same server-side checks. Each is rejected with **HTTP 400** and a specific error `code` before anything is created or emailed: | Condition | Code | | :--- | :--- | | Quote is not a draft | `QuoteNotSendable` | | No `validUntil` date set | `QuoteValidUntilRequired` | | `validUntil` is in the past | `QuoteExpired` | | No line items | `QuoteHasNoLineItems` | | Contact missing a name or email | `QuoteContactRequired` | | Company or contact deleted/deactivated | `QuoteCustomerInactive` | | No sender email resolvable (API-key callers) | `SenderEmailRequired` | A quote with **no line items cannot be sent** — add at least one product, bundle, or custom line item first. Likewise an **expired quote is rejected**; update `validUntil`, or use the handle-expired flow to void it and create a fresh draft. ::: :::caution Send preconditions Both send methods share the same server-side checks. Each is rejected with **HTTP 400** and a specific error `code` before anything is created or emailed: | Condition | Code | | :--- | :--- | | Quote is not a draft | `QuoteNotSendable` | | No `validUntil` date set | `QuoteValidUntilRequired` | | `validUntil` is in the past | `QuoteExpired` | | No line items | `QuoteHasNoLineItems` | | Contact missing a name or email | `QuoteContactRequired` | | Company or contact deleted/deactivated | `QuoteCustomerInactive` | | No sender email resolvable (API-key callers) | `SenderEmailRequired` | A quote with **no line items cannot be sent** — add at least one product, bundle, or custom line item first. Likewise an **expired quote is rejected**; update `validUntil`, or use the handle-expired flow to void it and create a fresh draft. ::: :::caution Send preconditions Both send methods share the same server-side checks. Each is rejected with **HTTP 400** and a specific error `code` before anything is created or emailed: | Condition | Code | | :--- | :--- | | Quote is not a draft | `QuoteNotSendable` | | No `validUntil` date set | `QuoteValidUntilRequired` | | `validUntil` is in the past | `QuoteExpired` | | No line items | `QuoteHasNoLineItems` | | Contact missing a name or email | `QuoteContactRequired` | | Company or contact deleted/deactivated | `QuoteCustomerInactive` | | No sender email resolvable (API-key callers) | `SenderEmailRequired` | A quote with **no line items cannot be sent** — add at least one product, bundle, or custom line item first. Likewise an **expired quote is rejected**; update `validUntil`, or use the handle-expired flow to void it and create a fresh draft. ::: :::caution Send preconditions Both send methods share the same server-side checks. Each is rejected with **HTTP 400** and a specific error `code` before anything is created or emailed: | Condition | Code | | :--- | :--- | | Quote is not a draft | `QuoteNotSendable` | | No `validUntil` date set | `QuoteValidUntilRequired` | | `validUntil` is in the past | `QuoteExpired` | | No line items | `QuoteHasNoLineItems` | | Contact missing a name or email | `QuoteContactRequired` | | Company or contact deleted/deactivated | `QuoteCustomerInactive` | | No sender email resolvable (API-key callers) | `SenderEmailRequired` | A quote with **no line items cannot be sent** — add at least one product, bundle, or custom line item first. Likewise an **expired quote is rejected**; update `validUntil`, or use the handle-expired flow to void it and create a fresh draft. ::: #### `sendQuote` / `send_quote` / SendQuote {#sendquote} Send a draft quote to the contact. Optionally include CC recipients, set a validity deadline, or attach a **reminder & expiration schedule**. ```typescript const { quote, message } = await TurboQuote.sendQuote('quote-uuid', { validUntil: '2026-07-31', ccEmails: ['manager@example.com'], // Optional reminder & expiration schedule (same shape as TurboSign sendSignature) remindersEnabled: true, reminderDelay: { value: 3, unit: 'days' }, // { value, unit } — 'hours' | 'days' reminderInterval: { value: 3, unit: 'days' }, maxReminders: 5, // -1 unlimited, 0 none, max 50 expirationEnabled: true, // toggles expiry on/off expirationWarning: { value: 3, unit: 'days' }, expirationWarningInterval: { value: 1, unit: 'days' }, }); ``` :::info Quote expiry is pinned to `validUntil` For a quote, the signing deadline is **hard-pinned to the quote's `validUntil` date** — you do **not** set `expireAfter`. `expirationEnabled` still toggles whether the signing window closes at all, but when it's on, the deadline is `validUntil` (any `expireAfter` you pass is ignored). The reminder and expiration-warning cadence still applies, and it must **fit inside** the `validUntil` window — a cadence that would outlive the quote's validity is rejected with a `400`. ::: ```python sent = await TurboQuote.send_quote("quote-uuid", { "ccEmails": ["manager@example.com"], "validUntil": "2026-09-30", }) # sent["quote"], sent["message"] ``` ```php use TurboDocx\Types\Requests\Quote\SendQuoteRequest; $result = TurboQuote::sendQuote('quote-uuid', new SendQuoteRequest( // optional overrides; pass null to use quote defaults )); // $result->quote — updated Quote // $result->message ``` Moves the quote from `draft` to `sent` and emails the proposal to the contact. ```go sent, err := qc.SendQuote(ctx, "quote-uuid", &turbodocx.SendQuoteRequest{ CCEmails: []string{"cc@example.com"}, ValidUntil: &[]string{"2026-09-01"}[0], }) // sent.QuoteResult Quote // sent.Message string ``` ```java SendQuoteResponse sendQuote(String id) SendQuoteResponse sendQuote(String id, SendQuoteRequest request) ``` Send a quote to the contact. Returns `{quote, message}`. Pass `SendQuoteRequest` to configure send options, or omit for defaults. ```java SendQuoteResponse resp = tq.sendQuote(quoteId); System.out.println("Status: " + resp.getQuote().getStatus()); ``` #### `sendQuoteWithDeliverable` / `send_quote_with_deliverable` / SendQuoteWithDeliverable {#sendquotewithdeliverable} Send a quote paired with a TurboDocx deliverable (e.g., a contract generated from a template). The deliverable is merged before or after the quote PDF. It accepts the **same reminder & expiration schedule** as `sendQuote` (expiry pinned to `validUntil`, per the note above). ```typescript const { quote, message, documentId } = await TurboQuote.sendQuoteWithDeliverable( 'quote-uuid', { deliverableId: 'deliverable-uuid', mergePosition: 'end', // 'beginning' | 'end' ccEmails: ['legal@example.com'], // Optional — same schedule fields as sendQuote remindersEnabled: true, reminderDelay: { value: 3, unit: 'days' }, expirationEnabled: true, }, ); // documentId — TurboSign document created for the merged PDF ``` Attach a TurboDocx-generated document to the sent quote. ```python result = await TurboQuote.send_quote_with_deliverable("quote-uuid", { "deliverableId": "deliverable-uuid", "ccEmails": ["manager@example.com"], }) # result["quote"], result["message"], result["documentId"] ``` Attach a Deliverable document (generated DOCX/PDF) alongside the quote. ```php use TurboDocx\Types\Requests\Quote\SendQuoteWithDeliverableRequest; $result = TurboQuote::sendQuoteWithDeliverable('quote-uuid', new SendQuoteWithDeliverableRequest( deliverableId: 'deliverable-uuid', mergePosition: 'end', )); // $result->quote // $result->message // $result->documentId — TurboSign document ID ``` Sends the quote with a TurboDocx-generated document (e.g., a proposal PDF) attached as a signature document. ```go resp, err := qc.SendQuoteWithDeliverable(ctx, "quote-uuid", &turbodocx.SendQuoteWithDeliverableRequest{ DeliverableID: "deliverable-uuid", MergePosition: "after", // "before" | "after" CCEmails: []string{"cc@example.com"}, }) // resp.DocumentID string — TurboSign document ID for tracking ``` ```java SendQuoteWithDeliverableResponse sendQuoteWithDeliverable(String id, SendQuoteWithDeliverableRequest request) ``` Send a quote with a TurboDocx deliverable attached. Returns `{quote, message, documentId}`. ```java SendQuoteWithDeliverableRequest req = new SendQuoteWithDeliverableRequest(); req.setDeliverableId("your-deliverable-id"); req.setMergePosition("end"); // "start" | "end" SendQuoteWithDeliverableResponse resp = tq.sendQuoteWithDeliverable(quoteId, req); System.out.println("Document ID: " + resp.getDocumentId()); ``` #### Reminders and expiration when sending a quote / Reminders and expiration on quote sends / Reminders and expiration on send / Reminders and expiration on a quote send {#reminders-and-expiration-when-sending-a-quote} Both `send_quote` and `send_quote_with_deliverable` accept the same reminder/expiration schedule as TurboSign. Because these are quote request-body fields, the keys are **camelCase** (like `ccEmails` / `validUntil` above), even though the method names are snake_case: ```python sent = await TurboQuote.send_quote("quote-uuid", { "validUntil": "2026-09-30", "remindersEnabled": True, "reminderDelay": {"value": 3, "unit": "days"}, # {value, unit}: "days" or "hours" "reminderInterval": {"value": 3, "unit": "days"}, "maxReminders": 5, # -1 unlimited, 0 none, max 50 "expirationEnabled": True, "expirationWarning": {"value": 1, "unit": "days"}, # 0 = never warn "expirationWarningInterval": {"value": 1, "unit": "days"}, }) ``` :::warning Quote expiry is pinned to `validUntil` For a quote, the signing deadline is **hard-pinned to the quote's `validUntil` date**. Any `expireAfter` you pass is **ignored** — `expirationEnabled` still toggles expiry on or off, but when on, the document expires exactly at `validUntil`. The reminder and expiration-warning cadence still applies and **must fit inside the `validUntil` window**; a cadence that would outlive the quote is rejected with `400`. Duration values are `{value, unit}` (`"days"` or `"hours"`), min `1`, max **999 days / 23976 hours**. ::: Both `sendQuote` and `sendQuoteWithDeliverable` accept the same TurboSign schedule fields as `TurboSign::sendSignature` — `remindersEnabled` / `reminderDelay` / `reminderInterval` / `maxReminders` and `expirationEnabled` / `expirationWarning` / `expirationWarningInterval`, each a `['value' => N, 'unit' => 'hours'|'days']` duration where applicable. ```php $result = TurboQuote::sendQuoteWithDeliverable('quote-uuid', new SendQuoteWithDeliverableRequest( deliverableId: 'deliverable-uuid', mergePosition: 'end', remindersEnabled: true, reminderDelay: ['value' => 3, 'unit' => 'days'], reminderInterval: ['value' => 3, 'unit' => 'days'], maxReminders: 5, // -1 unlimited, 0 none, max 50 expirationEnabled: true, // toggles expiry on the signature request expirationWarning: ['value' => 3, 'unit' => 'days'], // 0 = never warn )); ``` :::warning Quote expiry is pinned to `validUntil` For a quote send, **the signing deadline is hard-pinned to the quote's `validUntil` date** — `expireAfter` is **ignored**. `expirationEnabled` still toggles whether the signature request expires at all, but when on, the deadline is always `validUntil` (never a relative `expireAfter` window). The reminder and expiration-warning cadence still applies and **must fit inside `validUntil`**; a cadence that would outlive the quote's validity is rejected with a `400`. Set a `validUntil` far enough out to contain the reminders you schedule. ::: Both `SendQuoteRequest` and `SendQuoteWithDeliverableRequest` embed a `SignatureSchedule` — the same eight reminder/expiration override fields used by TurboSign's [`SendSignature`](/docs/SDKs/turbosign?language=go#schedule-reminders-and-expiration), with `Duration{Value, Unit}` durations (`Unit` is `"hours"` or `"days"`). This drives the reminder and expiry-warning cadence on the quote's signature request. ```go sent, err := qc.SendQuote(ctx, "quote-uuid", &turbodocx.SendQuoteRequest{ ValidUntil: &[]string{"2026-09-01"}[0], SignatureSchedule: turbodocx.SignatureSchedule{ RemindersEnabled: turbodocx.BoolPtr(true), ReminderDelay: &turbodocx.Duration{Value: 3, Unit: "days"}, ReminderInterval: &turbodocx.Duration{Value: 7, Unit: "days"}, ExpirationEnabled: turbodocx.BoolPtr(true), // ExpireAfter is intentionally omitted — see below. }, }) ``` :::caution Quote expiry is pinned to `validUntil` For a quote, the signing deadline is **hard-pinned to the quote's `ValidUntil`** — it always equals `validUntil`, so `ExpireAfter` is **ignored** on the quote send paths. `ExpirationEnabled` still toggles expiry on or off. The reminder and expiry-warning cadence still applies, but every scheduled reminder/warning must fall **inside** the `validUntil` window; a cadence that would outlive the quote's validity is rejected with **HTTP 400**. ::: `sendQuote` and `sendQuoteWithDeliverable` accept the same `SignatureSchedule` as TurboSign (`remindersEnabled` / `reminderDelay` / `reminderInterval` / `maxReminders` and `expirationEnabled` / `expireAfter` / `expirationWarning` / `expirationWarningInterval`), with **one quote-specific difference**: the signing deadline is **hard-pinned to the quote's `validUntil` date**. When `expirationEnabled` is `true` the document expires exactly at `validUntil` — **any `expireAfter` you pass is ignored** — while `expirationEnabled` still toggles whether expiry is enforced at all. The reminder and warning cadence still applies, but every reminder and warning must fall **inside** the `validUntil` window; a cadence that would outlive it is rejected with `400`. ```java SignatureSchedule schedule = SignatureSchedule.builder() .remindersEnabled(true) .reminderDelay(new SignatureSchedule.Duration(3, "days")) // must fit inside validUntil .maxReminders(5) // -1..50 (-1 unlimited, 0 none) .expirationEnabled(true) // expire at validUntil (expireAfter ignored) .expirationWarning(new SignatureSchedule.Duration(2, "days")) // 0 = never warn .build(); SendQuoteWithDeliverableRequest scheduledReq = new SendQuoteWithDeliverableRequest(); scheduledReq.setDeliverableId("your-deliverable-id"); scheduledReq.setSchedule(schedule); tq.sendQuoteWithDeliverable(quoteId, scheduledReq); ``` Each `Duration` is a `{value, unit}` pair (`unit` is `"hours"` or `"days"`, `value` a whole number from 1 up to 999 days / 23976 hours). #### `declineQuote` / `decline_quote` / DeclineQuote {#declinequote} Mark a quote as declined — either a **sent** quote (typically on behalf of the recipient) or a **draft** whose deal died before it was ever sent. `reason` (max 190 characters) is **required once a quote has been sent** — declining a sent quote without one returns `400 CANNOT_DECLINE_QUOTE`. A **draft is declined without a reason**: the reason is stored on the quote's linked signature document, and a draft has none, so any reason passed for a draft is accepted by the API and **not recorded**. ```typescript const quote = await TurboQuote.declineQuote('quote-uuid', { reason: 'Budget constraints for this quarter', }); // A draft is declined with no reason — one passed here would not be recorded. const closedOut = await TurboQuote.declineQuote('draft-quote-uuid', {}); ``` Declines a **sent** quote or a **draft**. `reason` (max 190 characters) is required once a quote has been sent. A draft is declined **without** a reason — a draft never reached the customer, and because the reason is stored on the linked signature document, a draft has nowhere to keep one, so anything passed is ignored. ```python quote = await TurboQuote.decline_quote("quote-uuid", { "reason": "Customer selected a competitor", }) # A draft is declined with no reason — one passed here would not be recorded. closed_out = await TurboQuote.decline_quote("draft-quote-uuid", {}) ``` Declines a **sent** quote or a **draft**. `reason` (max 190 characters) is required once a quote has been sent. A draft is declined **without** a reason — a draft never reached the customer, and because the reason is stored on the linked signature document, a draft has nowhere to keep one, so anything passed is ignored. ```php use TurboDocx\Types\Requests\Quote\DeclineQuoteRequest; $quote = TurboQuote::declineQuote('quote-uuid', new DeclineQuoteRequest( reason: 'Price out of budget', )); // A draft is declined with no reason — one passed here would not be recorded. $closedOut = TurboQuote::declineQuote('draft-quote-uuid', new DeclineQuoteRequest()); ``` Declines a **sent** quote or a **draft**. `Reason` (max 190 characters) is required once a quote has been sent. A draft is declined **without** a reason — a draft never reached the customer, and because the reason is stored on the linked signature document, a draft has nowhere to keep one, so anything passed is ignored — an unset `Reason` is omitted from the request. ```go quote, err := qc.DeclineQuote(ctx, "quote-uuid", &turbodocx.DeclineQuoteRequest{ Reason: "Budget constraints for this quarter", }) // A draft is declined with no reason — one passed here would not be recorded. closedOut, err := qc.DeclineQuote(ctx, "draft-quote-uuid", &turbodocx.DeclineQuoteRequest{}) ``` ```java Quote declineQuote(String id, DeclineQuoteRequest request) ``` Mark a quote as declined — either a **sent** quote or a **draft** whose deal died before it was ever sent. `reason` (max 190 characters) is **required once a quote has been sent** — declining a sent quote without one returns `400 CANNOT_DECLINE_QUOTE`. A **draft is declined without a reason**: the reason is stored on the quote's linked signature document, and a draft has none, so any reason passed for a draft is accepted by the API and **not recorded**. ```java DeclineQuoteRequest req = new DeclineQuoteRequest(); req.setReason("Budget constraints"); Quote declined = tq.declineQuote(quoteId, req); // A draft is declined with no reason — one passed here would not be recorded. Quote closedOut = tq.declineQuote(draftQuoteId, new DeclineQuoteRequest()); ``` #### `voidQuote` / `void_quote` / VoidQuote {#voidquote} Void a **sent** quote that should no longer be valid; a **draft cannot be voided**, since voiding an unsent quote is meaningless. ```typescript const quote = await TurboQuote.voidQuote('quote-uuid', { reason: 'Superseded by revised quote #Q-102', }); ``` Voids a **sent** quote; a **draft cannot be voided**, since voiding an unsent quote is meaningless. ```python quote = await TurboQuote.void_quote("quote-uuid", { "reason": "Terms expired before acceptance", }) ``` Voids a **sent** quote; a **draft cannot be voided**, since voiding an unsent quote is meaningless. ```php use TurboDocx\Types\Requests\Quote\VoidQuoteRequest; $quote = TurboQuote::voidQuote('quote-uuid', new VoidQuoteRequest( reason: 'Superseded by new proposal', )); ``` Voids a **sent** quote; a **draft cannot be voided**, since voiding an unsent quote is meaningless. ```go quote, err := qc.VoidQuote(ctx, "quote-uuid", &turbodocx.VoidQuoteRequest{ Reason: "Replaced by updated proposal", }) ``` ```java Quote voidQuote(String id, VoidQuoteRequest request) ``` Void a **sent** quote (cannot be undone); a **draft cannot be voided**, since voiding an unsent quote is meaningless. ```java VoidQuoteRequest req = new VoidQuoteRequest(); req.setReason("Replaced by new proposal"); Quote voided = tq.voidQuote(quoteId, req); ``` #### `handleExpiredQuote` / `handle_expired_quote` / HandleExpiredQuote {#handleexpiredquote} Handle a quote that has passed its `validUntil` date. The endpoint **closes out the original quote** — voiding or declining it depending on `action` — and then **creates a duplicate draft carrying `newValidUntil`** as its new validity date. The returned quote is the new duplicate; the original stays terminal. `action` (`'void'`, `'decline'` or `'renew'`) and `newValidUntil` (ISO date) are **required**. `reason` (≤ 190 characters) is required for `'void'` and `'decline'`, and optional for `'renew'` — a renewal closes nothing out, so there is nothing to give a reason for. Use `'renew'` when the quote's signature request has already expired on its own; use `'void'` or `'decline'` when the quote is merely past its `validUntil` and you are closing it yourself. ```typescript const quote = await TurboQuote.handleExpiredQuote('quote-uuid', { action: 'void', // 'void' | 'decline' | 'renew' reason: 'Expired — re-quoting', // required for void/decline, optional for renew newValidUntil: '2026-08-31', // required, ISO date carried onto the duplicate }); ``` :::warning There is no `extend` or `resend` action `action` accepts **only** `"void"`, `"decline"` and `"renew"`. `"extend"` and `"resend"` do not exist in the API and return a `400`. Extending is what the endpoint already does for you — pass `newValidUntil` and it lands on the duplicate it creates. ::: :::info The replacement draft keeps the original name Unlike `duplicateQuote`, the draft this endpoint creates is **not** prefixed with `Copy of ` — re-issuing the same deal keeps the original quote's name, so repeated renewals cannot compound into `Copy of Copy of …`. That matters because the next send snapshots the name onto the TurboSign document. ::: --- Handles a quote that has passed its `validUntil` date. The endpoint **closes out the original quote** — voiding or declining it depending on `action` — and then **creates a duplicate carrying `newValidUntil`** as its new validity date. The returned quote is the new duplicate; the original stays terminal. `action` (`"void"`, `"decline"` or `"renew"`) and `newValidUntil` (ISO date) are **required**. `reason` (max 190 characters) is required for `"void"` and `"decline"`, and optional for `"renew"` — a renewal closes nothing out, so there is nothing to give a reason for. Use `"renew"` when the quote's signature request has already expired on its own; use `"void"` or `"decline"` when the quote is merely past its `validUntil` and you are closing it yourself. ```python quote = await TurboQuote.handle_expired_quote("quote-uuid", { "action": "void", # required — "void", "decline" or "renew" "reason": "Quote expired", # required for void/decline, optional for renew "newValidUntil": "2026-12-31", # required — ISO date carried onto the duplicate }) ``` :::warning There is no `extend` or `resend` action `action` accepts **only** `"void"`, `"decline"` and `"renew"`. `"extend"` and `"resend"` do not exist in the API and return a `400`. Extending is what the endpoint already does — pass `newValidUntil` and it lands on the duplicate it creates. ::: :::info The replacement draft keeps the original name Unlike `duplicate_quote`, the draft this endpoint creates is **not** prefixed with `Copy of ` — re-issuing the same deal keeps the original quote's name, so repeated renewals cannot compound into `Copy of Copy of …`. That matters because the next send snapshots the name onto the TurboSign document. ::: :::note Terminal statuses `accepted`, `declined`, and `voided` are **terminal** — a quote in one of these states cannot be transitioned out of it, and any further status call returns a `400`. Check `quote["statusInfo"]` before attempting a transition, and use `duplicate_quote` when you need to revive a closed-out quote. ::: --- Handles a quote that has passed its `validUntil` date. The endpoint **closes out the original quote** — voiding or declining it depending on `action` — and then **creates a duplicate carrying `newValidUntil`** as its new validity date. The returned quote is the new duplicate; the original stays terminal. `action` (`'void'`, `'decline'` or `'renew'`) and `newValidUntil` (ISO date) are **required**. `reason` (max 190 characters) is required for `'void'` and `'decline'`, and optional for `'renew'` — a renewal closes nothing out, so there is nothing to give a reason for. Use `'renew'` when the quote's signature request has already expired on its own; use `'void'` or `'decline'` when the quote is merely past its `validUntil` and you are closing it yourself. ```php use TurboDocx\Types\Requests\Quote\HandleExpiredQuoteRequest; $quote = TurboQuote::handleExpiredQuote('quote-uuid', new HandleExpiredQuoteRequest( action: 'void', // required — 'void', 'decline' or 'renew' reason: 'Customer requested more time to review', // required for void/decline, optional for renew newValidUntil: '2026-12-31', // required — ISO date, carried onto the duplicate )); ``` :::warning There is no `extend` or `resend` action `action` accepts **only** `'void'`, `'decline'` and `'renew'`. `'extend'` and `'resend'` do not exist in the API and return a `400`. Extending is what the endpoint already does — pass `newValidUntil` and it lands on the duplicate it creates. ::: :::info The replacement draft keeps the original name Unlike `duplicateQuote`, the draft this endpoint creates is **not** prefixed with `Copy of ` — re-issuing the same deal keeps the original quote's name, so repeated renewals cannot compound into `Copy of Copy of …`. That matters because the next send snapshots the name onto the TurboSign document. ::: :::note Terminal statuses `accepted`, `declined`, and `voided` are **terminal** — a quote in one of these states cannot be transitioned out of it, and any further status call returns a `400`. Inspect the `statusInfo` merged onto the `Quote` before attempting a transition, and use `duplicateQuote` when you need to revive a closed-out quote. ::: Handles a `sent` quote that has passed its `validUntil` date. The endpoint **closes out the original quote** — voiding or declining it depending on `Action` — and then **creates a duplicate carrying `NewValidUntil`** as its new validity date. The returned quote is the new duplicate; the original stays terminal. `Action` (`"void"`, `"decline"` or `"renew"`) and `NewValidUntil` (ISO date) are **required**. `Reason` (max 190 characters) is required for `"void"` and `"decline"`, and optional for `"renew"` — a renewal closes nothing out, so there is nothing to give a reason for. Use `"renew"` when the quote's signature request has already expired on its own; use `"void"` or `"decline"` when the quote is merely past its `ValidUntil` and you are closing it yourself. ```go quote, err := qc.HandleExpiredQuote(ctx, "quote-uuid", &turbodocx.HandleExpiredQuoteRequest{ Action: "void", // required — "void", "decline" or "renew" Reason: "Customer requested more time", // required for void/decline, optional for renew NewValidUntil: "2026-10-01", // required — ISO date, carried onto the duplicate }) ``` :::warning There is no `extend` or `resend` action `Action` accepts **only** `"void"`, `"decline"` and `"renew"`. `"extend"` and `"resend"` do not exist in the API and return a `400`. Extending is what the endpoint already does — pass `NewValidUntil` and it lands on the duplicate it creates. ::: :::info The replacement draft keeps the original name Unlike `DuplicateQuote`, the draft this endpoint creates is **not** prefixed with `Copy of ` — re-issuing the same deal keeps the original quote's name, so repeated renewals cannot compound into `Copy of Copy of …`. That matters because the next send snapshots the name onto the TurboSign document. ::: ```java Quote handleExpiredQuote(String id, HandleExpiredQuoteRequest request) ``` Handle a quote that has passed its `validUntil` date. The endpoint **closes out the original quote** — voiding or declining it depending on the action — and then **creates a duplicate carrying `newValidUntil`** as its new validity date. The returned `Quote` is the new duplicate; the original stays terminal. `action` (`"void"`, `"decline"` or `"renew"`) and `newValidUntil` (ISO date) are **required**. `reason` (max 190 characters) is required for `"void"` and `"decline"`, and optional for `"renew"` — a renewal closes nothing out, so there is nothing to give a reason for. Use `"renew"` when the quote's signature request has already expired on its own; use `"void"` or `"decline"` when the quote is merely past its `validUntil` and you are closing it yourself. ```java HandleExpiredQuoteRequest req = new HandleExpiredQuoteRequest(); req.setAction("void"); // required — "void", "decline" or "renew" req.setReason("Expired — re-quoting"); // required for void/decline, optional for renew req.setNewValidUntil("2026-12-31"); // required — ISO date, carried onto the duplicate Quote quote = tq.handleExpiredQuote(quoteId, req); ``` :::warning There is no `extend` or `resend` action `action` accepts **only** `"void"`, `"decline"` and `"renew"`. `"extend"` and `"resend"` do not exist in the API and return a `400`. Extending is what the endpoint already does — set `newValidUntil` and it lands on the duplicate it creates. ::: :::info The replacement draft keeps the original name Unlike `duplicateQuote`, the draft this endpoint creates is **not** prefixed with `Copy of ` — re-issuing the same deal keeps the original quote's name, so repeated renewals cannot compound into `Copy of Copy of …`. That matters because the next send snapshots the name onto the TurboSign document. ::: :::note Terminal statuses `accepted`, `declined`, and `voided` are **terminal** — a quote in one of these states cannot be transitioned out of it, and any further status call returns a `400`. Check `quote.getStatusInfo()` before attempting a transition, and use `duplicateQuote` when you need to revive a closed-out quote. ::: --- ### Price Books (on a Quote) / Price Books on Quotes {#price-books-on-a-quote} Applying and removing a price book on a quote is documented with the other quote methods: see [applyPriceBook](#applypricebook) and [removePriceBook](#removepricebook). ### Line Items {#line-items} Line items attach products or bundles to a quote, each with a price, quantity, billing frequency, and optional discount. Line items belong to a quote. Products are added individually; bundles use a separate endpoint. #### `listLineItems` / `list_line_items` / ListLineItems {#listlineitems} ```typescript const { results, totalRecords } = await TurboQuote.listLineItems('quote-uuid', { limit: 50, billingFrequency: 'monthly', }); ``` ```python page = await TurboQuote.list_line_items("quote-uuid", {"limit": 50}) # page["results"], page["totalRecords"] ``` ```php use TurboDocx\Types\Requests\Quote\ListLineItemsRequest; $page = TurboQuote::listLineItems('quote-uuid', new ListLineItemsRequest( limit: 50, )); foreach ($page->results as $item) { echo " {$item->productName}: {$item->unitPrice} x {$item->quantity}\n"; } ``` ```go lineItemType := "product" list, err := qc.ListLineItems(ctx, "quote-uuid", &turbodocx.ListLineItemsOptions{ LineItemType: &lineItemType, }) // list.Results []LineItem // list.TotalRecords int ``` ```java LineItemListResponse listLineItems(String quoteId) LineItemListResponse listLineItems(String quoteId, ListLineItemsOptions options) ``` List line items for a quote. ```java LineItemListResponse items = tq.listLineItems(quoteId); items.getResults().forEach(i -> System.out.println(i.getProductName() + " x" + i.getQuantity())); ``` #### `addLineItems` / `add_line_items` / AddLineItems {#addlineitems} Add one or more product line items. Pass a single object or an array of up to **50** items. `productId`, `productName`, `unitPrice`, and `billingFrequency` are all **required** on every item. `productId` is special: the key must be **present**, but its value may be `null` for a custom (freeform) line item. Omitting the key entirely returns a `400`. `quantity` is optional and defaults to `1`. ```typescript // Single item await TurboQuote.addLineItems('quote-uuid', { productId: 'product-uuid', // required key — pass null for a custom (freeform) line item productName: 'Professional Services', unitPrice: 150, billingFrequency: 'one-time', quantity: 8, discountPercent: 10, discountType: 'percent', }); // Multiple items at once — array is capped at 50 items await TurboQuote.addLineItems('quote-uuid', [ { productId: 'p1', productName: 'Licence A', unitPrice: 500, billingFrequency: 'annual' }, { productId: 'p2', productName: 'Licence B', unitPrice: 300, billingFrequency: 'annual' }, { productId: null, productName: 'Custom Discount Credit', unitPrice: -100, billingFrequency: 'one-time' }, ]); ``` :::note Array caps `addLineItems` accepts a single object **or** an array of 1–**50** items. A reorder request accepts up to **200** items. Exceeding either cap returns a `400`. ::: Accepts a single item dict **or** a list of up to **50** items. Returns a list of created `LineItem` dicts. `productId`, `productName`, `unitPrice`, and `billingFrequency` are all **required** on every row. `productId` is special: the key must be **present**, but its value may be `None` for a custom (freeform) line item — omitting the key entirely returns a `400`. `quantity` is optional and defaults to `1`. ```python items = await TurboQuote.add_line_items("quote-uuid", [ { "productId": "product-uuid", # required key — None for a custom line item "productName": "Platform License", "quantity": 2, "unitPrice": "199.00", "billingFrequency": "annual", "discountPercent": "10.0", }, { "productId": None, # custom (freeform) line item — key still required "productName": "Implementation Credit", "unitPrice": "-250.00", "billingFrequency": "one-time", }, ]) ``` :::note List caps `add_line_items` accepts a single dict **or** a list of 1–**50** items. A reorder request accepts up to **200** items. Exceeding either cap returns a `400`. ::: Pass a single `AddLineItemRequest` or an array of up to **50** of them. `productId`, `productName`, `unitPrice`, and `billingFrequency` are all **required** on every item. `productId` is special: the key must be **present**, but its value may be `null` for a custom (freeform) line item. `quantity` is optional and defaults to `1`. ```php use TurboDocx\Types\Requests\Quote\AddLineItemRequest; $items = TurboQuote::addLineItems('quote-uuid', [ new AddLineItemRequest( productId: 'product-uuid-1', // required — null for a custom line item productName: 'Platform Subscription', unitPrice: 199.00, billingFrequency: 'monthly', quantity: 2, ), new AddLineItemRequest( productId: null, // custom (freeform) line item — still required, sent as null productName: 'Implementation Credit', unitPrice: -250.00, billingFrequency: 'one-time', quantity: 1, ), ]); ``` :::note Array caps `addLineItems` accepts a single request **or** an array of 1–**50** items. A reorder request accepts up to **200** items. Exceeding either cap returns a `400`. ::: Accepts one or more `AddLineItemRequest` values (variadic), up to **50** per call. Returns `[]LineItem`. `ProductID`, `ProductName`, `UnitPrice`, and `BillingFrequency` are all **required** on every item. `ProductID` is a `*string`: the `productId` key must be **present** on the wire, but its value may be `null` — set it to `nil` for a custom (freeform) line item. `Quantity` is optional and defaults to `1`. ```go qty := 3 disc := 10.0 productID := "product-uuid" items, err := qc.AddLineItems(ctx, "quote-uuid", turbodocx.AddLineItemRequest{ ProductID: &productID, // required — nil sends productId: null (custom line item) ProductName: "Support Plan", UnitPrice: 200.00, BillingFrequency: "monthly", Quantity: &qty, DiscountPercent: &disc, }, turbodocx.AddLineItemRequest{ ProductID: nil, // custom (freeform) line item ProductName: "Implementation Credit", UnitPrice: -250.00, BillingFrequency: "one-time", }, ) ``` :::note Slice caps `AddLineItems` accepts 1–**50** items per call. A reorder request accepts up to **200** items. Exceeding either cap returns a `400`. ::: ```java List addLineItems(String quoteId, AddLineItemRequest item) List addLineItems(String quoteId, List items) ``` Add one or more product line items to a quote. A single `AddLineItemRequest` is automatically wrapped; a list is capped at **50** items. `productId`, `productName`, `unitPrice`, and `billingFrequency` are all **required** on every item. `productId` is special: the key must be **present** on the wire, but its value may be `null` — call `setProductId(null)` for a custom (freeform) line item. `quantity` is optional and defaults to `1`. ```java AddLineItemRequest item = new AddLineItemRequest(); item.setProductId(productId); // required — null for a custom line item item.setProductName("Enterprise License"); item.setUnitPrice(1200.00); item.setQuantity(5.0); item.setBillingFrequency("annual"); // Custom (freeform) line item — productId is still set, explicitly to null AddLineItemRequest custom = new AddLineItemRequest(); custom.setProductId(null); custom.setProductName("Implementation Credit"); custom.setUnitPrice(-250.00); custom.setBillingFrequency("one-time"); List added = tq.addLineItems(quoteId, Arrays.asList(item, custom)); ``` :::note List caps `addLineItems` accepts a single request **or** a list of 1–**50** items. A reorder request accepts up to **200** items. Exceeding either cap returns a `400`. ::: #### `addBundleLineItems` / `add_bundle_line_items` / AddBundleLineItems {#addbundlelineitems} Add one or more bundle line items. ```typescript await TurboQuote.addBundleLineItems('quote-uuid', { bundleId: 'bundle-uuid', bundleName: 'Starter Bundle', quantity: 2, showItemsToEndUser: true, }); ``` `bundleId` and `bundleName` are both **required**; the server expands the bundle's child products for you. Accepts a single dict or a list of up to **50** items. ```python items = await TurboQuote.add_bundle_line_items("quote-uuid", [ { "bundleId": "bundle-uuid", "bundleName": "Starter Bundle", # required "quantity": 1, } ]) ``` Pass a single `AddBundleLineItemRequest` or an array of them. ```php use TurboDocx\Types\Requests\Quote\AddBundleLineItemRequest; $items = TurboQuote::addBundleLineItems('quote-uuid', [ new AddBundleLineItemRequest( bundleId: 'bundle-uuid-1', bundleName: 'Starter Bundle', quantity: 1, ), new AddBundleLineItemRequest( bundleId: 'bundle-uuid-2', bundleName: 'Premium Add-ons', quantity: 2, ), ]); ``` ```go items, err := qc.AddBundleLineItems(ctx, "quote-uuid", turbodocx.AddBundleLineItemRequest{ BundleID: "bundle-uuid", BundleName: "Starter Bundle", }, ) ``` ```java List addBundleLineItems(String quoteId, AddBundleLineItemRequest item) List addBundleLineItems(String quoteId, List items) ``` Add one or more bundle line items to a quote. `bundleId` and `bundleName` are both **required**; the server expands the bundle's child products for you. A list is capped at **50** items. ```java AddBundleLineItemRequest bundleItem = new AddBundleLineItemRequest(); bundleItem.setBundleId(bundleId); bundleItem.setBundleName("Starter Bundle"); // required bundleItem.setQuantity(2.0); List added = tq.addBundleLineItems(quoteId, bundleItem); ``` #### `updateLineItem` / `update_line_item` / UpdateLineItem {#updatelineitem} Update a single line item's price, quantity, discount, or billing frequency. ```typescript const updated = await TurboQuote.updateLineItem('quote-uuid', 'item-uuid', { quantity: 12, discountPercent: 15, billingFrequency: 'monthly', }); ``` ```python item = await TurboQuote.update_line_item("quote-uuid", "item-uuid", { "quantity": 5, "unitPrice": "179.00", }) ``` ```php use TurboDocx\Types\Requests\Quote\UpdateLineItemRequest; $item = TurboQuote::updateLineItem('quote-uuid', 'item-uuid', new UpdateLineItemRequest( quantity: 3, unitPrice: 189.00, )); ``` PATCH semantics. Use `Clear*` helpers for explicit nulls: `ClearCost`, `ClearCategoryID`, `ClearCategoryName`, `ClearProductSku`, `ClearProductDescription`, `ClearDisplayOrder`. ```go newPrice := 180.00 item, err := qc.UpdateLineItem(ctx, "quote-uuid", "item-uuid", &turbodocx.UpdateLineItemRequest{ UnitPrice: &newPrice, }) ``` ```java LineItem updateLineItem(String quoteId, String itemId, UpdateLineItemRequest request) ``` Update a line item on a quote. Only explicitly set fields are patched. ```java UpdateLineItemRequest req = new UpdateLineItemRequest(); req.setQuantity(10.0); req.setDiscountPercent(15.0); LineItem updated = tq.updateLineItem(quoteId, itemId, req); ``` #### `removeLineItem` / `remove_line_item` / RemoveLineItem {#removelineitem} Remove a line item from a quote. ```typescript const { message } = await TurboQuote.removeLineItem('quote-uuid', 'item-uuid'); ``` --- ```python result = await TurboQuote.remove_line_item("quote-uuid", "item-uuid") # result["message"] ``` --- ```php $result = TurboQuote::removeLineItem('quote-uuid', 'item-uuid'); echo $result->message; ``` ```go result, err := qc.RemoveLineItem(ctx, "quote-uuid", "item-uuid") ``` --- ```java SuccessResponse removeLineItem(String quoteId, String itemId) ``` Remove a line item from a quote. ```java tq.removeLineItem(quoteId, itemId); ``` --- ### Products {#products} Manage your product catalog. Products can include images (uploaded as multipart form data — the SDK detects the MIME type from magic bytes automatically). | Method | Signature | Returns | |---|---|---| | `listProducts` | `(opts?) → ProductListResponse` | Paginated list + catalog stats | | `createProduct` | `(req) → Product` | Created product | | `getProduct` | `(id) → Product` | Single product | | `updateProduct` | `(id, req) → Product` | Updated product | | `deleteProduct` | `(id) → SuccessResponse` | Message | | `duplicateProduct` | `(id) → Product` | New duplicate | | `getProductPrimaryImages` | `(productIds[]) → { [id]: ProductImage \| null }` | Primary image map | ```typescript // Create a product with an image const product = await TurboQuote.createProduct({ name: 'Enterprise Licence', listPrice: 1200, billingFrequency: 'annual', categoryId: 'category-uuid', sku: 'ENT-001', showInCatalog: true, images: ['/path/to/product-image.png'], // file path, Buffer, or File object }); // List with filters const { results, totalProducts } = await TurboQuote.listProducts({ categoryIds: ['cat-1', 'cat-2'], billingFrequency: 'monthly', showInCatalog: true, }); // Fetch primary images for multiple products at once const images = await TurboQuote.getProductPrimaryImages(['p-uuid-1', 'p-uuid-2']); // images['p-uuid-1'] → ProductImage | null ``` --- | Method | Signature | Returns | |---|---|---| | `list_products` | `(options?)` | paginated list | | `create_product` | `(request)` | `Product` | | `get_product` | `(id)` | `Product` | | `update_product` | `(id, request)` | `Product` | | `delete_product` | `(id)` | `{"message": ...}` | | `duplicate_product` | `(id)` | `Product` | | `get_product_primary_images` | `(product_ids)` | `{id: image \| None}` | :::caution `categoryId` is required on create `create_product` **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`categoryType: "product_category"`) — resolve or create it first with `list_types` / `create_type`. It is optional on `update_product`, so you only need to pass it when creating. ::: ```python # Resolve the product category first — create_product needs its UUID. page = await TurboQuote.list_types({"categoryType": "product_category"}) category = next((t for t in page["results"] if t["name"] == "Software"), None) if category is None: category = await TurboQuote.create_type({ "name": "Software", "categoryType": "product_category", }) # Create a product with images (multipart upload auto-detected) product = await TurboQuote.create_product({ "name": "Enterprise License", "categoryId": category["id"], # required "listPrice": "499.00", "cost": "200.00", "billingFrequency": "annual", "showInCatalog": True, "images": ["/path/to/product-photo.png"], # file path(s) or bytes }) # List products with filters page = await TurboQuote.list_products({ "limit": 20, "query": "enterprise", "showInCatalog": True, }) # Bulk fetch primary images for a set of product IDs images = await TurboQuote.get_product_primary_images(["id-1", "id-2", "id-3"]) # images["id-1"] → ProductImage dict, or None ``` --- | Method | Signature | Returns | |---|---|---| | `listProducts` | `(?ListProductsRequest)` | `ProductListResponse` | | `createProduct` | `(CreateProductRequest)` | `Product` | | `getProduct` | `(string $id)` | `Product` | | `updateProduct` | `(string $id, UpdateProductRequest)` | `Product` | | `deleteProduct` | `(string $id)` | `MessageResponse` | | `duplicateProduct` | `(string $id)` | `Product` | | `getProductPrimaryImages` | `(string[] $productIds)` | `array` | ```php use TurboDocx\Types\Requests\Quote\CreateProductRequest; use TurboDocx\Types\Requests\Quote\ListProductsRequest; // List products $page = TurboQuote::listProducts(new ListProductsRequest(limit: 20, query: 'license')); // Create a product $product = TurboQuote::createProduct(new CreateProductRequest( name: 'Enterprise Seat', listPrice: 299.00, billingFrequency: 'monthly', categoryId: 'category-uuid', sku: 'ENT-001', )); // Fetch primary images for a set of product IDs $images = TurboQuote::getProductPrimaryImages(['product-uuid-1', 'product-uuid-2']); // $images['product-uuid-1'] => image array or null ``` :::note Product images When `CreateProductRequest` or `UpdateProductRequest` includes an `images` key (array of file paths or raw bytes), the SDK automatically switches to multipart form upload. The MIME type is detected from magic bytes on the server side. ::: The product catalog powers line item selection in quotes. | Method | Signature | Returns | |---|---|---| | `listProducts` | `listProducts()` / `listProducts(ListProductsOptions)` | `ProductListResponse` | | `createProduct` | `createProduct(CreateProductRequest)` | `Product` | | `getProduct` | `getProduct(String id)` | `Product` | | `updateProduct` | `updateProduct(String id, UpdateProductRequest)` | `Product` | | `deleteProduct` | `deleteProduct(String id)` | `SuccessResponse` | | `duplicateProduct` | `duplicateProduct(String id)` | `Product` | | `getProductPrimaryImages` | `getProductPrimaryImages(List productIds)` | `Map` | :::caution `categoryId` is required on create `createProduct` **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`CategoryType.PRODUCT_CATEGORY`) — resolve or create it first with `listTypes` / `createType`. It is optional on `updateProduct`, so you only need to pass it when creating. ::: ```java // Resolve the product category first — createProduct needs its UUID. ListTypesOptions typeOpts = new ListTypesOptions(); typeOpts.setCategoryType(CategoryType.PRODUCT_CATEGORY); QuoteType category = tq.listTypes(typeOpts).getResults().stream() .filter(t -> "Software".equals(t.getName())) .findFirst() .orElse(null); if (category == null) { CreateQuoteTypeRequest typeReq = new CreateQuoteTypeRequest(); typeReq.setName("Software"); typeReq.setCategoryType(CategoryType.PRODUCT_CATEGORY); category = tq.createType(typeReq); } // Create a product CreateProductRequest req = new CreateProductRequest(); req.setName("Pro Platform"); req.setCategoryId(category.getId()); // required req.setSku("PRO-001"); req.setListPrice(500.00); req.setBillingFrequency("monthly"); Product product = tq.createProduct(req); // Upload with images — pass byte[][] via setImages() req.setImages(new byte[][] { imageBytes }); Product productWithImages = tq.createProduct(req); // Get primary images for a set of products Map images = tq.getProductPrimaryImages( Arrays.asList(product.getId(), anotherProductId)); // images.get(productId) — ProductImage or null ``` :::note Multipart Upload When `CreateProductRequest.getImages()` is non-empty, the SDK automatically switches to multipart form upload with magic-byte MIME type detection (PNG, JPEG, GIF, WebP supported). ::: --- #### ListProducts {#listproducts} ```go list, err := qc.ListProducts(ctx, &turbodocx.ListProductsOptions{ Query: &[]string{"license"}[0], }) // list.Results []Product // list.TotalProducts int // list.CatalogValue float64 ``` #### CreateProduct {#createproduct} ```go price := 99.99 product, err := qc.CreateProduct(ctx, &turbodocx.CreateProductRequest{ Name: "Starter License", ListPrice: price, BillingFrequency: "monthly", CategoryID: "category-uuid", }) ``` For products with images, set `Images []ProductImageInput` (supports `FilePath` or `Data` bytes) — the SDK automatically uses multipart upload: ```go product, err := qc.CreateProduct(ctx, &turbodocx.CreateProductRequest{ Name: "Starter License", ListPrice: 99.99, BillingFrequency: "monthly", CategoryID: "category-uuid", Images: []turbodocx.ProductImageInput{ {FilePath: "/path/to/logo.png"}, }, }) ``` #### GetProduct / DeleteProduct / DuplicateProduct {#getproduct--deleteproduct--duplicateproduct} ```go product, err := qc.GetProduct(ctx, "product-uuid") result, err := qc.DeleteProduct(ctx, "product-uuid") copy, err := qc.DuplicateProduct(ctx, "product-uuid") ``` #### UpdateProduct {#updateproduct} PATCH semantics with `Clear*` helpers: `ClearCost`, `ClearSku`, `ClearDescription`, `ClearDetailedSpecification`, `ClearInternalNotes`. Supports image uploads via `Images`. ```go newPrice := 109.99 product, err := qc.UpdateProduct(ctx, "product-uuid", &turbodocx.UpdateProductRequest{ ListPrice: &newPrice, }) ``` #### GetProductPrimaryImages {#getproductprimaryimages} Returns a `ProductPrimaryImagesResponse` (`map[string]*ProductImage`) keyed by product ID. ```go images, err := qc.GetProductPrimaryImages(ctx, []string{"product-uuid-1", "product-uuid-2"}) if img, ok := images["product-uuid-1"]; ok && img != nil { fmt.Println(img.FileName) } ``` --- ### Bundles {#bundles} Bundles group multiple products into a single purchasable unit with optional bundle-level discounts. | Method | Signature | Returns | |---|---|---| | `listBundles` | `(opts?) → BundleListResponse` | Paginated list + stats | | `createBundle` | `(req) → Bundle` | Created bundle | | `getBundle` | `(id) → Bundle` | Single bundle | | `updateBundle` | `(id, req) → Bundle` | Updated bundle | | `deleteBundle` | `(id) → SuccessResponse` | Message | | `duplicateBundle` | `(id) → Bundle` | New duplicate | ```typescript const bundle = await TurboQuote.createBundle({ name: 'Starter Pack', categoryId: 'category-uuid', currency: 'USD', showInCatalog: true, syncWithProducts: true, items: [ { productId: 'p1', unitPrice: 200, billingFrequency: 'monthly', quantity: 1 }, { productId: 'p2', unitPrice: 50, billingFrequency: 'monthly', quantity: 3 }, ], }); ``` --- | Method | Signature | Returns | |---|---|---| | `list_bundles` | `(options?)` | paginated list | | `create_bundle` | `(request)` | `Bundle` | | `get_bundle` | `(id)` | `Bundle` | | `update_bundle` | `(id, request)` | `Bundle` | | `delete_bundle` | `(id)` | `{"message": ...}` | | `duplicate_bundle` | `(id)` | `Bundle` | Each entry in `items` **requires** `productId`, `unitPrice`, and `billingFrequency`. `quantity` is optional and defaults to `1`. ```python bundle = await TurboQuote.create_bundle({ "name": "Starter Pack", "items": [ { "productId": "product-uuid-1", "unitPrice": "199.00", "billingFrequency": "monthly", "quantity": 1, }, { "productId": "product-uuid-2", "unitPrice": "49.00", "billingFrequency": "monthly", "quantity": 2, }, ], "discountPercent": "5.0", }) ``` --- | Method | Signature | Returns | |---|---|---| | `listBundles` | `(?ListBundlesRequest)` | `BundleListResponse` | | `createBundle` | `(CreateBundleRequest)` | `Bundle` | | `getBundle` | `(string $id)` | `Bundle` | | `updateBundle` | `(string $id, UpdateBundleRequest)` | `Bundle` | | `deleteBundle` | `(string $id)` | `MessageResponse` | | `duplicateBundle` | `(string $id)` | `Bundle` | Each entry in `items` **requires** `productId`, `unitPrice`, and `billingFrequency`. `quantity` is optional and defaults to `1`. ```php use TurboDocx\Types\Requests\Quote\CreateBundleRequest; $bundle = TurboQuote::createBundle(new CreateBundleRequest( name: 'Starter Kit', categoryId: 'category-uuid', items: [ [ 'productId' => 'product-uuid-1', 'unitPrice' => 199.00, 'billingFrequency' => 'monthly', 'quantity' => 1, ], [ 'productId' => 'product-uuid-2', 'unitPrice' => 49.00, 'billingFrequency' => 'monthly', 'quantity' => 2, ], ], )); $copy = TurboQuote::duplicateBundle($bundle->id); ``` Bundles group products into a single sellable unit. ```go // Create bundle, err := qc.CreateBundle(ctx, &turbodocx.CreateBundleRequest{ Name: "Starter Pack", CategoryID: "category-uuid", Items: []turbodocx.BundleItemInput{ { ProductID: "product-uuid", UnitPrice: 100.00, BillingFrequency: "monthly", }, }, }) // Get bundle, err = qc.GetBundle(ctx, "bundle-uuid") // Update bundle, err = qc.UpdateBundle(ctx, "bundle-uuid", &turbodocx.UpdateBundleRequest{ Name: &[]string{"Updated Pack"}[0], }) // Delete result, err := qc.DeleteBundle(ctx, "bundle-uuid") // Duplicate copy, err := qc.DuplicateBundle(ctx, "bundle-uuid") ``` ```go list, err := qc.ListBundles(ctx, &turbodocx.ListBundlesOptions{ ShowInCatalog: &[]bool{true}[0], }) // list.Results []Bundle // list.TotalBundles int // list.CatalogValue float64 ``` `UpdateBundle` has `Clear*` helpers for nullable fields: `ClearDescription`, `ClearSku`, `ClearCategoryID`. --- | Method | Signature | Returns | |---|---|---| | `listBundles` | `listBundles()` / `listBundles(ListBundlesOptions)` | `BundleListResponse` | | `createBundle` | `createBundle(CreateBundleRequest)` | `Bundle` | | `getBundle` | `getBundle(String id)` | `Bundle` | | `updateBundle` | `updateBundle(String id, UpdateBundleRequest)` | `Bundle` | | `deleteBundle` | `deleteBundle(String id)` | `SuccessResponse` | | `duplicateBundle` | `duplicateBundle(String id)` | `Bundle` | `name` and `categoryId` are **required** on the bundle itself. Each `BundleItemInput` **requires** `productId`, `unitPrice`, and `billingFrequency`; `quantity` is optional and defaults to `1`. :::caution `categoryId` is required `createBundle` needs the **UUID** of an existing type with `CategoryType.BUNDLE_CATEGORY` — resolve or create it first with `listTypes` / `createType`, exactly as you would for a product category. ::: ```java // 1. Resolve the bundle category — createBundle needs its UUID. ListTypesOptions typeOpts = new ListTypesOptions(); typeOpts.setCategoryType(CategoryType.BUNDLE_CATEGORY); QuoteType category = tq.listTypes(typeOpts).getResults().stream() .filter(t -> "Starter Kits".equals(t.getName())) .findFirst() .orElse(null); if (category == null) { CreateQuoteTypeRequest typeReq = new CreateQuoteTypeRequest(); typeReq.setName("Starter Kits"); typeReq.setCategoryType(CategoryType.BUNDLE_CATEGORY); category = tq.createType(typeReq); } // 2. Build the bundle items — productId, unitPrice and billingFrequency are all required. BundleItemInput platform = new BundleItemInput(); platform.setProductId("product-uuid-1"); platform.setUnitPrice(199.00); platform.setBillingFrequency("monthly"); platform.setQuantity(1.0); BundleItemInput support = new BundleItemInput(); support.setProductId("product-uuid-2"); support.setUnitPrice(49.00); support.setBillingFrequency("monthly"); support.setQuantity(2.0); // 3. Create the bundle. CreateBundleRequest req = new CreateBundleRequest(); req.setName("Starter Bundle"); // required req.setCategoryId(category.getId()); // required req.setItems(Arrays.asList(platform, support)); req.setBundleDiscountType(DiscountType.PERCENT); req.setBundleDiscountPercent(5.0); req.setShowInCatalog(true); Bundle bundle = tq.createBundle(req); ``` --- ### Price Books {#price-books} Price books let you define alternative pricing tiers. When a price book is applied to a quote, matching product line items are repriced automatically. | Method | Signature | Returns | |---|---|---| | `listPriceBooks` | `(opts?) → PriceBookListResponse` | Paginated list + stats | | `createPriceBook` | `(req) → PriceBook` | Created price book | | `getPriceBook` | `(id) → PriceBook` | Single price book | | `updatePriceBook` | `(id, req) → PriceBook` | Updated price book | | `deletePriceBook` | `(id) → SuccessResponse` | Message | | `duplicatePriceBook` | `(id) → PriceBook` | New duplicate | | `listPriceBookProducts` | `(id, opts?) → PaginatedResponse` | Per-product pricing | ```typescript const pb = await TurboQuote.createPriceBook({ name: 'Partner Tier', priceBookTypeId: 'type-uuid', validFrom: '2026-01-01', validTo: '2026-12-31', discountPercent: 20, isDefault: false, showInQuoteBuilder: true, productPricing: [ { productId: 'p1', discountPercent: 25, finalPrice: 900 }, ], }); // Apply to a quote const { updatedCount, skippedCount } = await TurboQuote.applyPriceBook( 'quote-uuid', pb.id, ); ``` --- | Method | Signature | Returns | |---|---|---| | `list_price_books` | `(options?)` | paginated list | | `create_price_book` | `(request)` | `PriceBook` | | `get_price_book` | `(id)` | `PriceBook` | | `update_price_book` | `(id, request)` | `PriceBook` | | `delete_price_book` | `(id)` | `{"message": ...}` | | `duplicate_price_book` | `(id)` | `PriceBook` | | `list_price_book_products` | `(id, options?)` | paginated list | ```python # name, priceBookTypeId, and validFrom are REQUIRED. # discountPercent is optional and defaults to 0 if not provided. # priceBookTypeId comes from a create_type with categoryType "pricebook_type". pricebook = await TurboQuote.create_price_book({ "name": "Partner Tier A", "priceBookTypeId": "pricebook-type-uuid", "validFrom": "2026-01-01", "discountPercent": 15, "isDefault": False, "productPricing": [ {"productId": "product-uuid-1", "discountType": "percent", "discountPercent": 25}, {"productId": "product-uuid-2", "discountType": "percent", "discountPercent": 30}, ], }) # List products attached to a price book page = await TurboQuote.list_price_book_products("pricebook-uuid", {"limit": 50}) ``` --- | Method | Signature | Returns | |---|---|---| | `listPriceBooks` | `(?ListPriceBooksRequest)` | `PriceBookListResponse` | | `createPriceBook` | `(CreatePriceBookRequest)` | `PriceBook` | | `getPriceBook` | `(string $id)` | `PriceBook` | | `updatePriceBook` | `(string $id, UpdatePriceBookRequest)` | `PriceBook` | | `deletePriceBook` | `(string $id)` | `MessageResponse` | | `duplicatePriceBook` | `(string $id)` | `PriceBook` | | `listPriceBookProducts` | `(string $id, ?ListPriceBookProductsRequest)` | `PriceBookProductListResponse` | ```php use TurboDocx\Types\Requests\Quote\CreatePriceBookRequest; $pb = TurboQuote::createPriceBook(new CreatePriceBookRequest( name: 'Partner Discount', priceBookTypeId: 'pricebook-type-uuid', validFrom: '2026-01-01', discountPercent: 15.0, )); // List products enrolled in the price book $products = TurboQuote::listPriceBookProducts($pb->id); ``` Price books apply per-product discounts or fixed prices to quotes in bulk. ```go validFrom := "2026-01-01" discountPercent := 15.0 priceBook, err := qc.CreatePriceBook(ctx, &turbodocx.CreatePriceBookRequest{ Name: "Partner Pricing", PriceBookTypeID: "type-uuid", ValidFrom: validFrom, DiscountPercent: &discountPercent, ProductPricing: []turbodocx.PriceBookProductPricingInput{ { ProductID: "product-uuid", DiscountPercent: &[]float64{15.0}[0], }, }, }) priceBook, err = qc.GetPriceBook(ctx, "pricebook-uuid") priceBook, err = qc.UpdatePriceBook(ctx, "pricebook-uuid", &turbodocx.UpdatePriceBookRequest{ Name: &[]string{"Updated Partner Pricing"}[0], }) result, err := qc.DeletePriceBook(ctx, "pricebook-uuid") copy, err := qc.DuplicatePriceBook(ctx, "pricebook-uuid") ``` ```go // List price books list, err := qc.ListPriceBooks(ctx, &turbodocx.ListPriceBooksOptions{ ShowInQuoteBuilder: &[]bool{true}[0], }) // list.Results []PriceBook // list.DefaultPriceBookName *string // List products within a price book products, err := qc.ListPriceBookProducts(ctx, "pricebook-uuid", &turbodocx.ListPriceBookProductsOptions{ Limit: &[]int{20}[0], }) // products.Results []PriceBookProductPricing — each entry has DiscountPercent + FinalPrice ``` `UpdatePriceBook` has `Clear*` helpers: `ClearDescription`, `ClearValidTo`. --- | Method | Signature | Returns | |---|---|---| | `listPriceBooks` | `listPriceBooks()` / `listPriceBooks(ListPriceBooksOptions)` | `PriceBookListResponse` | | `createPriceBook` | `createPriceBook(CreatePriceBookRequest)` | `PriceBook` | | `getPriceBook` | `getPriceBook(String id)` | `PriceBook` | | `updatePriceBook` | `updatePriceBook(String id, UpdatePriceBookRequest)` | `PriceBook` | | `deletePriceBook` | `deletePriceBook(String id)` | `SuccessResponse` | | `duplicatePriceBook` | `duplicatePriceBook(String id)` | `PriceBook` | | `listPriceBookProducts` | `listPriceBookProducts(String id)` / `listPriceBookProducts(String id, ListPriceBookProductsOptions)` | `PriceBookProductListResponse` | ```java // Create a price book with per-product pricing overrides PriceBookProductPricingInput pricing = new PriceBookProductPricingInput(); pricing.setProductId(productId); pricing.setDiscountType(DiscountType.PERCENT); pricing.setDiscountPercent(20.0); CreatePriceBookRequest req = new CreatePriceBookRequest(); req.setName("Partner Discount"); // required req.setPriceBookTypeId(typeId); // required — from a createType(categoryType=PRICEBOOK_TYPE) req.setValidFrom("2025-01-01"); // required req.setDiscountPercent(15.0); // required req.setProductPricing(Arrays.asList(pricing)); req.setShowInQuoteBuilder(true); PriceBook pb = tq.createPriceBook(req); // List products in a price book PriceBookProductListResponse pbProducts = tq.listPriceBookProducts(pb.getId()); System.out.println("Products: " + pbProducts.getTotalRecords()); ``` --- ### Companies {#companies} Companies represent the buyer organisations your quotes are addressed to. Each company must have at least one contact. | Method | Signature | Returns | |---|---|---| | `listCompanies` | `(opts?) → CompanyListResponse` | Paginated list | | `createCompany` | `(req) → Company` | Created company | | `getCompany` | `(id) → Company` | Single company | | `updateCompany` | `(id, req) → Company` | Updated company | | `deleteCompany` | `(id) → SuccessResponse` | Message | | `listCompanyContacts` | `(companyId, opts?) → ContactListResponse` | Company's contacts | ```typescript const company = await TurboQuote.createCompany({ name: 'Acme Corporation', phone: '+1-555-0100', city: 'San Francisco', state: 'CA', country: 'US', contacts: [ { name: 'Alice Smith', email: 'alice@acme.example', title: 'VP of Engineering' }, ], }); const { results: contacts } = await TurboQuote.listCompanyContacts(company.id); ``` --- | Method | Signature | Returns | |---|---|---| | `list_companies` | `(options?)` | paginated list | | `create_company` | `(request)` | `Company` | | `get_company` | `(id)` | `Company` | | `update_company` | `(id, request)` | `Company` | | `delete_company` | `(id)` | `{"message": ...}` | | `list_company_contacts` | `(company_id, options?)` | paginated list | ```python company = await TurboQuote.create_company({ "name": "Acme Corporation", "domain": "acme.com", "contacts": [ # at least one contact required { "firstName": "Jane", "lastName": "Doe", "email": "jane@acme.com", } ], }) # List contacts for a company contacts = await TurboQuote.list_company_contacts(company["id"]) ``` --- | Method | Signature | Returns | |---|---|---| | `listCompanies` | `(?ListCompaniesRequest)` | `CompanyListResponse` | | `createCompany` | `(CreateCompanyRequest)` | `Company` | | `getCompany` | `(string $id)` | `Company` | | `updateCompany` | `(string $id, UpdateCompanyRequest)` | `Company` | | `deleteCompany` | `(string $id)` | `MessageResponse` | | `listCompanyContacts` | `(string $companyId, ?ListContactsRequest)` | `ContactListResponse` | ```php use TurboDocx\Types\Requests\Quote\CreateCompanyRequest; // A company requires at least one contact on creation $company = TurboQuote::createCompany(new CreateCompanyRequest( name: 'Acme Corp', contacts: [ ['name' => 'Jane Doe', 'email' => 'jane@acme.com'], ], )); $contacts = TurboQuote::listCompanyContacts($company->id); ``` Companies are organizations you send quotes to. Each company must have at least one contact. ```go phone := "+1-555-0100" company, err := qc.CreateCompany(ctx, &turbodocx.CreateCompanyRequest{ Name: "Acme Corporation", Phone: &phone, Contacts: []turbodocx.CreateCompanyContactInput{ {Name: "Jane Smith", Email: "jane@acmecorp.com"}, }, }) company, err = qc.GetCompany(ctx, "company-uuid") company, err = qc.UpdateCompany(ctx, "company-uuid", &turbodocx.UpdateCompanyRequest{ Name: &[]string{"Acme Corp (Updated)"}[0], }) result, err := qc.DeleteCompany(ctx, "company-uuid") ``` ```go // List companies list, err := qc.ListCompanies(ctx, &turbodocx.ListCompaniesOptions{ Query: &[]string{"acme"}[0], }) // List contacts under a company contacts, err := qc.ListCompanyContacts(ctx, "company-uuid", &turbodocx.PaginationParams{ Limit: &[]int{10}[0], }) // contacts.Results []Contact ``` `UpdateCompany` has `Clear*` helpers: `ClearPhone`, `ClearCity`, `ClearState`, `ClearCountry`, `ClearIndustryID`. --- | Method | Signature | Returns | |---|---|---| | `listCompanies` | `listCompanies()` / `listCompanies(ListCompaniesOptions)` | `CompanyListResponse` | | `createCompany` | `createCompany(CreateCompanyRequest)` | `Company` | | `getCompany` | `getCompany(String id)` | `Company` | | `updateCompany` | `updateCompany(String id, UpdateCompanyRequest)` | `Company` | | `deleteCompany` | `deleteCompany(String id)` | `SuccessResponse` | | `listCompanyContacts` | `listCompanyContacts(String companyId)` / `listCompanyContacts(String companyId, PaginationParams)` | `ContactListResponse` | ```java // Create a company — contacts list is required (minimum one contact) CreateCompanyContactInput contact = new CreateCompanyContactInput(); contact.setName("Alice Buyer"); contact.setEmail("alice@example.com"); CreateCompanyRequest req = new CreateCompanyRequest(); req.setName("Acme Corp"); req.setCity("New York"); req.setContacts(Arrays.asList(contact)); Company company = tq.createCompany(req); // List contacts for that company ContactListResponse contacts = tq.listCompanyContacts(company.getId()); String contactId = contacts.getResults().get(0).getId(); ``` --- ### Contacts {#contacts} Contacts belong to a company and are the individuals a quote is addressed to. :::note No getContact endpoint There is no `getContact(id)` method — the backend does not expose a `GET /v1/contacts/:id` route. Retrieve an individual contact via `listContacts` with a search query, or fetch all contacts for a company with `listCompanyContacts`. ::: | Method | Signature | Returns | |---|---|---| | `listContacts` | `(opts?) → ContactListResponse` | Paginated list | | `createContact` | `(req) → Contact` | Created contact | | `updateContact` | `(id, req) → Contact` | Updated contact | | `deleteContact` | `(id) → SuccessResponse` | Message | ```typescript const contact = await TurboQuote.createContact({ name: 'Bob Jones', companyId: 'company-uuid', email: 'bob@acme.example', title: 'Procurement Manager', }); const { results } = await TurboQuote.listContacts({ companyId: 'company-uuid' }); ``` --- | Method | Signature | Returns | |---|---|---| | `list_contacts` | `(options?)` | paginated list | | `create_contact` | `(request)` | `Contact` | | `update_contact` | `(id, request)` | `Contact` | | `delete_contact` | `(id)` | `{"message": ...}` | :::note No `get_contact` by design The backend has no `GET /v1/contacts/:id` endpoint. Fetch individual contacts via `list_company_contacts` or `list_contacts` with a query filter. ::: ```python contact = await TurboQuote.create_contact({ "firstName": "John", "lastName": "Smith", "email": "john@acme.com", "companyId": "company-uuid", }) ``` --- | Method | Signature | Returns | |---|---|---| | `listContacts` | `(?ListContactsRequest)` | `ContactListResponse` | | `createContact` | `(CreateContactRequest)` | `Contact` | | `updateContact` | `(string $id, UpdateContactRequest)` | `Contact` | | `deleteContact` | `(string $id)` | `MessageResponse` | :::note No getContact There is no `getContact($id)` — the backend does not expose a `GET /v1/contacts/:id` endpoint. Use `listContacts` with a search query or `listCompanyContacts` instead. ::: ```php use TurboDocx\Types\Requests\Quote\CreateContactRequest; $contact = TurboQuote::createContact(new CreateContactRequest( name: 'John Smith', companyId: 'company-uuid', email: 'john@acme.com', )); ``` Contacts are individuals at a company. A quote is addressed to a specific contact. ```go // Create contact, err := qc.CreateContact(ctx, &turbodocx.CreateContactRequest{ Name: "Jane Smith", CompanyID: "company-uuid", Email: &[]string{"jane@acmecorp.com"}[0], }) // Update contact, err = qc.UpdateContact(ctx, "contact-uuid", &turbodocx.UpdateContactRequest{ Name: &[]string{"Jane M. Smith"}[0], }) // Delete result, err := qc.DeleteContact(ctx, "contact-uuid") // List (with optional companyId filter) list, err := qc.ListContacts(ctx, &turbodocx.ListContactsOptions{ CompanyID: &[]string{"company-uuid"}[0], }) ``` :::note No GetContact There is no `GetContact(id)` — the backend has no `GET /v1/contacts/:id` endpoint. Use `ListContacts` with a `CompanyID` filter to find contacts for a company, or use `ListCompanyContacts`. ::: `UpdateContact` has `Clear*` helpers: `ClearEmail`, `ClearPhone`, `ClearTitle`. --- | Method | Signature | Returns | |---|---|---| | `listContacts` | `listContacts()` / `listContacts(ListContactsOptions)` | `ContactListResponse` | | `createContact` | `createContact(CreateContactRequest)` | `Contact` | | `updateContact` | `updateContact(String id, UpdateContactRequest)` | `Contact` | | `deleteContact` | `deleteContact(String id)` | `SuccessResponse` | :::note No `getContact` There is no `getContact(id)` method — the backend has no `GET /v1/contacts/:id` endpoint. Use `listContacts` with a query filter, or `listCompanyContacts` to retrieve contacts for a known company. ::: ```java CreateContactRequest req = new CreateContactRequest(); req.setName("Bob Partner"); req.setEmail("bob@partner.example.com"); Contact contact = tq.createContact(req); ``` --- ### Quote Templates {#quote-templates} Quote templates control the visual presentation of the sent quote (logo, brand colours, disclaimer, terms, sender info). There is one active template per org, accessible via `getTemplate()`. You can also manage named templates. :::note Singleton vs. list `getTemplate()` returns the org's single active template via `GET /v1/quote-template` (singular path). `listTemplates()` returns all named templates via `GET /v1/quote-templates` (plural path). ::: :::warning Templates are auto-provisioned — use getTemplate() → updateTemplate() `getTemplate()` **self-heals**: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means: - `createTemplate()` returns **400 `TEMPLATE_ALREADY_EXISTS`** and is effectively unreachable. Do not build a get-then-create flow. - `deleteTemplate()` is really "reset to org branding defaults" — it soft-deletes, and the next `getTemplate()` regenerates a fresh one. The correct flow is **`getTemplate()` → `updateTemplate(tmpl.id, …)`**. ::: | Method | Signature | Returns | |---|---|---| | `getTemplate` | `() → QuoteTemplate` | Active org template (auto-created if none exists) | | `listTemplates` | `(opts?) → QuoteTemplateListResponse` | All named templates | | `getTemplateById` | `(id) → QuoteTemplate` | Named template by ID | | `createTemplate` | `(req) → QuoteTemplate` | Created template — `400` if one already exists | | `updateTemplate` | `(id, req) → QuoteTemplate` | Updated template | | `deleteTemplate` | `(id) → SuccessResponse` | Resets to org branding defaults | ```typescript // 1. Fetch the active template — created from org branding on first read const tmpl = await TurboQuote.getTemplate(); console.log(tmpl.primaryColor); // e.g. "#1a73e8" // 2. Brand it by updating the template you just fetched const branded = await TurboQuote.updateTemplate(tmpl.id, { logoUrl: 'https://cdn.example.com/logo.png', primaryColor: '#0057b8', primaryTextColor: '#ffffff', disclaimer: 'Prices valid for 30 days.', termsAndConditions: 'See attached terms...', senderName: 'TurboDocx Sales', senderEmail: 'sales@example.com', }); ``` --- | Method | Signature | Returns | |---|---|---| | `list_templates` | `(options?)` | paginated list | | `get_template` | `()` | singleton `QuoteTemplate` (auto-created if none exists) | | `get_template_by_id` | `(id)` | `QuoteTemplate` | | `create_template` | `(request)` | `QuoteTemplate` — `400` if one already exists | | `update_template` | `(id, request)` | `QuoteTemplate` | | `delete_template` | `(id)` | `{"message": ...}` — resets to org branding defaults | :::warning Templates are auto-provisioned — use `get_template()` → `update_template()` `get_template()` **self-heals**: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means: - `create_template()` returns **400 `TEMPLATE_ALREADY_EXISTS`** and is effectively unreachable. Do not build a get-then-create flow. - `delete_template()` is really "reset to org branding defaults" — it soft-deletes, and the next `get_template()` regenerates a fresh one. The correct flow is **`get_template()` → `update_template()`**. ::: ```python # 1. Get the org's quote template (created from org branding on first read) template = await TurboQuote.get_template() # 2. Brand it by updating the template you just fetched branded = await TurboQuote.update_template(template["id"], { "logoUrl": "https://cdn.example.com/logo.png", "primaryColor": "#0057b8", "senderName": "TurboDocx Sales", }) # Fetch a specific named template by ID other = await TurboQuote.get_template_by_id("template-uuid") ``` --- | Method | Signature | Returns | |---|---|---| | `listTemplates` | `(?ListTemplatesRequest)` | `QuoteTemplateListResponse` | | `getTemplate` | `()` | `QuoteTemplate` — auto-created if none exists | | `getTemplateById` | `(string $id)` | `QuoteTemplate` | | `createTemplate` | `(CreateQuoteTemplateRequest)` | `QuoteTemplate` — `400` if one already exists | | `updateTemplate` | `(string $id, UpdateQuoteTemplateRequest)` | `QuoteTemplate` | | `deleteTemplate` | `(string $id)` | `MessageResponse` — resets to org branding defaults | :::note getTemplate vs getTemplateById `getTemplate()` (no argument) hits `GET /v1/quote-template` (singular) and returns the org's currently active template. `getTemplateById($id)` hits `GET /v1/quote-templates/:id` and returns any specific template by ID. ::: :::warning Templates are auto-provisioned — use getTemplate() → updateTemplate() `getTemplate()` **self-heals**: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means: - `createTemplate()` returns **400 `TEMPLATE_ALREADY_EXISTS`** and is effectively unreachable. Do not build a get-then-create flow. - `deleteTemplate()` is really "reset to org branding defaults" — it soft-deletes, and the next `getTemplate()` regenerates a fresh one. The correct flow is **`getTemplate()` → `updateTemplate()`**. ::: ```php use TurboDocx\Types\Requests\Quote\UpdateQuoteTemplateRequest; // 1. Get the active org template (created from org branding on first read) $active = TurboQuote::getTemplate(); echo $active->name; // 2. Brand it by updating the template you just fetched $branded = TurboQuote::updateTemplate($active->id, new UpdateQuoteTemplateRequest( logoUrl: 'https://cdn.example.com/logo.png', primaryColor: '#0057b8', senderName: 'TurboDocx Sales', )); // List all templates $templates = TurboQuote::listTemplates(); ``` Quote templates control the branding and layout of sent quote emails and the customer-facing quote page (logo, colors, footer text, terms, sender info). :::warning Templates are auto-provisioned — use GetTemplate → UpdateTemplate `GetTemplate` **self-heals**: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means: - `CreateTemplate` returns **400 `TEMPLATE_ALREADY_EXISTS`** and is effectively unreachable. Do not build a get-then-create flow. - `DeleteTemplate` is really "reset to org branding defaults" — it soft-deletes, and the next `GetTemplate` regenerates a fresh one. The correct flow is **`GetTemplate` → `UpdateTemplate`**. ::: | Method | Signature | Returns | |---|---|---| | `listTemplates` | `listTemplates()` / `listTemplates(PaginationParams)` | `QuoteTemplateListResponse` | | `getTemplate` | `getTemplate()` | `QuoteTemplate` — auto-created if none exists | | `getTemplateById` | `getTemplateById(String id)` | `QuoteTemplate` | | `createTemplate` | `createTemplate(CreateQuoteTemplateRequest)` | `QuoteTemplate` — `400` if one already exists | | `updateTemplate` | `updateTemplate(String id, UpdateQuoteTemplateRequest)` | `QuoteTemplate` | | `deleteTemplate` | `deleteTemplate(String id)` | `SuccessResponse` — resets to org branding defaults | :::warning Templates are auto-provisioned — use `getTemplate()` → `updateTemplate()` `getTemplate()` **self-heals**: if the org has no template, the API creates one from your org branding and returns it. Every established org therefore already has a template, which means: - `createTemplate()` returns **400 `TEMPLATE_ALREADY_EXISTS`** and is effectively unreachable. Do not build a get-then-create flow. - `deleteTemplate()` is really "reset to org branding defaults" — it soft-deletes, and the next `getTemplate()` regenerates a fresh one. The correct flow is **`getTemplate()` → `updateTemplate()`**. ::: ```java // 1. Get the org's template (created from org branding on first read) QuoteTemplate defaultTemplate = tq.getTemplate(); // 2. Brand it by updating the template you just fetched UpdateQuoteTemplateRequest brandReq = new UpdateQuoteTemplateRequest(); brandReq.setLogoUrl("https://cdn.example.com/logo.png"); brandReq.setPrimaryColor("#0057b8"); brandReq.setSenderName("TurboDocx Sales"); QuoteTemplate branded = tq.updateTemplate(defaultTemplate.getId(), brandReq); // Get a specific template by ID QuoteTemplate template = tq.getTemplateById(templateId); // List all templates QuoteTemplateListResponse templates = tq.listTemplates(); ``` :::note `getTemplate()` vs `getTemplateById(id)` `getTemplate()` hits `GET /v1/quote-template` (singular) and returns the organization's default template. `getTemplateById(id)` hits `GET /v1/quote-templates/:id` (plural) and returns a specific template by ID. ::: --- #### GetTemplate {#gettemplate} Returns the active (default) quote template for the org, creating one from org branding if none exists. Use this for the most common case. ```go tmpl, err := qc.GetTemplate(ctx) fmt.Printf("Primary color: %s\n", tmpl.PrimaryColor) ``` #### GetTemplateByID {#gettemplatebyid} Retrieves a specific template by ID when you have multiple templates. ```go tmpl, err := qc.GetTemplateByID(ctx, "template-uuid") ``` #### ListTemplates / UpdateTemplate / DeleteTemplate {#listtemplates--updatetemplate--deletetemplate} Brand the org's template by fetching it and updating it in place — never by creating one. ```go list, err := qc.ListTemplates(ctx, &turbodocx.PaginationParams{ Limit: &[]int{5}[0], }) // Get the auto-provisioned template, then update it. tmpl, err := qc.GetTemplate(ctx) logoURL := "https://cdn.example.com/logo.png" tmpl, err = qc.UpdateTemplate(ctx, tmpl.ID, &turbodocx.UpdateQuoteTemplateRequest{ LogoURL: &logoURL, PrimaryColor: &[]string{"#0066CC"}[0], SenderName: &[]string{"Sales Team"}[0], }) // DeleteTemplate resets the org back to its branding defaults — // the next GetTemplate call regenerates a fresh template. result, err := qc.DeleteTemplate(ctx, tmpl.ID) ``` `UpdateQuoteTemplateRequest` has `Clear*` helpers: `ClearLogoURL`, `ClearDisclaimer`, `ClearTermsAndConditions`, `ClearClosingMessage`, `ClearSenderName`, `ClearSenderPhone`, `ClearContactEmail`. --- ### Types (Categories) / Types / Categories {#types-categories} Types are reusable category/classification values used across products, bundles, price books, and companies (e.g., industry tags, price book types). :::note No getType endpoint There is no `getType(id)` method — the backend does not expose `GET /v1/types/:id`. Use `listTypes` to retrieve individual types. ::: | Method | Signature | Returns | |---|---|---| | `listTypes` | `(opts?) → QuoteTypeListResponse` | Paginated list | | `createType` | `(req) → QuoteType` | Created type | | `updateType` | `(id, req) → QuoteType` | Updated type | | `deleteType` | `(id) → SuccessResponse` | Message | ```typescript const type = await TurboQuote.createType({ name: 'Software', categoryType: 'product_category', // 'product_category'|'pricebook_type'|'company_industry'|'bundle_category' }); const { results } = await TurboQuote.listTypes({ categoryType: 'company_industry', includeUsage: true, }); ``` --- | Method | Signature | Returns | |---|---|---| | `list_types` | `(options?)` | paginated list | | `create_type` | `(request)` | `QuoteType` | | `update_type` | `(id, request)` | `QuoteType` | | `delete_type` | `(id)` | `{"message": ...}` | :::note No `get_type` by design The backend has no `GET /v1/types/:id` endpoint. Use `list_types` to retrieve individual type records. ::: ```python quote_type = await TurboQuote.create_type({"name": "New Business"}) await TurboQuote.update_type(quote_type["id"], {"name": "New Logo Business"}) ``` --- | Method | Signature | Returns | |---|---|---| | `listTypes` | `(?ListTypesRequest)` | `QuoteTypeListResponse` | | `createType` | `(CreateQuoteTypeRequest)` | `QuoteType` | | `updateType` | `(string $id, UpdateQuoteTypeRequest)` | `QuoteType` | | `deleteType` | `(string $id)` | `MessageResponse` | :::note No getType There is no `getType($id)` — the backend does not expose a `GET /v1/types/:id` endpoint. Use `listTypes` instead. ::: `CreateQuoteTypeRequest` requires **both** `name` and `categoryType` — there is no default. `categoryType` is one of `product_category`, `pricebook_type`, `company_industry`, or `bundle_category`. ```php use TurboDocx\Types\Requests\Quote\CreateQuoteTypeRequest; $type = TurboQuote::createType(new CreateQuoteTypeRequest( name: 'Renewal', categoryType: 'product_category', )); ``` Types are shared category records used by products, bundles, price books, and companies. A single `CategoryType` field distinguishes their role. | `CategoryType` | Used for | |---|---| | `product_category` | Product and line item categories | | `pricebook_type` | Price book type classification | | `company_industry` | Company industry tags | | `bundle_category` | Bundle categories | ```go catType := turbodocx.CategoryTypeProductCategory qType, err := qc.CreateType(ctx, &turbodocx.CreateQuoteTypeRequest{ Name: "Software", CategoryType: catType, }) qType, err = qc.UpdateType(ctx, "type-uuid", &turbodocx.UpdateQuoteTypeRequest{ Name: &[]string{"Software & SaaS"}[0], }) result, err := qc.DeleteType(ctx, "type-uuid") // List with usage info includeUsage := true list, err := qc.ListTypes(ctx, &turbodocx.ListTypesOptions{ CategoryType: &[]string{"product_category"}[0], IncludeUsage: &includeUsage, }) // list.Results[i].Usage.UsageCount int ``` :::note No GetType There is no `GetType(id)` — the backend has no `GET /v1/types/:id` endpoint. Use `ListTypes` with a `CategoryType` filter. ::: --- | Method | Signature | Returns | |---|---|---| | `listTypes` | `listTypes()` / `listTypes(ListTypesOptions)` | `QuoteTypeListResponse` | | `createType` | `createType(CreateQuoteTypeRequest)` | `QuoteType` | | `updateType` | `updateType(String id, UpdateQuoteTypeRequest)` | `QuoteType` | | `deleteType` | `deleteType(String id)` | `SuccessResponse` | Types are used for categorization. `CategoryType` has four values — `PRODUCT_CATEGORY`, `PRICEBOOK_TYPE`, `COMPANY_INDUSTRY`, and `BUNDLE_CATEGORY` — and a type's `id` is the UUID you pass as `categoryId` when creating a product (`PRODUCT_CATEGORY`) or a bundle (`BUNDLE_CATEGORY`), or as `priceBookTypeId` when creating a price book (`PRICEBOOK_TYPE`). ```java CreateQuoteTypeRequest req = new CreateQuoteTypeRequest(); req.setName("Partner Pricing"); req.setCategoryType(CategoryType.PRICEBOOK_TYPE); QuoteType type = tq.createType(req); ``` :::note No `getType` There is no `getType(id)` method — the backend has no `GET /v1/types/:id` endpoint by design. Use `listTypes` to retrieve all types. ::: --- ### Bulk Imports {#bulk-imports} Every create-family entity has a matching `bulkCreate*` method for seeding a catalog or migrating CRM data in one call. Each method sends `POST {resource}/bulk` with an array of rows using the **same shape as that entity's single-create request** (e.g. `bulkCreateProducts` takes `CreateProductRequest[]`). Company rows require a `contacts` array with at least one contact; contact rows require a `companyId`. Rows process sequentially with **partial success** — a failed row does not throw and does not roll back earlier rows. Every bulk method resolves to a `BulkImportResult`: - `imported` — count of rows created - `failed` — array of `{ row, reason }` for rows that did not import; `row` is the **1-indexed** position in your request array - `adjusted` — array of `{ row, reason }` for rows that imported *with* a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped) Requests are capped at **500 rows** — anything above the cap returns a `400`. Available to admin and contributor API keys. :::caution Product rows require a real `categoryId` Every `bulkCreateProducts` row **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`categoryType: 'product_category'`) — there is no `categoryName` field on the bulk row, and the API rejects unknown keys, so passing one returns a `400`. Resolve or create the category first with `listTypes` / `createType`, then pass its `id`. ::: ```typescript // 1. Resolve the product category first — bulk rows need its UUID, not its name. const { results: categories } = await TurboQuote.listTypes({ categoryType: 'product_category' }); const category = categories.find((t) => t.name === 'Software') ?? (await TurboQuote.createType({ name: 'Software', categoryType: 'product_category' })); // 2. Import, passing the resolved UUID on every row. const result = await TurboQuote.bulkCreateProducts([ { name: 'Enterprise Licence', categoryId: category.id, listPrice: 1200, billingFrequency: 'annual' }, { name: 'Onboarding Package', categoryId: category.id, listPrice: 499, billingFrequency: 'one-time' }, ]); console.log(`Imported ${result.imported} of 2 rows`); for (const failure of result.failed) { console.error(`Row ${failure.row} failed: ${failure.reason}`); } for (const adjustment of result.adjusted) { console.warn(`Row ${adjustment.row} imported with adjustment: ${adjustment.reason}`); } ``` The other five bulk methods follow the exact same pattern: | Method | Argument | Returns | |---|---|---| | `bulkCreatePriceBooks` | `CreatePriceBookRequest[]` | `BulkImportResult` | | `bulkCreateBundles` | `CreateBundleRequest[]` | `BulkImportResult` | | `bulkCreateCompanies` | `CreateCompanyRequest[]` — each row needs `contacts` (min. 1) | `BulkImportResult` | | `bulkCreateContacts` | `CreateContactRequest[]` — each row needs `companyId` | `BulkImportResult` | | `bulkCreateTypes` | `CreateQuoteTypeRequest[]` | `BulkImportResult` | --- Every create-family entity has a matching `bulk_create_*` method for seeding a catalog or migrating CRM data in one call. Each method sends `POST {resource}/bulk` with a list of row dicts using the **same shape as that entity's single-create request** — keys stay camelCase, even in Python. Company rows require a `contacts` list with at least one contact; contact rows require a `companyId`. Rows process sequentially with **partial success** — a failed row does not raise and does not roll back earlier rows. Every bulk method returns a `BulkImportResult` dict: - `imported` — count of rows created - `failed` — list of `{"row": ..., "reason": ...}` for rows that did not import; `row` is the **1-indexed** position in your request list - `adjusted` — list of `{"row": ..., "reason": ...}` for rows that imported *with* a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped) Requests are capped at **500 rows** — anything above the cap returns a `400`. Available to admin and contributor API keys. :::caution Product rows require a real `categoryId` Every `bulk_create_products` row **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`categoryType: "product_category"`) — there is no `categoryName` key on the bulk row schema, and the API rejects unknown keys, so passing one returns a `400`. Resolve or create the category first with `list_types` / `create_type`, then pass its `id`. ::: ```python # 1. Resolve the product category first — bulk rows need its UUID, not its name. page = await TurboQuote.list_types({"categoryType": "product_category"}) category = next((t for t in page["results"] if t["name"] == "Software"), None) if category is None: category = await TurboQuote.create_type({ "name": "Software", "categoryType": "product_category", }) # 2. Import, passing the resolved UUID on every row. result = await TurboQuote.bulk_create_products([ {"name": "Enterprise License", "categoryId": category["id"], "listPrice": "499.00", "billingFrequency": "annual"}, {"name": "Onboarding Package", "categoryId": category["id"], "listPrice": "999.00", "billingFrequency": "one-time"}, ]) print(f"Imported {result['imported']} of 2 rows") for failure in result["failed"]: print(f"Row {failure['row']} failed: {failure['reason']}") for adjustment in result["adjusted"]: print(f"Row {adjustment['row']} imported with adjustment: {adjustment['reason']}") ``` The other five bulk methods follow the exact same pattern: | Method | Rows | |---|---| | `bulk_create_price_books` | list of `create_price_book` dicts | | `bulk_create_bundles` | list of `create_bundle` dicts | | `bulk_create_companies` | list of `create_company` dicts — each needs `contacts` (min. 1) | | `bulk_create_contacts` | list of `create_contact` dicts — each needs `companyId` | | `bulk_create_types` | list of `create_type` dicts | --- Every create-family entity has a matching `bulkCreate*` method for seeding a catalog or migrating CRM data in one call. Each method sends `POST {resource}/bulk` with an array of the **same typed request objects as that entity's single-create method** (e.g. `bulkCreateProducts` takes an array of `CreateProductRequest`). Company rows require a `contacts` array with at least one contact; contact rows require a `companyId`. Rows process sequentially with **partial success** — a failed row does not throw and does not roll back earlier rows. Every bulk method returns a typed `BulkImportResult`: - `$result->imported` — count of rows created - `$result->failed` — array of `BulkImportRowIssue` (`row` + `reason`) for rows that did not import; `row` is the **1-indexed** position in your request array - `$result->adjusted` — array of `BulkImportRowIssue` for rows that imported *with* a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped) Requests are capped at **500 rows** — anything above the cap returns a `400`. Available to admin and contributor API keys. :::caution Product rows require a real `categoryId` Every `bulkCreateProducts` row **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`categoryType: 'product_category'`) — there is no `categoryName` field on the bulk row schema, and the API rejects unknown keys, so passing one returns a `400`. Resolve or create the category first with `listTypes` / `createType`, then pass its `id`. ::: ```php use TurboDocx\Types\Requests\Quote\CreateProductRequest; use TurboDocx\Types\Requests\Quote\CreateQuoteTypeRequest; use TurboDocx\Types\Requests\Quote\ListTypesRequest; // 1. Resolve the product category first — bulk rows need its UUID, not its name. $types = TurboQuote::listTypes(new ListTypesRequest(categoryType: 'product_category')); $categoryId = null; foreach ($types->results as $type) { if ($type->name === 'Software') { $categoryId = $type->id; break; } } if ($categoryId === null) { $categoryId = TurboQuote::createType(new CreateQuoteTypeRequest( name: 'Software', categoryType: 'product_category', ))->id; } // 2. Import, passing the resolved UUID on every row. $result = TurboQuote::bulkCreateProducts([ new CreateProductRequest( name: 'Enterprise Seat', categoryId: $categoryId, listPrice: 299.00, billingFrequency: 'monthly', ), new CreateProductRequest( name: 'Onboarding Package', categoryId: $categoryId, listPrice: 499.00, billingFrequency: 'one-time', ), ]); echo "Imported {$result->imported} of 2 rows\n"; foreach ($result->failed as $failure) { echo "Row {$failure->row} failed: {$failure->reason}\n"; } foreach ($result->adjusted as $adjustment) { echo "Row {$adjustment->row} imported with adjustment: {$adjustment->reason}\n"; } ``` The other five bulk methods follow the exact same pattern: | Method | Rows | Returns | |---|---|---| | `bulkCreatePriceBooks` | `CreatePriceBookRequest[]` | `BulkImportResult` | | `bulkCreateBundles` | `CreateBundleRequest[]` | `BulkImportResult` | | `bulkCreateCompanies` | `CreateCompanyRequest[]` — each row needs `contacts` (min. 1) | `BulkImportResult` | | `bulkCreateContacts` | `CreateContactRequest[]` — each row needs `companyId` | `BulkImportResult` | | `bulkCreateTypes` | `CreateQuoteTypeRequest[]` | `BulkImportResult` | Every create-family entity has a matching `BulkCreate*` method for seeding a catalog or migrating CRM data in one call. Each method sends `POST {resource}/bulk` with a slice of rows using the **same request type as that entity's single-create method** (e.g. `BulkCreateProducts` takes `[]CreateProductRequest`). Company rows require a `Contacts` slice with at least one contact; contact rows require a `CompanyID`. Rows process sequentially with **partial success** — a failed row does not produce an error and does not roll back earlier rows. Every bulk method returns `*BulkImportResult`: - `Imported` — count of rows created - `Failed` — `[]BulkImportRowIssue` (`Row` + `Reason`) for rows that did not import; `Row` is the **1-indexed** position in your request slice - `Adjusted` — `[]BulkImportRowIssue` for rows that imported *with* a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped) Requests are capped at **500 rows** — anything above the cap returns a `400`. Available to admin and contributor API keys. :::caution Product rows require a real CategoryID Every `BulkCreateProducts` row **requires** `Name`, `CategoryID`, `ListPrice`, and `BillingFrequency`. `CategoryID` must be the **UUID** of an existing type (`CategoryType: "product_category"`) — there is no `CategoryName` field on the bulk row schema, and the API rejects unknown keys, so sending one returns a `400`. Resolve or create the category first with `ListTypes` / `CreateType`, then pass its `ID`. ::: ```go // 1. Resolve the product category first — bulk rows need its UUID, not its name. types, err := qc.ListTypes(ctx, &turbodocx.ListTypesOptions{ CategoryType: &[]string{"product_category"}[0], }) if err != nil { log.Fatal(err) } var categoryID string for _, t := range types.Results { if t.Name == "Software" { categoryID = t.ID break } } if categoryID == "" { created, err := qc.CreateType(ctx, &turbodocx.CreateQuoteTypeRequest{ Name: "Software", CategoryType: turbodocx.CategoryTypeProductCategory, }) if err != nil { log.Fatal(err) } categoryID = created.ID } // 2. Import, passing the resolved UUID on every row. result, err := qc.BulkCreateProducts(ctx, []turbodocx.CreateProductRequest{ { Name: "Enterprise License", CategoryID: categoryID, ListPrice: 1200.00, BillingFrequency: "annual", }, { Name: "Onboarding Package", CategoryID: categoryID, ListPrice: 499.00, BillingFrequency: "one-time", }, }) if err != nil { log.Fatal(err) } fmt.Printf("Imported %d of 2 rows\n", result.Imported) for _, failure := range result.Failed { fmt.Printf("Row %d failed: %s\n", failure.Row, failure.Reason) } for _, adjustment := range result.Adjusted { fmt.Printf("Row %d imported with adjustment: %s\n", adjustment.Row, adjustment.Reason) } ``` The other five bulk methods follow the exact same pattern — `(ctx, rows)` in, `(*BulkImportResult, error)` out: | Method | Rows | |---|---| | `BulkCreatePriceBooks` | `[]CreatePriceBookRequest` | | `BulkCreateBundles` | `[]CreateBundleRequest` | | `BulkCreateCompanies` | `[]CreateCompanyRequest` — each row needs `Contacts` (min. 1) | | `BulkCreateContacts` | `[]CreateContactRequest` — each row needs `CompanyID` | | `BulkCreateTypes` | `[]CreateQuoteTypeRequest` | --- Every create-family entity has a matching `bulkCreate*` method for seeding a catalog or migrating CRM data in one call. Each method sends `POST {resource}/bulk` with a list of the **same request objects as that entity's single-create method** (e.g. `bulkCreateProducts` takes `List`). Company rows require a `contacts` list with at least one contact; contact rows require a `companyId`. Rows process sequentially with **partial success** — a failed row does not throw and does not roll back earlier rows. Every bulk method returns a `BulkImportResult`: - `getImported()` — count of rows created - `getFailed()` — list of `BulkImportRowIssue` (`getRow()` + `getReason()`) for rows that did not import; the row number is the **1-indexed** position in your request list - `getAdjusted()` — list of `BulkImportRowIssue` for rows that imported *with* a server-side adjustment (e.g. a bundle item whose product wasn't found was dropped) Requests are capped at **500 rows** — anything above the cap returns a `400`. Available to admin and contributor API keys. #### `bulkCreateProducts` {#bulkcreateproducts} ```java BulkImportResult bulkCreateProducts(List rows) ``` :::caution Product rows require a real `categoryId` Every `bulkCreateProducts` row **requires** `name`, `categoryId`, `listPrice`, and `billingFrequency`. `categoryId` must be the **UUID** of an existing type (`CategoryType.PRODUCT_CATEGORY`) — there is no `categoryName` field on the bulk row schema, and the API rejects unknown keys, so sending one returns a `400`. Resolve or create the category first with `listTypes` / `createType`, then pass its ID. ::: ```java // 1. Resolve the product category first — bulk rows need its UUID, not its name. ListTypesOptions typeOpts = new ListTypesOptions(); typeOpts.setCategoryType(CategoryType.PRODUCT_CATEGORY); String categoryId = tq.listTypes(typeOpts).getResults().stream() .filter(t -> "Software".equals(t.getName())) .map(QuoteType::getId) .findFirst() .orElseGet(() -> { CreateQuoteTypeRequest typeReq = new CreateQuoteTypeRequest(); typeReq.setName("Software"); typeReq.setCategoryType(CategoryType.PRODUCT_CATEGORY); try { return tq.createType(typeReq).getId(); } catch (Exception e) { throw new RuntimeException(e); } }); // 2. Import, passing the resolved UUID on every row. CreateProductRequest row1 = new CreateProductRequest(); row1.setName("Enterprise License"); row1.setCategoryId(categoryId); row1.setListPrice(1200.00); row1.setBillingFrequency("annual"); CreateProductRequest row2 = new CreateProductRequest(); row2.setName("Onboarding Package"); row2.setCategoryId(categoryId); row2.setListPrice(499.00); row2.setBillingFrequency("one-time"); BulkImportResult result = tq.bulkCreateProducts(Arrays.asList(row1, row2)); System.out.println("Imported " + result.getImported() + " of 2 rows"); for (BulkImportRowIssue failure : result.getFailed()) { System.err.println("Row " + failure.getRow() + " failed: " + failure.getReason()); } for (BulkImportRowIssue adjustment : result.getAdjusted()) { System.out.println("Row " + adjustment.getRow() + " imported with adjustment: " + adjustment.getReason()); } ``` The other five bulk methods follow the exact same pattern: | Method | Rows | Returns | |---|---|---| | `bulkCreatePriceBooks` | `List` | `BulkImportResult` | | `bulkCreateBundles` | `List` | `BulkImportResult` | | `bulkCreateCompanies` | `List` — each row needs `contacts` (min. 1) | `BulkImportResult` | | `bulkCreateContacts` | `List` — each row needs `companyId` | `BulkImportResult` | | `bulkCreateTypes` | `List` | `BulkImportResult` | --- ## Enums and Constants {#enums-and-constants} | Type | Values | |---|---| | `QuoteStatus` | `draft`, `pending_approval`, `sent`, `accepted`, `declined`, `voided` | | `BillingFrequency` | `monthly`, `quarterly`, `annual`, `one-time` | | `LineItemType` | `product`, `bundle` | | `RenewalPeriod` | `weekly`, `monthly`, `quarterly`, `annually` | | `Currency` | `USD`, `EUR`, `GBP`, `CAD`, `AUD`, `INR` | | `CategoryType` | `product_category`, `pricebook_type`, `company_industry`, `bundle_category` | | `BundleItemStatus` | `active`, `product_deleted`, `product_unavailable`, `currency_mismatch` | | `DiscountType` | `percent`, `amount` | :::note Terminal statuses `accepted`, `declined`, and `voided` are **terminal** — a quote in one of these states cannot be transitioned out of it, and any further status call returns a `400`. Check `quote.StatusInfo.IsTerminal` (and the `Can*` flags) before attempting a transition, and use `DuplicateQuote` when you need to revive a closed-out quote. ::: --- ## Null-Clear Semantics (PATCH) {#null-clear-semantics-patch} Go's zero value is indistinguishable from "not set" at the JSON level, so `UpdateQuoteRequest` and related PATCH request types use a private `nullFields` map to track fields the caller explicitly wants to set to `null`. Call the `Clear*` method on the request before passing it: ```go req := &turbodocx.UpdateQuoteRequest{} req.ClearTaxRate() // sends "taxRate": null (removes tax from quote) req.ClearRenewalPeriod() // sends "renewalPeriod": null // Name left unset — omitted from payload entirely quote, err := qc.UpdateQuote(ctx, "quote-uuid", req) ``` Types with `Clear*` helpers: | Type | Clearable fields | |---|---| | `UpdateQuoteRequest` | `PriceBookID`, `ValidUntil`, `TaxRate`, `RenewalPeriod` | | `UpdateLineItemRequest` | `Cost`, `CategoryID`, `CategoryName`, `ProductSku`, `ProductDescription`, `DisplayOrder` | | `UpdateProductRequest` | `Cost`, `Sku`, `Description`, `DetailedSpecification`, `InternalNotes` | | `UpdatePriceBookRequest` | `Description`, `ValidTo` | | `UpdateBundleRequest` | `Description`, `Sku`, `CategoryID` | | `UpdateCompanyRequest` | `Phone`, `City`, `State`, `Country`, `IndustryID` | | `UpdateContactRequest` | `Email`, `Phone`, `Title` | | `UpdateQuoteTemplateRequest` | `LogoURL`, `Disclaimer`, `TermsAndConditions`, `ClosingMessage`, `SenderName`, `SenderPhone`, `ContactEmail` | --- ### Convenience / Convenience Method {#convenience} #### `create_and_send` / `createAndSend` {#create_and_send} Orchestrates multiple API calls: create quote → add product items → add bundle items → send. Returns `{"quote": }`. ```python result = await TurboQuote.create_and_send({ # CreateQuote fields "name": "Year-End Renewal", "companyId": "company-uuid", "contactId": "contact-uuid", "currency": "USD", # optional line items added before send "items": [ {"productId": "product-uuid-1", "productName": "Platform License", "quantity": 10, "unitPrice": "99.00", "billingFrequency": "monthly"}, ], # optional bundle items added before send "bundleItems": [ {"bundleId": "bundle-uuid-1", "bundleName": "Starter Pack", "quantity": 1}, ], # send options (ccEmails, validUntil) "send": { "ccEmails": ["finance@example.com"], "validUntil": "2026-12-31", }, }) print(result["quote"]["id"]) ``` --- Creates a quote, optionally adds line items and bundle items, then sends it — all in a single call. ```php use TurboDocx\Types\Requests\Quote\CreateAndSendRequest; use TurboDocx\Types\Requests\Quote\AddLineItemRequest; use TurboDocx\Types\Requests\Quote\AddBundleLineItemRequest; use TurboDocx\Types\Requests\Quote\SendQuoteRequest; $result = TurboQuote::createAndSend(new CreateAndSendRequest( name: 'Annual Plan', companyId: 'company-uuid', contactId: 'contact-uuid', validUntil: '2026-12-31', items: [ new AddLineItemRequest( productId: 'product-uuid', productName: 'Annual License', unitPrice: 999.00, billingFrequency: 'annual', quantity: 10, ), ], bundleItems: [ new AddBundleLineItemRequest( bundleId: 'bundle-uuid', bundleName: 'Annual Bundle', quantity: 1, ), ], send: new SendQuoteRequest(), )); echo "Sent quote: {$result->quote->id}\n"; ``` --- ```java CreateAndSendResponse createAndSend(CreateAndSendRequest request) ``` Create a quote, add line items and bundle items, and send it — all in one method call. Useful for programmatic quote generation pipelines. ```java AddLineItemRequest item = new AddLineItemRequest(); item.setProductId("product-uuid"); // required — null for a custom line item item.setProductName("Starter License"); item.setUnitPrice(999.00); item.setQuantity(1.0); item.setBillingFrequency("annual"); // required CreateAndSendRequest req = new CreateAndSendRequest(); req.setName("Quick Proposal"); req.setCompanyId(companyId); req.setContactId(contactId); req.setItems(Arrays.asList(item)); // req.setSend(sendOptions) — optional CreateAndSendResponse result = tq.createAndSend(req); System.out.println("Quote: " + result.getQuote().getId()); ``` --- ## TypeScript Types {#typescript-types} Key types exported from `@turbodocx/sdk`: ```typescript // Core quote Quote, QuoteStatusInfo, CreateQuoteRequest, UpdateQuoteRequest, ListQuotesOptions, QuoteListResponse, SendQuoteRequest, SendQuoteWithDeliverableRequest, DeclineQuoteRequest, VoidQuoteRequest, HandleExpiredQuoteRequest, ApplyPriceBookResponse, CreateAndSendRequest, // Line items LineItem, AddLineItemRequest, AddBundleLineItemRequest, UpdateLineItemRequest, ListLineItemsOptions, // Products Product, CreateProductRequest, UpdateProductRequest, ListProductsOptions, // Bundles Bundle, CreateBundleRequest, UpdateBundleRequest, // Price books PriceBook, CreatePriceBookRequest, // Companies & contacts Company, CreateCompanyRequest, Contact, CreateContactRequest, // Templates & types QuoteTemplate, QuoteType, // Enums QuoteStatus, BillingFrequency, Currency, RenewalPeriod, LineItemType, DiscountType, CategoryType, // Shared SuccessResponse, } from '@turbodocx/sdk'; ``` ### Key Enum Values {#key-enum-values} | Type | Values | |---|---| | `QuoteStatus` | `'draft'` `'pending_approval'` `'sent'` `'accepted'` `'declined'` `'voided'` | | `BillingFrequency` | `'monthly'` `'quarterly'` `'annual'` `'one-time'` | | `Currency` | `'USD'` `'EUR'` `'GBP'` `'CAD'` `'AUD'` `'INR'` | | `RenewalPeriod` | `'weekly'` `'monthly'` `'quarterly'` `'annually'` | | `DiscountType` | `'percent'` `'amount'` | | `CategoryType` | `'product_category'` `'pricebook_type'` `'company_industry'` `'bundle_category'` | :::note Terminal statuses `accepted`, `declined`, and `voided` are **terminal** — a quote in one of these states cannot be transitioned out of it, and any further status call returns a `400`. Check `quote.statusInfo` (`canSend`, `canAccept`, `canDecline`, `canVoid`) before attempting a transition, and `duplicateQuote` when you need to revive a closed-out quote. ::: --- ## Error Handling {#error-handling} ```typescript TurboQuote, TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, RateLimitError, NetworkError, } from '@turbodocx/sdk'; try { await TurboQuote.sendQuote('quote-uuid'); } catch (e) { if (e instanceof ValidationError) { // 400 — e.g. quote is not in draft status, missing required fields console.error('Validation failed:', e.message); } else if (e instanceof NotFoundError) { // 404 — quote, company, contact, or product does not exist console.error('Not found:', e.message); } else if (e instanceof AuthenticationError) { // 401 — bad or revoked API key console.error('Auth failed:', e.message); } else if (e instanceof AuthorizationError) { // 403 — API key does not have permission for this org console.error('Forbidden:', e.message); } else if (e instanceof RateLimitError) { // 429 — back off and retry console.error('Rate limited — retry after a moment'); } else if (e instanceof NetworkError) { // request never reached the server console.error('Network error:', e.message); } else if (e instanceof TurboDocxError) { // catch-all for any other typed SDK error console.error(`Error ${e.statusCode}: ${e.message}`); } else { throw e; } } ``` ```python from turbodocx_sdk import ( TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, ConflictError, RateLimitError, NetworkError, ) try: quote = await TurboQuote.create_quote({ "name": "Q3 Proposal", "companyId": "company-uuid", "contactId": "contact-uuid", }) except ValidationError as e: # 400 — missing required fields, invalid enum value, etc. print(f"Validation failed: {e}") except AuthenticationError: # 401 — bad or revoked API key pass except AuthorizationError: # 403 — key valid but lacks permission for this org/action pass except NotFoundError as e: # 404 — company, contact, or product not found print(f"Not found: {e}") except RateLimitError: # 429 — back off and retry pass except NetworkError: # request never reached the server (DNS, refused, timeout) pass except TurboDocxError as e: # catch-all for any other typed SDK error (raw 5xx, etc.) print(f"Error {getattr(e, 'status_code', '?')}: {e}") ``` ```php use TurboDocx\Exceptions\TurboDocxException; use TurboDocx\Exceptions\AuthenticationException; use TurboDocx\Exceptions\AuthorizationException; use TurboDocx\Exceptions\ValidationException; use TurboDocx\Exceptions\NotFoundException; use TurboDocx\Exceptions\RateLimitException; use TurboDocx\Exceptions\NetworkException; try { $quote = TurboQuote::createQuote(new CreateQuoteRequest( name: 'Q3 Proposal', companyId: 'company-uuid', contactId: 'contact-uuid', )); } catch (ValidationException $e) { // 400 — missing required fields, invalid values echo "Validation error: {$e->getMessage()}\n"; } catch (AuthenticationException $e) { // 401 — missing or revoked API key echo "Auth failed: {$e->getMessage()}\n"; } catch (AuthorizationException $e) { // 403 — key exists but lacks permission echo "Forbidden: {$e->getMessage()}\n"; } catch (NotFoundException $e) { // 404 — quote, company, product, etc. not found echo "Not found: {$e->getMessage()}\n"; } catch (RateLimitException $e) { // 429 — back off and retry echo "Rate limited: {$e->getMessage()}\n"; } catch (NetworkException $e) { // request never reached the server echo "Network error: {$e->getMessage()}\n"; } catch (TurboDocxException $e) { // catch-all for any other typed SDK error echo "Error {$e->statusCode}: {$e->getMessage()}\n"; } ``` ```go _, err := qc.CreateQuote(ctx, req) if err != nil { var valErr *turbodocx.ValidationError var auth *turbodocx.AuthenticationError var authz *turbodocx.AuthorizationError var nf *turbodocx.NotFoundError var rate *turbodocx.RateLimitError var netErr *turbodocx.NetworkError var tdx *turbodocx.TurboDocxError switch { case errors.As(err, &valErr): // 400 — invalid request body (missing required field, bad enum value, etc.) log.Printf("Validation: %s", valErr.Message) case errors.As(err, &auth): // 401 — missing or invalid API key case errors.As(err, &authz): // 403 — key valid but lacks required permissions case errors.As(err, &nf): // 404 — quote, product, bundle, etc. not found case errors.As(err, &rate): // 429 — back off and retry case errors.As(err, &netErr): // request never reached the server case errors.As(err, &tdx): log.Printf("API error %d: %s", tdx.StatusCode, tdx.Message) default: log.Fatal(err) } } ``` ```java try { Quote quote = tq.createQuote(req); } catch (TurboDocxException.ValidationException e) { // 400 — invalid request body (missing required field, bad enum value, etc.) System.err.println("Validation: " + e.getMessage()); } catch (TurboDocxException.AuthenticationException e) { // 401 — bad or revoked API key System.err.println("Auth: " + e.getMessage()); } catch (TurboDocxException.AuthorizationException e) { // 403 — key lacks required role System.err.println("Forbidden: " + e.getMessage()); } catch (TurboDocxException.NotFoundException e) { // 404 — quote, product, company, etc. not found System.err.println("Not found: " + e.getMessage()); } catch (TurboDocxException.RateLimitException e) { // 429 — back off and retry System.err.println("Rate limited: " + e.getMessage()); } catch (TurboDocxException.NetworkException e) { // request never reached the server (DNS, refused, timeout) System.err.println("Network error: " + e.getMessage()); } catch (TurboDocxException e) { // catch-all for any other typed SDK error System.err.println("Error " + e.getStatusCode() + ": " + e.getMessage()); } ``` ### Common Error Codes / Error Code Reference / Error Types {#common-error-codes} | Status | Class | When | |---|---|---| | 400 | `ValidationError` | Invalid request body, wrong quote status for transition | | 401 | `AuthenticationError` | Missing or invalid API key | | 403 | `AuthorizationError` | Valid key without permission for this resource | | 404 | `NotFoundError` | Quote, company, product, or contact not found | | 429 | `RateLimitError` | Rate limit exceeded — back off and retry | --- | Status | Class | When | |---|---|---| | 400 | `ValidationError` | Invalid fields, missing required keys, bad enum value | | 401 | `AuthenticationError` | Missing or invalid API key | | 403 | `AuthorizationError` | Valid key without permission for this operation | | 404 | `NotFoundError` | Quote, product, company, or other resource not found | | 409 | `ConflictError` | Duplicate resource (e.g., company domain conflict) | | 429 | `RateLimitError` | Rate limit exceeded — back off | --- | Status | Exception | When | |---|---|---| | 400 | `ValidationException` | Missing required fields, invalid enum value, malformed body | | 401 | `AuthenticationException` | Missing or invalid API key | | 403 | `AuthorizationException` | Valid key without sufficient permissions | | 404 | `NotFoundException` | Quote, product, company, etc. does not exist | | 409 | `TurboDocxException` | Conflict (e.g., duplicate SKU) | | 429 | `RateLimitException` | Rate limit exceeded — back off | --- | Status | Type | When | |---|---|---| | 400 | `*ValidationError` | Bad request body, missing required field, invalid enum | | 401 | `*AuthenticationError` | Missing or invalid API key | | 403 | `*AuthorizationError` | Key valid but lacks required permissions | | 404 | `*NotFoundError` | Quote, product, bundle, company, etc. not found | | 429 | `*RateLimitError` | Rate limit exceeded | | — | `*NetworkError` | DNS failure, refused connection, timeout | --- | Status | Type | When | |---|---|---| | 400 | `TurboDocxException.ValidationException` | Invalid request body, missing required field | | 401 | `TurboDocxException.AuthenticationException` | Missing or invalid API key | | 403 | `TurboDocxException.AuthorizationException` | Valid key without required role | | 404 | `TurboDocxException.NotFoundException` | Quote, product, company, contact, etc. not found | | 429 | `TurboDocxException.RateLimitException` | Rate limit exceeded — back off and retry | ## Runnable Examples / Runnable End-to-End Examples {#runnable-examples} Validated end-to-end examples live in the SDK repo: - **[`turboquote-basic.ts`](https://github.com/TurboDocx/SDK/blob/main/packages/js-sdk/examples/turboquote-basic.ts)** — full quote lifecycle (create → add line items → send → download PDF → delete) - **[`turboquote-products.ts`](https://github.com/TurboDocx/SDK/blob/main/packages/js-sdk/examples/turboquote-products.ts)** — product and bundle catalog management - **[`turboquote-pricebooks.ts`](https://github.com/TurboDocx/SDK/blob/main/packages/js-sdk/examples/turboquote-pricebooks.ts)** — price book CRUD and `applyPriceBook` Run any example with: ```bash export TURBODOCX_API_KEY=your_key export TURBODOCX_ORG_ID=your_org_id npx tsx examples/turboquote-basic.ts ``` --- Three fully runnable examples live in the SDK repo: - **[`TurboQuoteBasic.java`](https://github.com/TurboDocx/SDK/blob/main/packages/java-sdk/examples/TurboQuoteBasic.java)** — full quote lifecycle: create company, create quote, add line items, send, download PDF, clean up. - **[`TurboQuoteProducts.java`](https://github.com/TurboDocx/SDK/blob/main/packages/java-sdk/examples/TurboQuoteProducts.java)** — product and bundle catalog management. - **[`TurboQuotePricebooks.java`](https://github.com/TurboDocx/SDK/blob/main/packages/java-sdk/examples/TurboQuotePricebooks.java)** — price book CRUD, `applyPriceBook`, and optional `sendQuoteWithDeliverable` (set `TURBODOCX_DELIVERABLE_ID` env var). Run any example after exporting `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID`. ## See Also {#see-also} - [TurboSign JavaScript SDK](/docs/SDKs/turbosign?language=js) — send documents for e-signature - [TurboWebhooks JavaScript SDK](/docs/SDKs/webhooks?language=js) — receive real-time signature events - [Deliverable JavaScript SDK](/docs/SDKs/deliverable?language=js) — generate documents from templates - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [@turbodocx/sdk on npm](https://www.npmjs.com/package/@turbodocx/sdk) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [TurboQuote JavaScript / TypeScript SDK](/docs/SDKs/quote?language=js) — same API, JS idioms - [TurboSign Python SDK](/docs/SDKs/turbosign?language=python) — send documents for e-signature - [TurboWebhooks Python SDK](/docs/SDKs/webhooks?language=python) — receive real-time signature events - [Deliverable Python SDK](/docs/SDKs/deliverable?language=python) — generate documents from templates - [SDKs Overview](/docs/SDKs) — all SDKs across all languages - [turbodocx-sdk on PyPI](https://pypi.org/project/turbodocx-sdk/) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [TurboQuote Python SDK](/docs/SDKs/quote?language=python) — same surface in Python - [TurboSign PHP SDK](/docs/SDKs/turbosign?language=php) — sending documents for signature from PHP - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) — receiving signature events in PHP - [Deliverable PHP SDK](/docs/SDKs/deliverable?language=php) — document generation from PHP - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [TurboDocx SDK on Packagist](https://packagist.org/packages/turbodocx/sdk) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [TurboQuote JavaScript / TypeScript SDK](/docs/SDKs/quote?language=js) — same API, JS/TS idioms - [TurboQuote Python SDK](/docs/SDKs/quote?language=python) — same API, Python idioms - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [TurboSign Go SDK](/docs/SDKs/turbosign?language=go) — sending documents for e-signature - [Deliverable Go SDK](/docs/SDKs/deliverable?language=go) — generating documents from templates - [TurboWebhooks Go SDK](/docs/SDKs/webhooks?language=go) — real-time event delivery - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [TurboQuote JavaScript / TypeScript SDK](/docs/SDKs/quote?language=js) — same API, JS idioms - [TurboQuote Python SDK](/docs/SDKs/quote?language=python) — same API, Python idioms - [TurboQuote PHP SDK](/docs/SDKs/quote?language=php) — same API, PHP idioms - [TurboSign Java SDK](/docs/SDKs/turbosign?language=java) — sending documents for e-signature - [TurboWebhooks Java SDK](/docs/SDKs/webhooks?language=java) — receiving signature events - [SDKs Overview](/docs/SDKs) — all SDKs across all languages - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) --- # TurboSign SDK The official TurboDocx SDK for JavaScript and TypeScript applications. Build document generation and digital signature workflows with full TypeScript support, async/await patterns, and comprehensive error handling. Available on npm as `@turbodocx/sdk`. The official TurboDocx SDK for Python applications. Build document generation and digital signature workflows with async/await patterns and comprehensive error handling. Available on PyPI as `turbodocx-sdk`. The official TurboDocx SDK for PHP applications. Build document generation and digital signature workflows with modern PHP 8.1+ features, strong typing, and comprehensive error handling. Available on Packagist as `turbodocx/sdk`. The official TurboDocx SDK for Go applications. Build document generation and digital signature workflows with idiomatic Go patterns, context support, and comprehensive error handling. Available as `github.com/TurboDocx/SDK/packages/go-sdk`. The official TurboDocx SDK for Java applications. Build document generation and digital signature workflows with the Builder pattern, comprehensive error handling, and type-safe APIs. Available on Maven Central as `com.turbodocx:turbodocx-sdk`. ## Installation {#installation} ```bash npm install @turbodocx/sdk ``` ```bash yarn add @turbodocx/sdk ``` ```bash pnpm add @turbodocx/sdk ``` ```bash pip install turbodocx-sdk ``` ```bash poetry add turbodocx-sdk ``` ```bash pipenv install turbodocx-sdk ``` ```bash composer require turbodocx/sdk ``` ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ## Requirements {#requirements} - Node.js 18+ or modern browser - TypeScript 4.7+ (optional, for type checking) - Python 3.9+ - `httpx` (installed automatically) - PHP 8.1 or higher - Composer - ext-json - ext-fileinfo :::tip Modern PHP Features This SDK leverages PHP 8.1+ features including enums, named parameters, readonly classes, and match expressions for a superior developer experience. ::: - Go 1.21+ - Java 11+ - OkHttp 4.x (included) - Gson 2.x (included) --- ## Configuration {#configuration} ```javascript const { TurboSign } = require("@turbodocx/sdk"); // Configure globally (recommended for server-side) TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY, // Required : Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID, // Required: Your organization ID senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // Required: reply-to address for signature request emails senderName: "Your Company Name", // Optional but recommended: appears as the sender name // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` ```typescript // Configure globally (recommended for server-side) TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", // Required : Your TurboDocx API key orgId: process.env.TURBODOCX_ORG_ID || "", // Required: Your organization ID senderEmail: process.env.TURBODOCX_SENDER_EMAIL || "", // Required: reply-to address for signature request emails senderName: "Your Company Name", // Optional but recommended: appears as the sender name // Optional: override base URL for testing // baseUrl: 'https://api.turbodocx.com' }); ``` :::tip Authentication Authenticate using `apiKey`. API keys are recommended for server-side applications. ::: ```python from turbodocx_sdk import TurboSign # Configure globally (recommended) TurboSign.configure( api_key=os.environ["TURBODOCX_API_KEY"], # Required: Your TurboDocx API key org_id=os.environ["TURBODOCX_ORG_ID"], # Required: Your organization ID sender_email="contracts@yourcompany.com", # Required: Reply-to address for signature emails sender_name="Your Company", # Recommended: Sender name shown in emails # base_url="https://api.turbodocx.com" # Optional: Override base URL ) ``` :::tip Authentication Authenticate using `api_key`. API keys are recommended for server-side applications. ::: ```php ```php :::warning Sender Email Required The `senderEmail` parameter is **required** for TurboSign. This email appears as the reply-to address in signature request emails. If you omit it, `configure()` throws a `ValidationException` before any request is sent (unless `skipSenderValidation` is enabled, which TurboSign does not use). ::: ```go package main "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { // Create a new client (reads SenderEmail from TURBODOCX_SENDER_EMAIL) client, err := turbodocx.NewClient( os.Getenv("TURBODOCX_API_KEY"), os.Getenv("TURBODOCX_ORG_ID"), ) if err != nil { log.Fatal(err) } _ = client // Or with custom configuration client, err = turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), SenderEmail: os.Getenv("TURBODOCX_SENDER_EMAIL"), // Required for TurboSign BaseURL: "https://api.turbodocx.com", // Optional custom base URL }) if err != nil { log.Fatal(err) } _ = client } ``` ```java public class Main { public static void main(String[] args) { // Create client with Builder pattern TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .build(); // Or with custom base URL TurboDocxClient customClient = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .baseUrl("https://api.turbodocx.com") .build(); } } ``` ### Builder Options {#builder-options} | Method | Type | Required | Default | Description | | ------------------------------- | -------- | -------- | ----------------------------- | -------------------------------------------------- | | `apiKey(String)` | `String` | Yes\* | - | Organization API key | | `accessToken(String)` | `String` | Yes\* | - | Bearer access token (alternative to `apiKey`) | | `orgId(String)` | `String` | Yes | - | Organization ID | | `senderEmail(String)` | `String` | Yes | - | Reply-to address for signature request emails | | `senderName(String)` | `String` | No | - | Display name used on signature request emails | | `baseUrl(String)` | `String` | No | `https://api.turbodocx.com` | API base URL | | `connectTimeoutSeconds(int)` | `int` | No | `60` | Connection timeout | | `readTimeoutSeconds(int)` | `int` | No | `120` | Read timeout, raise it for large document uploads | | `writeTimeoutSeconds(int)` | `int` | No | `60` | Write timeout, raise it for large document uploads | \*Provide either `apiKey` or `accessToken`. ```java // Tune the timeouts for large documents TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .connectTimeoutSeconds(30) .readTimeoutSeconds(300) .writeTimeoutSeconds(300) .build(); ``` ### Closing the Client {#closing-the-client} `TurboDocxClient` implements `AutoCloseable`. Calling `close()` shuts down the underlying OkHttp dispatcher and connection pool, so long-running JVM services should close clients they no longer need. Use try-with-resources for short-lived clients: ```java try (TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .build()) { SendSignatureResponse result = client.turboSign().sendSignature(request); } ``` :::tip Reuse a single client Creating a client per request leaks OkHttp threads until they are closed. Prefer one long-lived client for the life of your application and call `client.close()` during shutdown. ::: ### Environment Variables {#environment-variables} ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here ``` ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here TURBODOCX_SENDER_EMAIL=contracts@yourcompany.com TURBODOCX_SENDER_NAME=Your Company ``` :::warning API Credentials Required `api_key` and `org_id` are **required** for all API requests. TurboSign additionally **requires `sender_email`** (set it on `configure()`, per call, or via the `TURBODOCX_SENDER_EMAIL` environment variable): `configure()` raises a `ValidationError` without it. `sender_name` is optional but strongly recommended. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: ```bash # .env TURBODOCX_API_KEY=your_api_key_here TURBODOCX_ORG_ID=your_org_id_here TURBODOCX_SENDER_EMAIL=you@company.com TURBODOCX_SENDER_NAME=Your Company Name ``` :::warning API Credentials Required Both `apiKey` and `orgId` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here export TURBODOCX_SENDER_EMAIL=you@example.com # Required for TurboSign (reply-to address) ``` ```bash export TURBODOCX_API_KEY=your_api_key_here export TURBODOCX_ORG_ID=your_org_id_here export TURBODOCX_SENDER_EMAIL=sender@yourcompany.com ``` :::warning API Credentials Required Three parameters are **required** for TurboSign operations: `apiKey` (or `accessToken`), `orgId`, and `senderEmail`. The `senderEmail` is used as the reply-to address for signature request emails. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: --- ## Quick Start {#quick-start} ### Send a Document for Signature {#send-a-document-for-signature} ```javascript const { TurboSign } = require("@turbodocx/sdk"); TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, senderEmail: process.env.TURBODOCX_SENDER_EMAIL, }); (async () => { // Send document with coordinate-based fields const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Service Agreement", senderName: "Acme Corp", senderEmail: "contracts@acme.com", recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, { name: "Bob Johnson", email: "bob@example.com", signingOrder: 2 }, ], fields: [ // Alice's signature { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "alice@example.com", }, { type: "date", page: 1, x: 320, y: 650, width: 100, height: 30, recipientEmail: "alice@example.com", // Pins a fixed date in MM/DD/YYYY; omit to auto-fill the signing date defaultValue: "12/31/2026", }, // Bob's signature { type: "signature", page: 1, x: 100, y: 720, width: 200, height: 50, recipientEmail: "bob@example.com", }, { type: "date", page: 1, x: 320, y: 720, width: 100, height: 30, recipientEmail: "bob@example.com", }, ], }); console.log(JSON.stringify(result, null, 2)); })(); ``` ```typescript TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", orgId: process.env.TURBODOCX_ORG_ID || "", senderEmail: process.env.TURBODOCX_SENDER_EMAIL || "", }); // Send document with coordinate-based fields const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Service Agreement", senderName: "Acme Corp", senderEmail: "contracts@acme.com", recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, { name: "Bob Johnson", email: "bob@example.com", signingOrder: 2 }, ], fields: [ // Alice's signature { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "alice@example.com", }, { type: "date", page: 1, x: 320, y: 650, width: 100, height: 30, recipientEmail: "alice@example.com", }, // Bob's signature { type: "signature", page: 1, x: 100, y: 720, width: 200, height: 50, recipientEmail: "bob@example.com", }, { type: "date", page: 1, x: 320, y: 720, width: 100, height: 30, recipientEmail: "bob@example.com", }, ], }); console.log(JSON.stringify(result, null, 2)); ``` ```python from turbodocx_sdk import TurboSign TurboSign.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], sender_email="contracts@acme.com", sender_name="Acme Corp", ) async def send_contract(): result = await TurboSign.send_signature( recipients=[ {"name": "Alice Smith", "email": "alice@example.com", "signingOrder": 1}, {"name": "Bob Johnson", "email": "bob@example.com", "signingOrder": 2} ], fields=[ # Alice's signature {"type": "signature", "page": 1, "x": 100, "y": 650, "width": 200, "height": 50, "recipientEmail": "alice@example.com"}, {"type": "date", "page": 1, "x": 320, "y": 650, "width": 100, "height": 30, "recipientEmail": "alice@example.com"}, # Bob's signature {"type": "signature", "page": 1, "x": 100, "y": 720, "width": 200, "height": 50, "recipientEmail": "bob@example.com"}, {"type": "date", "page": 1, "x": 320, "y": 720, "width": 100, "height": 30, "recipientEmail": "bob@example.com"} ], file_link="https://www.turbodocx.com/examples/turbodocx.pdf", document_name="Service Agreement", sender_name="Acme Corp", sender_email="contracts@acme.com", ) print("Result:", json.dumps(result, indent=2)) asyncio.run(send_contract()) ``` ```php documentId}\n"; ``` :::warning Always Handle Errors The above examples omit error handling for brevity. In production, wrap all TurboSign calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: ```go package main "context" "encoding/json" "fmt" "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { client, err := turbodocx.NewClient( os.Getenv("TURBODOCX_API_KEY"), os.Getenv("TURBODOCX_ORG_ID"), ) if err != nil { log.Fatal(err) } ctx := context.Background() result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", DocumentName: "Service Agreement", SenderName: "Acme Corp", SenderEmail: "contracts@acme.com", Recipients: []turbodocx.Recipient{ {Name: "Alice Smith", Email: "alice@example.com", SigningOrder: 1}, {Name: "Bob Johnson", Email: "bob@example.com", SigningOrder: 2}, }, Fields: []turbodocx.Field{ // Alice's signature {Type: "signature", Page: 1, X: 100, Y: 650, Width: 200, Height: 50, RecipientEmail: "alice@example.com"}, {Type: "date", Page: 1, X: 320, Y: 650, Width: 100, Height: 30, RecipientEmail: "alice@example.com"}, // Bob's signature {Type: "signature", Page: 1, X: 100, Y: 720, Width: 200, Height: 50, RecipientEmail: "bob@example.com"}, {Type: "date", Page: 1, X: 320, Y: 720, Width: 100, Height: 30, RecipientEmail: "bob@example.com"}, }, }) if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) } ``` ```java public class Main { public static void main(String[] args) throws Exception { TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail(System.getenv("TURBODOCX_SENDER_EMAIL")) .build(); Gson gson = new GsonBuilder().setPrettyPrinting().create(); SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .documentName("Service Agreement") .senderName("Acme Corp") .senderEmail("contracts@acme.com") .recipients(Arrays.asList( new Recipient("Alice Smith", "alice@example.com", 1), new Recipient("Bob Johnson", "bob@example.com", 2) )) .fields(Arrays.asList( // Alice's signature new Field("signature", 1, 100, 650, 200, 50, "alice@example.com"), // defaultValue pins a fixed date in MM/DD/YYYY; omit to auto-fill the signing date new Field("date", 1, 320, 650, 100, 30, "alice@example.com", "12/31/2026", null, null, null, null, null, null), // Bob's signature new Field("signature", 1, 100, 720, 200, 50, "bob@example.com"), new Field("date", 1, 320, 720, 100, 30, "bob@example.com") )) .build() ); System.out.println("Result: " + gson.toJson(result)); } } ``` ### Using Template-Based Fields {#using-template-based-fields} ```javascript // Use text anchors instead of coordinates const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", recipientEmail: "alice@example.com", template: { anchor: "{SIGNATURE_ALICE}", placement: "replace", size: { width: 200, height: 50 }, }, }, { type: "date", recipientEmail: "alice@example.com", template: { anchor: "{DATE_ALICE}", placement: "replace", size: { width: 100, height: 30 }, }, }, ], }); console.log(JSON.stringify(result, null, 2)); ``` ```typescript // Use text anchors instead of coordinates const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", recipientEmail: "alice@example.com", template: { anchor: "{SIGNATURE_ALICE}", placement: "replace", size: { width: 200, height: 50 }, }, }, { type: "date", recipientEmail: "alice@example.com", template: { anchor: "{DATE_ALICE}", placement: "replace", size: { width: 100, height: 30 }, }, }, ], }); console.log(JSON.stringify(result, null, 2)); ``` :::info Template Anchors Required **Important:** The document file must contain the anchor text (e.g., `{SIGNATURE_ALICE}`, `{DATE_ALICE}`) that you reference in your fields. If the anchors don't exist in the document, the API will return an error. **Alternative:** Use a TurboDocx template with pre-configured anchors: ```typescript const result = await TurboSign.sendSignature({ templateId: "template-uuid-from-turbodocx", // Template already contains anchors recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", recipientEmail: "alice@example.com", template: { anchor: "{SIGNATURE_ALICE}", placement: "replace", size: { width: 200, height: 50 }, }, }, ], }); ``` ::: ```python async def send_with_template(): result = await TurboSign.send_signature( recipients=[{"name": "Alice Smith", "email": "alice@example.com", "signingOrder": 1}], fields=[ { "type": "signature", "recipientEmail": "alice@example.com", "template": { "anchor": "{SIGNATURE_ALICE}", "placement": "replace", "size": {"width": 200, "height": 50}, }, }, { "type": "date", "recipientEmail": "alice@example.com", # Pins a fixed date in MM/DD/YYYY; omit to auto-fill the signing date "defaultValue": "12/31/2026", "template": { "anchor": "{DATE_ALICE}", "placement": "replace", "size": {"width": 100, "height": 30}, }, }, ], file_link="https://www.turbodocx.com/examples/turbodocx.pdf", ) print("Result:", json.dumps(result, indent=2)) asyncio.run(send_with_template()) ``` :::info Template Anchors Required **Important:** The document file must contain the anchor text (e.g., `{SIGNATURE_ALICE}`, `{DATE_ALICE}`) that you reference in your fields. If the anchors don't exist in the document, the API will return an error. ::: ```php 200, 'height' => 50] ) ), new Field( type: SignatureFieldType::DATE, recipientEmail: 'alice@example.com', template: new TemplateConfig( anchor: '{DATE_ALICE}', placement: FieldPlacement::REPLACE, size: ['width' => 100, 'height' => 30] ) ) ], fileLink: 'https://www.turbodocx.com/examples/turbodocx.pdf', senderName: 'Your Company', senderEmail: 'sender@company.com' ) ); ``` :::warning Always Handle Errors The above examples omit error handling for brevity. In production, wrap all TurboSign calls in try-catch blocks. See [Error Handling](#error-handling) for complete patterns. ::: :::info Template Anchors Required **Important:** The document file must contain the anchor text (e.g., `{SIGNATURE_ALICE}`, `{DATE_ALICE}`) that you reference in your fields. If the anchors don't exist in the document, the API will return an error. ::: ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", Recipients: []turbodocx.Recipient{ {Name: "Alice Smith", Email: "alice@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ { Type: "signature", RecipientEmail: "alice@example.com", Template: &turbodocx.TemplateAnchor{ Anchor: "{SIGNATURE_ALICE}", Placement: "replace", Size: &turbodocx.Size{Width: 200, Height: 50}, }, }, { Type: "date", RecipientEmail: "alice@example.com", // Pins a fixed date in MM/DD/YYYY; omit to auto-fill the signing date DefaultValue: "12/31/2026", Template: &turbodocx.TemplateAnchor{ Anchor: "{DATE_ALICE}", Placement: "replace", Size: &turbodocx.Size{Width: 100, Height: 30}, }, }, }, }) ``` :::info Template Anchors Required **Important:** The document file must contain the anchor text (e.g., `{SIGNATURE_ALICE}`, `{DATE_ALICE}`) that you reference in your fields. If the anchors don't exist in the document, the API will return an error. ::: ```java // Template-based field using anchor text Field.TemplateAnchor templateAnchor = new Field.TemplateAnchor( "{SIGNATURE_ALICE}", // anchor text to find null, // searchText (alternative to anchor) "replace", // placement: replace/before/after/above/below new Field.Size(200, 50), // size null, // offset false, // caseSensitive false // useRegex ); // Field with template anchor (no page/x/y coordinates needed) Field templateField = new Field( "signature", // type null, // page (null for template-based) null, // x (null for template-based) null, // y (null for template-based) null, // width (null, using template size) null, // height (null, using template size) "alice@example.com", // recipientEmail null, // defaultValue null, // isMultiline null, // isReadonly null, // required null, // backgroundColor templateAnchor // template anchor config ); SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .recipients(Arrays.asList( new Recipient("Alice Smith", "alice@example.com", 1) )) .fields(Arrays.asList(templateField)) .build() ); ``` :::info Template Anchors Required **Important:** The document file must contain the anchor text (e.g., `{SIGNATURE_ALICE}`, `{DATE_ALICE}`) that you reference in your fields. If the anchors don't exist in the document, the API will return an error. ::: --- ## File Input Methods {#file-input-methods} TurboSign supports four different ways to provide document files: TurboSign supports four different ways to provide document files: :::note Async context The examples below use `await`, so they must run inside an `async` function (call them with `asyncio.run(...)`). See the [Send a Document for Signature](#send-a-document-for-signature) Quick Start for the full runnable form. ::: TurboSign supports four different ways to provide document files: The SDK supports multiple ways to provide your document: The SDK supports multiple ways to provide your document: ### 1. File Upload ([]byte) / 1. File Upload (byte[]) {#1-file-upload-byte} Upload a document directly from file bytes: ```go pdfBytes, err := os.ReadFile("/path/to/document.pdf") if err != nil { log.Fatal(err) } result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ File: pdfBytes, Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) ``` Upload a document directly from file bytes: ```java byte[] pdfBytes = Files.readAllBytes(Paths.get("/path/to/document.pdf")); SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .file(pdfBytes) .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); ``` ### 1. File Upload (Direct) {#1-file-upload-direct} ```php ### 2. File URL {#2-file-url} ```php Provide a publicly accessible URL to your document: ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) ``` :::tip When to use FileLink Use `FileLink` when your documents are already hosted on cloud storage (S3, Google Cloud Storage, etc.). This is more efficient than downloading and re-uploading files. ::: Provide a publicly accessible URL to your document: ```java SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); ``` :::tip When to use fileLink Use `fileLink` when your documents are already hosted on cloud storage (S3, Google Cloud Storage, etc.). This is more efficient than downloading and re-uploading files. ::: ### 1. File Upload (bytes) {#1-file-upload-bytes} ```python with open("./contract.pdf", "rb") as f: pdf_buffer = f.read() result = await TurboSign.send_signature( file=pdf_buffer, recipients=[ {"name": "John Doe", "email": "john@example.com", "signingOrder": 1}, ], fields=[ { "type": "signature", "page": 1, "x": 100, "y": 650, "width": 200, "height": 50, "recipientEmail": "john@example.com", }, ], ) ``` ### 1. File Upload (Path, Buffer, or Browser File) {#1-file-upload-path-buffer-or-browser-file} ```javascript const { readFileSync } = require("fs"); const { TurboSign } = require("@turbodocx/sdk"); const fileBuffer = readFileSync("./contract.pdf"); (async () => { const result = await TurboSign.sendSignature({ file: fileBuffer, recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); })(); ``` ```typescript const fileBuffer = readFileSync("./contract.pdf"); const result = await TurboSign.sendSignature({ file: fileBuffer, recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` :::tip Pass a file path directly `file` accepts `string | File | Buffer`. A `string` is treated as a local file path: the SDK reads it and uses the basename as the document filename, so `file: "./contract.pdf"` works without `readFileSync`. A raw `Blob` is not supported; use a `Buffer` (Node) or a `File` (browser). When `file` is a `Buffer`, the filename defaults to `document.pdf` (extension detected from the content). Pass `fileName` to control it: ```javascript await TurboSign.sendSignature({ file: fileBuffer, fileName: "acme-msa.pdf", // ... }); ``` ::: ### 2. File URL (fileLink) / 2. File URL (file_link) {#2-file-url-filelink} ```javascript const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` ```typescript const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` :::tip When to use fileLink Use `fileLink` when your documents are already hosted on cloud storage (S3, Google Cloud Storage, etc.). This is more efficient than downloading and re-uploading files. ::: ```python result = await TurboSign.send_signature( file_link="https://www.turbodocx.com/examples/turbodocx.pdf", recipients=[ {"name": "John Doe", "email": "john@example.com", "signingOrder": 1}, ], fields=[ { "type": "signature", "page": 1, "x": 100, "y": 650, "width": 200, "height": 50, "recipientEmail": "john@example.com", }, ], ) ``` :::tip When to use file_link Use `file_link` when your documents are already hosted on cloud storage (S3, Google Cloud Storage, etc.). This is more efficient than downloading and re-uploading files. ::: ### 3. TurboDocx Deliverable ID {#3-turbodocx-deliverable-id} ```javascript // Use a previously generated TurboDocx document const result = await TurboSign.sendSignature({ deliverableId: "deliverable-uuid-from-turbodocx", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` ```typescript // Use a previously generated TurboDocx document const result = await TurboSign.sendSignature({ deliverableId: "deliverable-uuid-from-turbodocx", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` :::info Integration with TurboDocx `deliverableId` references documents generated using TurboDocx's document generation API. This creates a seamless workflow: generate → sign. ::: ```python # Use a previously generated TurboDocx document result = await TurboSign.send_signature( deliverable_id="deliverable-uuid-from-turbodocx", recipients=[ {"name": "John Doe", "email": "john@example.com", "signingOrder": 1}, ], fields=[ { "type": "signature", "page": 1, "x": 100, "y": 650, "width": 200, "height": 50, "recipientEmail": "john@example.com", }, ], ) ``` :::info Integration with TurboDocx `deliverable_id` references documents generated using TurboDocx's document generation API. This creates a seamless workflow: generate → sign. ::: ```php Use a document generated by TurboDocx document generation: ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ DeliverableID: "deliverable-uuid-from-turbodocx", Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) ``` :::info Integration with TurboDocx `DeliverableID` references documents generated using TurboDocx's document generation API. This creates a seamless workflow: generate → sign. ::: Use a document generated by TurboDocx document generation: ```java SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .deliverableId("deliverable-uuid-from-turbodocx") .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); ``` :::info Integration with TurboDocx `deliverableId` references documents generated using TurboDocx's document generation API. This creates a seamless workflow: generate → sign. ::: ### 4. TurboDocx Template ID {#4-turbodocx-template-id} ```javascript // Use a pre-configured TurboSign template const result = await TurboSign.sendSignature({ templateId: "template-uuid-from-turbodocx", // Template already contains anchors recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", recipientEmail: "alice@example.com", template: { anchor: "{SIGNATURE_ALICE}", placement: "replace", size: { width: 200, height: 50 }, }, }, ], }); ``` ```typescript // Use a pre-configured TurboSign template const result = await TurboSign.sendSignature({ templateId: "template-uuid-from-turbodocx", // Template already contains anchors recipients: [ { name: "Alice Smith", email: "alice@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", recipientEmail: "alice@example.com", template: { anchor: "{SIGNATURE_ALICE}", placement: "replace", size: { width: 200, height: 50 }, }, }, ], }); ``` :::info Integration with TurboDocx `templateId` references pre-configured TurboSign templates created in the TurboDocx dashboard. These templates come with built-in anchors and field positioning, making it easy to reuse signature workflows across multiple documents. ::: ```python # Use a pre-configured TurboSign template result = await TurboSign.send_signature( template_id="template-uuid-from-turbodocx", # Template already contains anchors recipients=[ {"name": "Alice Smith", "email": "alice@example.com", "signingOrder": 1}, ], fields=[ { "type": "signature", "recipientEmail": "alice@example.com", "template": { "anchor": "{SIGNATURE_ALICE}", "placement": "replace", "size": {"width": 200, "height": 50}, }, }, ], ) ``` :::info Integration with TurboDocx `template_id` references pre-configured TurboSign templates created in the TurboDocx dashboard. These templates come with built-in anchors and field positioning, making it easy to reuse signature workflows across multiple documents. ::: ```php 200, 'height' => 50] ) ) ] ) ); ``` :::info Integration with TurboDocx `templateId` references pre-configured TurboSign templates created in the TurboDocx dashboard. These templates come with built-in anchors and field positioning, making it easy to reuse signature workflows across multiple documents. ::: Use a pre-configured TurboDocx template: ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ TemplateID: "template-uuid-from-turbodocx", Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) ``` :::info Integration with TurboDocx `TemplateID` references pre-configured TurboSign templates created in the TurboDocx dashboard. These templates come with built-in anchors and field positioning, making it easy to reuse signature workflows across multiple documents. ::: Use a pre-configured TurboDocx template: ```java SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .templateId("template-uuid-from-turbodocx") .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); ``` :::info Integration with TurboDocx `templateId` references pre-configured TurboSign templates created in the TurboDocx dashboard. These templates come with built-in anchors and field positioning, making it easy to reuse signature workflows across multiple documents. ::: --- ## API Reference {#api-reference} :::note Async context The snippets below use `await`, so they must run inside an `async` function. For a standalone script, wrap the call and run it with `asyncio.run(...)`, and add `import json` if the snippet calls `json.dumps`: ```python async def main(): result = await TurboSign.get_status("document-uuid") print(json.dumps(result, indent=2)) asyncio.run(main()) ``` ::: :::note The snippets below assume a configured `client` and an available `Gson` instance, e.g. `Gson gson = new GsonBuilder().setPrettyPrinting().create();` (as shown in the [Quick Start](#quick-start)). ::: ### Configure {#configure} Configure the SDK with your API credentials and organization settings. ```typescript TurboSign.configure({ apiKey: string; // Required : Your TurboDocx API key orgId: string; // Required: Your organization ID senderEmail: string; // Required: reply-to address for signature request emails senderName?: string; // Optional: sender name in emails (defaults to your API key's name) baseUrl?: string; // Optional: API base URL (default: 'https://api.turbodocx.com') }); ``` **Example:** ```javascript const { TurboSign } = require("@turbodocx/sdk"); TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY, orgId: process.env.TURBODOCX_ORG_ID, senderEmail: process.env.TURBODOCX_SENDER_EMAIL, // Required for TurboSign // Optional: override for testing // baseUrl: 'https://api.turbodocx.com' }); ``` ```typescript TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY || "", orgId: process.env.TURBODOCX_ORG_ID || "", senderEmail: process.env.TURBODOCX_SENDER_EMAIL || "", // Required for TurboSign // Optional: override for testing // baseUrl: 'https://api.turbodocx.com' }); ``` :::warning API Credentials Required Both `apiKey` and `orgId` parameters are **required** for all API requests. To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: Configure the SDK with your API credentials and organization settings. ```python TurboSign.configure( api_key: str, # Required: Your TurboDocx API key org_id: str, # Required: Your organization ID sender_email: str, # Required: Reply-to address for signature emails sender_name: str = None, # Recommended: Sender name shown in emails base_url: str = "https://api.turbodocx.com" # Optional: API base URL ) ``` Configure the SDK with your API credentials and organization settings. ```php use TurboDocx\TurboSign; use TurboDocx\Config\HttpClientConfig; // Manual configuration TurboSign::configure(new HttpClientConfig( apiKey: 'your-api-key', orgId: 'your-org-id', senderEmail: 'you@company.com', senderName: 'Your Company' )); // Or from environment TurboSign::configure(HttpClientConfig::fromEnvironment()); ``` Create a new TurboDocx client. ```go // Simple initialization client, err := turbodocx.NewClient(apiKey, orgID string) // With custom configuration client, err := turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ APIKey: "your-api-key", OrgID: "your-org-id", SenderEmail: "you@example.com", // Required for TurboSign (reply-to address) BaseURL: "https://api.turbodocx.com", // Optional }) ``` :::warning API Credentials Required `APIKey` (or `AccessToken`), `OrgID`, **and** `SenderEmail` are **required** for TurboSign operations. `SenderEmail` is used as the reply-to address for signature request emails (it can also be supplied via the `TURBODOCX_SENDER_EMAIL` environment variable). To get your credentials, follow the **[Get Your Credentials](/docs/SDKs#1-get-your-credentials)** steps from the SDKs main page. ::: Create a new TurboDocx client using the Builder pattern. ```java TurboDocxClient client = new TurboDocxClient.Builder() .apiKey("your-api-key") // Required .orgId("your-org-id") // Required .senderEmail("sender@yourcompany.com") // Required for TurboSign .baseUrl("https://api.turbodocx.com") // Optional .build(); ``` ### Prepare for review {#prepare-for-review} Upload a document for preview without sending signature request emails. ```javascript const { documentId, previewUrl } = await TurboSign.createSignatureReviewLink({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Contract Draft", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` ```typescript const { documentId, previewUrl } = await TurboSign.createSignatureReviewLink({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Contract Draft", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); ``` Upload a document for preview without sending signature request emails. ```python result = await TurboSign.create_signature_review_link( recipients=[{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}], fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "john@example.com"}], file_link="https://www.turbodocx.com/examples/turbodocx.pdf", document_name="Contract Draft", ) print(result["documentId"]) print(result["previewUrl"]) ``` Upload a document for preview without sending signature request emails. ```php use TurboDocx\Types\Recipient; use TurboDocx\Types\Field; use TurboDocx\Types\SignatureFieldType; use TurboDocx\Types\Requests\CreateSignatureReviewLinkRequest; $result = TurboSign::createSignatureReviewLink( new CreateSignatureReviewLinkRequest( recipients: [ new Recipient('John Doe', 'john@example.com', 1) ], fields: [ new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'john@example.com', page: 1, x: 100, y: 500, width: 200, height: 50 ) ], fileLink: 'https://www.turbodocx.com/examples/turbodocx.pdf', documentName: 'Contract Draft' ) ); echo "Preview URL: {$result->previewUrl}\n"; echo "Document ID: {$result->documentId}\n"; ``` Upload a document for preview without sending emails. ```go result, err := client.TurboSign.CreateSignatureReviewLink(ctx, &turbodocx.CreateSignatureReviewLinkRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", DocumentName: "Contract Draft", Recipients: []turbodocx.Recipient{ {Name: "John Doe", Email: "john@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "john@example.com"}, }, }) b, _ := json.MarshalIndent(result, "", " "); fmt.Println("Result:", string(b)) ``` Upload a document for preview without sending emails. ```java CreateSignatureReviewLinkResponse result = client.turboSign().createSignatureReviewLink( new CreateSignatureReviewLinkRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .documentName("Contract Draft") .recipients(Arrays.asList( new Recipient("John Doe", "john@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "john@example.com") )) .build() ); System.out.println("Result: " + gson.toJson(result)); ``` ### Prepare for signing {#prepare-for-signing} Upload a document and immediately send signature requests to all recipients. ```javascript const { documentId } = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Service Agreement", senderName: "Your Company", senderEmail: "sender@company.com", recipients: [ { name: "Recipient Name", email: "recipient@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "recipient@example.com", }, ], }); ``` ```typescript const { documentId } = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", documentName: "Service Agreement", senderName: "Your Company", senderEmail: "sender@company.com", recipients: [ { name: "Recipient Name", email: "recipient@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 500, width: 200, height: 50, recipientEmail: "recipient@example.com", }, ], }); ``` Upload a document and immediately send signature requests to all recipients. ```python result = await TurboSign.send_signature( recipients=[{"name": "Recipient Name", "email": "recipient@example.com", "signingOrder": 1}], fields=[{"type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "recipient@example.com"}], file_link="https://www.turbodocx.com/examples/turbodocx.pdf", document_name="Service Agreement", sender_name="Your Company", sender_email="sender@company.com", ) print(result["documentId"]) ``` Upload a document and immediately send signature requests to all recipients. ```php use TurboDocx\Types\Recipient; use TurboDocx\Types\Field; use TurboDocx\Types\SignatureFieldType; use TurboDocx\Types\Requests\SendSignatureRequest; $result = TurboSign::sendSignature( new SendSignatureRequest( recipients: [ new Recipient('Recipient Name', 'recipient@example.com', 1) ], fields: [ new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'recipient@example.com', page: 1, x: 100, y: 500, width: 200, height: 50 ) ], fileLink: 'https://www.turbodocx.com/examples/turbodocx.pdf', documentName: 'Service Agreement' ) ); echo "Document ID: {$result->documentId}\n"; ``` Upload a document and immediately send signature requests. ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ FileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", DocumentName: "Service Agreement", SenderName: "Your Company", SenderEmail: "sender@company.com", Recipients: []turbodocx.Recipient{ {Name: "Recipient Name", Email: "recipient@example.com", SigningOrder: 1}, }, Fields: []turbodocx.Field{ {Type: "signature", Page: 1, X: 100, Y: 500, Width: 200, Height: 50, RecipientEmail: "recipient@example.com"}, }, }) ``` Upload a document and immediately send signature requests. ```java SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .fileLink("https://www.turbodocx.com/examples/turbodocx.pdf") .documentName("Service Agreement") .senderName("Your Company") .senderEmail("sender@company.com") .recipients(Arrays.asList( new Recipient("Recipient Name", "recipient@example.com", 1) )) .fields(Arrays.asList( new Field("signature", 1, 100, 500, 200, 50, "recipient@example.com") )) .build() ); System.out.println("Result: " + gson.toJson(result)); ``` ### Reminders and expiration {#reminders-and-expiration} `sendSignature` can also schedule automatic reminder emails and an expiration deadline. All eight schedule fields are optional and **both features are off by default**, so omitting them preserves the original send behavior. The resolved schedule is **frozen onto the document when it is sent**: changing your org defaults later never alters a document already out for signature. ```php use TurboDocx\Types\Requests\SendSignatureRequest; $result = TurboSign::sendSignature( new SendSignatureRequest( // ...recipients, fields, fileLink, documentName remindersEnabled: true, reminderDelay: ['value' => 3, 'unit' => 'days'], // time to the FIRST reminder reminderInterval: ['value' => 3, 'unit' => 'days'], // gap between later reminders maxReminders: 5, // -1 unlimited, 0 none, max 50 (default 5) expirationEnabled: true, expireAfter: ['value' => 30, 'unit' => 'days'], // how long the document stays signable expirationWarning: ['value' => 3, 'unit' => 'days'], // 0 = never warn expirationWarningInterval: ['value' => 1, 'unit' => 'days'] ) ); ``` Durations are `['value' => N, 'unit' => 'hours'|'days']` objects. `value` is a whole number from **1** up to a maximum of **999 days (23976 hours)**; `expirationWarning` also accepts `0` to disable warnings. `maxReminders` accepts `-1` (unlimited), `0` (none), or any value up to `50`. The resulting deadline is readable afterwards via `getStatus()->expiresAt`. ### Send a reminder {#send-a-reminder} Send a standalone reminder to whoever's turn it is to sign (`POST /turbosign/documents/{documentId}/send-reminder`). It is independent of the automatic cadence: it works even when reminders are disabled or the per-signer cap is already spent, does **not** consume that cap, and only emails signers at the *current* signing order. Pass `null` (or omit the argument) to remind everyone eligible; do not pass an empty array, which the API rejects. ```php // Remind everyone whose turn it is to sign. $result = TurboSign::sendReminder('document-uuid'); foreach ($result['results'] as $r) { // 'sent', 'skipped_wrong_order', 'skipped_completed', ... echo "{$r['recipientId']}: {$r['status']}\n"; } // Or limit to specific recipients (all-or-nothing): every id must be a current-order pending signer. TurboSign::sendReminder('document-uuid', ['recipient-uuid-1', 'recipient-uuid-2']); ``` #### Schedule reminders and expiration {#schedule-reminders-and-expiration} `send_signature()` also accepts an optional reminder + expiration schedule. Both features are **off by default**, so omitting these kwargs preserves the original send behavior. The resolved deadline is frozen onto the document at send time and is then readable via `get_status()["expiresAt"]`. ```python result = await TurboSign.send_signature( # ...recipients, fields, file source, etc. reminders_enabled=True, reminder_delay={"value": 2, "unit": "days"}, # time to the FIRST reminder reminder_interval={"value": 3, "unit": "days"}, # gap between later reminders max_reminders=5, # -1 unlimited, 0 none, max 50 expiration_enabled=True, expire_after={"value": 14, "unit": "days"}, # how long the document stays signable expiration_warning={"value": 1, "unit": "days"}, # 0 = never warn expiration_warning_interval={"value": 1, "unit": "days"}, ) ``` Durations are `{"value": N, "unit": "days" | "hours"}` objects. `value` is a whole number from `1` up to a maximum of **999 days / 23976 hours**; `max_reminders` accepts `-1` (unlimited), `0` (none), or up to `50`, defaulting to `5`; `expiration_warning` may be `0` to disable warnings. See the full field reference under [Request Parameters](#request-parameters). `SendSignature` accepts an optional `SignatureSchedule` that turns on automatic reminder emails and a signing deadline. Every field is a pointer, and **both features are off by default**: omit the schedule entirely to preserve the original send behavior. The resolved schedule is **frozen onto the document at send time**, so later changes to your org defaults never touch a document already out for signature. ```go result, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ // ...file, recipients, fields, etc. SignatureSchedule: turbodocx.SignatureSchedule{ RemindersEnabled: turbodocx.BoolPtr(true), ReminderDelay: &turbodocx.Duration{Value: 2, Unit: "days"}, // time to the first reminder ReminderInterval: &turbodocx.Duration{Value: 3, Unit: "days"}, // gap between later reminders MaxReminders: turbodocx.IntPtr(5), // cap per signer ExpirationEnabled: turbodocx.BoolPtr(true), ExpireAfter: &turbodocx.Duration{Value: 14, Unit: "days"}, // how long the document stays signable ExpirationWarning: &turbodocx.Duration{Value: 3, Unit: "days"}, // how far before expiry warnings start }, }) ``` | Field | Type | Notes | | ----- | ---- | ----- | | `RemindersEnabled` | `*bool` | Master switch for automatic reminders. Default off. | | `ReminderDelay` | `*Duration` | Time to the **first** reminder, measured from that signer's invitation. | | `ReminderInterval` | `*Duration` | Gap between **subsequent** reminders. | | `MaxReminders` | `*int` | Automatic reminders per signer. Valid range **-1..50**: `-1` unlimited, `0` none, default `5`. | | `ExpirationEnabled` | `*bool` | Master switch for the signing deadline. Default off. | | `ExpireAfter` | `*Duration` | How long the document stays signable, counted from sending. | | `ExpirationWarning` | `*Duration` | How far **before** expiry warnings start. `0` = never warn. | | `ExpirationWarningInterval` | `*Duration` | Gap between warnings once they start. | A `Duration` is a `{Value, Unit}` pair; `Unit` is `"hours"` or `"days"`. `Value` is a whole number, **minimum 1** and at most **999 days (23976 hours)**. Reminders and expiry warnings run as two independent clocks, so a signer can receive both streams; they are coordinated so a reminder and a warning never land in the same moment. `sendSignature` accepts an optional `SignatureSchedule` that turns on automatic reminder emails and/or a signing deadline. Both features are **off by default**, so omitting the schedule preserves the original send behavior. The resolved deadline is frozen onto the document at send time and is then readable via `getStatus().getExpiresAt()`. ```java SignatureSchedule schedule = SignatureSchedule.builder() .remindersEnabled(true) .reminderDelay(new SignatureSchedule.Duration(3, "days")) // time to the first reminder .reminderInterval(new SignatureSchedule.Duration(3, "days")) // gap between later reminders .maxReminders(5) // -1..50: -1 unlimited, 0 none (default 5) .expirationEnabled(true) .expireAfter(new SignatureSchedule.Duration(14, "days")) // how long the document stays signable .expirationWarning(new SignatureSchedule.Duration(2, "days")) // 0 = never warn .expirationWarningInterval(new SignatureSchedule.Duration(1, "days")) .build(); SendSignatureResponse result = client.turboSign().sendSignature( new SendSignatureRequest.Builder() // ...file, recipients, fields, etc. .schedule(schedule) .build() ); ``` Each `Duration` is a `{value, unit}` pair where `unit` is `"hours"` or `"days"` and `value` is a whole number from **1 up to 999 days (23976 hours)**. `maxReminders` accepts **-1 to 50** (`-1` unlimited, `0` none, default `5`) and caps only automatic reminders, never expiry warnings; `expirationWarning` may be `0` to disable warnings. Reminders and expiry warnings run as two independent clocks, coordinated so a signer never receives both at the same moment, and a reminder cadence that would outlive the expiry window is rejected with `400`. ### Reminders & expiration schedule {#reminders--expiration-schedule} `sendSignature` (and `createSignatureReviewLink`) accept an optional **reminder and expiration schedule**. Both features are **off by default**: omit these fields and the send behaves exactly as before. The resolved schedule is **frozen onto the document when it is sent**, so later changes to your org defaults never touch a document already out for signature. ```javascript const result = await TurboSign.sendSignature({ // ...fileLink, recipients, fields, etc. // Reminders: nudge signers who haven't signed yet remindersEnabled: true, reminderDelay: { value: 3, unit: "days" }, // time to the FIRST reminder reminderInterval: { value: 3, unit: "days" }, // gap between later reminders maxReminders: 5, // cap per signer // Expiration: close the signing window expirationEnabled: true, expireAfter: { value: 30, unit: "days" }, // how long the document stays signable expirationWarning: { value: 3, unit: "days" }, // how far before expiry warnings start expirationWarningInterval: { value: 1, unit: "days" }, }); ``` ```typescript const result = await TurboSign.sendSignature({ // ...fileLink, recipients, fields, etc. // Reminders: nudge signers who haven't signed yet remindersEnabled: true, reminderDelay: { value: 3, unit: "days" }, // time to the FIRST reminder reminderInterval: { value: 3, unit: "days" }, // gap between later reminders maxReminders: 5, // cap per signer // Expiration: close the signing window expirationEnabled: true, expireAfter: { value: 30, unit: "days" }, // how long the document stays signable expirationWarning: { value: 3, unit: "days" }, // how far before expiry warnings start expirationWarningInterval: { value: 1, unit: "days" }, }); ``` Durations are `{ value, unit }` objects: `unit` is `"hours"` or `"days"`, and `value` is a whole number from **1 to a maximum of 999 days (23976 hours)**. | Field | Type | Default | Notes | | --- | --- | --- | --- | | `remindersEnabled` | `boolean` | `false` | Send reminder emails at all | | `reminderDelay` | `Duration` | 3 days | Time to the **first** reminder, measured from that signer's invitation | | `reminderInterval` | `Duration` | 3 days | Gap between **subsequent** reminders | | `maxReminders` | `number` | `5` | Cap per signer, range **-1..50** (`-1` unlimited, `0` none). Never caps expiry warnings | | `expirationEnabled` | `boolean` | `false` | Expire the document at all | | `expireAfter` | `Duration` | 120 days | How long the document stays signable, counted from **sending** | | `expirationWarning` | `Duration` | 3 days | How far **before** expiry warnings start. `0` = never warn | | `expirationWarningInterval` | `Duration` | 1 day | Gap between warnings once they start | Reminders and expiry warnings run as **two independent clocks**, so a signer keeps getting reminders even after warnings begin; the two are coordinated so a reminder and a warning never land on the same tick. The API rejects a cadence that can't fit its window (for example a reminder interval that outlives `expireAfter`) with `400 InvalidSignatureSchedule`. See the [API validation rules](/docs/TurboSign/API-Signatures#reminders--expiration) for the full list. ### Send reminder {#send-reminder} Send a **standalone reminder** to whoever's turn it is to sign. Unlike the scheduled reminders above, it ignores the configured cadence, works even when reminders are disabled or the per-signer cap is already spent, and does **not** consume that cap. Only signers at the **current** signing order are eligible. It maps to `POST /turbosign/documents/{documentId}/send-reminder`. ```javascript // Remind everyone whose turn it is: omit the recipient ids const { results } = await TurboSign.sendReminder("document-uuid"); results.forEach((r) => { // Anyone not emailed comes back as a skipped_* status, so you can tell who was reached console.log(`${r.recipientId}: ${r.status}`); // e.g. "sent", "skipped_wrong_order" }); // Or limit the reminder to specific recipients await TurboSign.sendReminder("document-uuid", ["recipient-uuid-1"]); ``` ```typescript const { results } = await TurboSign.sendReminder("document-uuid"); results.forEach((r) => { console.log(`${r.recipientId}: ${r.status}`); }); await TurboSign.sendReminder("document-uuid", ["recipient-uuid-1"]); ``` :::warning Omit: don't send an empty array To remind everyone eligible, **omit** `recipientIds` entirely. Passing an empty array (`[]`) is rejected with a `400`. ::: Send a standalone reminder (`POST /turbosign/documents/:id/send-reminder`) to whoever's turn it is to sign. It is independent of the automatic reminder cadence: it works even when reminders are disabled or the per-signer `max_reminders` cap is already spent, does **not** consume that cap, and only emails signers at the *current* signing order. Omit `recipient_ids` to remind everyone eligible; do not pass an empty list, which the API rejects. ```python # Remind everyone whose turn it is: result = await TurboSign.send_reminder("document-uuid") # Or limit to specific recipients: result = await TurboSign.send_reminder("document-uuid", recipient_ids=["recipient-uuid-1"]) for entry in result["results"]: # status is e.g. 'sent' or 'skipped_wrong_order' print(f"{entry['recipientId']}: {entry['status']}") ``` Send a standalone reminder to whoever's turn it is to sign (`POST /turbosign/documents/:id/send-reminder`). It is independent of the automatic reminder cadence: it works even when reminders are disabled or the per-signer `MaxReminders` cap is already spent, does **not** consume that cap, and only emails signers at the **current** signing order. Pass `nil` for `recipientIDs` to remind everyone eligible; do **not** pass an empty slice, which the API rejects. ```go resp, err := client.TurboSign.SendReminder(ctx, "document-uuid", nil) if err != nil { log.Fatal(err) } for _, r := range resp.Results { // "sent", "skipped_wrong_order", "skipped_completed", ... fmt.Printf("%s: %s\n", r.RecipientID, r.Status) } ``` This differs from **Resend**: resend re-sends the original invitation email, while send-reminder sends the reminder copy. --- Send a standalone reminder to whoever's turn it is to sign (`POST /turbosign/documents/:id/send-reminder`). It is independent of the automatic reminder cadence: it works even when reminders are disabled or the `maxReminders` cap is spent, does **not** consume that cap, and only emails signers at the **current** signing order. Use the single-arg overload to remind everyone eligible; pass a list to limit it to specific recipients, but do **not** pass an empty list, which the API rejects. ```java // Remind everyone whose turn it is SendReminderResponse reminder = client.turboSign().sendReminder("document-uuid"); for (SendReminderResponse.ReminderResult r : reminder.getResults()) { // status is e.g. "sent", "skipped_wrong_order", "skipped_completed" System.out.println(r.getRecipientId() + ": " + r.getStatus()); } // Or limit to specific recipients client.turboSign().sendReminder("document-uuid", Arrays.asList("recipient-uuid-1")); ``` --- ### Get status {#get-status} Retrieve the document-level status. For per-signer detail, use [Get recipients](#get-recipients). ```javascript const result = await TurboSign.getStatus("document-uuid"); console.log(result.status); // 'under_review' | 'completed' | 'voided' | ... ``` ```typescript const result = await TurboSign.getStatus("document-uuid"); console.log(result.status); // 'under_review' | 'completed' | 'voided' | ... ``` The response carries the document-level **`status`** (`under_review`, `completed`, `voided`, `expired`, …) and **`expiresAt`** (the ISO 8601 signing-window deadline, or `undefined`/`null` when expiration is off). Once that deadline passes the document moves to the terminal **`expired`** status and its signing links stop working. The same `document.expiresAt` is returned by `getRecipients()` alongside per-recipient detail. Retrieve the document-level status. For per-signer detail, use [Get recipients](#get-recipients). ```python result = await TurboSign.get_status("document-uuid") print("Result:", json.dumps(result, indent=2)) # status is 'under_review', 'completed', 'voided', or the terminal 'expired' print("Status:", result["status"]) # expiresAt is the signing-window deadline (ISO 8601), or None when expiration is off. print("Expires:", result.get("expiresAt")) ``` Once a document's deadline passes it moves to the terminal `expired` status and its signing links stop working. The same `expiresAt` deadline is also returned on the document object from `get_recipients()` (`result["document"]["expiresAt"]`). Retrieve the current status of a document. When expiration is enabled, the response also carries the signing-window deadline as `expiresAt`; once that instant passes the document reaches the terminal `expired` status. The same `expiresAt` is available on the document returned by `getRecipients()`. For per-signer detail, use [Get recipients](#get-recipients). ```php $status = TurboSign::getStatus('document-uuid'); echo "Document Status: {$status->status}\n"; // 'under_review', 'completed', 'voided', 'expired' // expiresAt is the signing-window deadline (ISO 8601), or null when expiration is off. echo "Expires: " . ($status->expiresAt ?? 'never') . "\n"; ``` Check the status of a document. The response includes `ExpiresAt` (the signing-window deadline as an ISO 8601 string, or `""` when expiration is off) and a `Status` that can reach the terminal value `expired` once the deadline passes. For per-signer detail, use [Get recipients](#get-recipients). ```go status, err := client.TurboSign.GetStatus(ctx, "document-uuid") if err != nil { log.Fatal(err) } fmt.Printf("Status: %s\n", status.Status) // "under_review", "completed", "voided", "expired", ... // ExpiresAt is the signing-window deadline (ISO 8601), or "" when expiration is off. fmt.Printf("Expires: %s\n", status.ExpiresAt) ``` Check the document-level status. When an expiration schedule is set, the response also carries `getExpiresAt()` (the signing-window deadline, ISO 8601, or `null` when expiration is off). Once that deadline passes, the document moves to the terminal `expired` status and its signing links stop working. `getRecipients()` exposes the same deadline on `getDocument().getExpiresAt()`. For per-signer detail, use [Get recipients](#get-recipients). ```java DocumentStatusResponse status = client.turboSign().getStatus("document-uuid"); System.out.println("Status: " + status.getStatus()); // "under_review", "completed", "voided", "expired" System.out.println("Expires: " + status.getExpiresAt()); // ISO 8601, or null when expiration is off System.out.println("Result: " + gson.toJson(status)); ``` ### Get recipients {#get-recipients} See who the document went to, who has signed, who you are still waiting on, and who sent it. ```javascript const { document, recipients, summary } = await TurboSign.getRecipients("document-uuid"); console.log(`Sent by ${document.sentBy.name} on ${document.sentOn ?? "not sent yet"}`); console.log(`${summary.completed}/${summary.total} signed, waiting on ${summary.waitingOn}`); recipients.forEach((r) => { console.log(`${r.name} : ${r.effectiveStatus}`); console.log(` emailed ${r.delivery.totalSent}x, last ${r.delivery.lastSentOn ?? "never"}`); }); ``` ```typescript const { document, recipients, summary } = await TurboSign.getRecipients("document-uuid"); console.log(`${summary.completed}/${summary.total} signed, waiting on ${summary.waitingOn}`); const chasing = recipients.filter( (r) => r.effectiveStatus === "pending" || r.effectiveStatus === "viewed", ); ``` See who the document went to, who has signed, who you are still waiting on, and who sent it. ```python result = await TurboSign.get_recipients("document-uuid") summary = result["summary"] print(f"{summary['completed']}/{summary['total']} signed, waiting on {summary['waitingOn']}") for r in result["recipients"]: print(f"{r['name']} : {r['effectiveStatus']}") print(f" emailed {r['delivery']['totalSent']}x") ``` See who the document went to, who has signed, who you are still waiting on, and who sent it. ```php $progress = TurboSign::getRecipients('document-uuid'); echo "{$progress->summary->completed}/{$progress->summary->total} signed, "; echo "waiting on {$progress->summary->waitingOn}\n"; foreach ($progress->recipients as $r) { echo " {$r->name} email}>: {$r->effectiveStatus}"; echo " (emailed {$r->delivery->totalSent}x)\n"; } ``` See who the document went to, who has signed, who you are still waiting on, and who sent it. ```go progress, err := client.TurboSign.GetRecipients(ctx, "document-uuid") if err != nil { log.Fatal(err) } fmt.Printf("%d/%d signed, waiting on %d\n", progress.Summary.Completed, progress.Summary.Total, progress.Summary.WaitingOn) for _, r := range progress.Recipients { fmt.Printf("%s : %s (emailed %dx)\n", r.Name, r.Email, r.EffectiveStatus, r.Delivery.TotalSent) } ``` See who the document went to, who has signed, who you are still waiting on, and who sent it. ```java DocumentRecipientsResponse progress = client.turboSign().getRecipients("document-uuid"); System.out.println(progress.getSummary().getCompleted() + "/" + progress.getSummary().getTotal() + " signed, waiting on " + progress.getSummary().getWaitingOn()); for (DocumentRecipientsResponse.RecipientSignatureStatus r : progress.getRecipients()) { System.out.println(r.getName() + " : " + r.getEffectiveStatus() + " (emailed " + r.getDelivery().getTotalSent() + "x)"); } ``` :::tip Two status fields, and they differ on purpose `status` is the raw database value and is only ever `pending`, `viewed` or `completed`. `effectiveStatus` layers the document's outcome on top, adding `voided` and `expired`: that is the one to display. On a voided or expired document an unsigned signer still reads `pending` in `status`, so branching on it would show someone as "still to sign" when their signing link is already dead. A completed signature is never revoked: someone who signed before the document was voided still reads `completed`. `summary` counts by `effectiveStatus`, and `waitingOn` (pending + viewed) drops to zero once the document is terminal. ::: Each recipient also carries a `delivery` block: `firstSentOn`, `lastSentOn`, `totalSent`, `reminderCount`, `lastRemindedAt`, `warningCount`, `lastWarningAt`. It counts the signature request, resends, reminders, expiry warnings and terminal notices; CC notifications are excluded, since a CC address is not a signer. :::warning `reminderCount` and `lastRemindedAt` do not mean what their names suggest `reminderCount` counts **automatic (scheduled) reminders only**: the counter `maxReminders` caps. A manual "remind now" is a standalone nudge that must not consume the cap budget, so it does **not** increment this, even though the email it sends *does* appear in `totalSent`. `lastRemindedAt` is a **cadence clock**, not a record of a reminder: the initial signature-request send, each scheduled reminder, each manual "remind now" and each expiry warning all stamp it. Only scheduled reminders bump `reminderCount`. So a freshly-sent document returns a non-null `lastRemindedAt` equal to the invitation timestamp alongside `reminderCount: 0`: nobody has been reminded. To answer "have we actually chased this person", read `totalSent`, not `reminderCount`. `warningCount` / `lastWarningAt` have no such caveat. ::: ### Download document {#download-document} Download the completed signed document as a PDF Blob. ```javascript const { writeFileSync } = require("fs"); (async () => { const result = await TurboSign.download("document-uuid"); // Node.js: Save to file const buffer = Buffer.from(await result.arrayBuffer()); writeFileSync("signed-contract.pdf", buffer); })(); ``` ```typescript const result = await TurboSign.download("document-uuid"); // Node.js: Save to file const buffer = Buffer.from(await result.arrayBuffer()); writeFileSync("signed-contract.pdf", buffer); ``` Download the completed signed document as PDF bytes. ```python pdf_bytes = await TurboSign.download("document-uuid") # Save to file with open("signed-contract.pdf", "wb") as f: f.write(pdf_bytes) ``` Download the completed signed document as PDF content. ```php $pdfContent = TurboSign::download('document-uuid'); // Save to file file_put_contents('signed-contract.pdf', $pdfContent); // Or send as HTTP response header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="signed.pdf"'); echo $pdfContent; ``` Download the completed signed document. ```go pdfData, err := client.TurboSign.Download(ctx, "document-uuid") if err != nil { log.Fatal(err) } // Save to file err = os.WriteFile("signed-contract.pdf", pdfData, 0644) if err != nil { log.Fatal(err) } ``` Download the completed signed document. ```java byte[] pdfData = client.turboSign().download("document-uuid"); // Save to file Files.write(Paths.get("signed-contract.pdf"), pdfData); ``` ### Void {#void} Cancel/void a signature request. ```javascript await TurboSign.void("document-uuid", "Contract terms changed"); ``` ```typescript await TurboSign.void("document-uuid", "Contract terms changed"); ``` Cancel/void a signature request. ```python result = await TurboSign.void_document("document-uuid", reason="Contract terms changed") ``` Cancel/void a signature request that hasn't been completed. ```php use TurboDocx\Types\Responses\VoidDocumentResponse; $result = TurboSign::void('document-uuid', 'Document needs to be revised'); echo "Document ID: {$result->id}\n"; echo "Status: {$result->status}\n"; echo "Void Reason: {$result->voidReason}\n"; ``` Cancel/void a signature request. ```go result, err := client.TurboSign.VoidDocument(ctx, "document-uuid", "Contract terms changed") ``` Cancel/void a signature request. ```java VoidDocumentResponse result = client.turboSign().voidDocument("document-uuid", "Contract terms changed"); ``` ### Resend {#resend} Resend signature request emails to specific recipients. ```javascript // Resend to specific recipients await TurboSign.resend("document-uuid", [ "recipient-uuid-1", "recipient-uuid-2", ]); ``` ```typescript // Resend to specific recipients await TurboSign.resend("document-uuid", [ "recipient-uuid-1", "recipient-uuid-2", ]); ``` Resend signature request emails to specific recipients. ```python result = await TurboSign.resend_email("document-uuid", recipient_ids=["recipient-uuid-1", "recipient-uuid-2"]) ``` Resend signature request emails to specific recipients. ```php // Resend to specific recipients $result = TurboSign::resend('document-uuid', ['recipient-id-1', 'recipient-id-2']); // Resend to all recipients $result = TurboSign::resend('document-uuid', []); echo "Recipients notified: {$result->recipientCount}\n"; ``` Resend signature request emails. ```go // Resend to specific recipients result, err := client.TurboSign.ResendEmail(ctx, "document-uuid", []string{"recipient-uuid-1", "recipient-uuid-2"}) ``` Resend signature request emails. ```java // Resend to specific recipients ResendEmailResponse result = client.turboSign().resendEmail( "document-uuid", Arrays.asList("recipient-uuid-1", "recipient-uuid-2") ); ``` ### Get audit trail {#get-audit-trail} Retrieve the complete audit trail for a document, including all events and actions. ```javascript const result = await TurboSign.getAuditTrail("document-uuid"); console.log(JSON.stringify(result, null, 2)); ``` ```typescript const result = await TurboSign.getAuditTrail("document-uuid"); console.log(JSON.stringify(result, null, 2)); ``` --- Retrieve the complete audit trail for a document, including all events and actions. ```python result = await TurboSign.get_audit_trail("document-uuid") print("Result:", json.dumps(result, indent=2)) ``` --- Retrieve the complete audit trail for a document, including all events and actions. ```php $audit = TurboSign::getAuditTrail('document-uuid'); echo "Audit Trail:\n"; foreach ($audit->auditTrail as $entry) { echo " {$entry->timestamp} - {$entry->actionType}"; if ($entry->user) { echo " by {$entry->user->name}"; } echo "\n"; } ``` --- Retrieve the audit trail for a document. ```go auditTrail, err := client.TurboSign.GetAuditTrail(ctx, "document-uuid") if err != nil { log.Fatal(err) } b, _ := json.MarshalIndent(auditTrail, "", " "); fmt.Println("Result:", string(b)) ``` Retrieve the audit trail for a document. ```java AuditTrailResponse auditTrail = client.turboSign().getAuditTrail("document-uuid"); System.out.println("Result: " + gson.toJson(auditTrail)); ``` ## Field Types {#field-types} TurboSign supports 11 different field types using PHP enums: ```php use TurboDocx\Types\SignatureFieldType; SignatureFieldType::SIGNATURE // Signature field SignatureFieldType::INITIAL // Initial field SignatureFieldType::DATE // Date stamp (auto-filled when signed) SignatureFieldType::TEXT // Free text input SignatureFieldType::FULL_NAME // Full name (auto-filled from recipient) SignatureFieldType::FIRST_NAME // First name SignatureFieldType::LAST_NAME // Last name SignatureFieldType::EMAIL // Email address SignatureFieldType::TITLE // Job title SignatureFieldType::COMPANY // Company name SignatureFieldType::CHECKBOX // Checkbox field ``` ### Field Positioning {#field-positioning} TurboSign supports two ways to position fields: #### 1. Coordinate-Based (Pixel Perfect) {#1-coordinate-based-pixel-perfect} ```php new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'john@example.com', page: 1, // Page number (1-indexed) x: 100, // X coordinate y: 500, // Y coordinate width: 200, // Width in pixels height: 50 // Height in pixels ) ``` #### 2. Template Anchors (Dynamic) {#2-template-anchors-dynamic} ```php use TurboDocx\Types\TemplateConfig; use TurboDocx\Types\FieldPlacement; new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'john@example.com', template: new TemplateConfig( anchor: '{signature1}', // Text to find in PDF placement: FieldPlacement::REPLACE, // How to place the field size: ['width' => 100, 'height' => 30] ) ) ``` **Placement Options:** - `FieldPlacement::REPLACE` - Replace the anchor text - `FieldPlacement::BEFORE` - Place before the anchor - `FieldPlacement::AFTER` - Place after the anchor - `FieldPlacement::ABOVE` - Place above the anchor - `FieldPlacement::BELOW` - Place below the anchor ### Advanced Field Options {#advanced-field-options} ```php // Checkbox (pre-checked, readonly) new Field( type: SignatureFieldType::CHECKBOX, recipientEmail: 'john@example.com', page: 1, x: 100, y: 600, width: 20, height: 20, defaultValue: 'true', // Pre-checked isReadonly: true // Cannot be unchecked ) // Multiline text field new Field( type: SignatureFieldType::TEXT, recipientEmail: 'john@example.com', page: 1, x: 100, y: 200, width: 400, height: 100, isMultiline: true, // Allow multiple lines required: true, // Field is required backgroundColor: '#f0f0f0' // Background color ) // Readonly text (pre-filled, non-editable) new Field( type: SignatureFieldType::TEXT, recipientEmail: 'john@example.com', page: 1, x: 100, y: 300, width: 300, height: 30, defaultValue: 'This text is pre-filled', isReadonly: true ) ``` ### Conditional (IF/THEN) Fields {#conditional-ifthen-fields} The optional `metadata` builds IF/THEN relationships between fields. Put a `fieldKey` on a controlling checkbox, then point each dependent field's `controllingFieldKey` back at it. ```php use TurboDocx\Types\FieldMetadata; use TurboDocx\Types\FieldConditional; use TurboDocx\Types\ConditionalOperator; use TurboDocx\Types\ConditionalAction; // Controlling checkbox, carries a stable fieldKey new Field( type: SignatureFieldType::CHECKBOX, recipientEmail: 'reviewer@company.com', page: 1, x: 100, y: 400, width: 20, height: 20, metadata: new FieldMetadata(fieldKey: 'request_changes') ); // Dependent text field, hidden until the checkbox is checked new Field( type: SignatureFieldType::TEXT, recipientEmail: 'reviewer@company.com', page: 1, x: 130, y: 400, width: 300, height: 60, metadata: new FieldMetadata( conditional: new FieldConditional( controllingFieldKey: 'request_changes', // = the checkbox's fieldKey operator: ConditionalOperator::IS_CHECKED, // ::IS_CHECKED | ::IS_NOT_CHECKED action: ConditionalAction::SHOW // ::SHOW | ::UNLOCK ) ) ); ``` Use `action: 'unlock'` to keep a field visible but read-only until the box is checked. A malformed rule returns `400 InvalidConditionalRule`; a well-formed rule whose `controllingFieldKey` matches no checkbox **fails open** (the field stays visible/editable). See [Conditional (IF/THEN) Fields](/docs/TurboSign/Conditional-Fields). --- ## Error Handling {#error-handling} All errors are real `Error` subclasses (`instanceof` works) that extend the base `TurboDocxError`. `code` is a plain string, not an enum member: the HTTP client passes the API's own code through when the response includes one (for example `QUOTE_NOT_FOUND`), and only falls back to the class default below when it doesn't, so you can branch on `err.code` for the precise reason instead of just the HTTP category. Every error is a plain `Exception` subclass; catch the most specific one first, since `except TurboDocxError` also matches every subclass below it. Each of the 7 named subclasses sets its own `DEFAULT_CODE` class attribute, so `e.code` is populated for those even when the API response itself carries none; the base `TurboDocxError` raised for an unmapped status (e.g. an unexpected 5xx) has `DEFAULT_CODE = None`, so `e.code` can be `None` there. Every typed exception extends `TurboDocxException`, itself a plain `Exception` subclass with two extra readonly properties: `statusCode` (int, HTTP status) and `errorCode` (string, e.g. `'VALIDATION_ERROR'`). Because the constructor hardcodes PHP's built-in `Exception::getCode()` to `0`, read `$e->errorCode`, not `$e->getCode()`, for the machine-readable reason: ```php use TurboDocx\Exceptions\AuthenticationException; use TurboDocx\Exceptions\AuthorizationException; use TurboDocx\Exceptions\ValidationException; use TurboDocx\Exceptions\NotFoundException; use TurboDocx\Exceptions\ConflictException; use TurboDocx\Exceptions\RateLimitException; use TurboDocx\Exceptions\NetworkException; try { $result = TurboSign::sendSignature(/* ... */); } catch (AuthenticationException $e) { // 401 - Invalid API key or access token echo "Authentication failed: {$e->getMessage()}\n"; } catch (AuthorizationException $e) { // 403 - Valid credentials without permission for this operation echo "Authorization error: {$e->getMessage()}\n"; } catch (ValidationException $e) { // 400 - Invalid request data echo "Validation error: {$e->getMessage()}\n"; } catch (NotFoundException $e) { // 404 - Document not found echo "Not found: {$e->getMessage()}\n"; } catch (ConflictException $e) { // 409 - Conflicts with the current resource state echo "Conflict: {$e->getMessage()}\n"; } catch (RateLimitException $e) { // 429 - Rate limit exceeded echo "Rate limit: {$e->getMessage()}\n"; } catch (NetworkException $e) { // Network/connection error echo "Network error: {$e->getMessage()}\n"; } catch (TurboDocxException $e) { // Catch-all: read the machine-readable reason from errorCode, not getCode() echo "Error {$e->errorCode}: {$e->getMessage()} (status {$e->statusCode})\n"; } ``` Every typed error embeds `TurboDocxError` by value, which promotes its `Message string`, `StatusCode int`, and `Code string` fields onto the typed error, so read them directly off the matched variable (`authErr.Message`, not a getter). Match with `errors.As`, as the example below does: Every typed exception is a nested static class of `TurboDocxException` (`TurboDocxException.ValidationException`, not a separate top-level import) and extends `RuntimeException`, so the compiler never forces a catch: ### Error Types {#error-types} | Error Type | Status Code | Description | | --------------------- | ----------- | ---------------------------------- | | `TurboDocxError` | varies | Base error type for all API errors | | `AuthenticationError` | 401 | Invalid or missing API key | | `AuthorizationError` | 403 | Authenticated but lacks required permissions | | `ValidationError` | 400 | Invalid request parameters | | `NotFoundError` | 404 | Resource not found | | `ConflictError` | 409 | Request conflicts with current resource state; most common on the webhook routes (creating or renaming to a name that already exists) | | `RateLimitError` | 429 | Too many requests | | `NetworkError` | - | Network connectivity issues | | Error Type | Status Code | Description | | -------------------------------------------- | ----------- | ---------------------------------- | | `TurboDocxException` | varies | Base exception for all API errors | | `TurboDocxException.AuthenticationException` | 401 | Invalid or missing API credentials | | `TurboDocxException.ValidationException` | 400 | Invalid request parameters | | `TurboDocxException.AuthorizationException` | 403 | Authenticated but lacks permissions for the route | | `TurboDocxException.NotFoundException` | 404 | Document or resource not found | | `TurboDocxException.ConflictException` | 409 | Request conflicts with current state of resource (e.g., webhook name already exists) | | `TurboDocxException.RateLimitException` | 429 | Too many requests | | `TurboDocxException.NetworkException` | - | Network connectivity issues | ### Error Classes {#error-classes} | Error Class | Status Code | Code | Description | | --------------------- | ----------- | ---------------------- | ----------------------------------- | | `TurboDocxError` | varies | varies | Base error class for all SDK errors | | `AuthenticationError` | 401 | `AUTHENTICATION_ERROR` | Invalid or missing API credentials | | `AuthorizationError` | 403 | `AUTHORIZATION_ERROR` | API key lacks required permissions | | `ValidationError` | 400 | `VALIDATION_ERROR` | Invalid request parameters | | `NotFoundError` | 404 | `NOT_FOUND` | Document or resource not found | | `ConflictError` | 409 | `CONFLICT` | Resource conflict | | `RateLimitError` | 429 | `RATE_LIMIT_EXCEEDED` | Too many requests | | `NetworkError` | - | `NETWORK_ERROR` | Network connectivity issues | | Error Class | Status Code | Description | | --------------------- | ----------- | ----------------------------------- | | `TurboDocxError` | varies | Base error class for all SDK errors | | `AuthenticationError` | 401 | Invalid or missing API credentials | | `AuthorizationError` | 403 | Authenticated but lacks required permissions | | `ValidationError` | 400 | Invalid request parameters | | `NotFoundError` | 404 | Document or resource not found | | `ConflictError` | 409 | Request conflicts with current resource state | | `RateLimitError` | 429 | Too many requests | | `NetworkError` | - | Network connectivity issues | | Error Class | Status Code | Description | | ------------------------- | ----------- | ---------------------------------- | | `TurboDocxException` | varies | Base exception for all SDK errors | | `AuthenticationException` | 401 | Invalid or missing API credentials | | `AuthorizationException` | 403 | Valid credentials without permission for this operation | | `ValidationException` | 400 | Invalid request parameters | | `NotFoundException` | 404 | Document or resource not found | | `ConflictException` | 409 | Request conflicts with current resource state | | `RateLimitException` | 429 | Too many requests | | `NetworkException` | - | Network connectivity issues | All exceptions extend `TurboDocxException` and include: - `getMessage()` - Human-readable error message - `statusCode` - HTTP status code, a public readonly `?int` (null for `NetworkException`) - `errorCode` - Error code string (e.g., `'AUTHENTICATION_ERROR'`), a public readonly `?string` --- ### Handling Errors {#handling-errors} ```javascript const { TurboSign, TurboDocxError, AuthenticationError, ValidationError, NotFoundError, RateLimitError, NetworkError, } = require("@turbodocx/sdk"); (async () => { try { const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Authentication failed:", error.message); // Check your API key and org ID } else if (error instanceof ValidationError) { console.error("Validation error:", error.message); // Check request parameters } else if (error instanceof NotFoundError) { console.error("Resource not found:", error.message); // Document or recipient doesn't exist } else if (error instanceof RateLimitError) { console.error("Rate limited:", error.message); // Wait and retry } else if (error instanceof NetworkError) { console.error("Network error:", error.message); // Check connectivity } else if (error instanceof TurboDocxError) { console.error("SDK error:", error.message, error.statusCode, error.code); } } })(); ``` ```typescript TurboSign, TurboDocxError, AuthenticationError, ValidationError, NotFoundError, RateLimitError, NetworkError, } from "@turbodocx/sdk"; try { const result = await TurboSign.sendSignature({ fileLink: "https://www.turbodocx.com/examples/turbodocx.pdf", recipients: [ { name: "John Doe", email: "john@example.com", signingOrder: 1 }, ], fields: [ { type: "signature", page: 1, x: 100, y: 650, width: 200, height: 50, recipientEmail: "john@example.com", }, ], }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Authentication failed:", error.message); // Check your API key and org ID } else if (error instanceof ValidationError) { console.error("Validation error:", error.message); // Check request parameters } else if (error instanceof NotFoundError) { console.error("Resource not found:", error.message); // Document or recipient doesn't exist } else if (error instanceof RateLimitError) { console.error("Rate limited:", error.message); // Wait and retry } else if (error instanceof NetworkError) { console.error("Network error:", error.message); // Check connectivity } else if (error instanceof TurboDocxError) { console.error("SDK error:", error.message, error.statusCode, error.code); } } ``` ```python from turbodocx_sdk import ( TurboSign, TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, NotFoundError, ConflictError, RateLimitError, NetworkError, ) async def send_with_error_handling(): try: result = await TurboSign.send_signature( recipients=[{"name": "John Doe", "email": "john@example.com", "signingOrder": 1}], fields=[{ "type": "signature", "page": 1, "x": 100, "y": 650, "width": 200, "height": 50, "recipientEmail": "john@example.com", }], file_link="https://www.turbodocx.com/examples/turbodocx.pdf", ) except AuthenticationError as e: print(f"Authentication failed: {e}") # Check your API key and org ID except AuthorizationError as e: print(f"Not authorized: {e}") # Authenticated, but lacks permission for this operation except ValidationError as e: print(f"Validation error: {e}") # Check request parameters except NotFoundError as e: print(f"Resource not found: {e}") # Document or recipient doesn't exist except ConflictError as e: print(f"Conflict: {e}") # Request conflicts with the current resource state except RateLimitError as e: print(f"Rate limited: {e}") # Wait and retry except NetworkError as e: print(f"Network error: {e}") # Check connectivity except TurboDocxError as e: print(f"SDK error: {e}, status_code={e.status_code}, code={e.code}") asyncio.run(send_with_error_handling()) ``` ### Error Properties {#error-properties} All errors include these `readonly` properties: | Property | Type | Description | | ------------ | --------------------- | -------------------------------- | | `message` | `string` | Human-readable error description | | `statusCode` | `number \| undefined` | HTTP status code (if applicable) | | `code` | `string \| undefined` | Machine-readable error code | --- All errors include these instance attributes: | Property | Type | Description | | ------------- | ------------- | --------------------------------------------------- | | `message` | `str` | Human-readable error description (via `str(error)`) | | `status_code` | `int \| None` | HTTP status code (if applicable) | | `code` | `str \| None` | Machine-readable error code; the API's code wins when present, otherwise the class's `DEFAULT_CODE` | --- | Property | Type | Description | | ------------ | -------- | ----------------------------- | | `Message` | `string` | Human-readable error message. `Error()` does not return this bare string: it returns `TurboDocx API error [CODE]: MESSAGE (status N)`, omitting the `[CODE]` segment when `Code` is empty. Compare against `.Message` directly, not `err.Error()` | | `StatusCode` | `int` | HTTP status code | | `Code` | `string` | Machine-readable code; the API's code wins when present, otherwise the SDK fills in a per-status default for each of the 7 named types above. The bare `TurboDocxError` returned for an unmapped status (e.g. an unexpected 5xx) can have an empty `Code` if the API didn't supply one | | Property | Type | Description | | ----------------- | -------- | ---------------------------- | | `getMessage()` | `String` | Human-readable error message | | `getStatusCode()` | `int` | HTTP status code | | `getCode()` | `String` | Machine-readable code; each of the 7 named subclasses falls back to its own default (e.g. `AuthenticationException`'s `AUTHENTICATION_ERROR`) whenever the API response carries none. The bare `TurboDocxException` thrown for an unmapped status (e.g. an unexpected 5xx) can return `null` | ### Example {#example} ```go "errors" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) result, err := client.TurboSign.SendSignature(ctx, request) if err != nil { // Check for specific error types var authErr *turbodocx.AuthenticationError var authzErr *turbodocx.AuthorizationError var validationErr *turbodocx.ValidationError var notFoundErr *turbodocx.NotFoundError var conflictErr *turbodocx.ConflictError var rateLimitErr *turbodocx.RateLimitError var networkErr *turbodocx.NetworkError switch { case errors.As(err, &authErr): log.Printf("Authentication failed: %s", authErr.Message) case errors.As(err, &authzErr): log.Printf("Authorization failed: %s", authzErr.Message) case errors.As(err, &validationErr): log.Printf("Validation error: %s", validationErr.Message) case errors.As(err, ¬FoundErr): log.Printf("Not found: %s", notFoundErr.Message) case errors.As(err, &conflictErr): log.Printf("Conflict: %s", conflictErr.Message) case errors.As(err, &rateLimitErr): log.Printf("Rate limited: %s", rateLimitErr.Message) case errors.As(err, &networkErr): log.Printf("Network error: %s", networkErr.Message) default: // Base TurboDocxError or unexpected error var turboErr *turbodocx.TurboDocxError if errors.As(err, &turboErr) { log.Printf("API error [%d]: %s", turboErr.StatusCode, turboErr.Message) } else { log.Fatal(err) } } } ``` --- ```java try { SendSignatureResponse result = client.turboSign().sendSignature(request); } catch (TurboDocxException.AuthenticationException e) { System.err.println("Authentication failed: " + e.getMessage()); // Check your API key and org ID } catch (TurboDocxException.ValidationException e) { System.err.println("Validation error: " + e.getMessage()); // Check request parameters } catch (TurboDocxException.NotFoundException e) { System.err.println("Not found: " + e.getMessage()); // Document or recipient doesn't exist } catch (TurboDocxException.RateLimitException e) { System.err.println("Rate limited: " + e.getMessage()); // Wait and retry } catch (TurboDocxException.NetworkException e) { System.err.println("Network error: " + e.getMessage()); // Check connectivity } catch (TurboDocxException e) { // Base exception for other API errors System.err.println("API error [" + e.getStatusCode() + "]: " + e.getMessage()); } ``` --- ## TypeScript Types / Python Types / PHP Types / Types {#typescript-types} The SDK exports TypeScript types for full type safety. Import them directly from the package. The SDK uses Python type hints with `Dict[str, Any]` for flexible JSON-like structures. ### Signature Field Types {#signature-field-types} The `Type` field accepts the following string values: | Type | Description | | -------------- | ---------------- | | `"signature"` | Signature field | | `"initials"` | Initials field | | `"text"` | Text input field | | `"date"` | Date field | | `"checkbox"` | Checkbox field | | `"full_name"` | Full name field | | `"first_name"` | First name field | | `"last_name"` | Last name field | | `"email"` | Email field | | `"title"` | Title field | | `"company"` | Company field | The `type` field accepts the following string values: | Type | Description | | -------------- | ---------------- | | `"signature"` | Signature field | | `"initial"` | Initials field | | `"text"` | Text input field | | `"date"` | Date field | | `"checkbox"` | Checkbox field | | `"full_name"` | Full name field | | `"first_name"` | First name field | | `"last_name"` | Last name field | | `"email"` | Email field | | `"title"` | Title field | | `"company"` | Company field | ### Enums {#enums} The SDK uses PHP 8.1+ enums for type safety: ```php // Field types enum SignatureFieldType: string { case SIGNATURE = 'signature'; case INITIAL = 'initial'; case DATE = 'date'; case TEXT = 'text'; case FULL_NAME = 'full_name'; case FIRST_NAME = 'first_name'; case LAST_NAME = 'last_name'; case EMAIL = 'email'; case TITLE = 'title'; case COMPANY = 'company'; case CHECKBOX = 'checkbox'; } // Field placement enum FieldPlacement: string { case REPLACE = 'replace'; case BEFORE = 'before'; case AFTER = 'after'; case ABOVE = 'above'; case BELOW = 'below'; } // Document status enum DocumentStatus: string { case DRAFT = 'draft'; case SETUP_COMPLETE = 'setup_complete'; case REVIEW_READY = 'review_ready'; case UNDER_REVIEW = 'under_review'; case COMPLETED = 'completed'; case VOIDED = 'voided'; } // Conditional (IF/THEN) operator, the condition evaluated against the controlling checkbox enum ConditionalOperator: string { case IS_CHECKED = 'is_checked'; case IS_NOT_CHECKED = 'is_not_checked'; } // Conditional (IF/THEN) action, what happens to the dependent field until the condition is met enum ConditionalAction: string { case SHOW = 'show'; // hidden until met case UNLOCK = 'unlock'; // visible but read-only until met } ``` ### Readonly Classes {#readonly-classes} The SDK uses readonly classes with typed properties: ```php final class Recipient { public function __construct( public string $name, public string $email, public int $signingOrder ) {} } final class Field { public function __construct( public SignatureFieldType $type, public string $recipientEmail, public ?int $page = null, public ?int $x = null, public ?int $y = null, public ?int $width = null, public ?int $height = null, public ?TemplateConfig $template = null, public ?string $defaultValue = null, // checkbox: 'true'/'false'; date: a fixed MM/DD/YYYY (omit to auto-fill the signing date) public bool $isMultiline = false, public bool $isReadonly = false, public bool $required = false, public ?string $backgroundColor = null, public ?FieldMetadata $metadata = null // Conditional (IF/THEN) metadata ) {} } // Conditional (IF/THEN) metadata final class FieldMetadata { public function __construct( public ?string $fieldKey = null, // On a controlling checkbox public ?FieldConditional $conditional = null // On a dependent field ) {} } final class FieldConditional { public function __construct( public string $controllingFieldKey, // = the checkbox's fieldKey (non-empty) public ConditionalOperator $operator, // ConditionalOperator::IS_CHECKED | ::IS_NOT_CHECKED public ConditionalAction $action // ConditionalAction::SHOW | ::UNLOCK ) {} } ``` ### Request Objects {#request-objects} ```php final class SendSignatureRequest { public function __construct( public array $recipients, // Recipient[] public array $fields, // Field[] public ?string $file = null, public ?string $fileName = null, public ?string $fileLink = null, public ?string $deliverableId = null, public ?string $templateId = null, public ?string $documentName = null, public ?string $documentDescription = null, public ?string $senderName = null, public ?string $senderEmail = null, public ?array $ccEmails = null ) {} } ``` :::info File Source (Conditional) Exactly one file source is required: `file`, `fileLink`, `deliverableId`, or `templateId`. ::: --- ### Importing Types {#importing-types} ```typescript // Field types SignatureFieldType, Field, Recipient, // Request types CreateSignatureReviewLinkRequest, SendSignatureRequest, } from "@turbodocx/sdk"; ``` ```python from typing import Dict, List, Any, Optional ``` ### SignatureFieldType {#signaturefieldtype} Union type for all available field types: ```typescript type SignatureFieldType = | "signature" | "initial" | "date" | "text" | "full_name" | "title" | "company" | "first_name" | "last_name" | "email" | "checkbox"; ``` String literal values for field types: ```python # Available field type values field_types = [ "signature", "initial", "date", "text", "full_name", "title", "company", "first_name", "last_name", "email", "checkbox", ] ``` ### Recipient {#recipient} Recipient configuration for signature requests: | Property | Type | Required | Description | | -------------- | -------- | -------- | ------------------------- | | `name` | `string` | Yes | Recipient's full name | | `email` | `string` | Yes | Recipient's email address | | `signingOrder` | `number` | Yes | Signing order (1-indexed) | Recipient configuration for signature requests:   | Property | Type | Required | Description | | -------------- | ----- | -------- | ------------------------- | | `name` | `str` | Yes | Recipient's full name | | `email` | `str` | Yes | Recipient's email address | | `signingOrder` | `int` | Yes | Signing order (1-indexed) | ```python recipient: Dict[str, Any] = { "name": "John Doe", "email": "john@example.com", "signingOrder": 1 } ``` | Property | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------------------- | | `Name` | `string` | Yes | Recipient's full name | | `Email` | `string` | Yes | Recipient's email address | | `SigningOrder` | `int` | Yes | Order in which recipient should sign (1, 2, 3...) | | Property | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------------------- | | `name` | `String` | Yes | Recipient's full name | | `email` | `String` | Yes | Recipient's email address | | `signingOrder` | `int` | Yes | Order in which recipient should sign (1, 2, 3...) | ### Field {#field} Field configuration supporting both coordinate-based and template-based positioning: | Property | Type | Required | Description | | ----------------- | -------------------- | -------- | --------------------------------------------------- | | `type` | `SignatureFieldType` | Yes | Field type | | `recipientEmail` | `string` | Yes | Which recipient fills this field | | `page` | `number` | No\* | Page number (1-indexed) | | `x` | `number` | No\* | X coordinate in pixels | | `y` | `number` | No\* | Y coordinate in pixels | | `width` | `number` | No\* | Field width in pixels | | `height` | `number` | No\* | Field height in pixels | | `defaultValue` | `string` | No | Default value (checkbox: `"true"`/`"false"`; date: a fixed `MM/DD/YYYY`, omit to auto-fill the signing date) | | `isMultiline` | `boolean` | No | Enable multiline text | | `isReadonly` | `boolean` | No | Make field read-only (pre-filled) | | `required` | `boolean` | No | Whether field is required | | `backgroundColor` | `string` | No | Background color (hex, rgb, or named) | | `template` | `object` | No | Template anchor configuration | | `metadata` | `object` | No | Conditional (IF/THEN) metadata, see below | \*Required when not using template anchors **Metadata Configuration (Conditional Fields):** The optional `metadata` object builds IF/THEN relationships between fields. Put a `fieldKey` on a controlling `checkbox`, then point each dependent field's `conditional.controllingFieldKey` back at it. | Property | Type | Required | Description | | ----------------------------------- | -------- | -------- | ---------------------------------------------------------------- | | `fieldKey` | `string` | No | Stable id on a **controlling checkbox** (`type: "checkbox"`). | | `conditional` | `object` | No | Rule on a **dependent field** (see below). | | `conditional.controllingFieldKey` | `string` | Yes | The controlling checkbox's `fieldKey`. Must be non-empty. | | `conditional.operator` | `string` | Yes | `"is_checked"` \| `"is_not_checked"`. | | `conditional.action` | `string` | Yes | `"show"` (hidden until met) \| `"unlock"` (locked until met). | ```typescript // Checkbox reveals a text field when checked const fields: Field[] = [ { type: "checkbox", recipientEmail: "reviewer@company.com", page: 1, x: 100, y: 400, width: 20, height: 20, metadata: { fieldKey: "request_changes" }, }, { type: "text", recipientEmail: "reviewer@company.com", page: 1, x: 130, y: 400, width: 300, height: 60, metadata: { conditional: { controllingFieldKey: "request_changes", operator: "is_checked", action: "show", }, }, }, ]; ``` A malformed rule returns `400 InvalidConditionalRule`; a well-formed rule whose `controllingFieldKey` matches no checkbox **fails open** (the field stays visible/editable). See [Conditional (IF/THEN) Fields](/docs/TurboSign/Conditional-Fields). **Template Configuration:** | Property | Type | Required | Description | | --------------- | --------- | -------- | ---------------------------------------------------------------- | | `anchor` | `string` | No† | Text anchor pattern like `{TagName}` | | `searchText` | `string` | No† | Alternative to `anchor`: search for any text in the document | | `placement` | `string` | No | `"replace"` \| `"before"` \| `"after"` \| `"above"` \| `"below"` | | `size` | `object` | No | `{ width: number; height: number }` | | `offset` | `object` | No | `{ x: number; y: number }` | | `caseSensitive` | `boolean` | No | Case sensitive search (default: false) | | `useRegex` | `boolean` | No | Use regex for anchor/searchText (default: false) | †All template properties are optional in the type definition, but at least one of `anchor` or `searchText` must be provided for anchor-based positioning to work. Field configuration supporting both coordinate-based and template-based positioning:   | Property | Type | Required | Description | | ----------------- | ------ | -------- | --------------------------------------------------- | | `type` | `str` | Yes | Field type (see SignatureFieldType) | | `recipientEmail` | `str` | Yes | Which recipient fills this field | | `page` | `int` | No\* | Page number (1-indexed) | | `x` | `int` | No\* | X coordinate in pixels | | `y` | `int` | No\* | Y coordinate in pixels | | `width` | `int` | No\* | Field width in pixels | | `height` | `int` | No\* | Field height in pixels | | `defaultValue` | `str` | No | Default value (checkbox: `"true"`/`"false"`; date: a fixed `MM/DD/YYYY`, omit to auto-fill the signing date) | | `isMultiline` | `bool` | No | Enable multiline text | | `isReadonly` | `bool` | No | Make field read-only (pre-filled) | | `required` | `bool` | No | Whether field is required | | `backgroundColor` | `str` | No | Background color (hex, rgb, or named) | | `template` | `Dict` | No | Template anchor configuration | | `metadata` | `Dict` | No | Conditional (IF/THEN) metadata, see below | \*Required when not using template anchors **Metadata Configuration (Conditional Fields):** The optional `metadata` dict builds IF/THEN relationships between fields. Put a `fieldKey` on a controlling `checkbox`, then point each dependent field's `conditional.controllingFieldKey` back at it. | Property | Type | Required | Description | | ----------------------------------- | ------ | -------- | ---------------------------------------------------------------- | | `fieldKey` | `str` | No | Stable id on a **controlling checkbox** (`type: "checkbox"`). | | `conditional` | `Dict` | No | Rule on a **dependent field** (see below). | | `conditional.controllingFieldKey` | `str` | Yes | The controlling checkbox's `fieldKey`. Must be non-empty. | | `conditional.operator` | `str` | Yes | `"is_checked"` \| `"is_not_checked"`. | | `conditional.action` | `str` | Yes | `"show"` (hidden until met) \| `"unlock"` (locked until met). | ```python # Checkbox reveals a text field when checked fields = [ { "type": "checkbox", "recipientEmail": "reviewer@company.com", "page": 1, "x": 100, "y": 400, "width": 20, "height": 20, "metadata": {"fieldKey": "request_changes"}, }, { "type": "text", "recipientEmail": "reviewer@company.com", "page": 1, "x": 130, "y": 400, "width": 300, "height": 60, "metadata": { "conditional": { "controllingFieldKey": "request_changes", "operator": "is_checked", "action": "show", } }, }, ] ``` A malformed rule returns `400 InvalidConditionalRule`; a well-formed rule whose `controllingFieldKey` matches no checkbox **fails open** (the field stays visible/editable). See [Conditional (IF/THEN) Fields](/docs/TurboSign/Conditional-Fields). **Template Configuration:** | Property | Type | Required | Description | | --------------- | ------ | -------- | ---------------------------------------------------------------- | | `anchor` | `str` | Yes | Text anchor pattern like `{TagName}` | | `placement` | `str` | Yes | `"replace"` \| `"before"` \| `"after"` \| `"above"` \| `"below"` | | `size` | `Dict` | Yes | `{ "width": int, "height": int }` | | `offset` | `Dict` | No | `{ "x": int, "y": int }` | | `caseSensitive` | `bool` | No | Case sensitive search (default: False) | | `useRegex` | `bool` | No | Use regex for anchor/searchText (default: False) | ```python field: Dict[str, Any] = { "type": "signature", "page": 1, "x": 100, "y": 500, "width": 200, "height": 50, "recipientEmail": "john@example.com" } ``` | Property | Type | Required | Description | | ----------------- | ----------------- | -------- | ------------------------------------------- | | `Type` | `string` | Yes | Field type (see table above) | | `RecipientEmail` | `string` | Yes | Email of the recipient who fills this field | | `Page` | `int` | No\* | Page number (1-indexed) | | `X` | `int` | No\* | X coordinate in pixels | | `Y` | `int` | No\* | Y coordinate in pixels | | `Width` | `int` | No\* | Field width in pixels | | `Height` | `int` | No\* | Field height in pixels | | `DefaultValue` | `string` | No | Pre-filled value (checkbox: `"true"`/`"false"`; date: a fixed `MM/DD/YYYY`, omit to auto-fill the signing date) | | `IsMultiline` | `bool` | No | Enable multiline for text fields | | `IsReadonly` | `bool` | No | Make field read-only | | `Required` | `bool` | No | Make field required | | `BackgroundColor` | `string` | No | Background color | | `Template` | `*TemplateAnchor` | No | Template anchor configuration | | `Metadata` | `*FieldMetadata` | No | Conditional (IF/THEN) metadata, see below | \*Required when not using template anchors The coordinate-based constructor takes positional arguments in this order: `new Field(type, page, x, y, width, height, recipientEmail)`. For template-based fields, use the extended constructor shown in [Using Template-Based Fields](#using-template-based-fields). | Property | Type | Required | Description | | ----------------- | ---------------- | -------- | ------------------------------------------- | | `type` | `String` | Yes | Field type (see table above) | | `recipientEmail` | `String` | Yes | Email of the recipient who fills this field | | `page` | `Integer` | No\* | Page number (1-indexed) | | `x` | `Integer` | No\* | X coordinate in pixels | | `y` | `Integer` | No\* | Y coordinate in pixels | | `width` | `Integer` | No\* | Field width in pixels | | `height` | `Integer` | No\* | Field height in pixels | | `defaultValue` | `String` | No | Pre-filled value (checkbox: `"true"`/`"false"`; date: a fixed `MM/DD/YYYY`, omit to auto-fill the signing date) | | `isMultiline` | `Boolean` | No | Enable multiline for text fields | | `isReadonly` | `Boolean` | No | Make field read-only | | `required` | `Boolean` | No | Make field required | | `backgroundColor` | `String` | No | Background color | | `template` | `TemplateAnchor` | No | Template anchor configuration | \*Required when not using template anchors #### Metadata Configuration (Conditional Fields) {#metadata-configuration-conditional-fields} The optional `Metadata` builds IF/THEN relationships between fields. Put a `FieldKey` on a controlling `checkbox`, then point each dependent field's `Conditional.ControllingFieldKey` back at it. | Property | Type | Required | Description | | --------------------------------- | ----------------- | -------- | ------------------------------------------------------------- | | `FieldKey` | `string` | No | Stable id on a **controlling checkbox** (`Type: "checkbox"`). | | `Conditional` | `*FieldConditional`| No | Rule on a **dependent field** (see below). | | `Conditional.ControllingFieldKey` | `string` | Yes | The controlling checkbox's `FieldKey`. Must be non-empty. | | `Conditional.Operator` | `string` | Yes | `"is_checked"` or `"is_not_checked"`. | | `Conditional.Action` | `string` | Yes | `"show"` (hidden until met) or `"unlock"` (locked until met). | ```go // Checkbox reveals a text field when checked fields := []turbodocx.Field{ { Type: "checkbox", RecipientEmail: "reviewer@company.com", Page: 1, X: 100, Y: 400, Width: 20, Height: 20, Metadata: &turbodocx.FieldMetadata{ FieldKey: "request_changes", }, }, { Type: "text", RecipientEmail: "reviewer@company.com", Page: 1, X: 130, Y: 400, Width: 300, Height: 60, Metadata: &turbodocx.FieldMetadata{ Conditional: &turbodocx.FieldConditional{ ControllingFieldKey: "request_changes", Operator: "is_checked", Action: "show", }, }, }, } ``` A malformed rule returns `400 InvalidConditionalRule`; a well-formed rule whose `ControllingFieldKey` matches no checkbox **fails open** (the field stays visible/editable). See [Conditional (IF/THEN) Fields](/docs/TurboSign/Conditional-Fields). #### Template Configuration {#template-configuration} When using `Template` instead of coordinates: | Property | Type | Required | Description | | --------------- | -------- | -------- | ------------------------------------------------------------------------------------- | | `Anchor` | `string` | Yes | Text to find in document (e.g., `"{SIGNATURE}"`) | | `Placement` | `string` | Yes | Position relative to anchor: `"replace"`, `"before"`, `"after"`, `"above"`, `"below"` | | `Size` | `*Size` | Yes | Size with `Width` and `Height` | | `Offset` | `*Point` | No | Offset with `X` and `Y` | | `CaseSensitive` | `bool` | No | Case-sensitive anchor search | | `UseRegex` | `bool` | No | Use regex for anchor search | When using `template` instead of coordinates: | Property | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------------- | --- | | `anchor` | `String` | Yes | Text to find in document (e.g., `"{SIGNATURE}"`) | | | `placement` | `String` | Yes | Position relative to anchor: `"replace"`, `"before"`, `"after"`, `"above"`, `"below"` | | `size` | `Size` | Yes | Size with `width` and `height` | | `offset` | `Offset` | No | Offset with `x` and `y` | | `caseSensitive` | `Boolean` | No | Case-sensitive anchor search | | `useRegex` | `Boolean` | No | Use regex for anchor search | ### Request Parameters {#request-parameters} Request configuration for `create_signature_review_link` and `send_signature` methods:   | Parameter | Type | Required | Description | | ---------------------- | ------------ | ----------- | ------------------------------ | | `recipients` | `List[Dict]` | Yes | Recipients who will sign | | `fields` | `List[Dict]` | Yes | Signature fields configuration | | `file` | `bytes` | Conditional | PDF file content as bytes | | `file_name` | `str` | No | Original filename (used with `file` bytes) | | `file_link` | `str` | Conditional | URL to document file | | `deliverable_id` | `str` | Conditional | TurboDocx deliverable ID | | `template_id` | `str` | Conditional | TurboDocx template ID | | `document_name` | `str` | No | Document name | | `document_description` | `str` | No | Document description | | `sender_name` | `str` | No | Sender name (overrides the configured value) | | `sender_email` | `str` | No\*\* | Sender / reply-to email (overrides the configured value) | | `cc_emails` | `List[str]` | No | Array of CC email addresses | | `reminders_enabled` | `bool` | No | Send reminder emails to signers who haven't signed. Off by default | | `reminder_delay` | `Dict` | No | `{"value": N, "unit": "days"\|"hours"}`, time to the FIRST reminder | | `reminder_interval` | `Dict` | No | `{"value": N, "unit": ...}`, gap between later reminders | | `max_reminders` | `int` | No | Cap per signer. `-1` unlimited, `0` none, max `50`. Default `5` | | `expiration_enabled` | `bool` | No | Close the signing window after `expire_after`. Off by default | | `expire_after` | `Dict` | No | `{"value": N, "unit": ...}`, how long the document stays signable | | `expiration_warning` | `Dict` | No | `{"value": N, "unit": ...}`, how far before expiry warnings start. `0` = never warn | | `expiration_warning_interval` | `Dict` | No | `{"value": N, "unit": ...}`, gap between warnings once they start | :::info Duration bounds Each duration `{"value", "unit"}` uses `"days"` or `"hours"`; `value` is a whole number from `1` up to **999 days / 23976 hours**. Reminder and expiration are independent and both **off by default**. See [Schedule reminders and expiration](#schedule-reminders-and-expiration). ::: :::info File Source (Conditional) Exactly one file source is required: `file`, `file_link`, `deliverable_id`, or `template_id`. ::: \*\* `sender_email` is optional per call but **required at the SDK level** for TurboSign: it must be supplied via `configure()`, the `TURBODOCX_SENDER_EMAIL` environment variable, or this per-call parameter, otherwise the SDK raises a `ValidationError`. --- Both `CreateSignatureReviewLinkRequest` and `SendSignatureRequest` accept: | Property | Type | Required | Description | | --------------------- | ------------- | ----------- | ------------------------ | | `File` | `[]byte` | Conditional | File content as bytes | | `FileLink` | `string` | Conditional | URL to document | | `DeliverableID` | `string` | Conditional | TurboDocx deliverable ID | | `TemplateID` | `string` | Conditional | TurboDocx template ID | | `Recipients` | `[]Recipient` | Yes | List of recipients | | `Fields` | `[]Field` | Yes | List of fields | | `DocumentName` | `string` | No | Document display name | | `DocumentDescription` | `string` | No | Document description | | `SenderName` | `string` | No | Sender's name | | `SenderEmail` | `string` | No | Sender's email | | `CCEmails` | `[]string` | No | CC email addresses | :::info File Source (Conditional) Exactly one file source is required: `File`, `FileLink`, `DeliverableID`, or `TemplateID`. ::: --- Both `CreateSignatureReviewLinkRequest` and `SendSignatureRequest` accept: | Property | Type | Required | Description | | --------------------- | ----------------- | ----------- | ------------------------ | | `file` | `byte[]` | Conditional | File content as bytes | | `fileLink` | `String` | Conditional | URL to document | | `deliverableId` | `String` | Conditional | TurboDocx deliverable ID | | `templateId` | `String` | Conditional | TurboDocx template ID | | `recipients` | `List` | Yes | List of recipients | | `fields` | `List` | Yes | List of fields | | `documentName` | `String` | No | Document display name | | `documentDescription` | `String` | No | Document description | | `senderName` | `String` | No | Sender's name | | `senderEmail` | `String` | No | Sender's email | | `ccEmails` | `List` | No | CC email addresses | | `schedule` | `SignatureSchedule` | No | Reminder + expiration schedule (see [Schedule reminders and expiration](#schedule-reminders-and-expiration)) | :::info File Source (Conditional) Exactly one file source is required: `file`, `fileLink`, `deliverableId`, or `templateId`. ::: --- ### CreateSignatureReviewLinkRequest / SendSignatureRequest {#createsignaturereviewlinkrequest--sendsignaturerequest} Request configuration for `createSignatureReviewLink` and `sendSignature` methods: | Property | Type | Required | Description | | --------------------- | ------------- | ----------- | ------------------------------ | | `file` | `string \| File \| Buffer` | Conditional | Document as a local file path, `Buffer`, or browser `File` | | `fileName` | `string` | No | Original filename, used when `file` is a `Buffer` (defaults to `document.`) | | `fileLink` | `string` | Conditional | URL to document file | | `deliverableId` | `string` | Conditional | TurboDocx deliverable ID | | `templateId` | `string` | Conditional | TurboDocx template ID | | `recipients` | `Recipient[]` | Yes | Recipients who will sign | | `fields` | `Field[]` | Yes | Signature fields configuration | | `documentName` | `string` | No | Document name | | `documentDescription` | `string` | No | Document description | | `senderName` | `string` | No | Sender name, falls back to `senderName` in the SDK config, then your API key's name | | `senderEmail` | `string` | Conditional | Sender email, **required on the request** unless supplied via `TurboSign.configure({ senderEmail })` or `TURBODOCX_SENDER_EMAIL` | | `ccEmails` | `string[]` | No | Array of CC email addresses | | `remindersEnabled` | `boolean` | No | Send reminder emails to signers who haven't signed (default `false`) | | `reminderDelay` | `Duration` | No | `{ value, unit }`, time to the first reminder | | `reminderInterval` | `Duration` | No | `{ value, unit }`, gap between later reminders | | `maxReminders` | `number` | No | Cap per signer, range **-1..50** (`-1` unlimited, `0` none, default `5`) | | `expirationEnabled` | `boolean` | No | Close the signing window after `expireAfter` (default `false`) | | `expireAfter` | `Duration` | No | `{ value, unit }`, how long the document stays signable | | `expirationWarning` | `Duration` | No | `{ value, unit }`, how far before expiry warnings start (`0` = never warn) | | `expirationWarningInterval` | `Duration` | No | `{ value, unit }`, gap between warnings once they start | :::info Durations A `Duration` is `{ value: number, unit: "hours" | "days" }`. `value` is a whole number from **1 to 999 days (23976 hours)**. ::: :::info File Source (Conditional) Exactly one file source is required: `file`, `fileLink`, `deliverableId`, or `templateId`. ::: :::caution Sender email is enforced by the SDK, not by a `SenderEmailRequired`/`SenderNameRequired` API error Unlike TurboQuote (where the sender comes from the org quote template and there is no per-request field), TurboSign expects the sender to come from the request body, `TurboSign.configure({ senderEmail })`, or the `TURBODOCX_SENDER_EMAIL` environment variable. The **SDK enforces this itself**: `TurboSign.configure()` throws a `ValidationError` if no `senderEmail` is configured (client-side, before any request is sent). The API itself does not reject a send that omits a sender: if no sender email or name can be resolved, it falls back to a generic TurboDocx sender identity rather than returning `400 SenderEmailRequired` or `400 SenderNameRequired`. ::: --- ## Additional Documentation {#additional-documentation} For detailed information about advanced configuration and API concepts, see: ### Core API References {#core-api-references} - **[Request Body Reference](/docs/TurboSign/API-Signatures#request-body-multipartform-data)** - Complete request body parameters, file sources, and multipart/form-data structure - **[Recipients Reference](/docs/TurboSign/API-Signatures#recipients-reference)** - Recipient properties, signing order, metadata, and configuration options - **[Field Types Reference](/docs/TurboSign/API-Signatures#field-types-reference)** - All available field types (signature, date, text, checkbox, etc.) with properties and behaviors - **[Field Positioning Methods](/docs/TurboSign/API-Signatures#field-positioning-methods)** - Template-based vs coordinate-based positioning, anchor configuration, and best practices --- ## Resources {#resources} - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [npm Package](https://www.npmjs.com/package/@turbodocx/sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [PyPI Package](https://pypi.org/project/turbodocx-sdk/) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [Packagist Package](https://packagist.org/packages/turbodocx/sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [GitHub Repository](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) - [Maven Central](https://search.maven.org/artifact/com.turbodocx/turbodocx-sdk) - [API Reference](/docs/TurboSign/API-Signatures) - [Webhook Configuration](/docs/TurboSign/Webhooks) --- # TurboWebhooks SDK The official TurboDocx Webhooks SDK for Node.js applications (Express, Fastify, Next.js API routes, AWS Lambda, etc.). Subscribe a single per-organization HTTPS endpoint to TurboDocx signature events, verify inbound signatures with HMAC-SHA256, replay delivery attempts, and rotate secrets — all from Node 18+. Available on npm as `@turbodocx/sdk` (same package as TurboSign). The official TurboDocx Webhooks SDK for Python applications (Flask, FastAPI, Django, AWS Lambda, etc.). Subscribe a single per-organization HTTPS endpoint to TurboDocx signature events, verify inbound signatures with HMAC-SHA256, replay delivery attempts, and rotate secrets — all from Python 3.9+. Distributed on PyPI as `turbodocx-sdk` (same package as TurboSign). The official TurboDocx Webhooks SDK for PHP applications. Subscribe a single per-organization HTTPS endpoint to TurboDocx signature events, verify inbound signatures with HMAC-SHA256, replay delivery attempts, and rotate secrets — all from PHP 8.1+. Available on Packagist as `turbodocx/sdk` (same package as TurboSign). The official TurboDocx Webhooks SDK for Go applications (net/http, Gin, Echo, Chi, AWS Lambda, etc.). Subscribe a single per-organization HTTPS endpoint to TurboDocx signature events, verify inbound signatures with HMAC-SHA256, replay delivery attempts, and rotate secrets — all from Go 1.21+. Distributed as `github.com/TurboDocx/SDK/packages/go-sdk` (same module as TurboSign). The official TurboDocx Webhooks SDK for Java applications (Spring Boot, Servlet, Jakarta EE, etc.). Subscribe a single per-organization HTTPS endpoint to TurboDocx signature events, verify inbound signatures with HMAC-SHA256, replay delivery attempts, and rotate secrets — all from Java 11+. Distributed as `com.turbodocx:turbodocx-sdk` on Maven Central (same artifact as TurboSign). :::info What is TurboWebhooks? TurboWebhooks lets your application receive real-time notifications across the whole signature lifecycle — sent, viewed, each recipient signing, partial progress, completed, voided, and finalization failures — instead of polling the API. Each organization has a single, named webhook (`signature`) that mirrors the **Signature Webhooks** page in the dashboard, so SDK-managed and UI-managed configuration stays in sync. For the full conceptual overview of how webhooks work in TurboSign (delivery retries, payload schema, dashboard UI), see [TurboSign → Webhooks](/docs/TurboSign/Webhooks). ::: ## Installation {#installation} ```bash npm install @turbodocx/sdk ``` ```bash pnpm add @turbodocx/sdk ``` ```bash yarn add @turbodocx/sdk ``` ```bash pip install turbodocx-sdk ``` ```bash poetry add turbodocx-sdk ``` ```bash pipenv install turbodocx-sdk ``` ```bash composer require turbodocx/sdk ``` ```bash go get github.com/TurboDocx/SDK/packages/go-sdk ``` Then import: ```go ``` ```xml com.turbodocx turbodocx-sdk 0.7.0 ``` ```groovy implementation 'com.turbodocx:turbodocx-sdk:0.7.0' ``` ```kotlin implementation("com.turbodocx:turbodocx-sdk:0.7.0") ``` Then import: ```java ``` ## Requirements {#requirements} - Node.js 18 or higher (native `fetch` + `crypto.timingSafeEqual`) - An **administrator** TurboDocx API key (the webhook routes are gated on the administrator role — non-admin keys return HTTP 403) - Zero runtime dependencies — the SDK only uses Node built-ins - Python 3.9 or higher - An **administrator** TurboDocx API key (the webhook routes are gated on the administrator role — non-admin keys return HTTP 403) - All SDK methods are `async` — call them from an `async def` (or wrap with `asyncio.run(...)` in synchronous contexts) - PHP 8.1 or higher - Composer 2.x - An **administrator** TurboDocx API key (the webhook routes are gated on the administrator role — non-admin keys return HTTP 403) - Go 1.21 or higher - An **administrator** TurboDocx API key (the webhook routes are gated on the administrator role — non-admin keys return HTTP 403) - All client methods accept a `context.Context` — pass `context.Background()` for one-offs or the request context inside handlers - Java 11 or higher - An **administrator** TurboDocx API key (the webhook routes are gated on the administrator role — non-admin keys return HTTP 403) - All TurboWebhooks methods return `com.google.gson.JsonObject` for forward compatibility — new server fields surface without an SDK upgrade ## Configuration {#configuration} ```typescript TurboWebhooks.configure({ apiKey: process.env.TURBODOCX_API_KEY!, orgId: process.env.TURBODOCX_ORG_ID!, }); ``` `skipSenderValidation: true` is hardcoded inside `TurboWebhooks.configure()` because webhooks don't send email — only TurboSign needs `senderEmail`. If you skip the explicit call, the SDK lazily configures itself from `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID` on first method invocation. ```python from turbodocx_sdk import TurboWebhooks TurboWebhooks.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], ) ``` `skip_sender_validation=True` is hardcoded inside `TurboWebhooks.configure()` because webhooks don't send email — only TurboSign needs `sender_email`. If you skip the explicit call, the SDK lazily configures itself from `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID` on first method invocation. ```php ```go "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) wh, err := turbodocx.NewWebhooksClientWithConfig(turbodocx.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } ``` `NewWebhooksClientWithConfig` does **not** require `SenderEmail` — webhook routes don't send email, so the sender validation that `NewClientWithConfig` enforces for TurboSign is skipped here. If `APIKey`, `OrgID`, or `BaseURL` are blank, the SDK falls back to `TURBODOCX_API_KEY`, `TURBODOCX_ORG_ID`, and `TURBODOCX_BASE_URL`. ```java TurboWebhooks webhooks = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildWebhooksClient(); ``` `buildWebhooksClient()` does **not** require `senderEmail` — webhook routes don't send email, so the sender validation that `build()` enforces for TurboSign is skipped here. The returned `TurboWebhooks` is an admin-scoped client; construct once and reuse. :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role throws `TurboDocxException.AuthorizationException` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ### Environment Variables {#environment-variables} ```bash TURBODOCX_API_KEY=your_admin_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com # store the secret returned by createWebhook so your receiver can verify signatures TURBODOCX_WEBHOOK_SECRET=whsec_... ``` :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role will throw `AuthorizationError` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ```bash TURBODOCX_API_KEY=your_admin_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com # store the secret returned by create_webhook so your receiver can verify signatures TURBODOCX_WEBHOOK_SECRET=whsec_... ``` :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role will raise `AuthorizationError` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ```bash TURBODOCX_API_KEY=your_admin_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com # store the secret returned by createWebhook so your receiver can verify signatures TURBODOCX_WEBHOOK_SECRET=whsec_... ``` :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role will throw `AuthorizationException` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ```bash TURBODOCX_API_KEY=your_admin_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com # store the secret returned by CreateWebhook so your receiver can verify signatures TURBODOCX_WEBHOOK_SECRET=whsec_... ``` :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role returns `*turbodocx.AuthorizationError` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ```bash TURBODOCX_API_KEY=your_admin_api_key TURBODOCX_ORG_ID=your_org_id # optional — defaults to https://api.turbodocx.com TURBODOCX_BASE_URL=https://api.turbodocx.com # store the secret returned by createWebhook so your receiver can verify signatures TURBODOCX_WEBHOOK_SECRET=whsec_... ``` :::warning Administrator role required TurboWebhooks endpoints require the **administrator** role on the API key. A valid TDX- key without the role throws `TurboDocxException.AuthorizationException` (HTTP 403). Generate or rotate keys in the **Settings → API Keys** page. ::: ## Webhook Events {#webhook-events} TurboSign dispatches **seven** events. Subscribe to any subset — `events` requires at least one. | Event | Constant | Fires when | |---|---|---| | `signature.document.sent` | `WebhookEvents.SENT` | The document is dispatched to recipients | | `signature.document.viewed` | `WebhookEvents.VIEWED` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | `WebhookEvents.RECIPIENT_SIGNED` | Any individual signer completes their signature — fires **once per signer**, including the last | | `signature.document.signed` | `WebhookEvents.SIGNED` | A signer signs and the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | `WebhookEvents.COMPLETED` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | `WebhookEvents.FINALIZATION_FAILED` | The signed PDF fails to finalize (e.g. a KMS signing error); the document is **not** completed | | `signature.document.voided` | `WebhookEvents.VOIDED` | The document is voided or cancelled | :::danger `signed` does not mean "the document is done" `recipient_signed` is the **per-person** event: it fires once for **every** signer (including the last) and carries the signer's identity plus `is_final_signer` and `remaining_signers`. `signed` is a document-level **partial-progress** event. On each signature `recipient_signed` fires first, then exactly one of `signed` (signers still remain), `completed` (that was the final signature and finalization succeeded), or `finalization_failed` (final signature, finalization failed). Two consequences: - **`signed` never fires on the final signature.** - **A single-signer document never emits `signed` at all** — it emits `recipient_signed` (`is_final_signer: true`) then `completed`. To detect "the whole document is done", use `completed` (or `recipient_signed` with `is_final_signer: true`) — never `signed`. See [TurboSign → Webhooks](/docs/TurboSign/Webhooks) for the full payload schemas and the lifecycle diagram. ::: TurboSign dispatches **seven** events. Subscribe to any subset — `events` requires at least one. | Event | Constant | Fires when | |---|---|---| | `signature.document.sent` | `WEBHOOK_EVENT_SENT` | The document is dispatched to recipients | | `signature.document.viewed` | `WEBHOOK_EVENT_VIEWED` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | `WEBHOOK_EVENT_RECIPIENT_SIGNED` | Any individual signer completes their signature — fires **once per signer**, including the last | | `signature.document.signed` | `WEBHOOK_EVENT_SIGNED` | A signer signs and the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | `WEBHOOK_EVENT_COMPLETED` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | `WEBHOOK_EVENT_FINALIZATION_FAILED` | The signed PDF fails to finalize (e.g. a KMS signing error); the document is **not** completed | | `signature.document.voided` | `WEBHOOK_EVENT_VOIDED` | The document is voided or cancelled | :::danger `signed` does not mean "the document is done" `recipient_signed` is the **per-person** event: it fires once for **every** signer (including the last) and carries the signer's identity plus `is_final_signer` and `remaining_signers`. `signed` is a document-level **partial-progress** event. On each signature `recipient_signed` fires first, then exactly one of `signed` (signers still remain), `completed` (that was the final signature and finalization succeeded), or `finalization_failed` (final signature, finalization failed). Two consequences: - **`signed` never fires on the final signature.** - **A single-signer document never emits `signed` at all** — it emits `recipient_signed` (`is_final_signer: True`) then `completed`. To detect "the whole document is done", use `completed` (or `recipient_signed` with `is_final_signer: True`) — never `signed`. See [TurboSign → Webhooks](/docs/TurboSign/Webhooks) for the full payload schemas and the lifecycle diagram. ::: TurboSign dispatches **seven** events. Subscribe to any subset — `events` requires at least one. | Event | Enum case | Fires when | |---|---|---| | `signature.document.sent` | `WebhookEvent::SENT` | The document is dispatched to recipients | | `signature.document.viewed` | `WebhookEvent::VIEWED` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | `WebhookEvent::RECIPIENT_SIGNED` | Any individual signer completes their signature — fires **once per signer**, including the last | | `signature.document.signed` | `WebhookEvent::SIGNED` | A signer signs and the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | `WebhookEvent::COMPLETED` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | `WebhookEvent::FINALIZATION_FAILED` | The signed PDF fails to finalize (e.g. a KMS signing error); the document is **not** completed | | `signature.document.voided` | `WebhookEvent::VOIDED` | The document is voided or cancelled | :::danger `signed` does not mean "the document is done" `recipient_signed` is the **per-person** event: it fires once for **every** signer (including the last) and carries the signer's identity plus `is_final_signer` and `remaining_signers`. `signed` is a document-level **partial-progress** event. On each signature `recipient_signed` fires first, then exactly one of `signed` (signers still remain), `completed` (that was the final signature and finalization succeeded), or `finalization_failed` (final signature, finalization failed). Two consequences: - **`signed` never fires on the final signature.** - **A single-signer document never emits `signed` at all** — it emits `recipient_signed` (`is_final_signer: true`) then `completed`. To detect "the whole document is done", use `completed` (or `recipient_signed` with `is_final_signer: true`) — never `signed`. See [TurboSign → Webhooks](/docs/TurboSign/Webhooks) for the full payload schemas and the lifecycle diagram. ::: TurboSign dispatches **seven** events. Subscribe to any subset — `Events` requires at least one. | Event | Constant | Fires when | |---|---|---| | `signature.document.sent` | `turbodocx.WebhookEventSent` | The document is dispatched to recipients | | `signature.document.viewed` | `turbodocx.WebhookEventViewed` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | `turbodocx.WebhookEventRecipientSigned` | Any individual signer completes their signature — fires **once per signer**, including the last | | `signature.document.signed` | `turbodocx.WebhookEventSigned` | A signer signs and the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | `turbodocx.WebhookEventCompleted` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | `turbodocx.WebhookEventFinalizationFailed` | The signed PDF fails to finalize (e.g. a KMS signing error); the document is **not** completed | | `signature.document.voided` | `turbodocx.WebhookEventVoided` | The document is voided or cancelled | :::danger `signed` does not mean "the document is done" `recipient_signed` is the **per-person** event: it fires once for **every** signer (including the last) and carries the signer's identity plus `is_final_signer` and `remaining_signers`. `signed` is a document-level **partial-progress** event. On each signature `recipient_signed` fires first, then exactly one of `signed` (signers still remain), `completed` (that was the final signature and finalization succeeded), or `finalization_failed` (final signature, finalization failed). Two consequences: - **`signed` never fires on the final signature.** - **A single-signer document never emits `signed` at all** — it emits `recipient_signed` (`is_final_signer: true`) then `completed`. To detect "the whole document is done", use `completed` (or `recipient_signed` with `is_final_signer: true`) — never `signed`. See [TurboSign → Webhooks](/docs/TurboSign/Webhooks) for the full payload schemas and the lifecycle diagram. ::: TurboSign dispatches **seven** events. Subscribe to any subset — the events list requires at least one. | Event | Enum constant | Fires when | |---|---|---| | `signature.document.sent` | `WebhookEvent.SENT` | The document is dispatched to recipients | | `signature.document.viewed` | `WebhookEvent.VIEWED` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | `WebhookEvent.RECIPIENT_SIGNED` | Any individual signer completes their signature — fires **once per signer**, including the last | | `signature.document.signed` | `WebhookEvent.SIGNED` | A signer signs and the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | `WebhookEvent.COMPLETED` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | `WebhookEvent.FINALIZATION_FAILED` | The signed PDF fails to finalize (e.g. a KMS signing error); the document is **not** completed | | `signature.document.voided` | `WebhookEvent.VOIDED` | The document is voided or cancelled | :::danger `signed` does not mean "the document is done" `recipient_signed` is the **per-person** event: it fires once for **every** signer (including the last) and carries the signer's identity plus `is_final_signer` and `remaining_signers`. `signed` is a document-level **partial-progress** event. On each signature `recipient_signed` fires first, then exactly one of `signed` (signers still remain), `completed` (that was the final signature and finalization succeeded), or `finalization_failed` (final signature, finalization failed). Two consequences: - **`signed` never fires on the final signature.** - **A single-signer document never emits `signed` at all** — it emits `recipient_signed` (`is_final_signer: true`) then `completed`. To detect "the whole document is done", use `completed` (or `recipient_signed` with `is_final_signer: true`) — never `signed`. See [TurboSign → Webhooks](/docs/TurboSign/Webhooks) for the full payload schemas and the lifecycle diagram. ::: ### Event constants {#event-constants} The SDK exports the events as first-class symbols, so a typo is a compile error rather than a webhook that silently never fires. ```typescript TurboWebhooks, WebhookEvents, // const object — WebhookEvents.COMPLETED, .VOIDED, ... WEBHOOK_EVENTS, // readonly array of all 7, in lifecycle order type WebhookEvent, // the event type } from '@turbodocx/sdk'; // Subscribe to a chosen subset await TurboWebhooks.createWebhook({ urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [ WebhookEvents.SENT, WebhookEvents.VIEWED, WebhookEvents.RECIPIENT_SIGNED, WebhookEvents.COMPLETED, WebhookEvents.FINALIZATION_FAILED, WebhookEvents.VOIDED, ], }); // ...or to everything TurboSign emits (spread — WEBHOOK_EVENTS is readonly) await TurboWebhooks.createWebhook({ urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [...WEBHOOK_EVENTS], }); // Raw strings still work — WebhookEvent is `KnownWebhookEvent | (string & {})`, // so you get autocomplete on the 7 known events without the union being closed. const event: WebhookEvent = 'signature.document.completed'; ``` :::note Raw strings still work Nothing was narrowed. `events` still accepts plain strings, so existing code keeps compiling and the backend can add new events without an SDK release. The constants exist for discoverability and type safety. `getWebhook()` also returns `availableEvents` — the list the backend advertises at runtime. ::: The SDK exports the events as module-level constants, so a typo is caught at import time (and by your type checker) rather than becoming a webhook that silently never fires. ```python from turbodocx_sdk import ( TurboWebhooks, WEBHOOK_EVENTS, # tuple of all 7, in lifecycle order WEBHOOK_EVENT_SENT, WEBHOOK_EVENT_VIEWED, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, WebhookEvent, # Literal type of the 7 known events ) # Subscribe to a chosen subset await TurboWebhooks.create_webhook( urls=["https://your-server.example.com/webhooks/turbodocx"], events=[ WEBHOOK_EVENT_SENT, WEBHOOK_EVENT_VIEWED, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, ], ) # ...or to everything TurboSign emits await TurboWebhooks.create_webhook( urls=["https://your-server.example.com/webhooks/turbodocx"], events=list(WEBHOOK_EVENTS), ) # WebhookEvent is a Literal — useful for annotating your own dispatch code. event: WebhookEvent = WEBHOOK_EVENT_COMPLETED ``` :::note Raw strings still work Nothing was narrowed. `events` still accepts plain strings (`"signature.document.completed"`), so existing code keeps running and the backend can add new events without an SDK release. The constants exist for discoverability and type safety. `get_webhook()` also returns `availableEvents` — the list the backend advertises at runtime. ::: The events ship as a native PHP 8.1 **backed enum**, `TurboDocx\Types\Enums\WebhookEvent`. `createWebhook` takes wire strings, so pass `->value` (or one of the helpers below). ```php value gives the wire string. TurboWebhooks::createWebhook( urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [ WebhookEvent::SENT->value, WebhookEvent::VIEWED->value, WebhookEvent::RECIPIENT_SIGNED->value, WebhookEvent::COMPLETED->value, WebhookEvent::FINALIZATION_FAILED->value, WebhookEvent::VOIDED->value, ], ); // ...or to everything TurboSign emits. TurboWebhooks::createWebhook( urls: ['https://your-server.example.com/webhooks/turbodocx'], events: WebhookEvent::all(), // array — all 7 wire strings ); // Helpers: WebhookEvent::all(); // all 7 wire strings, lifecycle order WebhookEvent::values(); // alias of all() WebhookEvent::cases(); // native — the 7 enum cases WebhookEvent::from('signature.document.voided'); // native — string -> case (throws on unknown) ``` :::note Raw strings still work Nothing was narrowed. `createWebhook(events: [...])` still takes plain strings, so existing code keeps running and the backend can add new events without an SDK release. The enum exists for discoverability and type safety. `getWebhook()` also returns `availableEvents` — the list the backend advertises at runtime. ::: The SDK exports the events as a typed `turbodocx.WebhookEvent` string type, plus `turbodocx.AllWebhookEvents` (a `[]WebhookEvent` holding all 7 in lifecycle order). :::warning `WebhookEvent` is not a `string` — convert it `CreateWebhookRequest.Events` and `UpdateWebhookRequest.Events` are `[]string`, and Go will **not** implicitly assign `[]WebhookEvent` (or a `WebhookEvent`) into them — that's a compile error. Convert with `turbodocx.WebhookEventStrings(...)`, or `string(event)` for a single value. ::: ```go // Subscribe to a chosen subset — WebhookEventStrings turns typed events into []string. created, err := wh.CreateWebhook(ctx, turbodocx.CreateWebhookRequest{ URLs: []string{"https://your-server.example.com/webhooks/turbodocx"}, Events: turbodocx.WebhookEventStrings( turbodocx.WebhookEventSent, turbodocx.WebhookEventViewed, turbodocx.WebhookEventRecipientSigned, turbodocx.WebhookEventCompleted, turbodocx.WebhookEventFinalizationFailed, turbodocx.WebhookEventVoided, ), }) // ...or to everything TurboSign emits — spread AllWebhookEvents. created, err = wh.CreateWebhook(ctx, turbodocx.CreateWebhookRequest{ URLs: []string{"https://your-server.example.com/webhooks/turbodocx"}, Events: turbodocx.WebhookEventStrings(turbodocx.AllWebhookEvents...), }) // Single value: use String() (or a string(...) conversion) — e.g. for TestWebhook. result, err := wh.TestWebhook(ctx, turbodocx.TestWebhookRequest{ EventType: turbodocx.WebhookEventCompleted.String(), Payload: map[string]interface{}{"documentId": "..."}, }) ``` :::note Raw strings still work Nothing was narrowed. `Events` is still `[]string`, so existing code that passes `[]string{"signature.document.completed"}` keeps compiling and the backend can add new events without an SDK release. The constants exist for discoverability and type safety. `GetWebhook()` also returns `availableEvents` — the list the backend advertises at runtime. ::: The events ship as the `com.turbodocx.WebhookEvent` enum. `createWebhook` takes a `List` of wire strings, so pass `getValue()` (or `allValues()`). ```java // Subscribe to a chosen subset — getValue() gives the wire string. JsonObject created = webhooks.createWebhook( Arrays.asList("https://your-server.example.com/webhooks/turbodocx"), Arrays.asList( WebhookEvent.SENT.getValue(), WebhookEvent.VIEWED.getValue(), WebhookEvent.RECIPIENT_SIGNED.getValue(), WebhookEvent.COMPLETED.getValue(), WebhookEvent.FINALIZATION_FAILED.getValue(), WebhookEvent.VOIDED.getValue() ) ); // ...or to everything TurboSign emits. JsonObject all = webhooks.createWebhook( Arrays.asList("https://your-server.example.com/webhooks/turbodocx"), WebhookEvent.allValues() // List — all 7 wire strings, lifecycle order ); // Single value, e.g. for testWebhook / listWebhookDeliveries: webhooks.testWebhook(WebhookEvent.COMPLETED.getValue(), payload); ``` :::note Raw strings still work Nothing was narrowed. The events list is still `List`, so existing code keeps compiling and the backend can add new events without an SDK release. The enum exists for discoverability and type safety. `getWebhook()` also returns `availableEvents` — the list the backend advertises at runtime. ::: ### Delivered payload shape {#delivered-payload-shape} Every delivery posts this envelope: ```json { "event": "signature.document.completed", "event_id": "evt_a1b2c3…", "created_at": "2026-01-15T10:30:00.000Z", "version": "1.0", "data": { "documentId": "…", "documentName": "…" } } ``` :::warning Dispatch on `event`, not `eventType` The event name travels in the top-level **`event`** field. There is no `eventType` key on the wire — a receiver written against it reads `undefined` and silently dispatches nothing, which looks exactly like "the webhook never fired". `eventType` **is** correct in two other places, which is where the confusion comes from: it is the request parameter name for `testWebhook` and `listWebhookDeliveries`, and it is the column name on the stored delivery-history rows those return. Those are not the delivered envelope. ::: :::warning Dispatch on `event`, not `eventType` The event name travels in the top-level **`event`** field. There is no `eventType` key on the wire — a receiver written against it reads a `KeyError` and silently dispatches nothing, which looks exactly like "the webhook never fired". `eventType` **is** correct in two other places, which is where the confusion comes from: it is the request parameter name for `test_webhook` and `list_webhook_deliveries`, and it is the column name on the stored delivery-history rows those return. Those are not the delivered envelope. ::: :::warning Dispatch on `event`, not `eventType` The event name travels in the top-level **`event`** field. There is no `eventType` key on the wire — a receiver written against it reads `null` and silently dispatches nothing, which looks exactly like "the webhook never fired". `eventType` **is** correct in two other places, which is where the confusion comes from: it is the request parameter name for `testWebhook` and `listWebhookDeliveries`, and it is the column name on the stored delivery-history rows those return. Those are not the delivered envelope. ::: :::warning Dispatch on `event`, not `eventType` The event name travels in the top-level **`event`** field. There is no `eventType` key on the wire — a receiver written against it reads the zero value and silently dispatches nothing, which looks exactly like "the webhook never fired". `eventType` **is** correct in two other places, which is where the confusion comes from: it is the request parameter name for `TestWebhook` and `ListWebhookDeliveries`, and it is the column name on the stored delivery-history rows those return. Those are not the delivered envelope. ::: :::warning Dispatch on `event`, not `eventType` The event name travels in the top-level **`event`** field. There is no `eventType` key on the wire — a receiver written against it reads `null` and silently dispatches nothing, which looks exactly like "the webhook never fired". `eventType` **is** correct in two other places, which is where the confusion comes from: it is the request parameter name for `testWebhook` and `listWebhookDeliveries`, and it is the column name on the stored delivery-history rows those return. Those are not the delivered envelope. ::: ## Quick Start {#quick-start} ### 1. Create the signature webhook {#1-create-the-signature-webhook} ```typescript TurboWebhooks, WebhookEvents, ConflictError, ValidationError, } from '@turbodocx/sdk'; TurboWebhooks.configure({ apiKey: process.env.TURBODOCX_API_KEY!, orgId: process.env.TURBODOCX_ORG_ID!, }); try { const created = await TurboWebhooks.createWebhook({ urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [ WebhookEvents.SENT, WebhookEvents.VIEWED, WebhookEvents.RECIPIENT_SIGNED, WebhookEvents.COMPLETED, WebhookEvents.FINALIZATION_FAILED, WebhookEvents.VOIDED, ], }); // SAVE THIS SECRET — it is shown ONCE and cannot be retrieved later. writeFileSync('.secret', created.secret, { mode: 0o600 }); console.log(`Created webhook id=${created.id}`); } catch (e) { if (e instanceof ConflictError) { // 409 — the signature webhook already exists for this org. // Use TurboWebhooks.updateWebhook(...) or .deleteWebhook() instead. console.log('Webhook already exists. Use updateWebhook or deleteWebhook.'); } else if (e instanceof ValidationError) { // 400 — most commonly a non-HTTPS URL or empty events array. console.log(`Validation failed: ${e.message}`); } else { throw e; } } ``` :::warning HTTPS only TurboDocx rejects non-HTTPS webhook URLs with HTTP 400. For local development, expose your receiver via an HTTPS tunnel ([ngrok](https://ngrok.com), [cloudflared](https://github.com/cloudflare/cloudflared), or [webhook.site](https://webhook.site)) and pass the tunnel URL to `createWebhook`. ::: ```python from turbodocx_sdk import ( TurboWebhooks, ConflictError, ValidationError, WEBHOOK_EVENT_SENT, WEBHOOK_EVENT_VIEWED, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, ) async def setup_webhook(): TurboWebhooks.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], ) try: created = await TurboWebhooks.create_webhook( urls=["https://your-server.example.com/webhooks/turbodocx"], events=[ WEBHOOK_EVENT_SENT, WEBHOOK_EVENT_VIEWED, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, ], ) # SAVE THIS SECRET — it is shown ONCE and cannot be retrieved later. with open(".secret", "w") as f: f.write(created["secret"]) os.chmod(".secret", 0o600) print(f"Created webhook id={created['id']}") except ConflictError: # 409 — the signature webhook already exists for this org. # Use TurboWebhooks.update_webhook(...) or .delete_webhook() instead. print("Webhook already exists. Use update_webhook or delete_webhook.") except ValidationError as e: # 400 — most commonly a non-HTTPS URL or empty events array. print(f"Validation failed: {e}") asyncio.run(setup_webhook()) ``` :::warning HTTPS only TurboDocx rejects non-HTTPS webhook URLs with HTTP 400. For local development, expose your receiver via an HTTPS tunnel ([ngrok](https://ngrok.com), [cloudflared](https://github.com/cloudflare/cloudflared), or [webhook.site](https://webhook.site)) and pass the tunnel URL to `create_webhook`. ::: ```php value, WebhookEvent::VIEWED->value, WebhookEvent::RECIPIENT_SIGNED->value, WebhookEvent::COMPLETED->value, WebhookEvent::FINALIZATION_FAILED->value, WebhookEvent::VOIDED->value, ], ); // SAVE THIS SECRET — it is shown ONCE and cannot be retrieved later. file_put_contents('.secret', $created['secret']); echo "Created webhook id={$created['id']}\n"; } catch (ConflictException $e) { // 409 — the signature webhook already exists for this org. // Use TurboWebhooks::updateWebhook(...) or ::deleteWebhook() instead. echo "Webhook already exists. Use updateWebhook or deleteWebhook.\n"; } catch (ValidationException $e) { // 400 — most commonly a non-HTTPS URL or empty events array. echo "Validation failed: {$e->getMessage()}\n"; } ``` :::warning HTTPS only TurboDocx rejects non-HTTPS webhook URLs with HTTP 400. For local development, expose your receiver via an HTTPS tunnel ([ngrok](https://ngrok.com), [cloudflared](https://github.com/cloudflare/cloudflared), etc.) and pass the tunnel URL to `createWebhook`. ::: ```go package main "context" "errors" "fmt" "log" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { ctx := context.Background() wh, err := turbodocx.NewWebhooksClientWithConfig(turbodocx.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), OrgID: os.Getenv("TURBODOCX_ORG_ID"), }) if err != nil { log.Fatal(err) } created, err := wh.CreateWebhook(ctx, turbodocx.CreateWebhookRequest{ URLs: []string{"https://your-server.example.com/webhooks/turbodocx"}, // Events is []string — WebhookEventStrings converts the typed constants. Events: turbodocx.WebhookEventStrings( turbodocx.WebhookEventSent, turbodocx.WebhookEventViewed, turbodocx.WebhookEventRecipientSigned, turbodocx.WebhookEventCompleted, turbodocx.WebhookEventFinalizationFailed, turbodocx.WebhookEventVoided, ), }) if err != nil { var conflict *turbodocx.ConflictError var valErr *turbodocx.ValidationError switch { case errors.As(err, &conflict): // 409 — the signature webhook already exists for this org. // Use UpdateWebhook or DeleteWebhook instead. log.Println("Webhook already exists. Use UpdateWebhook or DeleteWebhook.") return case errors.As(err, &valErr): // 400 — most commonly a non-HTTPS URL or empty events array. log.Fatalf("Validation failed: %s", valErr.Message) default: log.Fatal(err) } } // SAVE THIS SECRET — it is shown ONCE and cannot be retrieved later. if err := os.WriteFile(".secret", []byte(created.Secret), 0600); err != nil { log.Fatal(err) } fmt.Printf("Created webhook id=%s\n", created.ID) } ``` :::warning HTTPS only TurboDocx rejects non-HTTPS webhook URLs with HTTP 400. For local development, expose your receiver via an HTTPS tunnel ([ngrok](https://ngrok.com), [cloudflared](https://github.com/cloudflare/cloudflared), or [webhook.site](https://webhook.site)) and pass the tunnel URL to `CreateWebhook`. ::: ```java public class CreateSignatureWebhook { public static void main(String[] args) throws Exception { TurboWebhooks webhooks = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) .orgId(System.getenv("TURBODOCX_ORG_ID")) .buildWebhooksClient(); try { JsonObject created = webhooks.createWebhook( Arrays.asList("https://your-server.example.com/webhooks/turbodocx"), Arrays.asList( WebhookEvent.SENT.getValue(), WebhookEvent.VIEWED.getValue(), WebhookEvent.RECIPIENT_SIGNED.getValue(), WebhookEvent.COMPLETED.getValue(), WebhookEvent.FINALIZATION_FAILED.getValue(), WebhookEvent.VOIDED.getValue() ) ); // SAVE THIS SECRET — it is shown ONCE and cannot be retrieved later. Files.writeString(Paths.get(".secret"), created.get("secret").getAsString()); System.out.println("Created webhook id=" + created.get("id").getAsString()); } catch (TurboDocxException.ConflictException e) { // 409 — the signature webhook already exists for this org. // Use updateWebhook or deleteWebhook instead. System.out.println("Webhook already exists. Use updateWebhook or deleteWebhook."); } catch (TurboDocxException.ValidationException e) { // 400 — most commonly a non-HTTPS URL or empty events list. System.err.println("Validation failed: " + e.getMessage()); } } } ``` :::warning HTTPS only TurboDocx rejects non-HTTPS webhook URLs with HTTP 400. For local development, expose your receiver via an HTTPS tunnel ([ngrok](https://ngrok.com), [cloudflared](https://github.com/cloudflare/cloudflared), or [webhook.site](https://webhook.site)) and pass the tunnel URL to `createWebhook`. ::: ### 2. Verify inbound webhook signatures {#2-verify-inbound-webhook-signatures} When TurboDocx POSTs to your receiver, every request carries an `X-TurboDocx-Signature` header. Verify it before trusting the payload — the helper enforces a 300-second timestamp tolerance and uses `crypto.timingSafeEqual` for constant-time comparison. ```typescript const app = express(); // IMPORTANT: use express.raw — the signature is computed over raw bytes. // express.json() will mangle whitespace and break verification. app.post( '/webhooks/turbodocx', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.header('x-turbodocx-signature') ?? ''; const timestamp = req.header('x-turbodocx-timestamp') ?? ''; const secret = process.env.TURBODOCX_WEBHOOK_SECRET!; if (!verifyWebhookSignature(req.body, signature, timestamp, secret)) { return res.status(401).send('Invalid signature'); } const event = JSON.parse((req.body as Buffer).toString('utf8')); // process event.event, event.data, ... (NOT event.eventType — not on the wire) res.status(200).send('ok'); }, ); ``` :::danger Use the raw request body The HMAC is computed over the **exact bytes** that left the TurboDocx server. Never `JSON.parse` and re-stringify before verifying — re-encoded JSON will not byte-match and verification will fail. Use `express.raw()`, Fastify's `rawBody` option, or Next.js Edge's `await request.text()`. ::: The signature contract: | Field | Value | |---|---| | Header | `X-TurboDocx-Signature: sha256=` | | Timestamp header | `X-TurboDocx-Timestamp: ` | | Signed string | `${timestamp}.${rawBody}` | | Algorithm | HMAC-SHA256 | | Tolerance | 300 seconds (configurable) | | Comparison | `crypto.timingSafeEqual` (constant-time) | When TurboDocx POSTs to your receiver, every request carries an `X-TurboDocx-Signature` header. Verify it before trusting the payload — the helper enforces a 300-second timestamp tolerance and uses `hmac.compare_digest` for constant-time comparison. ```python from flask import Flask, request, abort from turbodocx_sdk import verify_webhook_signature app = Flask(__name__) @app.post("/webhooks/turbodocx") def turbodocx_webhook(): # IMPORTANT: read raw bytes — the signature is computed over them. # request.get_json() will mangle whitespace and break verification. raw_body = request.get_data() signature = request.headers.get("X-TurboDocx-Signature", "") timestamp = request.headers.get("X-TurboDocx-Timestamp", "") secret = os.environ["TURBODOCX_WEBHOOK_SECRET"] if not verify_webhook_signature(raw_body, signature, timestamp, secret): abort(401, "Invalid signature") event = json.loads(raw_body) # process event["event"], event["data"], ... (NOT "eventType" — not on the wire) return ("ok", 200) ``` ```python from fastapi import FastAPI, Request, HTTPException from turbodocx_sdk import verify_webhook_signature app = FastAPI() @app.post("/webhooks/turbodocx") async def turbodocx_webhook(request: Request): # IMPORTANT: read raw bytes — the signature is computed over them. # await request.json() will mangle whitespace and break verification. raw_body = await request.body() signature = request.headers.get("x-turbodocx-signature", "") timestamp = request.headers.get("x-turbodocx-timestamp", "") secret = os.environ["TURBODOCX_WEBHOOK_SECRET"] if not verify_webhook_signature(raw_body, signature, timestamp, secret): raise HTTPException(status_code=401, detail="Invalid signature") event = json.loads(raw_body) # process event["event"], event["data"], ... (NOT "eventType" — not on the wire) return {"ok": True} ``` :::danger Use the raw request body The HMAC is computed over the **exact bytes** that left the TurboDocx server. Never call `json.loads(...)` and re-serialize before verifying — re-encoded JSON will not byte-match and verification will fail. Use Flask's `request.get_data()`, FastAPI's `await request.body()`, or Django's `request.body`. ::: The signature contract: | Field | Value | |---|---| | Header | `X-TurboDocx-Signature: sha256=` | | Timestamp header | `X-TurboDocx-Timestamp: ` | | Signed string | `f"{timestamp}.{raw_body}"` | | Algorithm | HMAC-SHA256 | | Tolerance | 300 seconds (configurable) | | Comparison | `hmac.compare_digest` (constant-time) | When TurboDocx POSTs to your receiver, every request carries an `X-TurboDocx-Signature` header. Verify it before trusting the payload — the helper enforces a 300-second timestamp tolerance and uses constant-time comparison. ```php ` | | Timestamp header | `X-TurboDocx-Timestamp: ` | | Signed string | `${timestamp}.${rawBody}` | | Algorithm | HMAC-SHA256 | | Tolerance | 300 seconds (configurable) | | Comparison | `hash_equals` (constant-time) | When TurboDocx POSTs to your receiver, every request carries an `X-TurboDocx-Signature` header. Verify it before trusting the payload — the helper enforces a 300-second timestamp tolerance and uses `hmac.Equal` for constant-time comparison. ```go package main "io" "log" "net/http" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func turbodocxWebhook(w http.ResponseWriter, r *http.Request) { // IMPORTANT: read raw bytes — the signature is computed over them. // Decoding to a struct first will lose whitespace and break verification. rawBody, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read failed", http.StatusBadRequest) return } defer r.Body.Close() signature := r.Header.Get("X-TurboDocx-Signature") timestamp := r.Header.Get("X-TurboDocx-Timestamp") secret := os.Getenv("TURBODOCX_WEBHOOK_SECRET") if !turbodocx.VerifyWebhookSignature(rawBody, signature, timestamp, secret, nil) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // Now safe to json.Unmarshal(rawBody, &event) and dispatch on the top-level // `event` field (tag it `json:"event"`) — NOT `eventType`, which is not on the wire. w.WriteHeader(http.StatusOK) } func main() { http.HandleFunc("/webhooks/turbodocx", turbodocxWebhook) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` ```go package main "io" "net/http" "os" "github.com/gin-gonic/gin" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { r := gin.Default() r.POST("/webhooks/turbodocx", func(c *gin.Context) { rawBody, err := io.ReadAll(c.Request.Body) if err != nil { c.String(http.StatusBadRequest, "read failed") return } defer c.Request.Body.Close() if !turbodocx.VerifyWebhookSignature( rawBody, c.GetHeader("X-TurboDocx-Signature"), c.GetHeader("X-TurboDocx-Timestamp"), os.Getenv("TURBODOCX_WEBHOOK_SECRET"), nil, ) { c.String(http.StatusUnauthorized, "invalid signature") return } // dispatch event["event"] ... (NOT "eventType" — not on the wire) c.Status(http.StatusOK) }) r.Run(":3000") } ``` ```go package main "io" "net/http" "os" "github.com/labstack/echo/v4" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func main() { e := echo.New() e.POST("/webhooks/turbodocx", func(c echo.Context) error { rawBody, err := io.ReadAll(c.Request().Body) if err != nil { return c.String(http.StatusBadRequest, "read failed") } defer c.Request().Body.Close() if !turbodocx.VerifyWebhookSignature( rawBody, c.Request().Header.Get("X-TurboDocx-Signature"), c.Request().Header.Get("X-TurboDocx-Timestamp"), os.Getenv("TURBODOCX_WEBHOOK_SECRET"), nil, ) { return c.String(http.StatusUnauthorized, "invalid signature") } // dispatch event["event"] ... (NOT "eventType" — not on the wire) return c.NoContent(http.StatusOK) }) e.Logger.Fatal(e.Start(":3000")) } ``` :::danger Use the raw request body The HMAC is computed over the **exact bytes** that left the TurboDocx server. Never decode JSON into a struct and re-marshal before verifying — re-encoded JSON will not byte-match and verification will fail. Always read with `io.ReadAll(r.Body)` first. ::: The signature contract: | Field | Value | |---|---| | Header | `X-TurboDocx-Signature: sha256=` | | Timestamp header | `X-TurboDocx-Timestamp: ` | | Signed string | `timestamp + "." + rawBody` | | Algorithm | HMAC-SHA256 | | Tolerance | 300 seconds (configurable via `VerifyWebhookSignatureOptions.ToleranceSeconds`) | | Comparison | `hmac.Equal` (constant-time) | When TurboDocx POSTs to your receiver, every request carries an `X-TurboDocx-Signature` header. Verify it before trusting the payload — the helper enforces a 300-second timestamp tolerance and uses `MessageDigest.isEqual` for constant-time comparison. Java has no free functions, so the helper is exposed as `WebhookSignatureVerifier.verify(...)` — a static method on a final utility class. Semantically equivalent to the free-function form in JS / Py / Go / PHP. ```java package com.example.webhooks; @RestController public class TurboDocxWebhookController { @Value("${turbodocx.webhook.secret}") private String secret; // IMPORTANT: bind to byte[], not a parsed DTO. The signature is computed // over raw bytes — Jackson would re-serialize and whitespace mismatch // breaks HMAC verification. @PostMapping(value = "/webhooks/turbodocx", consumes = "application/json") public ResponseEntity receive( @RequestBody byte[] rawBody, @RequestHeader("X-TurboDocx-Signature") String signature, @RequestHeader("X-TurboDocx-Timestamp") String timestamp) { if (!WebhookSignatureVerifier.verify(rawBody, signature, timestamp, secret)) { return ResponseEntity.status(401).build(); } // Now safe to parse rawBody as JSON and dispatch on the top-level "event" // field — NOT "eventType", which is not on the wire. return ResponseEntity.ok().build(); } } ``` ```java package com.example.webhooks; @WebServlet("/webhooks/turbodocx") public class TurboDocxWebhookServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { // IMPORTANT: read raw bytes — never call getReader() or getParameter*, // those decode and re-encode the payload and break HMAC verification. byte[] rawBody = req.getInputStream().readAllBytes(); String signature = req.getHeader("X-TurboDocx-Signature"); String timestamp = req.getHeader("X-TurboDocx-Timestamp"); String secret = System.getenv("TURBODOCX_WEBHOOK_SECRET"); if (!WebhookSignatureVerifier.verify(rawBody, signature, timestamp, secret)) { resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "invalid signature"); return; } // dispatch on the top-level "event" field ... (NOT "eventType") resp.setStatus(HttpServletResponse.SC_OK); } } ``` ```java package com.example.webhooks; @Path("/webhooks/turbodocx") public class TurboDocxWebhookResource { // JAX-RS deserializes byte[] entities as the raw request body — exactly // what HMAC verification needs. @POST public Response receive( byte[] rawBody, @HeaderParam("X-TurboDocx-Signature") String signature, @HeaderParam("X-TurboDocx-Timestamp") String timestamp) { String secret = System.getenv("TURBODOCX_WEBHOOK_SECRET"); if (!WebhookSignatureVerifier.verify(rawBody, signature, timestamp, secret)) { return Response.status(Response.Status.UNAUTHORIZED).build(); } // dispatch on the top-level "event" field ... (NOT "eventType") return Response.ok().build(); } } ``` :::danger Use the raw request body The HMAC is computed over the **exact bytes** that left the TurboDocx server. Never decode JSON into a DTO and re-serialize before verifying — re-encoded JSON will not byte-match and verification will fail. In Spring, bind to `@RequestBody byte[]`. In a Servlet, use `request.getInputStream().readAllBytes()`. In JAX-RS, declare a `byte[]` entity parameter. ::: The signature contract: | Field | Value | |---|---| | Header | `X-TurboDocx-Signature: sha256=` | | Timestamp header | `X-TurboDocx-Timestamp: ` | | Signed string | `timestamp + "." + rawBody` | | Algorithm | HMAC-SHA256 | | Tolerance | 300 seconds (configurable via the 6-arg `verify` overload) | | Comparison | `MessageDigest.isEqual` (constant-time) | ## Method Reference {#method-reference} All methods are static; configure once, then call on the `TurboWebhooks` class. All methods are `@classmethod`s on `TurboWebhooks`; configure once, then call on the class. All methods are static; configure once, then call on the `TurboWebhooks` class. All methods are instance methods on `*turbodocx.WebhooksClient`. Construct once, then reuse. All methods are instance methods on `com.turbodocx.TurboWebhooks`. Construct once via `new TurboDocxClient.Builder()...buildWebhooksClient()` and reuse. ### createWebhook / create_webhook / CreateWebhook {#createwebhook} Subscribe the org to events. Returns `{id, secret}` — the **secret is shown once**. ```typescript const created = await TurboWebhooks.createWebhook({ urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [ WebhookEvents.RECIPIENT_SIGNED, WebhookEvents.COMPLETED, WebhookEvents.FINALIZATION_FAILED, WebhookEvents.VOIDED, ], // or subscribe to all 7: events: [...WEBHOOK_EVENTS] }); ``` `urls` accepts **1 to 10** HTTPS URLs. `events` requires **at least 1** of the [seven events](#webhook-events) — raw strings and `WebhookEvents.*` constants are interchangeable. Both fields are required on create. | Throws | Why | |---|---| | `ConflictError` (409) | The signature webhook already exists for this org. | | `ValidationError` (400) | A URL is not HTTPS, `urls` is empty or has more than 10 entries, or `events` is empty. | | `AuthorizationError` (403) | API key lacks the administrator role. | Subscribe the org to events. Returns a dict with `id` and `secret` — the **secret is shown once**. ```python from turbodocx_sdk import ( WEBHOOK_EVENTS, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, ) created = await TurboWebhooks.create_webhook( urls=["https://your-server.example.com/webhooks/turbodocx"], events=[ WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_FINALIZATION_FAILED, WEBHOOK_EVENT_VOIDED, ], # or subscribe to all 7: events=list(WEBHOOK_EVENTS) ) ``` `urls` accepts **1 to 10** HTTPS URLs. `events` requires **at least 1** of the [seven events](#webhook-events) — raw strings and `WEBHOOK_EVENT_*` constants are interchangeable. Both are required on create. | Raises | Why | |---|---| | `ConflictError` (409) | The signature webhook already exists for this org. | | `ValidationError` (400) | A URL is not HTTPS, `urls` is empty or has more than 10 entries, or `events` is empty. | | `AuthorizationError` (403) | API key lacks the administrator role. | Subscribe the org to events. Returns `{id, secret}` — the **secret is shown once**. ```php use TurboDocx\Types\Enums\WebhookEvent; $created = TurboWebhooks::createWebhook( urls: ['https://your-server.example.com/webhooks/turbodocx'], events: [ WebhookEvent::RECIPIENT_SIGNED->value, WebhookEvent::COMPLETED->value, WebhookEvent::FINALIZATION_FAILED->value, WebhookEvent::VOIDED->value, ], // or subscribe to all 7: events: WebhookEvent::all() ); ``` `urls` accepts **1 to 10** HTTPS URLs. `events` requires **at least 1** of the [seven events](#webhook-events), as wire strings — raw strings and `WebhookEvent::*->value` are interchangeable. Both are required on create. | Throws | Why | |---|---| | `ConflictException` (409) | The signature webhook already exists for this org. | | `ValidationException` (400) | A URL is not HTTPS, `urls` is empty or has more than 10 entries, or `events` is empty. | | `AuthorizationException` (403) | API key lacks the administrator role. | Subscribe the org to events. Returns `*CreateWebhookResponse` with `ID` and `Secret` — the **secret is shown once**. ```go created, err := wh.CreateWebhook(ctx, turbodocx.CreateWebhookRequest{ URLs: []string{"https://your-server.example.com/webhooks/turbodocx"}, Events: turbodocx.WebhookEventStrings( turbodocx.WebhookEventRecipientSigned, turbodocx.WebhookEventCompleted, turbodocx.WebhookEventFinalizationFailed, turbodocx.WebhookEventVoided, ), // or subscribe to all 7: // Events: turbodocx.WebhookEventStrings(turbodocx.AllWebhookEvents...), }) ``` `URLs` accepts **1 to 10** HTTPS URLs. `Events` requires **at least 1** of the [seven events](#webhook-events). `Events` is `[]string`, so raw strings still work — but a typed `turbodocx.WebhookEvent` must go through `WebhookEventStrings` (or `string(...)`) or it won't compile. Both fields are required on create. | Error type | Why | |---|---| | `*ConflictError` (409) | The signature webhook already exists for this org. | | `*ValidationError` (400) | A URL is not HTTPS, `URLs` is empty or has more than 10 entries, or `Events` is empty. | | `*AuthorizationError` (403) | API key lacks the administrator role. | Subscribe the org to events. Returns a `JsonObject` with `id` and `secret` — the **secret is shown once**. ```java JsonObject created = webhooks.createWebhook( Arrays.asList("https://your-server.example.com/webhooks/turbodocx"), Arrays.asList( WebhookEvent.RECIPIENT_SIGNED.getValue(), WebhookEvent.COMPLETED.getValue(), WebhookEvent.FINALIZATION_FAILED.getValue(), WebhookEvent.VOIDED.getValue() ) ); // or subscribe to all 7: // JsonObject created = webhooks.createWebhook(urls, WebhookEvent.allValues()); ``` The URL list accepts **1 to 10** HTTPS URLs. The events list requires **at least 1** of the [seven events](#webhook-events), as wire strings — raw strings and `WebhookEvent.*.getValue()` are interchangeable. Both are required on create. | Exception | Why | |---|---| | `TurboDocxException.ConflictException` (409) | The signature webhook already exists for this org. | | `TurboDocxException.ValidationException` (400) | A URL is not HTTPS, the URL list is empty or has more than 10 entries, or the events list is empty. | | `TurboDocxException.AuthorizationException` (403) | API key lacks the administrator role. | ### getWebhook / get_webhook / GetWebhook {#getwebhook} Get the org's signature webhook plus delivery statistics. ```typescript const webhook = await TurboWebhooks.getWebhook(); // webhook.urls, webhook.events, webhook.isActive // webhook.deliveryStats.{totalDeliveries, successfulDeliveries, failedDeliveries, pendingRetries} // webhook.availableEvents ``` Get the org's signature webhook plus delivery statistics. ```python webhook = await TurboWebhooks.get_webhook() # webhook["urls"], webhook["events"], webhook["isActive"] # webhook["deliveryStats"]: {"totalDeliveries", "successfulDeliveries", "failedDeliveries", "pendingRetries"} # webhook["availableEvents"] ``` Get the org's signature webhook plus delivery statistics. ```php $webhook = TurboWebhooks::getWebhook(); // $webhook['urls'], $webhook['events'], $webhook['isActive'] // $webhook['deliveryStats']['totalDeliveries'] // $webhook['deliveryStats']['successfulDeliveries'] // $webhook['deliveryStats']['failedDeliveries'] // $webhook['deliveryStats']['pendingRetries'] // $webhook['availableEvents'] ``` Get the org's signature webhook plus delivery statistics. Returns `map[string]interface{}` so new fields surface without an SDK upgrade. ```go webhook, err := wh.GetWebhook(ctx) // webhook["urls"], webhook["events"], webhook["isActive"] // webhook["deliveryStats"]: { totalDeliveries, successfulDeliveries, failedDeliveries, pendingRetries } // webhook["availableEvents"] ``` Get the org's signature webhook plus delivery statistics. ```java JsonObject webhook = webhooks.getWebhook(); // webhook.get("urls"), webhook.get("events"), webhook.get("isActive") // webhook.getAsJsonObject("deliveryStats"): // { totalDeliveries, successfulDeliveries, failedDeliveries, pendingRetries } // webhook.get("availableEvents") ``` ### updateWebhook / update_webhook / UpdateWebhook {#updatewebhook} Patch one or more fields. All fields are optional — pass only what changes. ```typescript await TurboWebhooks.updateWebhook({ urls: ['https://your-server.example.com/webhooks/turbodocx'], events: ['signature.document.completed'], isActive: true, }); // Leaving urls and events alone: OMIT the keys entirely. await TurboWebhooks.updateWebhook({ isActive: false }); ``` :::danger Never send an empty array `urls` and `events` are optional on update, but optional does **not** relax their minimum length. `urls: []` or `events: []` is a **400** — the same as sending an empty array on create. To leave a field unchanged, **omit the key entirely**. `urls` still caps at 10 entries on update. ::: Patch one or more fields. All fields are keyword-only and optional — pass only what changes. ```python await TurboWebhooks.update_webhook( urls=["https://your-server.example.com/webhooks/turbodocx"], events=["signature.document.completed"], is_active=True, ) # Leaving urls and events alone: just don't pass them. await TurboWebhooks.update_webhook(is_active=False) ``` :::danger Never send an empty list `urls` and `events` are optional on update, but optional does **not** relax their minimum length. `urls=[]` or `events=[]` is a **400** — the same as sending an empty list on create. To leave a field unchanged, **omit the argument entirely**. `urls` still caps at 10 entries on update. ::: Patch one or more fields. All parameters are optional — pass only what changes. ```php TurboWebhooks::updateWebhook( urls: ['https://your-server.example.com/webhooks/turbodocx'], events: ['signature.document.completed'], isActive: true, ); // Leaving urls and events alone: just don't pass those named arguments. TurboWebhooks::updateWebhook(isActive: false); ``` :::danger Never send an empty array `urls` and `events` are optional on update, but optional does **not** relax their minimum length. `urls: []` or `events: []` is a **400** — the same as sending an empty array on create. To leave a field unchanged, **omit the argument entirely**. `urls` still caps at 10 entries on update. ::: Patch one or more fields. Leave any field at its zero value to skip it. Use `turbodocx.BoolPtr(false)` to toggle `IsActive`. ```go updated, err := wh.UpdateWebhook(ctx, turbodocx.UpdateWebhookRequest{ URLs: []string{"https://your-server.example.com/webhooks/turbodocx"}, Events: turbodocx.WebhookEventStrings( turbodocx.WebhookEventRecipientSigned, turbodocx.WebhookEventCompleted, ), IsActive: turbodocx.BoolPtr(true), }) // Leaving URLs and Events alone: leave them nil so the SDK omits the keys. updated, err = wh.UpdateWebhook(ctx, turbodocx.UpdateWebhookRequest{ IsActive: turbodocx.BoolPtr(false), }) ``` :::danger You cannot clear `URLs` or `Events` `URLs` and `Events` are optional on update, but optional does **not** relax their minimum length: a list you do send must be non-empty, and `URLs` still caps at 10 entries. Both fields are tagged `omitempty`, so an empty `[]string{}` is dropped from the request body exactly like `nil`, leaving the existing list untouched rather than clearing it. There is no way to empty a webhook's URL or event list through the SDK. Delete the webhook and recreate it instead. ::: Patch one or more fields. Pass `null` for any argument you don't want to change. Renaming is not supported. ```java JsonObject updated = webhooks.updateWebhook( Arrays.asList("https://your-server.example.com/webhooks/turbodocx"), // urls Arrays.asList( // events WebhookEvent.RECIPIENT_SIGNED.getValue(), WebhookEvent.COMPLETED.getValue() ), Boolean.TRUE // isActive ); // Leaving urls and events alone: pass null, NOT an empty list. JsonObject deactivated = webhooks.updateWebhook(null, null, Boolean.FALSE); ``` :::danger Never pass an empty list `urls` and `events` are optional on update, but optional does **not** relax their minimum length. An empty list serializes to `urls: []` / `events: []`, which the API rejects with a **400** — exactly as it would on create. To leave a field unchanged, pass `null` so the key is omitted; never pass `Collections.emptyList()`. The URL list still caps at 10 entries on update. ::: ### deleteWebhook / delete_webhook / DeleteWebhook {#deletewebhook} Soft-delete the webhook and its delivery history. ```typescript await TurboWebhooks.deleteWebhook(); ``` ```python await TurboWebhooks.delete_webhook() ``` ```php TurboWebhooks::deleteWebhook(); ``` ```go _, err := wh.DeleteWebhook(ctx) ``` ```java JsonObject deleted = webhooks.deleteWebhook(); ``` ### testWebhook / test_webhook / TestWebhook {#testwebhook} Fire a synthetic delivery to every URL configured on the webhook. Useful for CI smoke tests before flipping a new receiver into production. ```typescript const result = await TurboWebhooks.testWebhook({ eventType: 'signature.document.completed', payload: { documentId: '...', documentName: '...' }, }); console.log(`${result.summary.successful}/${result.summary.total} succeeded`); for (const err of result.summary.errors) { console.log(` failure: ${err}`); // per-URL failure messages } ``` ```python result = await TurboWebhooks.test_webhook( event_type="signature.document.completed", payload={"documentId": "...", "documentName": "..."}, ) print(f"{result['summary']['successful']}/{result['summary']['total']} succeeded") for err in result["summary"].get("errors", []): print(f" failure: {err}") # per-URL failure messages ``` ```php $result = TurboWebhooks::testWebhook( eventType: 'signature.document.completed', payload: ['documentId' => '...', 'documentName' => '...'], ); echo "{$result['summary']['successful']}/{$result['summary']['total']} succeeded\n"; foreach ($result['summary']['errors'] as $err) { echo " failure: {$err}\n"; // per-URL failure messages } ``` ```go result, err := wh.TestWebhook(ctx, turbodocx.TestWebhookRequest{ // EventType is a string — convert the typed constant with .String(). EventType: turbodocx.WebhookEventCompleted.String(), Payload: map[string]interface{}{ "documentId": "...", "documentName": "...", }, }) summary := result["summary"].(map[string]interface{}) fmt.Printf("%v/%v succeeded\n", summary["successful"], summary["total"]) if errs, ok := summary["errors"].([]interface{}); ok { for _, e := range errs { fmt.Printf(" failure: %v\n", e) // per-URL failure messages } } ``` `NotifyWebhook` is also exposed for symmetry with the backend surface — it routes through the same handler and returns the same shape. Prefer `TestWebhook` in new code. ```java Map payload = new LinkedHashMap(); payload.put("documentId", "..."); payload.put("documentName", "..."); JsonObject result = webhooks.testWebhook(WebhookEvent.COMPLETED.getValue(), payload); JsonObject summary = result.getAsJsonObject("summary"); System.out.println(summary.get("successful") + "/" + summary.get("total") + " succeeded"); if (summary.has("errors") && summary.get("errors").isJsonArray()) { summary.getAsJsonArray("errors").forEach(err -> System.out.println(" failure: " + err)); // per-URL failure messages } ``` `notifyWebhook` is also exposed for symmetry with the backend surface — it routes through the same handler and returns the same shape. Prefer `testWebhook` in new code. ### notifyWebhook / notify_webhook {#notifywebhook} Manually send a notification to all URLs configured on the webhook. Routes through the same backend handler as `testWebhook` and returns an identical response shape. Exposed for symmetry with the backend surface; prefer `testWebhook` in new code. ```typescript const result = await TurboWebhooks.notifyWebhook({ eventType: 'signature.document.completed', payload: { documentId: '...', documentName: '...' }, }); console.log(`${result.summary.successful}/${result.summary.total} succeeded`); for (const err of result.summary.errors) { console.log(` failure: ${err}`); // per-URL failure messages } ``` Manually send a notification to every URL configured on the webhook. Routes through the same backend handler as `test_webhook` and returns an identical response shape. Exposed for symmetry with the backend surface; prefer `test_webhook` in new code. ```python result = await TurboWebhooks.notify_webhook( event_type="signature.document.completed", payload={"documentId": "...", "documentName": "..."}, ) print(f"{result['summary']['successful']}/{result['summary']['total']} succeeded") for err in result["summary"].get("errors", []): print(f" failure: {err}") # per-URL failure messages ``` Send a manual notification. Routes through the same backend handler as `testWebhook` and returns the same shape — the only wire-level difference is the response message string. ```php $result = TurboWebhooks::notifyWebhook( eventType: 'signature.document.completed', payload: ['documentId' => '...', 'documentName' => '...'], ); ``` :::tip Prefer `testWebhook` `notifyWebhook` exists for cross-SDK parity. For smoke-testing a receiver, use [`testWebhook`](#testwebhook) — same behaviour, clearer intent. ::: ### regenerateWebhookSecret / regenerate_webhook_secret / RegenerateWebhookSecret {#regeneratewebhooksecret} Rotate the HMAC secret. The new secret is shown **once**; old signatures fail immediately after rotation. ```typescript const rotated = await TurboWebhooks.regenerateWebhookSecret(); // rotated.secret // rotated.regeneratedAt ``` ```python rotated = await TurboWebhooks.regenerate_webhook_secret() # rotated["secret"] # rotated["regeneratedAt"] ``` ```php $rotated = TurboWebhooks::regenerateWebhookSecret(); // $rotated['secret'] // $rotated['regeneratedAt'] ``` ```go rotated, err := wh.RegenerateWebhookSecret(ctx) newSecret := rotated["secret"] // rotated["regeneratedAt"] ``` ```java JsonObject rotated = webhooks.regenerateWebhookSecret(); String newSecret = rotated.get("secret").getAsString(); // rotated.get("regeneratedAt") ``` ### listWebhookDeliveries / list_webhook_deliveries / ListWebhookDeliveries {#listwebhookdeliveries} Page through historical delivery attempts with filters. ```typescript const page = await TurboWebhooks.listWebhookDeliveries({ limit: 20, offset: 0, eventType: 'signature.document.completed', isDelivered: false, httpStatus: 500, }); // page.results: WebhookDelivery[] // page.totalRecords ``` Page through historical delivery attempts with filters. ```python page = await TurboWebhooks.list_webhook_deliveries( limit=20, offset=0, event_type="signature.document.completed", is_delivered=False, http_status=500, ) # page["results"]: list of WebhookDelivery # page["totalRecords"] ``` Page through historical delivery attempts with filters. ```php $page = TurboWebhooks::listWebhookDeliveries( limit: 20, offset: 0, eventType: 'signature.document.completed', isDelivered: false, httpStatus: 500, ); // $page['results']: WebhookDelivery[] // $page['totalRecords'] ``` Page through historical delivery attempts with filters. `Limit`, `Offset`, `IsDelivered`, and `HTTPStatus` are pointers, so leave them nil to skip. `EventType` is a plain `string`; leave it empty to skip. ```go limit := 20 delivered := false httpStatus := 500 page, err := wh.ListWebhookDeliveries(ctx, turbodocx.ListDeliveriesRequest{ Limit: &limit, EventType: "signature.document.completed", IsDelivered: &delivered, HTTPStatus: &httpStatus, }) // page["results"]: []interface{} (each element is a map[string]interface{} delivery row) // page["totalRecords"] ``` Page through historical delivery attempts with filters. Pass `null` for any filter to skip it; the no-arg overload skips all filters. ```java JsonObject page = webhooks.listWebhookDeliveries( 20, // limit null, // offset "signature.document.completed", // eventType Boolean.FALSE, // isDelivered 500 // httpStatus ); // page.getAsJsonArray("results") // page.get("totalRecords") ``` ### replayWebhookDelivery / replay_webhook_delivery / ReplayWebhookDelivery {#replaywebhookdelivery} Manually retry a past delivery by ID. Returns a freshly-created delivery row. ```typescript const replay = await TurboWebhooks.replayWebhookDelivery('delivery-uuid-here'); // replay.id, replay.httpStatus, replay.attemptCount, ... ``` ```python replay = await TurboWebhooks.replay_webhook_delivery("delivery-uuid-here") # replay["id"], replay["httpStatus"], replay["attemptCount"], ... ``` ```php $replay = TurboWebhooks::replayWebhookDelivery('delivery-uuid-here'); // $replay['id'], $replay['httpStatus'], $replay['attemptCount'], ... ``` ```go replayed, err := wh.ReplayWebhookDelivery(ctx, "delivery-uuid-here") // replayed["id"], replayed["httpStatus"], replayed["attemptCount"], ... ``` ```java JsonObject replayed = webhooks.replayWebhookDelivery("delivery-uuid-here"); // replayed.get("id"), replayed.get("httpStatus"), replayed.get("attemptCount"), ... ``` ### getWebhookStats / get_webhook_stats / GetWebhookStats {#getwebhookstats} Aggregate delivery stats over a sliding window. ```typescript const stats = await TurboWebhooks.getWebhookStats({ days: 30 }); // stats.summary.successRate // stats.summary.avgResponseTime (milliseconds) // stats.eventBreakdown (per-event totals) ``` Aggregate delivery stats over a sliding window. ```python stats = await TurboWebhooks.get_webhook_stats(days=30) # stats["summary"]["successRate"] # stats["summary"]["avgResponseTime"] (milliseconds) # stats["eventBreakdown"] (per-event totals) ``` Aggregate delivery stats over a sliding window. ```php $stats = TurboWebhooks::getWebhookStats(days: 30); // $stats['summary']['successRate'] // $stats['summary']['avgResponseTime'] (milliseconds) // $stats['eventBreakdown'] (per-event totals) ``` Aggregate delivery stats over a sliding window. Pass `0` for the backend default (30 days). ```go stats, err := wh.GetWebhookStats(ctx, 30) // stats["summary"]["successRate"] // stats["summary"]["avgResponseTime"] (milliseconds) // stats["eventBreakdown"] (per-event totals) ``` Aggregate delivery stats over a sliding window. Pass `null` for the backend default (30 days). ```java JsonObject stats = webhooks.getWebhookStats(30); // stats.getAsJsonObject("summary").get("successRate") // stats.getAsJsonObject("summary").get("avgResponseTime") (milliseconds) // stats.get("eventBreakdown") (per-event totals) ``` ### verifyWebhookSignature (free function) / verify_webhook_signature (free function) / VerifyWebhookSignature (free function) / WebhookSignatureVerifier.verify (static utility) {#verifywebhooksignature-free-function} Verify the `X-TurboDocx-Signature` header on an incoming request. Exported directly from `@turbodocx/sdk` and does **not** require `TurboWebhooks.configure()` — receivers commonly run in a different process (or different deploy) than the management code. ```typescript const ok = verifyWebhookSignature( rawBody, // string | Buffer — raw bytes as received signatureHeader, // value of X-TurboDocx-Signature timestampHeader, // value of X-TurboDocx-Timestamp webhookSecret, // the secret from createWebhook { toleranceSeconds: 300 }, // default; pass 0 to disable timestamp check (NOT recommended) ); ``` Verify the `X-TurboDocx-Signature` header on an incoming request. Exported directly from `turbodocx_sdk` and does **not** require `TurboWebhooks.configure()` — receivers commonly run in a different process (or different deploy) than the management code. ```python from turbodocx_sdk import verify_webhook_signature ok = verify_webhook_signature( raw_body, # str | bytes — raw bytes as received signature_header, # value of X-TurboDocx-Signature timestamp_header, # value of X-TurboDocx-Timestamp webhook_secret, # the secret from create_webhook tolerance_seconds=300, # default; pass 0 to disable timestamp check (NOT recommended) ) ``` Verify the `X-TurboDocx-Signature` header on an incoming request. Lives in the `TurboDocx\Utils` namespace and does **not** require `TurboWebhooks::configure()` — receivers commonly run in a different process than the management code. ```php use function TurboDocx\Utils\verifyWebhookSignature; $ok = verifyWebhookSignature( rawBody: $rawBody, signatureHeader: $signatureHeader, timestampHeader: $timestampHeader, secret: $webhookSecret, toleranceSeconds: 300, // default; pass 0 to disable timestamp check (NOT recommended) ); ``` Verify the `X-TurboDocx-Signature` header on an incoming request. Exported directly from the package and does **not** require a `WebhooksClient` — receivers commonly run in a different process (or different deploy) than the management code. ```go ok := turbodocx.VerifyWebhookSignature( rawBody, // []byte — raw bytes as received signatureHeader, // value of X-TurboDocx-Signature timestampHeader, // value of X-TurboDocx-Timestamp webhookSecret, // the Secret from CreateWebhook nil, // *VerifyWebhookSignatureOptions — nil uses 300s tolerance ) ``` Pass `&turbodocx.VerifyWebhookSignatureOptions{ToleranceSeconds: 60}` to tighten the window, or `ToleranceSeconds: -1` to disable the timestamp check entirely (NOT recommended in production). Verify the `X-TurboDocx-Signature` header on an incoming request. Exposed as a static method on a final utility class — Java has no free functions, but the helper has no `apiKey` / `orgId` dependency, so it can be called from a receiver that runs in a completely different process (or deploy) than the management code. ```java boolean ok = WebhookSignatureVerifier.verify( rawBody, // byte[] — raw bytes as received signatureHeader, // value of X-TurboDocx-Signature timestampHeader, // value of X-TurboDocx-Timestamp webhookSecret // the secret from createWebhook ); ``` A `String` body overload is provided for convenience (`verify(String rawBody, ...)`), and a 6-arg overload accepts a custom `toleranceSeconds` plus an optional `LongSupplier now` for testing. Pass `toleranceSeconds = 0` to disable the timestamp check entirely (NOT recommended in production). ## Laravel Integration Example {#laravel-integration-example} Set up TurboWebhooks once in a service provider and add a controller for the receiver. ```php // app/Providers/TurboDocxServiceProvider.php namespace App\Providers; use Illuminate\Support\ServiceProvider; use TurboDocx\TurboWebhooks; use TurboDocx\Config\HttpClientConfig; class TurboDocxServiceProvider extends ServiceProvider { public function boot(): void { TurboWebhooks::configure(new HttpClientConfig( apiKey: config('services.turbodocx.api_key'), orgId: config('services.turbodocx.org_id'), skipSenderValidation: true, )); } } ``` ```php // app/Http/Controllers/WebhookController.php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Http\Response; use TurboDocx\Types\Enums\WebhookEvent; use function TurboDocx\Utils\verifyWebhookSignature; class WebhookController extends Controller { public function handle(Request $request): Response { $rawBody = $request->getContent(); $signatureHeader = $request->header('X-TurboDocx-Signature', ''); $timestampHeader = $request->header('X-TurboDocx-Timestamp', ''); $secret = config('services.turbodocx.webhook_secret'); if (!verifyWebhookSignature($rawBody, $signatureHeader, $timestampHeader, $secret)) { return response('', 401); } $event = json_decode($rawBody, true); // tryFrom() returns null for an event the SDK doesn't know yet (forward-compatible). match (WebhookEvent::tryFrom($event['event'])) { // fires once per signer — $event['data']['is_final_signer'] marks the last WebhookEvent::RECIPIENT_SIGNED => $this->onRecipientSigned($event['data']), // partial progress only — NEVER fires on the final signature WebhookEvent::SIGNED => $this->onPartialProgress($event['data']), // the document is done WebhookEvent::COMPLETED => $this->onCompleted($event['data']), WebhookEvent::FINALIZATION_FAILED => $this->onFinalizationFailed($event['data']), WebhookEvent::VOIDED => $this->onVoided($event['data']), default => null, }; return response('', 200); } } ``` ```php // routes/web.php (or routes/api.php) Route::post('/webhooks/turbodocx', [WebhookController::class, 'handle']); ``` ## Framework Examples {#framework-examples} ```typescript // server.ts TurboWebhooks.configure({ apiKey: process.env.TURBODOCX_API_KEY!, orgId: process.env.TURBODOCX_ORG_ID!, }); const app = express(); // Receiver — MUST use express.raw, not express.json app.post( '/webhooks/turbodocx', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyWebhookSignature( req.body, req.header('x-turbodocx-signature') ?? '', req.header('x-turbodocx-timestamp') ?? '', process.env.TURBODOCX_WEBHOOK_SECRET!, ); if (!ok) return res.status(401).send('Invalid signature'); const event = JSON.parse((req.body as Buffer).toString('utf8')); switch (event.event) { case WebhookEvents.SENT: /* ... */ break; case WebhookEvents.VIEWED: /* ... */ break; // fires once per signer — event.data.is_final_signer marks the last one case WebhookEvents.RECIPIENT_SIGNED: /* ... */ break; // partial progress only — NEVER fires on the final signature case WebhookEvents.SIGNED: /* ... */ break; // the document is done case WebhookEvents.COMPLETED: /* ... */ break; case WebhookEvents.FINALIZATION_FAILED: /* ... */ break; case WebhookEvents.VOIDED: /* ... */ break; } res.status(200).send('ok'); }, ); app.listen(3000); ``` ```typescript // app/api/webhooks/turbodocx/route.ts export async function POST(req: NextRequest) { // Read raw bytes BEFORE parsing — required for HMAC verification. const rawBody = await req.text(); const ok = verifyWebhookSignature( rawBody, req.headers.get('x-turbodocx-signature') ?? '', req.headers.get('x-turbodocx-timestamp') ?? '', process.env.TURBODOCX_WEBHOOK_SECRET!, ); if (!ok) return new NextResponse('Invalid signature', { status: 401 }); const event = JSON.parse(rawBody); // dispatch event.event ... (NOT event.eventType — not on the wire) return NextResponse.json({ ok: true }); } ``` ```typescript // server.ts const app = Fastify(); // Capture raw body for signature verification app.addContentTypeParser( 'application/json', { parseAs: 'buffer' }, (_req, body, done) => done(null, body), ); app.post('/webhooks/turbodocx', (req, reply) => { const rawBody = req.body as Buffer; const ok = verifyWebhookSignature( rawBody, (req.headers['x-turbodocx-signature'] as string) ?? '', (req.headers['x-turbodocx-timestamp'] as string) ?? '', process.env.TURBODOCX_WEBHOOK_SECRET!, ); if (!ok) return reply.code(401).send('Invalid signature'); const event = JSON.parse(rawBody.toString('utf8')); // dispatch ... reply.code(200).send('ok'); }); app.listen({ port: 3000 }); ``` ```python # server.py from flask import Flask, request, abort from turbodocx_sdk import ( TurboWebhooks, verify_webhook_signature, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_VOIDED, ) TurboWebhooks.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], ) app = Flask(__name__) @app.post("/webhooks/turbodocx") def receive_webhook(): raw_body = request.get_data() # raw bytes — required for HMAC verification ok = verify_webhook_signature( raw_body, request.headers.get("X-TurboDocx-Signature", ""), request.headers.get("X-TurboDocx-Timestamp", ""), os.environ["TURBODOCX_WEBHOOK_SECRET"], ) if not ok: abort(401, "Invalid signature") event = json.loads(raw_body) event_type = event["event"] if event_type == WEBHOOK_EVENT_RECIPIENT_SIGNED: # fires once per signer — event["data"]["is_final_signer"] marks the last pass # ... elif event_type == WEBHOOK_EVENT_SIGNED: # partial progress only — NEVER fires on the final signature pass # ... elif event_type == WEBHOOK_EVENT_COMPLETED: # the document is done pass # ... elif event_type == WEBHOOK_EVENT_VOIDED: pass # ... return ("ok", 200) if __name__ == "__main__": app.run(port=3000) ``` ```python # server.py from fastapi import FastAPI, Request, HTTPException from turbodocx_sdk import ( TurboWebhooks, verify_webhook_signature, WEBHOOK_EVENT_RECIPIENT_SIGNED, WEBHOOK_EVENT_SIGNED, WEBHOOK_EVENT_COMPLETED, WEBHOOK_EVENT_VOIDED, ) TurboWebhooks.configure( api_key=os.environ["TURBODOCX_API_KEY"], org_id=os.environ["TURBODOCX_ORG_ID"], ) app = FastAPI() @app.post("/webhooks/turbodocx") async def receive_webhook(request: Request): raw_body = await request.body() # raw bytes — required for HMAC verification ok = verify_webhook_signature( raw_body, request.headers.get("x-turbodocx-signature", ""), request.headers.get("x-turbodocx-timestamp", ""), os.environ["TURBODOCX_WEBHOOK_SECRET"], ) if not ok: raise HTTPException(status_code=401, detail="Invalid signature") event = json.loads(raw_body) event_type = event["event"] if event_type == WEBHOOK_EVENT_RECIPIENT_SIGNED: # fires once per signer — event["data"]["is_final_signer"] marks the last pass # ... elif event_type == WEBHOOK_EVENT_SIGNED: # partial progress only — NEVER fires on the final signature pass # ... elif event_type == WEBHOOK_EVENT_COMPLETED: # the document is done pass # ... elif event_type == WEBHOOK_EVENT_VOIDED: pass # ... return {"ok": True} ``` ```python # views.py from django.http import HttpResponse, HttpResponseBadRequest from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from turbodocx_sdk import verify_webhook_signature @csrf_exempt @require_POST def turbodocx_webhook(request): raw_body = request.body # raw bytes — required for HMAC verification ok = verify_webhook_signature( raw_body, request.headers.get("X-TurboDocx-Signature", ""), request.headers.get("X-TurboDocx-Timestamp", ""), os.environ["TURBODOCX_WEBHOOK_SECRET"], ) if not ok: return HttpResponseBadRequest("Invalid signature") event = json.loads(raw_body) # dispatch event["event"] ... (NOT "eventType" — not on the wire) return HttpResponse("ok") ``` ## Error Handling {#error-handling} ```typescript TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, ConflictError, NotFoundError, RateLimitError, NetworkError, } from '@turbodocx/sdk'; try { await TurboWebhooks.createWebhook({ urls, events }); } catch (e) { if (e instanceof ConflictError) { // 409 — signature webhook already exists; update or delete it instead } else if (e instanceof ValidationError) { // 400 — non-HTTPS URL, empty events array, etc. } else if (e instanceof AuthorizationError) { // 403 — API key lacks the administrator role } else if (e instanceof AuthenticationError) { // 401 — bad or revoked API key } else if (e instanceof NotFoundError) { // 404 — operating on a non-existent webhook } else if (e instanceof RateLimitError) { // 429 — back off and retry } else if (e instanceof NetworkError) { // request never reached the server (DNS, refused, timeout) } else if (e instanceof TurboDocxError) { // catch-all for any other typed SDK error (raw 5xx, etc.) console.error(`Error ${e.statusCode}: ${e.message}`); } else { throw e; } } ``` ```python from turbodocx_sdk import ( TurboDocxError, AuthenticationError, AuthorizationError, ValidationError, ConflictError, NotFoundError, RateLimitError, NetworkError, ) try: await TurboWebhooks.create_webhook(urls=urls, events=events) except ConflictError: # 409 — signature webhook already exists; update or delete it instead pass except ValidationError: # 400 — non-HTTPS URL, empty events array, etc. pass except AuthorizationError: # 403 — API key lacks the administrator role pass except AuthenticationError: # 401 — bad or revoked API key pass except NotFoundError: # 404 — operating on a non-existent webhook pass except RateLimitError: # 429 — back off and retry pass except NetworkError: # request never reached the server (DNS, refused, timeout) pass except TurboDocxError as e: # catch-all for any other typed SDK error (raw 5xx, etc.) print(f"Error {getattr(e, 'status_code', '?')}: {e}") ``` ```php use TurboDocx\Exceptions\TurboDocxException; use TurboDocx\Exceptions\AuthenticationException; use TurboDocx\Exceptions\AuthorizationException; use TurboDocx\Exceptions\ValidationException; use TurboDocx\Exceptions\ConflictException; use TurboDocx\Exceptions\NotFoundException; use TurboDocx\Exceptions\RateLimitException; use TurboDocx\Exceptions\NetworkException; try { TurboWebhooks::createWebhook(urls: $urls, events: $events); } catch (ConflictException $e) { // 409 — signature webhook already exists; update or delete it instead } catch (ValidationException $e) { // 400 — non-HTTPS URL, empty events array, etc. } catch (AuthorizationException $e) { // 403 — API key lacks the administrator role } catch (AuthenticationException $e) { // 401 — bad or revoked API key } catch (NotFoundException $e) { // 404 — read/update/delete against a webhook that doesn't exist } catch (RateLimitException $e) { // 429 — back off and retry } catch (NetworkException $e) { // request never reached the server (DNS, refused, timeout) } catch (TurboDocxException $e) { // catch-all for any other typed SDK error (raw 5xx, etc.) echo "Error {$e->statusCode}: {$e->getMessage()}\n"; } ``` ```go _, err := wh.CreateWebhook(ctx, req) if err != nil { var conflict *turbodocx.ConflictError var valErr *turbodocx.ValidationError var authz *turbodocx.AuthorizationError var auth *turbodocx.AuthenticationError var nf *turbodocx.NotFoundError var rate *turbodocx.RateLimitError var netErr *turbodocx.NetworkError var tdx *turbodocx.TurboDocxError switch { case errors.As(err, &conflict): // 409 — signature webhook already exists; update or delete it instead case errors.As(err, &valErr): // 400 — non-HTTPS URL, empty events array, etc. case errors.As(err, &authz): // 403 — API key lacks the administrator role case errors.As(err, &auth): // 401 — bad or revoked API key case errors.As(err, &nf): // 404 — operating on a non-existent webhook case errors.As(err, &rate): // 429 — back off and retry case errors.As(err, &netErr): // request never reached the server (DNS, refused, timeout) case errors.As(err, &tdx): // catch-all for any other typed SDK error (raw 5xx, etc.) log.Printf("Error %d: %s", tdx.StatusCode, tdx.Message) } } ``` ```java try { webhooks.createWebhook(urls, events); } catch (TurboDocxException.ConflictException e) { // 409 — signature webhook already exists; update or delete it instead } catch (TurboDocxException.ValidationException e) { // 400 — non-HTTPS URL, empty events list, etc. } catch (TurboDocxException.AuthorizationException e) { // 403 — API key lacks the administrator role } catch (TurboDocxException.AuthenticationException e) { // 401 — bad or revoked API key } catch (TurboDocxException.NotFoundException e) { // 404 — operating on a non-existent webhook } catch (TurboDocxException.RateLimitException e) { // 429 — back off and retry } catch (TurboDocxException.NetworkException e) { // request never reached the server (DNS, refused, timeout) } catch (TurboDocxException e) { // catch-all for any other typed SDK error (raw 5xx, etc.) System.err.println("Error " + e.getStatusCode() + ": " + e.getMessage()); } ``` ### Common Error Codes {#common-error-codes} | Status | Class | When | |---|---|---| | 400 | `ValidationError` | Non-HTTPS URL, empty `urls`/`events` array, more than 10 URLs, invalid body | | 401 | `AuthenticationError` | Missing or invalid API key | | 403 | `AuthorizationError` | Valid key without administrator role | | 404 | `NotFoundError` | Operating on a non-existent webhook | | 409 | `ConflictError` | Creating when the signature webhook already exists | | 429 | `RateLimitError` | Rate limit exceeded — back off | | Status | Class | When | |---|---|---| | 400 | `ValidationError` | Non-HTTPS URL, empty `urls`/`events` list, more than 10 URLs, invalid body | | 401 | `AuthenticationError` | Missing or invalid API key | | 403 | `AuthorizationError` | Valid key without administrator role | | 404 | `NotFoundError` | Operating on a non-existent webhook | | 409 | `ConflictError` | Creating when the signature webhook already exists | | 429 | `RateLimitError` | Rate limit exceeded — back off | | Status | Exception | When | |---|---|---| | 400 | `ValidationException` | Non-HTTPS URL, empty `urls`/`events` array, more than 10 URLs, invalid body | | 401 | `AuthenticationException` | Missing or invalid API key | | 403 | `AuthorizationException` | Valid key without administrator role | | 404 | `NotFoundException` | Operating on a non-existent webhook | | 409 | `ConflictException` | Creating when the signature webhook already exists | | 429 | `RateLimitException` | Rate limit exceeded — back off | | Status | Type | When | |---|---|---| | 400 | `*ValidationError` | Non-HTTPS URL, empty `urls`/`events` array, more than 10 URLs, invalid body | | 401 | `*AuthenticationError` | Missing or invalid API key | | 403 | `*AuthorizationError` | Valid key without administrator role | | 404 | `*NotFoundError` | Operating on a non-existent webhook | | 409 | `*ConflictError` | Creating when the signature webhook already exists | | 429 | `*RateLimitError` | Rate limit exceeded — back off | | Status | Type | When | |---|---|---| | 400 | `TurboDocxException.ValidationException` | Non-HTTPS URL, empty `urls`/`events` list, more than 10 URLs, invalid body | | 401 | `TurboDocxException.AuthenticationException` | Missing or invalid API key | | 403 | `TurboDocxException.AuthorizationException` | Valid key without administrator role | | 404 | `TurboDocxException.NotFoundException` | Operating on a non-existent webhook | | 409 | `TurboDocxException.ConflictException` | Creating when the signature webhook already exists | | 429 | `TurboDocxException.RateLimitException` | Rate limit exceeded — back off | ## Runnable End-to-End Example {#runnable-end-to-end-example} A complete, validated CRUD walkthrough lives in the SDK repo: **[`packages/js-sdk/examples/turbowebhooks-crud.ts`](https://github.com/TurboDocx/SDK/blob/main/packages/js-sdk/examples/turbowebhooks-crud.ts)** It exercises every CRUD step plus every error branch (400 / 401 / 403 / 404 / 409) against a live backend. Run with `npx tsx examples/turbowebhooks-crud.ts` after exporting `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID`. Override `TURBODOCX_RECEIVER_URL` to point at a real receiver (e.g. webhook.site, ngrok). **[`packages/py-sdk/examples/turbowebhooks_crud.py`](https://github.com/TurboDocx/SDK/blob/main/packages/py-sdk/examples/turbowebhooks_crud.py)** It exercises every CRUD step plus every error branch (400 / 401 / 403 / 404 / 409) against a live backend. Run with `python examples/turbowebhooks_crud.py` after exporting `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID`. Override `TURBODOCX_RECEIVER_URL` to point at a real receiver (e.g. webhook.site, ngrok). **[`packages/php-sdk/examples/turbowebhooks-crud.php`](https://github.com/TurboDocx/SDK/blob/main/packages/php-sdk/examples/turbowebhooks-crud.php)** It exercises every CRUD step plus every error branch (400 / 401 / 403 / 404 / 409) against a live backend. **[`packages/go-sdk/examples/turbowebhooks_crud.go`](https://github.com/TurboDocx/SDK/blob/main/packages/go-sdk/examples/turbowebhooks_crud.go)** It exercises every CRUD step plus every error branch (400 / 401 / 403 / 404 / 409) against a live backend. Run with `go run examples/turbowebhooks_crud.go` after exporting `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID`. Override `TURBODOCX_RECEIVER_URL` to point at a real receiver (e.g. webhook.site, ngrok). **[`packages/java-sdk/examples/TurboWebhooksCrud.java`](https://github.com/TurboDocx/SDK/blob/main/packages/java-sdk/examples/TurboWebhooksCrud.java)** It exercises every CRUD step plus every error branch (400 / 401 / 403 / 404 / 409) against a live backend. Run it after exporting `TURBODOCX_API_KEY` and `TURBODOCX_ORG_ID`; override `TURBODOCX_RECEIVER_URL` to point at a real receiver (e.g. webhook.site, ngrok). ## Gotchas {#gotchas} - **One webhook per org.** Every method targets the fixed-name `signature` webhook. Creating it twice returns `ConflictError` (409). To manage multiple webhooks per org, call the REST API directly. - **Save the secret immediately.** `createWebhook` and `regenerateWebhookSecret` return the HMAC secret **once**. There is no endpoint to retrieve it later. If you lose it, rotate. - **Use the raw bytes for verification.** The HMAC is over the exact request body received. Never `JSON.parse` first. In Express, use `express.raw({ type: 'application/json' })`; in Next.js, `await req.text()`; in Fastify, register a raw-body content-type parser. - **`verifyWebhookSignature` is a free function**, not a method on `TurboWebhooks` — import it directly from `@turbodocx/sdk`. It has no `apiKey`/`orgId` dependency. - **`replayWebhookDelivery` returns the full delivery row.** Earlier SDK versions documented a partial shape — current versions return the complete `WebhookDelivery` object. - **`testWebhook` summary now includes per-URL errors.** Check `result.summary.errors` to see exactly which receiver failed and why. - **An empty array is not "no change".** On `updateWebhook`, `urls: []` and `events: []` are 400s, not no-ops. Omit the key to leave the field alone. - **`signature.document.signed` is partial progress, not completion.** It never fires on the final signature, and a single-signer document never emits it at all. Use `WebhookEvents.COMPLETED` to detect a finished document. See [Webhook Events](#webhook-events). - **`WEBHOOK_EVENTS` is `readonly`.** It's declared `as const`, so spread it (`events: [...WEBHOOK_EVENTS]`) rather than passing it directly into the mutable `events: WebhookEvent[]` field. - **One webhook per org.** Every method targets the fixed-name `signature` webhook. Creating it twice raises `ConflictError` (409). To manage multiple webhooks per org, call the REST API directly. - **Save the secret immediately.** `create_webhook` and `regenerate_webhook_secret` return the HMAC secret **once**. There is no endpoint to retrieve it later. If you lose it, rotate. - **Use the raw bytes for verification.** The HMAC is over the exact request body received. Never `json.loads(...)` first. In Flask, use `request.get_data()`; in FastAPI, `await request.body()`; in Django, `request.body`. - **`verify_webhook_signature` is a free function**, not a method on `TurboWebhooks` — import it directly from `turbodocx_sdk`. It has no `api_key`/`org_id` dependency. - **All methods are async.** Call them from inside an `async def`, or wrap with `asyncio.run(...)` from a synchronous context (e.g. a sync Flask view). Mixing `asyncio.run` per-request inside a hot path will reinitialize the event loop on every call — prefer FastAPI or an async Flask variant for production receivers that also need to make SDK calls. - **`test_webhook` summary now includes per-URL errors.** Check `result["summary"]["errors"]` to see exactly which receiver failed and why. - **An empty list is not "no change".** On `update_webhook`, `urls=[]` and `events=[]` are 400s, not no-ops. Omit the argument to leave the field alone. - **`signature.document.signed` is partial progress, not completion.** It never fires on the final signature, and a single-signer document never emits it at all. Use `WEBHOOK_EVENT_COMPLETED` to detect a finished document. See [Webhook Events](#webhook-events). - **The event constants are singular, the collection is plural.** `WEBHOOK_EVENT_COMPLETED` (one event) vs `WEBHOOK_EVENTS` (a **tuple** of all 7) — easy to typo. Wrap it with `list(...)` when passing it as `events`. - **One webhook per org.** Every method targets the fixed-name `signature` webhook. Creating it twice returns `ConflictException` (409). To manage multiple webhooks per org, call the REST API directly. - **Save the secret immediately.** `createWebhook` and `regenerateWebhookSecret` return the HMAC secret **once**. There is no endpoint to retrieve it later. If you lose it, rotate. - **Use the raw bytes for verification.** The HMAC is over the exact request body received. Never `json_decode` first. - **`replayWebhookDelivery` returns the full delivery row.** Earlier SDK versions documented a partial shape (`{id, httpStatus, message}`) — current versions return the complete `WebhookDelivery` object. - **`testWebhook` summary now includes per-URL errors.** Check `$result['summary']['errors']` to see exactly which receiver failed and why. - **`createWebhook` and `regenerateWebhookSecret` return data without a `message` field.** The success message lives at the response envelope and is extracted away by the SDK. - **An empty array is not "no change".** On `updateWebhook`, `urls: []` and `events: []` are 400s, not no-ops. Omit the argument to leave the field alone. - **`createWebhook` takes wire strings, not enum cases.** Pass `WebhookEvent::COMPLETED->value` — a bare `WebhookEvent::COMPLETED` case will not serialize to the right JSON. Use `WebhookEvent::all()` (or its alias `values()`) to get all 7 as strings in one call. - **`signature.document.signed` is partial progress, not completion.** It never fires on the final signature, and a single-signer document never emits it at all. Use `WebhookEvent::COMPLETED` to detect a finished document. See [Webhook Events](#webhook-events). - **Use `WebhookEvent::tryFrom()` in receivers, not `from()`.** `from()` throws a `ValueError` on an event string the enum doesn't know — a new backend event would crash your receiver. `tryFrom()` returns `null` instead. - **One webhook per org.** Every method targets the fixed-name `signature` webhook. Creating it twice returns `*ConflictError` (409). To manage multiple webhooks per org, call the REST API directly. - **Save the secret immediately.** `CreateWebhook` and `RegenerateWebhookSecret` return the HMAC secret **once**. There is no endpoint to retrieve it later. If you lose it, rotate. - **Use the raw bytes for verification.** The HMAC is over the exact request body received. Never unmarshal-then-remarshal first. Always `io.ReadAll(r.Body)` (or framework-equivalent) before calling `VerifyWebhookSignature`. - **`VerifyWebhookSignature` is a free function**, not a method on `WebhooksClient` — it has no `APIKey`/`OrgID` dependency. Pass `nil` for `opts` to use the default 300-second tolerance. - **Pointer-typed optional fields.** `UpdateWebhookRequest.IsActive` and `ListDeliveriesRequest.{Limit, Offset, IsDelivered, HTTPStatus}` are pointers so a zero value (`false` or `0`) can be told apart from "leave unchanged" / "no filter." Use `turbodocx.BoolPtr(false)` / `&n` to set them. - **`TestWebhook` summary now includes per-URL errors.** Type-assert `result["summary"].(map[string]interface{})["errors"].([]interface{})` to see exactly which receiver failed and why. - **An empty slice does not clear a list.** `URLs` and `Events` are `omitempty`, so `[]string{}` is omitted from the body just like `nil` and the existing list survives the patch. If an intentional "clear" appears to do nothing, this is why. Delete and recreate the webhook instead. - **`WebhookEvent` is a distinct type — it will not assign into `Events []string`.** `Events: []turbodocx.WebhookEvent{...}` and `Events: turbodocx.AllWebhookEvents` are both **compile errors**. Always wrap with `turbodocx.WebhookEventStrings(...)` (variadic — spread the slice: `WebhookEventStrings(turbodocx.AllWebhookEvents...)`), or use `.String()` / `string(...)` for a single value like `TestWebhookRequest.EventType`. - **`signature.document.signed` is partial progress, not completion.** It never fires on the final signature, and a single-signer document never emits it at all. Use `turbodocx.WebhookEventCompleted` to detect a finished document. See [Webhook Events](#webhook-events). - **One webhook per org.** Every method targets the fixed-name `signature` webhook. Creating it twice throws `TurboDocxException.ConflictException` (409). To manage multiple webhooks per org, call the REST API directly. - **Save the secret immediately.** `createWebhook` and `regenerateWebhookSecret` return the HMAC secret **once**. There is no endpoint to retrieve it later. If you lose it, rotate. - **`WebhookSignatureVerifier` is a static utility** — Java has no free functions, so call it as `WebhookSignatureVerifier.verify(...)`. Final class with a private constructor; do not subclass. - **Use the raw bytes for verification.** The HMAC is over the exact request body received. In Spring, bind to `@RequestBody byte[] rawBody` — never `Map`/DTO; Jackson re-serialization breaks verification. In Servlets, use `request.getInputStream().readAllBytes()`. In JAX-RS, declare a `byte[]` entity parameter. - **Administrator role required.** The webhook routes are gated on `requireOrgRole(administrator)`. Valid TDX- keys without the role throw `TurboDocxException.AuthorizationException` (403). - **`null` skips fields on `updateWebhook` and `listWebhookDeliveries`.** Pass `null` for any argument you don't want to change/filter. - **An empty list is not "no change".** On `updateWebhook`, an empty `urls`/`events` list serializes to `[]` and is a **400**, not a no-op. Pass `null` — never `Collections.emptyList()`. - **`testWebhook` summary includes per-URL errors.** Read `result.getAsJsonObject("summary").getAsJsonArray("errors")` to see exactly which receiver failed and why. - **All TurboWebhooks methods return `JsonObject`.** New server fields surface without an SDK upgrade — use `.has(key)` / `.get(key)` to navigate. - **`createWebhook` takes wire strings, not enum constants.** Pass `WebhookEvent.COMPLETED.getValue()` — the events parameter is `List`. `WebhookEvent.allValues()` returns all 7 wire strings in one call (as an unmodifiable list; copy it if you need to mutate). - **`signature.document.signed` is partial progress, not completion.** It never fires on the final signature, and a single-signer document never emits it at all. Use `WebhookEvent.COMPLETED` to detect a finished document. See [Webhook Events](#webhook-events). ## See Also {#see-also} - [TurboSign → Webhooks](/docs/TurboSign/Webhooks) — concepts, dashboard UI, retry behavior - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) — same API, PHP idioms - [TurboSign JavaScript SDK](/docs/SDKs/turbosign?language=js) — sending documents for signature - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [@turbodocx/sdk on npm](https://www.npmjs.com/package/@turbodocx/sdk) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/js-sdk) - [TurboSign → Webhooks](/docs/TurboSign/Webhooks) — concepts, dashboard UI, retry behavior - [TurboWebhooks JavaScript / TypeScript SDK](/docs/SDKs/webhooks?language=js) — same API, JS idioms - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) — same API, PHP idioms - [TurboSign Python SDK](/docs/SDKs/turbosign?language=python) — sending documents for signature - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [turbodocx-sdk on PyPI](https://pypi.org/project/turbodocx-sdk/) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/py-sdk) - [TurboSign → Webhooks](/docs/TurboSign/Webhooks) — concepts, dashboard UI, retry behavior - [TurboSign PHP SDK](/docs/SDKs/turbosign?language=php) — sending documents for signature - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [TurboDocx SDK on Packagist](https://packagist.org/packages/turbodocx/sdk) - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/php-sdk) - [TurboSign → Webhooks](/docs/TurboSign/Webhooks) — concepts, dashboard UI, retry behavior - [TurboWebhooks JavaScript / TypeScript SDK](/docs/SDKs/webhooks?language=js) — same API, JS idioms - [TurboWebhooks Python SDK](/docs/SDKs/webhooks?language=python) — same API, Python idioms - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) — same API, PHP idioms - [TurboSign Go SDK](/docs/SDKs/turbosign?language=go) — sending documents for signature - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/go-sdk) - [TurboSign → Webhooks](/docs/TurboSign/Webhooks) — concepts, dashboard UI, retry behavior - [TurboWebhooks JavaScript / TypeScript SDK](/docs/SDKs/webhooks?language=js) — same API, JS idioms - [TurboWebhooks Python SDK](/docs/SDKs/webhooks?language=python) — same API, Python idioms - [TurboWebhooks Go SDK](/docs/SDKs/webhooks?language=go) — same API, Go idioms - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) — same API, PHP idioms - [TurboSign Java SDK](/docs/SDKs/turbosign?language=java) — sending documents for signature - [SDKs Overview](/docs/SDKs) — all SDKs across all six languages - [TurboDocx SDK on GitHub](https://github.com/TurboDocx/SDK/tree/main/packages/java-sdk) --- # Template Generation API Integration This comprehensive guide walks you through the Template Generation API integration process. Learn how to programmatically upload new templates or select existing ones, then generate beautiful documents with variable substitution using our RESTful API. ![Template Generation API Integration Overview](/img/template-generation-api/banner.gif) ## Overview The Template Generation API offers **two flexible paths** to document creation, because we believe in choice (and because forcing everyone down one path would be like making everyone eat vanilla ice cream forever): ### **Path A: Upload New Template** 1. **Upload & Create** - Upload your .docx/.pptx template and extract variables automatically 2. **Generate Document** - Fill variables and create your deliverable ### **Path B: Select Existing Template** 1. **Browse Templates** - Explore your template library with search and filtering 2. **Template Details** - Load template variables and preview PDF 3. **Generate Document** - Fill variables and create your deliverable ![Template Generation API Workflow](/img/template-generation-api/options.png) ### Key Features - **RESTful API**: Standard HTTP methods with JSON and multipart payloads - **Bearer Token Authentication**: Secure API access using JWT tokens - **Dual Entry Points**: Start fresh with uploads OR use existing templates - **Smart Variable Extraction**: Automatic detection of placeholders in uploaded documents - **Rich Variable Types**: Support for text, subvariables, stacks, and AI-powered content - **Template Library**: Full CRUD operations with search, filtering, and organization - **Real-time Processing**: Track document generation status throughout the process ## TLDR; Complete Working Examples 🚀 Don't want to read the novel? Here's the executive summary: ### Available Variable Types | Type | Description | Example Placeholder | Use Case | | --------------- | ----------------------------- | ---------------------- | --------------------------- | | `text` | Simple text replacement | `{CompanyName}` | Basic text substitution | | `subvariables` | Nested variable structures | `{Employee.FirstName}` | Complex hierarchical data | | `variableStack` | Multiple instances of content | `{Projects[0].Name}` | Repeating sections, lists | | `richText` | HTML/formatted text content | `{Description}` | Formatted text with styling | | `aiPrompt` | AI-generated content | `{Summary}` | Dynamic content generation | ### Complete Dual-Path Workflow ### Quick Variable Structure Example Here's what a complex variable payload looks like: ```json { "templateId": "abc123-def456-ghi789", "name": "Employee Contract Draft", "description": "Generated contract for new hire", "variables": [ { "name": "Employee", "placeholder": "{Employee}", "text": "John Smith", "subvariables": [ { "placeholder": "{Employee.Title}", "text": "Senior Developer" }, { "placeholder": "{Employee.StartDate}", "text": "2024-01-15" } ] }, { "name": "Projects", "placeholder": "{Projects}", "variableStack": { "0": { "text": "Project Alpha - Backend Development", "subvariables": [ { "placeholder": "{Projects.Duration}", "text": "6 months" } ] }, "1": { "text": "Project Beta - API Integration", "subvariables": [ { "placeholder": "{Projects.Duration}", "text": "3 months" } ] } } } ] } ``` Now let's dive into the template wizardry... ## Prerequisites Before you begin your journey into template automation nirvana, ensure you have: - **API Access Token**: Bearer token for authentication - **Organization ID**: Your organization identifier - **Template Files**: .docx or .pptx files with placeholder variables (for uploads) ### Getting Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or retrieve your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ![Template API Credentials](/img/turbosign/api/api-key.png) ![Organization ID Location](/img/turbosign/api/org-id.png) ## Authentication All Template Generation API requests require authentication using a Bearer token in the Authorization header: ```http Authorization: Bearer YOUR_API_TOKEN ``` Additional required headers for all requests: ```http x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ## Path A: Upload New Template Start fresh by uploading a new template document. This path is perfect when you've crafted a beautiful new template and want to jump straight into document generation. ### Endpoint ```http POST https://api.turbodocx.com/template/upload-and-create ``` ### Headers ```http Content-Type: multipart/form-data Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Request Body (Form Data) ```javascript { "templateFile": [DOCX_OR_PPTX_FILE_BINARY], "name": "Employee Contract Template", "description": "Standard employee contract with variable placeholders", "variables": "[]", // Optional: Pre-defined variables (usually extracted automatically) "tags": "[]" // Optional: Categorization tags } ``` ### Response ```json { "data": { "results": { "template": { "name": "Employee Contract Template", "description": "Standard employee contract with variable placeholders", "fonts": [ { "name": "Arial", "usage": 269 }, { "name": "Calibri", "usage": 45 } ], "defaultFont": "Arial", "orgId": "2d66ecf0-a749-475d-9403-9956d0f67884", "createdBy": "9d829e80-1135-4a97-93ea-cc2a1eecc9da", "createdOn": "2025-09-19T15:45:47.000Z", "updatedOn": "2025-09-19T15:45:47.000Z", "isActive": 1, "id": "31cc4cce-4ed7-4f3b-aa80-0b9a4f995412", "variables": null, "projectspaceId": null, "templateFolderId": null, "metadata": null }, "redirectUrl": "/templates/31cc4cce-4ed7-4f3b-aa80-0b9a4f995412/generate" } } } ``` ### Response Fields | Field | Type | Description | | ---------------------------------------- | ------------ | ----------------------------------------------- | | `data.results.template.id` | string | Unique template identifier (use for generation) | | `data.results.template.name` | string | Template name as provided | | `data.results.template.description` | string | Template description | | `data.results.template.fonts` | array | Array of font objects with name and usage | | `data.results.template.defaultFont` | string | Default font name for the template | | `data.results.template.orgId` | string | Organization ID | | `data.results.template.createdBy` | string | User ID who created the template | | `data.results.template.createdOn` | string | ISO timestamp of template creation | | `data.results.template.updatedOn` | string | ISO timestamp of last template update | | `data.results.template.isActive` | number | Active status (1 = active, 0 = inactive) | | `data.results.template.variables` | array\|null | Auto-extracted variables (null if none found) | | `data.results.template.projectspaceId` | string\|null | Project space ID (null if not assigned) | | `data.results.template.templateFolderId` | string\|null | Folder ID (null if not in folder) | | `data.results.template.metadata` | object\|null | Additional metadata (null if not set) | | `data.results.redirectUrl` | string | Frontend URL to redirect for variable filling | ### Deliverable Response Fields | Field | Type | Description | | ------------------------------------- | ------- | -------------------------------- | | `data.results.deliverable.id` | string | Unique deliverable identifier | | `data.results.deliverable.name` | string | Deliverable name as provided | | `data.results.deliverable.createdBy` | string | Email of the user who created it | | `data.results.deliverable.createdOn` | string | ISO timestamp of creation | | `data.results.deliverable.orgId` | string | Organization ID | | `data.results.deliverable.isActive` | boolean | Whether deliverable is active | | `data.results.deliverable.templateId` | string | Original template ID used | ### Code Examples ## Path B: Select Existing Template Browse your template library to find the perfect starting point. This path lets you leverage templates you've already created and organized. ### Step 1: Browse Templates List all available templates and folders in your organization. #### Endpoint ```http GET https://api.turbodocx.com/template-item ``` #### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` #### Query Parameters ```http ?limit=25&offset=0&query=contract&showTags=true&selectedTags[]=tag-123 ``` | Parameter | Type | Description | | -------------- | ------- | ----------------------------------- | | `limit` | number | Items per page (default: 6) | | `offset` | number | Pagination offset (default: 0) | | `query` | string | Search term for template names | | `showTags` | boolean | Include tag information in response | | `selectedTags` | array | Filter by specific tag IDs | #### Response ```json { "data": { "results": [ { "id": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "name": "Employee / Contractor IP Agreement Example", "description": "A Statement of Work template provided by TurboDocx", "createdOn": "2025-05-29T19:07:16.000Z", "updatedOn": "2025-09-08T11:44:11.000Z", "isActive": 1, "type": "template", "createdBy": "9d829e80-1135-4a97-93ea-cc2a1eecc9da", "email": "kahlerasse@gmail.com", "templateFolderId": null, "deliverableCount": 4, "fileSize": 24942, "fileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "templateCount": 0, "tags": [] }, { "id": "604d1e63-6cdc-4849-9e71-548409bfec69", "name": "Legal", "description": null, "createdOn": "2025-05-29T19:07:16.000Z", "updatedOn": "2025-05-29T19:07:16.000Z", "isActive": 1, "type": "folder", "createdBy": "9d829e80-1135-4a97-93ea-cc2a1eecc9da", "email": "kahlerasse@gmail.com", "templateFolderId": null, "deliverableCount": 0, "fileSize": null, "fileType": null, "templateCount": 0, "tags": [] } ], "totalRecords": 26 } } ``` #### Browse Response Fields | Field | Type | Description | | --------------------------------- | ------------ | ----------------------------------------------- | | `data.results[]` | array | Array of templates and folders | | `data.results[].id` | string | Unique identifier for template or folder | | `data.results[].name` | string | Name of the template or folder | | `data.results[].description` | string\|null | Description (null for some items) | | `data.results[].createdOn` | string | ISO timestamp of creation | | `data.results[].updatedOn` | string | ISO timestamp of last update | | `data.results[].isActive` | number | Active status (1 = active, 0 = inactive) | | `data.results[].type` | string | Item type ("template" or "folder") | | `data.results[].createdBy` | string | User ID who created the item | | `data.results[].email` | string | Email of the creator | | `data.results[].templateFolderId` | string\|null | Parent folder ID (null if at root level) | | `data.results[].deliverableCount` | number | Number of deliverables generated from template | | `data.results[].fileSize` | number\|null | File size in bytes (null for folders) | | `data.results[].fileType` | string\|null | MIME type of template file (null for folders) | | `data.results[].templateCount` | number | Number of templates in folder (0 for templates) | | `data.results[].tags` | array | Array of tag objects | | `data.totalRecords` | number | Total number of items available | ### Step 2: Get Template Details Load specific template information including variables and metadata. #### Endpoint ```http GET https://api.turbodocx.com/template/{templateId} ``` #### Response ```json { "data": { "results": { "id": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "name": "Employee Contract Template", "description": "Standard employment agreement template", "templateFileType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "templateFolderId": "folder-def456", "variables": [ { "id": "var-123", "name": "Employee Name", "placeholder": "{EmployeeName}", "text": "", "mimeType": "text", "allowRichTextInjection": false, "order": 1, "subvariables": [] }, { "id": "var-456", "name": "Department", "placeholder": "{Department}", "text": "", "mimeType": "text", "allowRichTextInjection": true, "order": 2, "subvariables": [ { "placeholder": "{Department.Manager}", "text": "" } ] } ], "fonts": [ { "name": "Arial", "usage": 269 } ], "defaultFont": "Arial" } } } ``` ### Step 3: Get PDF Preview (Optional) Generate a preview PDF of the template for visual confirmation. #### Endpoint ```http GET https://api.turbodocx.com/template/{templateId}/previewpdflink ``` #### Response ```json { "results": "https://api.turbodocx.com/template/pdf/preview-abc123.pdf" } ``` ### Code Examples ## Final Step: Generate Deliverable Both paths converge here - time to fill those variables and create your masterpiece! This is where the magic happens and placeholders become real content. ### Step 1: Generate Deliverable ### Endpoint ```http POST https://api.turbodocx.com/v1/deliverable ``` ### Headers ```http Content-Type: application/json Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Request Body ```json { "templateId": "0b1099cf-d7b9-41a4-822b-51b68fd4885a", "name": "Employee Contract - John Smith", "description": "Employment contract for new senior developer", "variables": [ { "mimeType": "text", "name": "Employee Name", "placeholder": "{EmployeeName}", "text": "John Smith", "allowRichTextInjection": 0, "autogenerated": false, "count": 1, "order": 1, "subvariables": [ { "placeholder": "{Employee.Title}", "text": "Senior Software Developer" }, { "placeholder": "{Employee.StartDate}", "text": "January 15, 2024" } ], "aiPrompt": "Generate a professional job description for a senior developer role" }, { "mimeType": "text", "name": "Department", "placeholder": "{Department}", "text": "Engineering", "allowRichTextInjection": 1, "autogenerated": false, "count": 3, "order": 2, "subvariables": [ { "placeholder": "{Department.Manager}", "text": "Sarah Johnson" } ], "variableStack": { "0": { "text": "Frontend Development Team", "subvariables": [ { "placeholder": "{Team.Focus}", "text": "React and TypeScript development" } ] }, "1": { "text": "Backend Development Team", "subvariables": [ { "placeholder": "{Team.Focus}", "text": "Node.js and database optimization" } ] } }, "metadata": { "customField": "Engineering Department" }, "aiPrompt": "Describe the key responsibilities of the engineering department" } ], "tags": ["hr-template", "contract", "full-time"], "metadata": { "sessions": [ { "id": "session-abc123", "starttime": "2024-01-15T14:12:10.721Z", "endtime": "2024-01-15T14:13:45.724Z" } ] } } ``` ### Response ```json { "data": { "results": { "deliverable": { "id": "39697685-ca00-43b8-92b8-7722544c574f", "name": "Employee Contract - John Smith", "description": "Employment contract for new senior developer", "createdBy": "api-user@company.com", "createdOn": "2024-12-19T21:22:10.000Z", "orgId": "your-organization-id", "isActive": true, "templateId": "0b1099cf-d7b9-41a4-822b-51b68fd4885a" } } } } ``` ### Variable Structure Deep Dive Understanding the variable structure is key to successful document generation: #### Basic Variable ```json { "name": "Company Name", "placeholder": "{CompanyName}", "text": "Acme Corporation", "mimeType": "text", "allowRichTextInjection": false, "order": 1 } ``` #### Variable with Subvariables ```json { "name": "Employee", "placeholder": "{Employee}", "text": "John Smith", "subvariables": [ { "placeholder": "{Employee.Title}", "text": "Senior Developer" }, { "placeholder": "{Employee.Email}", "text": "john.smith@company.com" } ] } ``` #### Variable Stack (Repeating Content) ```json { "name": "Projects", "placeholder": "{Projects}", "variableStack": { "0": { "text": "Project Alpha", "subvariables": [ { "placeholder": "{Projects.Status}", "text": "In Progress" } ] }, "1": { "text": "Project Beta", "subvariables": [ { "placeholder": "{Projects.Status}", "text": "Completed" } ] } } } ``` ### Request Fields | Field | Type | Required | Description | | ------------------------------------ | ------- | -------- | ---------------------------------------------- | | `templateId` | string | Yes | Template ID from upload or selection | | `name` | string | Yes | Name for the generated deliverable | | `description` | string | No | Description of the deliverable | | `variables[]` | array | Yes | Array of variable definitions and values | | `variables[].name` | string | Yes | Variable display name | | `variables[].placeholder` | string | Yes | Placeholder text in template (e.g., `{Name}`) | | `variables[].text` | string | Yes | Actual value to replace placeholder | | `variables[].mimeType` | string | Yes | Content type ("text", "html", etc.) | | `variables[].allowRichTextInjection` | number | No | Allow HTML/rich text (0 or 1) | | `variables[].subvariables` | array | No | Nested variables within this variable | | `variables[].variableStack` | object | No | Multiple instances for repeating content | | `variables[].aiPrompt` | string | No | AI prompt for content generation | | `variables[].metadata` | object | No | Custom metadata for the variable | | `tags` | array | No | Tags for categorization | | `metadata` | object | No | Additional metadata (sessions, tracking, etc.) | ### Step 2: Download Generated File After generating a deliverable, you'll need to download the actual file. #### Endpoint ```http GET https://api.turbodocx.com/v1/deliverable/file/{deliverableId} ``` #### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` #### Example Request ```bash curl -X GET "https://api.turbodocx.com/v1/deliverable/file/39697685-ca00-43b8-92b8-7722544c574f" \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORGANIZATION_ID" \ -H "User-Agent: TurboDocx API Client" \ --output "employee-contract-john-smith.docx" ``` #### Response Returns the binary content of the generated document with appropriate content-type headers: ```http Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document Content-Disposition: attachment; filename="employee-contract-john-smith.docx" Content-Length: 287456 ``` ### Code Examples ## Best Practices ### Security - **Never expose API tokens**: Store tokens securely in environment variables - **Use HTTPS only**: All API calls must use HTTPS in production - **Validate file uploads**: Check file types and sizes before upload - **Sanitize variables**: Validate variable content to prevent injection attacks ### Error Handling - **Check HTTP status codes**: Always verify response status before processing - **Handle file upload failures**: Implement retry logic for large file uploads - **Validate template variables**: Ensure all required variables are provided - **Log API responses**: Keep detailed logs for debugging and monitoring ### Performance - **Optimize file uploads**: Compress .docx/.pptx files when possible - **Cache template details**: Store frequently used template information - **Batch variable processing**: Group related variables together - **Async processing**: Use webhooks for long-running document generation ### Template Preparation - **Use clear placeholders**: Name variables descriptively (e.g., `{CompanyName}` not `{CN}`) - **Consistent formatting**: Use consistent placeholder formats throughout templates - **Test variable extraction**: Verify automatic variable detection works correctly - **Document structure**: Organize templates logically with folders and tags ### Variable Management - **Hierarchical organization**: Use subvariables for related data - **Stack for repetition**: Use variableStack for lists and repeating sections - **Rich text sparingly**: Only enable rich text injection when formatting is needed - **AI prompts**: Provide clear, specific prompts for AI-generated content ## Error Handling & Troubleshooting ### Common HTTP Status Codes | Status Code | Description | Solution | | ----------- | --------------------- | --------------------------------------------- | | `200` | Success | Request completed successfully | | `400` | Bad Request | Check request body format and required fields | | `401` | Unauthorized | Verify API token and headers | | `403` | Forbidden | Check organization ID and permissions | | `404` | Not Found | Verify template ID and endpoint URLs | | `413` | Payload Too Large | Reduce file size or compress template | | `422` | Unprocessable Entity | Validate field values and constraints | | `429` | Too Many Requests | Implement rate limiting and retry logic | | `500` | Internal Server Error | Contact support if persistent | ### Common Issues #### Template Upload Failures **Symptoms**: Upload returns error or times out **Solutions**: - Verify file is .docx or .pptx format - Check file size is under the maximum limit (typically 25MB) - Ensure file is not corrupted or password-protected - Verify network connection and try again #### Variable Extraction Issues **Symptoms**: Variables not detected automatically **Solutions**: - Use consistent placeholder format: `{VariableName}` - Avoid special characters in placeholder names - Ensure placeholders are in main document content (not headers/footers) - Check that placeholders are properly formatted text (not images) #### Template Selection Problems **Symptoms**: Templates not appearing in browse results **Solutions**: - Verify organization ID matches your account - Check that templates are active and not archived - Use correct API endpoint for browsing vs. folder contents - Verify search parameters and filters #### Document Generation Failures **Symptoms**: Deliverable generation fails or produces incorrect output **Solutions**: - Validate all required variables have values - Check variable names match template placeholders exactly - Ensure subvariable structure matches template expectations - Verify file permissions and storage availability #### Font and Formatting Issues **Symptoms**: Generated documents have incorrect fonts or formatting **Solutions**: - Check that rich text injection is enabled only when needed - Validate HTML content in rich text variables ### Debugging Tips 1. **Test with simple templates**: Start with basic templates before adding complexity 2. **Validate JSON payloads**: Use JSON validators before sending requests 3. **Check file encoding**: Ensure .docx/.pptx files are not corrupted 4. **Monitor API quotas**: Track usage to avoid rate limiting 5. **Use development endpoints**: Test with development environment first ## Next Steps ### Advanced Features to Explore Now that you've mastered the basics, consider exploring these advanced capabilities: 📖 **[AI-Powered Content Generation →](/docs/TurboDocx-Templating/ai-variable-generation)** 📖 **[Webhook Integration for Status Updates →](/docs/TurboSign/Webhooks)** ### Related Documentation - [Variable Types and Formatting](/docs/API/Deliverable-API#variable-object-structure) - [API Authentication](/docs/API/turbodocx-api-documentation) ## Support Need help with your template integration? We've got you covered: - **Discord Community**: [Join our Discord server](https://discord.gg/NYKwz4BcpX) for real-time support and template wizardry discussions - **Documentation**: [https://docs.turbodocx.com](https://docs.turbodocx.com) - **Template Gallery**: Browse example templates for inspiration --- Ready to become a template automation wizard? Choose your path (upload new or select existing) and start generating beautiful documents programmatically! 🎯 --- ## Additional Information # Guides, Tips and Tricks Here you will find additional standalone guides to features, uses and tips. ## Variable Entries There are multiple ways to enter and edit your variables. In this guide we will break down the different ways in three parts: Simple Text, Advanced, Variable Stack and Disable Variable. ### Simple Text Entries In this first example we will be using simple text entries. Things such as "CustomerName" or "ProjectName" already have formating in the template so just clicking on the box and typing your entry will be sufficient for us here. ![Simple text variable entry fields for CustomerName and ProjectName](/img/how_to_create_a_deliverable/step_6.png) ### Advanced Entries What happens if we need a more robust entry? Maybe we want something from our Knowledgebase entries, an image or just add some formatting to the text. We'll go through each option in this section. 1. First, what we will do is click the more options or the triple dot button located next to the variable boxes. Here you will see all the options we just discussed and more. ![more options](/img/how_to_create_a_deliverable/step_10.png) 2. Next, please select the option you would like to utilize. #### Lookup To select something from your Knowledgebase, click Lookup. A popup window will display all your entries. Scroll or search through them. The search feature will look for the keyword in the name, tags, or content. Larger entries have a preview option. Once found, click the blue "Select" button. ![lookup](/img/how_to_create_a_deliverable/step_20.png) :::tip - This is where tags and descriptions when creating the Knowledgebase entry may come in handy. - Even after selection, you can edit variables for any necessary changes from the Knowledgebase entry. Simply click the content box for simple text changes. For other variable types, use the pencil and paper icon to edit. ::: #### Image For images, click Image. A pop up will come up where you can click on it to open your file browser and choose an image. ![Image](/img/how_to_create_a_deliverable/step_img.PNG) #### Rich Text For text that you need to format, or if you'd like to add a table you will click on Rich Text. A new window will pop-up as shown in the image below. Here you will see familiar options such as text formats, colors as well as the ability to add tables among other things. Just click the "Save" button when you're done ![rich text](/img/how_to_create_a_deliverable/step_24.png) #### Date For a date, click on Date. A calendar will pop-up above the variable which you can then use to select the date. ![date](/img/how_to_create_a_deliverable/date.png) ### Variable Stack and Disable Variable Sometimes you will need more than one entry for a given variable and others you will not need a certain variable at all. These two options will allow you to do that. #### Variable Stack If you need multiple variable entries or images for a section of your Deliverable, click Create Variable Stack under more options. You'll return to the original page with a "+" button under the variable. Add stacks by clicking "+", to remove them click "-". ![rich text](/img/how_to_create_a_deliverable/step_vs.PNG) #### Disable Variable Sometimes a variable is helpful to have in a template but may not be needed in every Deliverable. For instances where the variable is not needed you can click on more options and then Disable Variable. This will remove the requirement to have content in that variable. To undo this just click the button that now replaced the more options button. ![rich text](/img/how_to_create_a_deliverable/step_10.png) ## Subvariables In your Knowledgebase you may have entries that you will want to have variables in. For example a entry outlining each parties responsibites with `{CustomerName}` or `{OrganizationName}` as variables. We call these Subvariables. Here we will go over how you can utilize them. ### Adding Subvariables 1. We will start by creating a new Knowledgebase entry (see How to Create a Knowledgebase Entry if you need a refresher), and we will be selecting "Rich Text" from our more options menu. 2. Much like adding a variable in a Template you will fill out your entry utilizing \{brackets\} around the subvariable that you would like to create, in this example we will be using \{CustomerName\} in part of our chart. 3. As you can see once the subvariable is created a seperate input box will appear on the left side under `{CustomerName}`. Whatever you input into this box will be placed in the subvariable! ![img](/img/additional_information/subvariables.png) :::tip When you are a creating a deliverable and choose an entry with a subvariable click on the pencil and paper icon to see the same window in step 3 and enter your subvariable input. ![Subvariable input field shown when editing a knowledgebase entry in a deliverable](/img/additional_information/subvdeliv.png) ::: ## AI Generation Do you have notes on your CRM, or a website that can help you fill out some of the variables when creating a Deliverable? Our Ai Generation will allow you to input data and find the information it needs to fill out variables in your Deliverable! This guide will show you how. ### Navigating to AI Generation 1. Select a Template and create a Deliverable (see How to Create a Deliverable if you need a refresher). 2. Click on the "Fill in With Notes + AI" ![Fill in With Notes plus AI button on the deliverable creation page](/img/additional_information/aibutton.png) ### Utilizing the AI Generator #### Landing Page After you've navigated to the "Fill in With Notes + AI" page it should look like the image below. As you can see there are areas of input for Websites and other things on the left, an area to input notes you may have on the right (after you click on Advanced Settings) and your variables below. ![AI Generation landing page with website input, notes area, and variable fields](/img/additional_information/ai1.png) #### Input and Generate 1. For this example we will be using the a fictional company's website and notes from our CRM. Once we have added our content hit the "Generate" button. ![AI Generation page with website URL and CRM notes entered, ready to generate](/img/additional_information/aiinput.png) 2. Our AI scans through notes and websites to identify anything matching your template variables. It populates this information in the "Value" column, which you can review and approve by checking the corresponding boxes on the right. ![AI-generated variable values with approval checkboxes for review](/img/additional_information/ai2.png) 3. Click the "Save" button and you will be brought back to the original "Create Deliverable" screen where you can continue to fill out your variables and create your Deliverable. ## Variable Troubleshooting Here are some best practices when troubleshooting why your variable is not showing up in the Template. ### Video Tutorial 1. All variables will need to be one word, so do not put any spaces in the middle of the `{brackets}`. 2. If the variable will be an image or rich text, make sure the variable is on it's own line. Use the show paragraph tool if you need help. The location for that tool in Word and Google Docs are in the image below. ![Paragraph marker tool location in Word and Google Docs toolbars](/img/how_to_create_a_template/paragraphtool.png) When you activate the tool, it will resemble the image below. Since `{CustomerName}` is plain text, it can be incorporated into a paragraph. However, we will be utilizing a chart in Rich Text for `{Scope}`, so it needs to be on its own line. Paragraph symbols will indicate this distinction clearly. ![Paragraph markers showing inline text variable vs. section variable on its own line](/img/additional_information/ptoolexample.png) --- # Brand Identity ## Why Brand Identity Matters Your brand is one of your most valuable business assets. Whether you're a law firm, consulting agency, or enterprise organization, consistent branding across all client-facing documents: - **Builds trust and professionalism** - clients immediately recognize your materials - **Saves time and reduces errors** - eliminates manual formatting and brand guideline violations - **Ensures compliance** - automatically enforces corporate brand standards across all teams - **Increases efficiency** - setup once, apply everywhere automatically TurboDocx's Brand Identity feature allows you to configure organization-wide branding settings that apply consistently across all your document templates. Unlike template-specific font embedding, Brand Identity creates tenant-wide standards for colors, typography, and styling that ensure brand consistency across all generated documents. ## Prerequisites Checklist Before you begin, ensure you have: - [ ] **Organization administrator access** to your TurboDocx account - [ ] **Your organization logo** (PNG or JPG format, 200x200px or larger recommended) - [ ] **Brand color codes** (hex codes if you have them, or we'll extract from your logo) - [ ] **A few minutes** for initial setup :::tip Ready to Start? If you just want to get up and running quickly, jump to the [Quick Start](#quick-start) section below. You can always come back to customize further. ::: ## Quick Start **Goal**: Get your brand colors and basic styling applied across all documents quickly. ### Step 1: Access Brand Settings 1. Go to **Settings** → **Branding and formatting** (under **Documents**) 2. You'll see the Brand Identity interface with upload, controls, and preview areas ### Step 2: Upload Your Logo ![Organization Logo upload area highlighted on the Branding and formatting page](/img/brandidentity/LogoUpload.png) 1. Click the logo area under **Organization Logo** (or drag your logo file onto it) and select your company logo 2. Click **Save Changes** - TurboDocx will automatically extract your brand colors 3. You should see extracted colors appear in the Brand Colors section ### Step 3: Apply Quick Typography ![Quick Setup size presets highlighted](/img/brandidentity/QuickSetupFonts.png) 1. In the **Quick Setup** section, choose a preset size: - **Small**: Conservative, formal documents - **Normal**: Balanced for most business use - **Large**: Bold, presentation-style 2. Preview panel updates with your new heading sizes ### 🎉 You're Done! Your brand identity is now active across all new documents. Existing templates will update to use your brand settings automatically. **Immediate value**: All new documents will now use your brand colors and consistent typography without any additional setup. --- ## Overview Brand Identity configuration includes: - **Logo Upload**: Upload your organization's logo for automatic color extraction - **Brand Colors**: Use extracted colors or customize manually - **Typography**: Configure heading and body text sizes and colors - **Table Styling**: Customize table backgrounds, headers, and borders - **Real-time Preview**: See changes instantly as you configure :::info Key Difference **Brand Identity** sets organization-wide styling standards, while **[Working with Fonts](./Working-with-Fonts.md)** covers embedding specific desktop fonts in individual templates. ::: ## Detailed Configuration (Optional) *Already completed the Quick Start? Skip to [What's Next](#whats-next) or continue here for advanced customization.* ### Choose Your Configuration Approach **Option A: Automatic Setup** *(Recommended for most users)* - Upload logo → Let TurboDocx extract colors → Use presets - **Best for**: Quick deployment, consistent results **Option B: Manual Customization** *(For brand-specific requirements)* - Custom color selection → Granular typography control → Advanced table styling - **Best for**: Strict brand guidelines, unique styling ### Accessing Brand Identity Settings 1. Click your account avatar, then click **Settings** 2. In the menu on the left, select **Branding and formatting** (under **Documents**) 3. You'll see the Brand Identity configuration interface with three main areas: - Logo upload section (top) - Configuration controls - Live preview panel (beside the controls on wide screens, below them on smaller ones) ### Logo Upload & Save ![Organization Logo upload area highlighted on the Branding and formatting page](/img/brandidentity/LogoUpload.png) 1. **Upload Your Logo** - Click the logo area under **Organization Logo** at the top of the page (or drag your logo file onto it) - Select your logo file (recommended formats: PNG, JPG) - Optimal size: 200x200px or larger for best results - Logo preview appears in upload area 2. **Save Changes** - After uploading, click **Save Changes** to apply the logo - TurboDocx will automatically analyze your logo to extract brand colors - The extraction process may take a few moments - Brand colors appear automatically in the Brand Colors section below :::tip Logo Tips - Use high-resolution logos for better color extraction - PNG files with transparent backgrounds work best - Square or horizontal logos typically work better than vertical ones ::: ## Brand Colors ![Brand Colors and Preview Panel](/img/brandidentity/BrandColorsandPreview.png) ### Automatic Color Extraction Once you upload your logo, TurboDocx automatically extracts your brand's primary colors: - **Primary Color**: The dominant color from your logo - **Secondary Color**: The most prominent accent color - **Additional Colors**: Supporting colors found in your logo The extracted colors appear in the **Brand Colors** section on the left-hand side of the interface. ### Manual Color Override **When to customize**: Your extracted colors don't match your brand guidelines exactly, or you need specific hex codes. You can customize any extracted color: 1. **Override Extracted Colors** - Click on any color swatch in the Brand Colors section - Use the color picker to select a new color - Enter specific hex codes for precise color matching - Colors update in real-time in the preview panel 2. **Color Guidelines** - Ensure sufficient contrast between text and background colors - Test colors across different document types in the preview - Text remains clearly readable in preview panel ### Using the Preview Panel The **Preview Panel** on the right-hand side shows: - **Real-time Updates**: Changes appear instantly as you modify colors - **Document Samples**: See how colors look in actual document layouts - **Different Elements**: Preview headings, body text, tables, and other components Preview panel shows your colors applied to sample document content ## Typography & Font Configuration ### Quick Setup Options For rapid configuration, use the **Quick Setup** presets: 1. **Preset Sizes** for Headings 1–3: - **Small**: Conservative sizing for formal documents (H1: 16pt, H2: 14pt, H3: 12pt) - **Normal**: Balanced sizing for most use cases (H1: 18pt, H2: 16pt, H3: 14pt) - **Large**: Bold sizing for impactful presentations (H1: 22pt, H2: 18pt, H3: 16pt) 2. **Apply Presets**: - Select your preferred preset - Changes apply immediately to H1, H2, and H3 headings in preview panel - You can still make granular adjustments afterward **Stop here if**: You're satisfied with the preset sizes and want to keep setup simple. ### Granular Typography Adjustments For precise control, expand the typography accordions: #### Headings (H1, H2, H3) ![Headings (1-3) section expanded and highlighted](/img/brandidentity/HeadingConfig.png) **For each heading level, configure:** - **Font Size**: Adjust size in points or pixels - **Font Color**: Choose from brand colors or custom colors **Best Practices:** - Maintain clear hierarchy: H1 > H2 > H3 in size - Use consistent color schemes across heading levels - Ensure sufficient contrast for accessibility #### Body Text Configuration **Configure body text settings:** - **Font Size**: Optimal reading size (typically 11-12pt for documents) - **Font Color**: Usually darker colors for readability ## Table Styling Configuration ![Tables section expanded and highlighted, with the table preview](/img/brandidentity/TableSetup.png) Customize how tables appear in your documents: ### Table Background Colors - **Main Table Background**: Overall table background color - **Alternating Rows**: Optional striped pattern for better readability - **Color Intensity**: Adjust opacity for subtle backgrounds ### Header & Subheader Styling **Table Headers:** - **Header Background Color**: Primary header row styling - **Header Text Color**: Ensure contrast with background - **Subheader Background Color**: Secondary header styling for complex tables **Styling Tips:** - Use brand colors for headers to maintain consistency - Ensure text remains readable on colored backgrounds - Consider print-friendly colors for documents that may be printed ### Border & Text Styling **Border Configuration:** - **Table Border Color**: Outline color for the entire table - **Cell Border Color**: Internal grid line colors - **Border Width**: Thin, medium, or thick border options **Text Colors (Optional):** - **Data Text Color**: Color for table cell content - **Override Body Text**: Use different colors in tables vs. regular text - **Emphasis Colors**: Highlight important data points ### Table Preview The preview panel shows your table styling applied to sample data, allowing you to: - Verify color combinations work well together - Check readability across different cell types - Ensure consistent branding appearance ## Advanced Configuration Tips ### Color Consistency - **Use Brand Color Palette**: Stick to extracted or manually set brand colors - **Create Color Hierarchy**: Primary colors for headers, secondary for accents ### Typography Hierarchy - **Establish Clear Levels**: Make H1-H3 distinctly different sizes - **Maintain Proportions**: Use mathematical ratios (1.25x, 1.5x, 2x) between levels - **Consider Context**: Adjust sizes based on document types (reports vs. presentations) ### Template Integration Your Brand Identity settings automatically apply to: - **New Templates**: All newly created templates inherit brand settings - **Existing Templates**: Update existing templates to use brand settings - **Generated Documents**: All output maintains brand consistency ## What's Next? Now that your Brand Identity is configured, here's how to put it to work: ### Immediate Next Steps 1. **Test with existing templates** - Generate a document from an existing template to see your branding applied 2. **Create your first branded template** - Follow our [How to Create a Template](./How-to-Create-a-Template.md) guide 3. **Set up team access** - Ensure team members have appropriate permissions to use templates ### Building Your Document Workflow - **Templates**: Your brand settings automatically apply to all new and existing templates - **Deliverables**: Every generated document will use your brand identity consistently - **Knowledge Base**: Combine with [knowledge base entries](./How-to-Create-a-Knowledgebase-Entry.md) for fully automated, branded documents ### Integration Opportunities - **Salesforce Integration**: Branded proposals generated directly from CRM data - **Teams/Zoom Integration**: Consistent meeting follow-ups and reports - **Document Library**: Build a library of branded templates for different use cases :::tip Pro Tip Start with 2-3 core templates (proposal, report, letter) to see immediate value, then expand your template library as needed. ::: ## Troubleshooting ### Problem: Logo Won't Upload **Is your file under 5MB?** - ❌ **No**: Compress your logo file or use a smaller version - ✅ **Yes**: Continue to format check **Is your file PNG or JPG format?** - ❌ **No**: Convert your logo to PNG or JPG format - ✅ **Yes**: Check image resolution (should be 200x200px or larger) ### Problem: Colors Aren't Extracting Well **Does your logo have multiple distinct colors?** - ❌ **No (monochrome/single color)**: Use manual color override to add your brand colors - ✅ **Yes**: Continue to contrast check **Are colors in your logo high contrast?** - ❌ **No (subtle/low contrast)**: Use manual color override for better results - ✅ **Yes**: Try a higher resolution logo or manual override specific colors ### Problem: Changes Not Appearing **Are changes showing in the preview panel?** - ❌ **No**: Refresh the page and try again - ✅ **Yes**: Changes may take a few minutes to apply to generated documents **Did you click "Save Changes"?** - ❌ **No**: Click Save Changes to apply your brand settings - ✅ **Yes**: Generate a test document to verify settings are applied ### Template Application Issues **Brand Settings Not Applying:** - Verify templates are set to use organization brand settings - Check template-specific overrides that may conflict - Ensure templates are saved after brand updates **Font Conflicts:** - TurboDocx by default uses the font found in the template - For custom fonts, use [Working with Fonts](./Working-with-Fonts.md) embedding ## Getting Help If you encounter issues with Brand Identity configuration: 1. **Check Template Compatibility**: Ensure templates support brand identity features 2. **Verify Permissions**: Confirm you have organization admin privileges 3. **Test with Sample Documents**: Generate test documents to verify settings 4. **Contact Support**: Provide specific details about configuration issues and document types For technical font embedding questions, refer to [Working with Fonts](./Working-with-Fonts.md). :::info Next Steps After configuring your Brand Identity, create new templates or update existing ones to see your branding applied consistently across all generated documents. ::: --- ## How to Create a Deliverable Now that you have a Template and entries in your Knowledgebase, let's put it all together and create your first document or what we call a Deliverable. ## Step 1: Getting to and Selecting your Deliverable Let's get you to your Templates and select the template for your first Deliverable. 1. On the left hand side click on the Templates tab. 2. Next select the template you want to use. If you have folders set up or pages of templates that makes it hard to find the template you want don't forget the search feature! ![This is the image for A with the text: Login and then clicked](/img/how_to_create_a_deliverable/step_1.png) 3. With your template open it should look like the image below. Here you can check to make sure all your variables are present and also have a preview of the template to make sure you have the right document. If everything looks correct, you can go ahead and click "Create Deliverable ![This is the image for SPAN with the text: TurboDocx Demo and then clicked](/img/how_to_create_a_deliverable/step_2.png) ## Step 2: Creating your Deliverable After you've clicked "Create Deliverable" you will be brought to a new page where you will be able to enter and edit all your variables. There are multiple ways to enter and edit your variables so for Step 2 we will break it down into 3 parts: Simple Text Entries, Advanced Entries, Variable Stack and Disable Variable. For future reference this information can also be found under the "Additional Information" tab under "Variable Entry". ### Simple Text Entries In this first example we will be using simple text entries. Things such as "CustomerName" or "ProjectName" already have formating in the template so just clicking on the box and typing your entry will be sufficient for us here. ![Simple text variable entry fields for CustomerName and ProjectName](/img/how_to_create_a_deliverable/step_6.png) ### Advanced Entries What happens if we need a more robust entry? Maybe we want something from our Knowledgebase entries, an image or just add some formatting to the text. We'll go through each option in this section. 1. First, what we will do is click the more options or the triple dot button located next to the variable boxes. Here you will see all the options we just discussed and more. ![more options](/img/how_to_create_a_deliverable/step_10.png) 2. Next, please select the option you would like to utilize. #### Lookup To select something from your Knowledgebase, click Lookup. A popup window will display all your entries. Scroll or search through them. The search feature will look for the keyword in the name, tags, or content. Larger entries have a preview option. Once found, click the blue "Select" button. ![lookup](/img/how_to_create_a_deliverable/step_20.png) :::tip - This is where tags and descriptions when creating the Knowledgebase entry may come in handy. - Even after selection, you can edit variables for any necessary changes from the Knowledgebase entry. Simply click the content box for simple text changes. For other variable types, use the pencil and paper icon to edit. ::: #### Image For images, click Image. A pop up will come up where you can click on it to open your file browser and choose an image. ![Image](/img/how_to_create_a_deliverable/step_img.PNG) #### Rich Text For text that you need to format, or if you'd like to add a table you will click on Rich Text. A new window will pop-up as shown in the image below. Here you will see familiar options such as text formats, colors as well as the ability to add tables among other things. Just click the "Save" button when you're done ![rich text](/img/how_to_create_a_deliverable/step_24.png) #### Date For a date, click on Date. A calendar will pop-up above the variable which you can then use to select the date. ![date](/img/how_to_create_a_deliverable/date.png) ### Variable Stack and Disable Variable Sometimes you will need more than one entry for a given variable and others you will not need a certain variable at all. These two options will allow you to do that. #### Variable Stack If you need multiple variable entries or images for a section of your Deliverable, click Create Variable Stack under more options. You'll return to the original page with a "+" button under the variable. Add stacks by clicking "+", to remove them click "-". ![rich text](/img/how_to_create_a_deliverable/step_vs.PNG) #### Disable Variable Sometimes a variable is helpful to have in a template but may not be needed in every Deliverable. For instances where the variable is not needed you can click on more options and then Disable Variable. This will remove the requirement to have content in that variable. To undo this just click the button that now replaced the more options button. ![rich text](/img/how_to_create_a_deliverable/step_10.png) ## Step 3: Generate Deliverable Once you've completed filling out the variables in your Deliverable, click the "Generate Deliverable" button on the bottom right of the page and then "Confirm" on the pop up. ![This is the image for BUTTON with the text: Generate Deliverable and then clicked](/img/how_to_create_a_deliverable/step_29.png) ## Step 4: Export Deliverable Once you've clicked "Confirm" the next screen will be a preview of your deliverable. After you've taken a look you can press the "Export" button on the top right. Select how you would like to export it and you're done! ![This is the image for BUTTON with the text: Confirm and then clicked](/img/how_to_create_a_deliverable/step_35.png) ## Finished Congratulations on creating your first Deliverable. I hope these tutorials were helpful and we look forward to working with you to create quick and easy documentation automation! --- ## How to Create Document Templates # Create Document Templates for Automated Proposals, Contracts & Reports Transform data from meetings, CRM systems, and integrations into professional documents automatically. This guide shows you how to create document templates for proposals, statements of work, quotes, and contracts that can be populated with content from your connected data sources. **Perfect for creating:** - 📄 **Proposals** from discovery calls, opportunities, and project data - 📋 **Statements of Work (SOW)** based on requirement discussions and business data - 💰 **Quotes & Estimates** using meeting notes, specifications, and pricing information - 📝 **Project Contracts** incorporating terms from calls and account details - 🤝 **Service Agreements** with details from consultations and contact systems - 📊 **Reports & Summaries** with action items, metrics, and data from your integrations Transform conversations and CRM data into professional documents automatically - streamlining your workflow with intelligent document generation. Congratulations on starting your first document template. In this guide, we will show you how to create your first document template and how to upload it to your TurboDocx account. ## Video Tutorial ## Step 1: Create New Document Template Let's start with creating our first document template. 1. Open up whatever application you will be using to create your document (Word, Google Drive, etc). 2. Anything you would like to be a variable or an item that will change from document to document will be put between these `{brackets}`. This can be single items such as `{Date}` or `{CustomerName}` or it can be whole sections such as `{Scope}` as seen in the images below. ![Word document template with bracket variables like CustomerName and Date](/img/how_to_create_a_template/CreatingATemplateDoc1Title.PNG) ![Second page of template document showing Scope section variable](/img/how_to_create_a_template/CreatingATemplateDoc2Title.PNG) 3. Once you've finished your document with the variables that you want please save it and move on to the next step. :::tip - If you've previously created a document you want to use as a template, go ahead and use that. Just remember to replace all the variables within the `{brackets}`. Find and replace may be helpful here. - All variables will need to be one word, so do not put any spaces in the middle of the `{brackets}`. - Remember your Knowledgebase, if the variable has the same name as something in your Knowledgebase TurboDocx will use the info from the Knowledgebase to fill in the corresponding Variable. - If you want to include an image or rich text, make sure the variable is on it's own line. Use the show paragraph tool if you need help. The location for that tool in Word and Google Docs are in the image below. ![Paragraph marker tool location in Word and Google Docs toolbars](/img/how_to_create_a_template/paragraphtool.png) ::: ## Step 2: Upload Template Document Now that we've created our first template, let's upload it to TurboDocx. 1. Login to your TurboDocx account you should land on the Templates tab if not it can be found on the left side. Then, On the top right of your window, click on the "New Template" button. ![TurboDocx Templates tab with New Template button highlighted](/img/how_to_create_a_template/newtemp.png) 2. As you can see, there are multiple ways to upload a Document. For this example, we will click on the "Upload Template" area then choose the document you just saved in the last step or drag and drop your that document into the area. ![Template upload dialog with drag-and-drop area and upload options](/img/how_to_create_a_template/step_1.png) ## Step 3: Preview Template On the next screen, you will see a preview of your template. 1. Check to make sure all the variables you wanted are listed under the variables column. - (Optional) - On this page, you can also create default entries for your template that you can change later when you are creating the deliverable. ![Template preview screen showing detected variables and Create Template button](/img/how_to_create_a_template/step_3.png) 2. Once you've checked to make sure all your variable are set, go ahead and click the "Create Template" button on the bottom right corner (you may need to scroll) :::tip - Tags can be very useful to help find certain templates, and Knowledgebase entries. Don't forget to utilize them! You can also always come back and add them in the future. - If your variables are not showing up, go back to step one and check if you're using the right brackets, and that there are no spaces in your variable name. ::: ## Finished Congratulations on uploading your first document template. You should be able to see it on the template tab once you go back to the main page. --- ## How to Create a Knowledgebase Entry Congratulations on starting your first entry in the Knowledgebase. In this guide, we will show you how to create your first entry. ## Step 1: Navigate to and Create a Knowledgebase Entry Let's start by getting to the Knowledgebase and creating a new entry. 1. On the left side of the screen click on the tab "Knowledgebases". ![Knowledgebases tab in TurboDocx left navigation sidebar](/img/how_to_create_knowledgebases/step_1.png) 2. Next, click the blue "New" button on the top left of the page. ![New button on the Knowledgebases page to create an entry](/img/how_to_create_knowledgebases/step_2a.png) ## Step 2: Enter the Information After clicking "New", a new window should pop-up on the right side of your screen. Here is where you will input all of the relevant information. 1. First, name your entry. We would suggest something that will help to indicate to other users what the Knowledgebase entry will include. 2. Next, create a placeholder. Don't worry the brackets are automatically used for you. 3. In the "Default Value" box, enter the info you want to use for this Knowledgebase entry. For plain text, type directly into the box. If you need formatting, images, or dates, click the "More Options" button for those choices. - For additional info on how to use these tools, see the "Additional Information" tab under "Variable Entry". - You can also add subvariables in your entries. For more information on how, see the "Additional Information" tab under "Subvariables" ![Knowledgebase entry form with name, placeholder, and default value fields](/img/how_to_create_knowledgebases/step_8.png) 4. Lastly, press the "Submit" button on the bottom right of the page. :::tip - Although "Tags" and "Add Description" are optional, this can help others find and understand the purpose of the entry quickly and easily. - If the "Placeholder" is the same as a variable used in the Template TurboDocx will automatically place the Knowledgebases entry in that variable slot. Of course, you will still be able to change it. ::: ## Step 3: Search Once you've clicked the "Submit" button you should go back to the main Knowledgebases screen. From here you can add new entries, delete outdated or uneeded entries, manage your tags, or search for a specific entry. Note: When searching TurboDocx will look for the keyword in the name, tags and content! ![Knowledgebases main screen with search bar, tag management, and entry list](/img/how_to_create_knowledgebases/step_2b.png) ## Finished Congratulations on creating your first Knowledgebases entry. --- ## How to Create Presentation Templates # Create Presentation Templates for Data-Driven Presentations Turn your business data into professional presentations automatically. This guide shows you how to create presentation templates that can be populated with data from meetings, CRM systems, project management tools, and other integrations. **Ideal for:** - 📞 **Post-meeting summaries** from calls and conferences - 🎯 **Client presentations** based on opportunity data and discovery sessions - 📋 **Project status decks** from planning meetings and project systems - 📊 **Sales presentations** customized with prospect and opportunity information - 🔄 **Recurring reporting formats** with dynamic content from your integrations - 📈 **Executive dashboards** with real-time metrics and KPIs Congratulations on starting your first presentation template. In this guide, we will show you how to create your first presentation template and how to upload it to your TurboDocx account. ## Video Tutorial ## Step 1: Create New Presentation Template Let's start with creating our first presentation template. 1. Open PowerPoint or your preferred presentation application. 2. Create your slide content, leaving areas where you want dynamic content. 3. For each variable area, you'll need to create invisible shapes to hold your variables. Screenshots Coming Soon ## Step 2: Setting Up Variables in Slides For slide deck templates, follow these specific steps to ensure variables work correctly: 1. **Delete existing content** from the slide where you want dynamic content 2. **Insert a shape:** - Go to **Insert → Shape** in the ribbon - Choose a rectangle - Draw the shape where you want the dynamic content to go 3. **Add your variable:** - Click inside the shape - Type your variable name (e.g., `{WhatWeHeard}`) - Remember: all one word, no spaces 4. **Align text to top:** - With the shape selected, go to **Shape Format → Align Text → Top** - This keeps everything uniform across slides 5. **Make the shape invisible:** - Set **Shape Fill** to "No Fill" - Set **Shape Outline** to "No Fill" - This removes the colored box, making it invisible 6. **Format the text:** - Change the font color to black (or your preferred color) - This makes it easy and clear to see in your final deck Screenshots Coming Soon ### Example Slide Setup For a slide with sections like: - What we heard - How we can help - Follow-up questions Create separate invisible shapes for each section with variables like: - `{WhatWeHeard}` - `{HowWeCanHelp}` - `{FollowUpQuestions}` Each variable should be in its own invisible rectangle shape, not mixed with other text. ## Step 3: Upload Presentation Template Now that we've created our first presentation template, let's upload it to TurboDocx. 1. Login to your TurboDocx account you should land on the Templates tab if not it can be found on the left side. Then, On the top right of your window, click on the "New Template" button. 2. As you can see, there are multiple ways to upload a Document. For this example, we will click on the "Upload Template" area then choose the presentation you just saved in the last step or drag and drop your presentation into the area. Screenshots Coming Soon ## Step 4: Preview Template On the next screen, you will see a preview of your template. 1. Check to make sure all the variables you wanted are listed under the variables column. - (Optional) - On this page, you can also create default entries for your template that you can change later when you are creating the deliverable. 2. Once you've checked to make sure all your variable are set, go ahead and click the "Create Template" button on the bottom right corner (you may need to scroll) Screenshots Coming Soon :::tip - Tags can be very useful to help find certain templates, and Knowledgebase entries. Don't forget to utilize them! You can also always come back and add them in the future. - If your variables are not showing up, make sure you're using invisible rectangle shapes and that there are no spaces in your variable name. ::: ## Finished Congratulations on uploading your first presentation template. You should be able to see it on the template tab once you go back to the main page. --- ## How to Create a Template Congratulations on starting your first template. In this guide, we will show you how to create your first template and how to upload it to your TurboDocx account. ## Video Tutorial ## Step 1: Create New Template Let's start with creating our first template. 1. Open up whatever application you will be using to create your document (Word, Google Drive, etc). 2. Anything you would like to be a variable or an item that will change from document to document will be put between these `{brackets}`. This can be single items such as `{Date}` or `{CustomerName}` or it can be whole sections such as `{Scope}` as seen in the images below. ![Word document template with bracket variables like CustomerName and Date](/img/how_to_create_a_template/CreatingATemplateDoc1Title.PNG) ![Second page of template document showing Scope section variable](/img/how_to_create_a_template/CreatingATemplateDoc2Title.PNG) 3. Once you've finished your document with the variables that you want please save it and move on to the next step. :::tip - If you've previously created a document you want to use as a template, go ahead and use that. Just remember to replace all the variables within the `{brackets}`. Find and replace may be helpful here. - All variables will need to be one word, so do not put any spaces in the middle of the `{brackets}`. - Remember your Knowledgebase, if the variable has the same name as something in your Knowledgebase TurboDocx will use the info from the Knowledgebase to fill in the corresponding Variable. - If you want to include an image or rich text, make sure the variable is on it's own line. Use the show paragraph tool if you need help. The location for that tool in Word and Google Docs are in the image below. ![Paragraph marker tool location in Word and Google Docs toolbars](/img/how_to_create_a_template/paragraphtool.png) ::: ## Step 2: Upload Template Document Now that we've created our first template, let's upload it to TurboDocx. 1. Login to your TurboDocx account you should land on the Templates tab if not it can be found on the left side. Then, On the top right of your window, click on the "New Template" button. ![TurboDocx Templates tab with New Template button highlighted](/img/how_to_create_a_template/newtemp.png) 2. As you can see, there are multiple ways to upload a Document. For this example, we will click on the "Upload Template" area then choose the document you just saved in the last step or drag and drop your that document into the area. ![Template upload dialog with drag-and-drop area and upload options](/img/how_to_create_a_template/step_1.png) ## Step 3: Preview Template On the next screen, you will see a preview of your template. 1. Check to make sure all the variables you wanted are listed under the variables column. - (Optional) - On this page, you can also create default entries for your template that you can change later when you are creating the deliverable. ![Template preview screen showing detected variables and Create Template button](/img/how_to_create_a_template/step_3.png) 2. Once you've checked to make sure all your variable are set, go ahead and click the "Create Template" button on the bottom right corner (you may need to scroll) :::tip - Tags can be very useful to help find certain templates, and Knowledgebase entries. Don't forget to utilize them! You can also always come back and add them in the future. - If your variables are not showing up, go back to step one and check if you're using the right brackets, and that there are no spaces in your variable name. ::: ### Example Slide Setup For a slide with sections like: - What we heard - How we can help - Follow-up questions Create separate invisible shapes for each section with variables like: - `{WhatWeHeard}` - `{HowWeCanHelp}` - `{FollowUpQuestions}` Each variable should be in its own invisible rectangle shape, not mixed with other text. --> ## Finished Congratulations on uploading your first template. You should be able to see it on the template tab once you go back to the main page. --- # Office Integration Edit your TurboDocx templates and deliverables directly in Microsoft Word and PowerPoint with full Office functionality, while keeping everything synchronized and preventing editing conflicts. ## What You Can Do With Office Integration, you can: - **Open templates in Word** - Edit with all Word features (track changes, comments, formatting, etc.) - **Open presentations in PowerPoint** - Use PowerPoint's full editing capabilities - **Save changes automatically** - Your edits sync back to TurboDocx in real-time - **Work safely** - File locking prevents others from editing while you work - **See updates instantly** - PDF previews regenerate automatically after changes ## Supported Applications - **Microsoft Word** - For `.docx` template files - **Microsoft PowerPoint** - For `.pptx` presentation files :::info Office Installation Required You need Microsoft Office installed on your computer. The web versions of Office don't support this integration. ::: ## How to Use Office Integration ### Opening a Document 1. **Find your template or deliverable** in TurboDocx 2. **Click "Open in Word"** or **"Open in PowerPoint"** button 3. **Wait for Office to launch** - This may take a few seconds 4. **Start editing** - The document opens with all your content ready to edit :::tip First Time Setup Your browser may ask permission to open Office applications. Click "Allow" or "Open" to enable the integration. ::: ### While Editing **What you'll see:** - Your document opens in Word/PowerPoint as normal - An overlay appears in your browser showing "Editing in Progress" - The document is locked - other users can't edit it while you work **What you can do:** - Edit the document with full Office features - Use track changes, comments, and formatting tools - Save your work frequently (Ctrl+S or Cmd+S) - Work offline - changes sync when you're back online **What happens automatically:** - Your changes save back to TurboDocx when you press Ctrl+S - Template variables are detected and updated - PDF previews regenerate in the background - Other users see the document is being edited ### Finishing Your Edits When you're done editing: 1. **Save your final changes** in Word/PowerPoint (Ctrl+S) 2. **Return to your browser** with TurboDocx open 3. **Click "I'm Done Editing"** on the overlay 4. **The document becomes available** for other users :::warning Important: What Happens After "I'm Done Editing" Once you click "I'm Done Editing", **your Office document becomes read-only**. If you try to save again in Word/PowerPoint, you'll get an error message. This is normal and prevents conflicts with other users. **If you need to make more changes:** - Click "Open in Word" again to start a new editing session - This will give you a fresh editing session with save permissions ::: ## What Other Users See ### When Someone is Editing - **Lock indicator** shows who is currently editing - **"Being edited by [Name]"** message appears - **All editing functions are disabled** for other users - **PDF preview updates** as the editor saves changes ### When Editing is Complete - **Lock is automatically removed** - **All users can edit again** - **Updated PDF preview** reflects the latest changes ## Common Workflows ### Quick Edit Workflow Perfect for small changes: 1. Click "Open in Word" 2. Make your changes 3. Save (Ctrl+S) 4. Click "I'm Done Editing" ### Extended Editing Session For major revisions: 1. Click "Open in Word" 2. Make multiple rounds of changes 3. Save frequently throughout your session 4. Take breaks - your session stays active 5. When completely finished, click "I'm Done Editing" ### Collaboration Workflow Working with team members: 1. **Person A** opens document for editing 2. **Person B** sees lock message and waits 3. **Person A** completes edits and clicks "I'm Done Editing" 4. **Person B** can now click "Open in Word" to make their changes ### Template Variable Updates When you add or modify template variables: 1. Edit your template in Word 2. Save your changes (Ctrl+S) 3. Return to browser - you'll see "Files are changed" button 4. Click "Files are changed" to update variables 5. Fill in any new template variables 6. Click "I'm Done Editing" when finished ## Troubleshooting ### "Office Won't Open" Issues **Problem:** Clicking "Open in Word" doesn't open the document **Solutions:** 1. **Check Office Installation** - Make sure Microsoft Office is installed and activated 2. **Try a Different Browser** - Edge works best, Chrome is also good 3. **Allow Pop-ups** - Your browser might be blocking the Office protocol 4. **Restart Your Browser** - Close all tabs and restart 5. **Clear Cache** - Clear your browser cache and cookies ### "Can't Save" Issues **Problem:** Getting errors when trying to save in Word/PowerPoint **Most Common Cause:** You clicked "I'm Done Editing" but Office is still open **Solution:** This is normal behavior! Once you click "I'm Done Editing", Word becomes read-only to prevent conflicts. **To continue editing:** 1. Go back to TurboDocx in your browser 2. Click "Open in Word" again 3. This starts a new editing session with save permissions **Other Save Issues:** - **Check your internet connection** - Saves need an internet connection - **Make sure no one else is editing** - Only one person can edit at a time - **Try saving again** - Sometimes network delays cause temporary save failures ### "Document is Locked" Messages **Problem:** TurboDocx says the document is being edited by someone else **What this means:** Another user currently has the document open for editing **Solutions:** 1. **Wait for them to finish** - They'll click "I'm Done Editing" when finished 2. **Contact the person** - Ask them if they're still actively editing 3. **Admin can force unlock** - If you're an admin and the person isn't responding ### "Long Delay" When Opening Word **Problem:** Word takes 10+ seconds to open after making template changes **Why this happens:** TurboDocx is processing your template variable changes **Solutions:** - **Be patient** - This is normal after variable updates - **Don't click multiple times** - Wait for Word to open - **Check your internet speed** - Slow connections take longer ## Tips for Best Results ### Save Frequently - Press **Ctrl+S** (Windows) or **Cmd+S** (Mac) often - Don't rely on auto-save - manual saves sync faster - Save before making major changes ### Use "I'm Done Editing" Properly - **Always click it** when you're completely finished - **Don't click it** if you plan to continue editing soon - **Remember** - Office becomes read-only after clicking ### Collaboration Etiquette - **Communicate with your team** about who's editing what - **Don't keep documents open** unnecessarily - **Finish editing sessions promptly** so others can work ### Working Offline - You can work offline - changes sync when you reconnect - Save frequently when you're back online - Avoid having multiple people edit the same document offline ## System Requirements ### Computer Requirements - **Windows 10/11** or **macOS** (latest versions recommended) - **Microsoft Office installed** (Office 365, Office 2019, or newer) - **Stable internet connection** for syncing changes ### Browser Requirements - **Chrome** (recommended) - **Microsoft Edge** (recommended) - **Firefox** (may need setup) - **Safari** (limited support) ### Office Versions - ✅ **Office 365** - Full support - ✅ **Office 2021** - Full support - ✅ **Office 2019** - Full support - ❌ **Office Online** - Not supported (web versions) - ❌ **Older versions** - May not work properly ## Getting Help If you're still having issues: 1. **Contact your team admin** - They can check locks and permissions 2. **Try a different computer** - Helps identify computer-specific issues 3. **Test with a different template** - Helps identify template-specific issues 4. **Check with IT support** - They can verify Office installation and network settings Remember: Office Integration is designed to work seamlessly, but like any technology, sometimes you need to try a few solutions to get everything working perfectly! --- # Template Troubleshooting Before you throw your computer, watch this video! Your variables aren't broken, but your template might need a little bit of tender loving care. Here are the most common issues and how to fix them. ## Video Tutorial ## Common Variable Issues ### 1. Variable Naming Rules All variables must be **one word only** - no spaces allowed inside the `{brackets}`. ✅ Correct - One word only {'{CustomerName}'} {'{ProjectDate}'} {'{ScopeSection}'} ✨ These variable names will work perfectly in your templates ❌ Incorrect - Contains spaces {'{Customer Name}'} {'{Project Date}'} {'{Scope Section}'} ⚠️ Spaces inside brackets will prevent variables from being recognized :::tip Naming Best Practices - Use **CamelCase** for multi-word variables: `{CustomerName}` instead of `{Customer Name}` - Keep names **descriptive but concise**: `{ProjectStartDate}` instead of `{Date}` - Be **consistent** across your templates: always use the same format ::: ### 2. Variables Must Be On Their Own Line **Critical Rule:** When inserting sections, images, or rich text, the variable needs to be on its own line. Don't squeeze it into a sentence. #### Examples: How to Format Variables Correctly ❌ Incorrect - Variable mixed with other text Rich Text/Section Variables: Proposal Section: {'{ProposalSection}'}, If you have any questions about the proposal... Image Variables: Photo: {'{HeadshotImage}'}, Nicolas Fry, CEO ⚠️ This format will prevent variables from working properly ✅ Correct - Variable on its own line Rich Text/Section Variables: Proposal Section: {'{ProposalSection}'} If you have any questions about the proposal... Image Variables: Photo: {'{HeadshotImage}'} Nicolas Fry, CEO ✨ This format ensures variables work correctly and content flows properly :::tip Key Takeaway Notice how in the correct examples, each variable is **completely isolated** on its own line with blank lines above and below. This separation is crucial for rich text, images, and section variables to function properly. ::: ### 3. Use Paragraph Markers to Debug One of the most common mistakes is thinking a variable is on its own line when it's not. To be sure, turn on the paragraph marker tool: **In Microsoft Word:** - Click the paragraph icon (¶) to reveal hidden spaces and line breaks - It's the fastest way to catch formatting issues before they break your template **In Google Docs:** - Go to **View → Show non-printing characters** - You'll get the same results as Word ![Paragraph marker tool location in Word and Google Docs toolbars](/img/how_to_create_a_template/paragraphtool.png) When you activate the tool, it will resemble the image below. Since `{CustomerName}` is plain text, it can be incorporated into a paragraph. However, we will be utilizing a chart in Rich Text for `{Scope}`, so it needs to be on its own line. Paragraph symbols will indicate this distinction clearly. ![Paragraph markers showing inline text variable vs. section variable on its own line](/img/additional_information/ptoolexample.png) ### 4. Images Must Be PNG or JPEG Your document or presentation can only carry **PNG** and **JPEG** images. If you put any other format into a rich text or image variable, TurboDocx leaves a short message in its place: ``` [Image not supported: SVG — use PNG or JPEG (diagram.svg)] ``` That message tells you three things: the format that wasn't accepted, what to use instead, and the file name, so you can find it among many images. **Formats that get replaced with the message:** SVG, GIF, WebP, BMP, TIFF, HEIC, AVIF and ICO. :::caution The editor will show you the image anyway The rich text editor previews SVGs, GIFs and WebP files perfectly well, because your browser can display them even though a Word or PowerPoint file can't store them. So an image can look right while you're editing and still be replaced in the finished deliverable. If you see the "Image not supported" message in a document you just generated, that's why — nothing is broken. Convert the image and generate again. ::: **How to fix it:** open the image in any editor and re-save it as PNG or JPEG, then replace it in your variable. For logos and diagrams saved as SVG, exporting at 2× the size you need keeps them looking sharp. ## Quick Troubleshooting Steps When your variables aren't working, follow these steps in order: ### 1. Check Variable Naming No spaces inside brackets - use `{CustomerName}` not `{Customer Name}` ### 2. Verify Line Placement Variables for images, rich text, or sections must be on their own line ### 3. Use Paragraph Markers Turn on ¶ symbols to see hidden formatting issues ### 4. Check Your Image Formats Only PNG and JPEG can go into a deliverable — convert SVG, GIF, WebP, BMP, TIFF, HEIC, AVIF and ICO first ### 5. For Presentation Templates Use invisible rectangle shapes, not text boxes → [See presentation setup guide](./How-to-Create-a-Presentation-Template) ### 6. Test Your Template Create a simple deliverable to verify everything works → [Learn how to create deliverables](./How-to-Create-a-Deliverable) :::tip Advanced Troubleshooting & Best Practices **If variables still aren't working:** - **Start simple:** Test with basic text variables first, then add complex ones - **Double-check spacing:** Use paragraph markers to confirm variables are completely isolated - **For presentations:** Ensure shapes are truly invisible (no fill, no outline) **Best practices for success:** - **Test your template** by creating a deliverable before finalizing → [See how](./How-to-Create-a-Deliverable) - **Keep variable names descriptive** but concise - **Use consistent formatting** across all templates → [Document templates](./How-to-Create-a-Document-Template) | [Presentation templates](./How-to-Create-a-Presentation-Template) ::: ## Need More Help? Still stuck? We're here to help! Check out our comprehensive guides: - [📄 Document Templates](./How-to-Create-a-Document-Template) - Learn to create Word/Google Doc templates - [📊 Presentation Templates](./How-to-Create-a-Presentation-Template) - Learn to create PowerPoint templates - [🎯 Create Deliverables](./How-to-Create-a-Deliverable) - Learn to generate documents from templates - [📚 Full Documentation](https://docs.turbodocx.com) - Complete TurboDocx documentation If you need additional help, don't hesitate to reach out to our support team. --- # Working with Fonts TurboDocx supports a wide variety of fonts for document generation. This guide covers the fonts available in TurboDocx, how to use them effectively, and how to embed custom fonts in your templates. ## Embedding Fonts in Templates For documents that require specific fonts not included in TurboDocx's standard collection, you can embed fonts directly in your DOCX or PPTX templates. ### For Microsoft Word (DOCX Templates) #### Windows 1. Open your template in Microsoft Word 2. Go to **File → Options ![This is the image for clicking file -> options in Microsoft Word.](/img/embedding_fonts/FileOptions.png) 3. Go to the **Save** tab 4. Under "Preserve fidelity when sharing this document," check: - ✅ **Embed fonts in the file** - ✅ **Embed only the characters used in the document** (recommended for smaller file sizes) - ✅ **Do not embed common system fonts** (optional but recommended) ![This is the image for clicking file -> options in microsoft Word.](/img/embedding_fonts/SaveandEmbedFontsInFile.png) 5. Click **OK** 6. Save your document 7. Upload the updated template to TurboDocx #### macOS 1. Open your template in Microsoft Word 2. Go to **Word → Preferences → Save** 3. Under "Preserve fidelity when sharing this document," check: - ✅ **Embed fonts in the file** - ✅ **Embed only the characters used in the document** (recommended) 4. Click **OK** 5. Save your document 6. Upload the updated template to TurboDocx ### For Microsoft PowerPoint (PPTX Templates) #### Windows 1. Open your template in Microsoft PowerPoint 2. Go to **File → Options** ![This is the image for clicking file -> options in Microsoft PowerPoint.](/img/embedding_fonts/pptxFileOptions.png) 3. Go to the **Save** Tab 4. Under "Preserve fidelity when sharing this presentation," check: - ✅ **Embed fonts in the file** - ✅ **Embed only the characters used in the presentation** ![This is the image for clicking file -> options in Microsoft PowerPoint.](/img/embedding_fonts/pptxSaveandEmbed.png) 5. Click **OK** 6. Save your presentation 7. Upload the updated template to TurboDocx #### macOS 1. Open your template in Microsoft PowerPoint 2. Go to **PowerPoint → Preferences → Save** 3. Under "Preserve fidelity when sharing this presentation," check: - ✅ **Embed fonts in the file** - ✅ **Embed only the characters used in the presentation** 4. Click **OK** 5. Save your presentation 6. Upload the updated template to TurboDocx :::tip Pro Tip Embedding only characters used in the document significantly reduces file size while ensuring your custom fonts display correctly. ::: ## Font Support in TurboDocx ### PDF Generation (TurboSign) TurboDocx's PDF generator includes many common fonts for high-quality document rendering: #### Sans-Serif Fonts - **Poppins** - Modern geometric font, ideal for headings and contemporary designs - **Lato** - Professional and neutral, excellent for body text and business documents - **Inter** - Screen-optimized font, perfect for digital documents and small text - **Roboto** - Clean and readable, Google's system font - **Open Sans** - Friendly and approachable, widely used in web and print #### Serif Fonts - **Merriweather** - Elegant and readable, designed for long-form reading - **Roboto Slab** - Modern slab serif that pairs well with sans-serif fonts - **Playfair Display** - Stylish display font for headers and decorative text - **Tinos** - Times New Roman alternative, perfect for traditional documents #### Monospace Fonts - **Courier Prime** - Clean monospace font for code snippets and technical content ### System Fonts TurboDocx also supports standard system fonts including: - Arial, Helvetica (substituted with Roboto) - Times New Roman (substituted with Tinos) - Georgia (substituted with Merriweather) - Calibri (substituted with Carlito) - Cambria (substituted with Caladea) :::info Font Substitution TurboDocx automatically substitutes proprietary fonts with high-quality open-source alternatives to ensure consistent rendering across all platforms if font's are not embedded within your Document Template. ::: ## Troubleshooting Font Issues ### Common Problems and Solutions **Font not displaying correctly:** 1. Ensure the font is properly embedded in your template 2. Check that you've uploaded the updated template with embedded fonts 3. Verify the font name matches exactly (including spaces and capitalization) **File size too large:** - Enable "Embed only characters used in document" option - Consider using TurboDocx's built-in fonts instead - Remove unnecessary font variants (bold, italic) if not used **Font substitution occurring:** - Check that the font is properly embedded - Ensure the font license allows embedding - Verify template was saved after enabling font embedding ### Getting Help If you encounter font-related issues: 1. Check that your template uses supported fonts 2. Verify font embedding settings are correctly configured 3. Test with TurboDocx's built-in fonts as alternatives 4. Contact support with specific details about the font and document type :::info Microsoft Support For detailed instructions on font embedding in Microsoft Office, refer to [Microsoft's official documentation](https://support.microsoft.com/en-us/office/benefits-of-embedding-custom-fonts-cb3982aa-ea76-4323-b008-86670f222dbc). ::: --- # AI-Powered Variable Generation API Transform your document workflows with intelligent, context-aware variable generation. This API leverages advanced AI to automatically create rich, relevant content for your template variables by analyzing uploaded files, understanding context, and generating human-quality text based on your specific prompts. ## Overview The AI Variable Generation API represents the cutting edge of document automation, enabling you to: - **Generate Intelligent Content**: Create contextually relevant variable content using AI - **Process File Attachments**: Extract insights from Excel, Word, PDF, and other document formats - **Context-Aware Generation**: Leverage template context for more accurate content creation - **Rich Text Support**: Generate formatted content with HTML, markdown, or plain text - **Smart Data Extraction**: Automatically parse and understand structured data from spreadsheets ### Key Capabilities 🧠 **AI-Powered Content Creation**: Advanced language models generate human-quality content 📎 **File Attachment Processing**: Upload and analyze documents for context-driven generation 📊 **Spreadsheet Intelligence**: Select specific sheets and extract relevant data automatically 🎯 **Context Integration**: Use existing templates to inform and guide content generation ✨ **Rich Text Generation**: Create formatted content with styling and structure 🔧 **Customizable Prompts**: Fine-tune AI behavior with specific instructions and hints ## How It Works The AI Variable Generation process follows a simple but powerful workflow: 1. **Upload Context Files** - Attach documents (Excel, Word, PDF) that contain relevant data 2. **Define Variable Parameters** - Specify the variable name, placeholder, and generation context 3. **Craft AI Prompts** - Provide specific instructions to guide content generation 4. **Generate Content** - AI analyzes files and context to create intelligent variable content 5. **Integrate Results** - Use generated content directly in your template workflows ## TLDR; Quick Example 🚀 Ready to jump in? Here's a complete working example: ```bash curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-123e4567-e89b-12d3-a456-426614174000=@financial-report.xlsx' \ -F 'fileResourceMetadata={"123e4567-e89b-12d3-a456-426614174000":{"selectedSheet":"Q4 Results","hasMultipleSheets":true}}' \ -F 'name=Company Performance Summary' \ -F 'placeholder={Q4Performance}' \ -F 'templateId=template-abc123' \ -F 'aiHint=Generate a professional executive summary of Q4 financial performance based on the attached spreadsheet data' \ -F 'richTextEnabled=true' ``` **Response:** ```json { "data": { "mimeType": "html", "text": "Q4 Performance Summary: Our organization achieved exceptional results in Q4 2024, with revenue growing 23% year-over-year to $4.2M. Key highlights include improved operational efficiency, successful product launches, and strong market penetration in target segments." } } ``` Now let's dive into the complete implementation guide... ## Prerequisites Before you begin generating AI-powered variables, ensure you have: - **API Access Token**: Bearer token for authentication - **Organization ID**: Your organization identifier - **Template Context** (Optional): Existing template ID for enhanced context - **Source Files** (Optional): Documents containing data for AI analysis ### Getting Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or retrieve your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ### Supported File Types The AI Variable Generation API supports a wide range of file formats: | File Type | Extensions | Use Cases | | ----------------- | ----------------------- | ----------------------------------------------- | | **Spreadsheets** | `.xlsx`, `.xls`, `.csv` | Financial data, reports, lists, structured data | | **Documents** | `.docx`, `.doc`, `.pdf` | Contracts, reports, proposals, text content | | **Presentations** | `.pptx`, `.ppt` | Slide content, presentations, visual data | | **Images** | `.png`, `.jpg`, `.jpeg` | Charts, diagrams, visual content analysis | | **Text Files** | `.txt`, `.md` | Plain text, documentation, notes | ## Authentication All AI Variable Generation API requests require authentication using a Bearer token: ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx AI Client ``` ## API Reference ### Generate Single Variable Create AI-powered content for a single template variable with optional file attachments. #### Endpoint ```http POST https://api.turbodocx.com/ai/generate/variable/one ``` #### Headers ```http Content-Type: multipart/form-data Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx AI Client ``` #### Request Body (Form Data) The request uses multipart form data to support file uploads alongside variable parameters: ```javascript { // File attachment (optional) "FileResource-{uuid}": [BINARY_FILE_DATA], // File metadata (required if file attached) "fileResourceMetadata": "{\"file-uuid\":{\"selectedSheet\":\"Sheet1\",\"hasMultipleSheets\":true}}", // Variable definition "name": "Company Performance Summary", "placeholder": "{PerformanceSummary}", // Context and guidance "templateId": "template-abc123", // Optional: for context "aiHint": "Generate a professional summary of company performance", // Output settings "richTextEnabled": "true" // or "false" } ``` #### Request Parameters | Parameter | Type | Required | Description | | ---------------------- | ------ | ----------- | ---------------------------------------------------- | | `FileResource-{uuid}` | file | No | Binary file data for AI analysis | | `fileResourceMetadata` | string | Conditional | JSON metadata for attached files | | `name` | string | Yes | Display name for the variable | | `placeholder` | string | Yes | Template placeholder (e.g., `{VariableName}`) | | `templateId` | string | No | Template ID for context-aware generation | | `aiHint` | string | Yes | Instructions for AI content generation | | `richTextEnabled` | string | No | Enable HTML/rich text output (`"true"` or `"false"`) | #### File Metadata Structure When attaching files, provide metadata to guide AI processing: ```json { "file-uuid-here": { "selectedSheet": "Q4 Results", // For spreadsheets: specific sheet "hasMultipleSheets": true, // Whether file has multiple sheets "dataRange": "A1:D100", // Optional: specific cell range "contentType": "financial-data" // Optional: content classification } } ``` #### Response ```json { "data": { "mimeType": "text|html|markdown", "text": "Generated variable content based on AI analysis" } } ``` #### Response Fields | Field | Type | Description | | --------------- | ------ | ------------------------------------------- | | `data.mimeType` | string | Content format (`text`, `html`, `markdown`) | | `data.text` | string | Generated variable content | ## Advanced Features ### File Attachment Workflows #### Excel/Spreadsheet Processing When working with spreadsheets, the AI can analyze specific sheets and data ranges: ```bash # Example: Financial data analysis curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-fin123=@quarterly-financials.xlsx' \ -F 'fileResourceMetadata={"fin123":{"selectedSheet":"Income Statement","hasMultipleSheets":true,"dataRange":"A1:F50"}}' \ -F 'name=Revenue Analysis' \ -F 'placeholder={RevenueAnalysis}' \ -F 'aiHint=Analyze the quarterly revenue trends and provide insights on growth patterns, highlighting key metrics and year-over-year changes' \ -F 'richTextEnabled=true' ``` #### Document Content Extraction For text documents, the AI can extract and synthesize key information: ```bash # Example: Contract analysis curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-doc456=@contract-draft.docx' \ -F 'fileResourceMetadata={"doc456":{"contentType":"legal-document"}}' \ -F 'name=Contract Key Terms' \ -F 'placeholder={KeyTerms}' \ -F 'aiHint=Extract and summarize the key terms, obligations, and important dates from this contract document' \ -F 'richTextEnabled=false' ``` ### Context-Aware Generation Leverage existing template context for more accurate content generation: ```bash # Using template context curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'name=Project Scope Description' \ -F 'placeholder={ProjectScope}' \ -F 'templateId=project-template-789' \ -F 'aiHint=Generate a detailed project scope description that aligns with the project template structure and includes deliverables, timeline, and success criteria' \ -F 'richTextEnabled=true' ``` ### Rich Text Generation Enable rich text for formatted output with HTML styling: ```bash # Rich text example curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'name=Executive Summary' \ -F 'placeholder={ExecutiveSummary}' \ -F 'aiHint=Create a comprehensive executive summary with bullet points, key metrics, and strategic recommendations formatted for presentation' \ -F 'richTextEnabled=true' ``` **Rich Text Response Example:** ```json { "data": { "mimeType": "html", "text": "Executive SummaryOverview: Q4 2024 delivered exceptional results across all key performance indicators.Revenue Growth: 23% increase year-over-yearMarket Expansion: Successfully entered 3 new geographic marketsOperational Efficiency: 15% improvement in cost optimizationStrategic Recommendations: Continue aggressive growth strategy while maintaining operational excellence." } } ``` ## Code Examples ### Complete Implementation Examples ## Use Cases & Examples ### 1. Financial Report Analysis **Scenario**: Generate executive summaries from quarterly financial spreadsheets ```bash curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-12345=@Q4-financials.xlsx' \ -F 'fileResourceMetadata={"12345":{"selectedSheet":"Summary","hasMultipleSheets":true}}' \ -F 'name=Financial Performance Summary' \ -F 'placeholder={FinancialSummary}' \ -F 'aiHint=Create a concise executive summary highlighting revenue growth, profit margins, and key financial metrics from the Q4 data' \ -F 'richTextEnabled=true' ``` ### 2. Contract Key Terms Extraction **Scenario**: Extract important terms and dates from legal documents ```bash curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-67890=@service-agreement.pdf' \ -F 'name=Contract Terms' \ -F 'placeholder={ContractTerms}' \ -F 'aiHint=Extract contract duration, payment terms, key obligations, and important deadlines in a structured format' \ -F 'richTextEnabled=false' ``` ### 3. Project Proposal Generation **Scenario**: Create project descriptions based on scope documents ```bash curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-abcde=@project-requirements.docx' \ -F 'name=Project Description' \ -F 'placeholder={ProjectDescription}' \ -F 'templateId=proposal-template-123' \ -F 'aiHint=Generate a professional project description including objectives, deliverables, timeline, and success criteria based on the requirements document' \ -F 'richTextEnabled=true' ``` ### 4. Data-Driven Insights **Scenario**: Generate insights from research data and surveys ```bash curl 'https://api.turbodocx.com/ai/generate/variable/one' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -H 'x-rapiddocx-org-id: YOUR_ORG_ID' \ -H 'Content-Type: multipart/form-data' \ -F 'FileResource-xyz789=@market-research.xlsx' \ -F 'fileResourceMetadata={"xyz789":{"selectedSheet":"Survey Results","hasMultipleSheets":true}}' \ -F 'name=Market Insights' \ -F 'placeholder={MarketInsights}' \ -F 'aiHint=Analyze the survey data and generate key market insights, trends, and actionable recommendations for product strategy' \ -F 'richTextEnabled=true' ``` ## AI Prompt Engineering ### Writing Effective AI Hints The quality of generated content depends heavily on your AI prompts. Follow these best practices: #### ✅ Do: Be Specific and Clear ```bash # Good prompt 'aiHint=Generate a professional project timeline with 5 key milestones, including dates, deliverables, and success criteria for a 6-month software development project' # Poor prompt 'aiHint=make a timeline' ``` #### ✅ Do: Provide Context and Format Requirements ```bash # Good prompt 'aiHint=Create an executive summary in bullet point format highlighting Q4 revenue (target: $2M), customer acquisition metrics, and year-over-year growth percentages' # Poor prompt 'aiHint=summarize the data' ``` #### ✅ Do: Specify Tone and Audience ```bash # Good prompt 'aiHint=Write a formal, executive-level summary suitable for board presentation, focusing on strategic implications and ROI metrics' # Poor prompt 'aiHint=write a summary' ``` ### Advanced Prompt Techniques #### 1. Role-Based Prompts ```bash 'aiHint=Acting as a senior financial analyst, review the quarterly data and provide insights on revenue trends, cost optimization opportunities, and market positioning recommendations' ``` #### 2. Structured Output Prompts ```bash 'aiHint=Generate a risk assessment with three sections: 1) High-priority risks with mitigation strategies, 2) Medium-priority risks with monitoring plans, 3) Risk summary and overall assessment' ``` #### 3. Data-Driven Prompts ```bash 'aiHint=Based on the attached sales data, calculate month-over-month growth rates, identify top-performing products, and recommend strategies for underperforming segments' ``` ## Best Practices ### File Preparation #### Excel/Spreadsheet Files - **Clean Data**: Remove empty rows, merged cells, and formatting inconsistencies - **Clear Headers**: Use descriptive column headers in the first row - **Consistent Formatting**: Use consistent date formats, number formats, and text casing - **Specific Sheets**: Select the most relevant sheet containing the target data #### Document Files - **Clear Structure**: Use headings, bullet points, and logical organization - **Relevant Content**: Include only content relevant to the AI task - **Text Format**: Ensure text is selectable (not embedded images) - **File Size**: Keep files under 25MB for optimal processing ### Performance Optimization #### Efficient File Usage ```bash # Good: Specific sheet selection 'fileResourceMetadata={"uuid":{"selectedSheet":"Revenue Data","dataRange":"A1:E100"}}' # Poor: Processing entire workbook 'fileResourceMetadata={"uuid":{"hasMultipleSheets":true}}' ``` #### Smart Prompting ```bash # Good: Specific, actionable prompt 'aiHint=Extract the top 5 revenue-generating products from the sales data and provide a brief analysis of their performance trends' # Poor: Vague prompt 'aiHint=tell me about the sales' ``` ### Error Handling #### File Processing Errors - **Check File Format**: Ensure files are in supported formats - **Verify File Size**: Keep attachments under the size limit - **Test File Access**: Ensure files are not corrupted or password-protected #### AI Generation Errors - **Simplify Prompts**: Break complex requests into smaller, specific tasks - **Provide Context**: Include relevant background information in prompts - **Iterate and Refine**: Test prompts and refine based on output quality ### Security Considerations #### Data Privacy - **Sensitive Information**: Review files for confidential data before upload - **Access Controls**: Ensure proper API token management and access restrictions - **Data Retention**: Understand how uploaded files are processed and stored #### API Security - **Token Protection**: Store API tokens securely in environment variables - **HTTPS Only**: Always use HTTPS for API communication - **Rate Limiting**: Implement appropriate rate limiting for production use ## Error Handling & Troubleshooting ### Common HTTP Status Codes | Status Code | Description | Solution | | ----------- | --------------------- | ----------------------------------------------------------- | | `200` | Success | Request completed successfully | | `400` | Bad Request | Check request format, file attachments, and required fields | | `401` | Unauthorized | Verify API token and authentication headers | | `403` | Forbidden | Check organization ID and API permissions | | `413` | Payload Too Large | Reduce file size or compress attachments | | `422` | Unprocessable Entity | Validate AI prompt, file metadata, and parameters | | `429` | Too Many Requests | Implement rate limiting and retry logic | | `500` | Internal Server Error | Contact support if persistent | ### Common Issues #### File Upload Problems **Symptoms**: Files not processing or upload errors **Solutions**: - Verify file format is supported (Excel, Word, PDF, etc.) - Check file size is under 25MB - Ensure file is not corrupted or password-protected - Validate file metadata JSON format #### AI Generation Quality Issues **Symptoms**: Generated content is not relevant or useful **Solutions**: - Provide more specific and detailed AI prompts - Include relevant context in the aiHint parameter - Use templateId for additional context - Break complex requests into smaller, focused tasks #### Context Recognition Problems **Symptoms**: AI not understanding file content correctly **Solutions**: - Use selectedSheet parameter for Excel files - Specify relevant data ranges in file metadata - Ensure file content is clearly structured - Provide additional context in AI prompts ### Debugging Tips 1. **Start Simple**: Test with basic prompts before adding complexity 2. **Validate Files**: Ensure uploaded files are properly formatted and accessible 3. **Check Metadata**: Verify file metadata JSON is properly structured 4. **Monitor Responses**: Review mimeType and content format in responses 5. **Iterate Prompts**: Refine AI hints based on output quality ## Integration Patterns ### Template Workflow Integration Combine AI variable generation with template processing for complete automation: ```javascript // 1. Generate AI content const aiResponse = await generateAIVariable({ file: "financial-data.xlsx", aiHint: "Generate Q4 performance summary", richText: true, }); // 2. Use in template generation const templateData = { templateId: "quarterly-report-template", variables: [ { name: "Q4 Performance", placeholder: "{Q4Performance}", text: aiResponse.data.text, mimeType: aiResponse.data.mimeType, }, ], }; // 3. Generate final document const deliverable = await generateDeliverable(templateData); ``` ### Batch Processing Pattern Process multiple variables with AI for comprehensive content generation: ```javascript const aiVariables = [ { name: "Executive Summary", hint: "Create executive summary" }, { name: "Financial Analysis", hint: "Analyze financial metrics" }, { name: "Market Insights", hint: "Generate market insights" }, ]; const generatedContent = await Promise.all( aiVariables.map((variable) => generateAIVariable({ file: "company-data.xlsx", aiHint: variable.hint, name: variable.name, }) ) ); ``` ## Next Steps ### Advanced AI Features to Explore 📖 **[Template Generation API →](/docs/TurboDocx-Templating/API-Templates)** 📖 **[Webhook Integration →](/docs/TurboSign/Webhooks)** 📖 **[API Authentication →](/docs/API/turbodocx-api-documentation)** ### Related Documentation - [Variable Types and Formatting](/docs/API/Deliverable-API#variable-object-structure) ## Support Need help with AI-powered variable generation? We're here to help: - **Discord Community**: [Join our Discord server](https://discord.gg/NYKwz4BcpX) for real-time support - **Documentation**: [https://docs.turbodocx.com](https://docs.turbodocx.com) - **AI Examples**: Browse our example gallery for inspiration --- Ready to revolutionize your document workflows with AI-powered content generation? Start creating intelligent, context-aware variables that transform how you build documents! 🤖✨ --- ## Adding a New Product # Adding a New Product to a Quote This guide walks you through adding a new custom product to a sales quote in TurboQuote. You'll learn how to create a product record with category, billing frequency, and pricing details, then insert it as a line item in your quote. ## What You'll Accomplish By the end of this guide, you will have: - 🗂️ **Opened the product catalog** from within a quote - 📦 **Created a new product** with name, category, and billing frequency - 💵 **Set up list price** and optional cost for margin tracking - ➕ **Inserted the product** as a line item in your active quote :::tip Quick Start Promise Adding products to your catalog takes only a moment, and reusable products make every future quote faster to build. 🚀 ::: ## Step 1: Open the Add Product Interface Initiate the process of adding a product line item to the current quote by accessing the product selection interface. This sets the stage for searching, selecting, or creating products to enrich the quote. **Instruction:** - Click the **+ Add Product** button in the top action bar above the quote line items section. ![Open the Add Product interface](/img/turboquote/product-step-01.png) ## Step 2: Begin Creating a New Product Open the new product creation form when the desired product is not available in the existing catalog. This step enables adding a new product record to reuse in the quote and future transactions. **Instruction:** - In the Product Catalog dialog, click the **+ New Product** button located to the right of the search field. ![Begin creating a new product](/img/turboquote/product-step-02.png) ## Step 3: Focus the Product Name Field Set the cursor in the Product Name input field to begin specifying a clear and recognizable name for the new product. **Instruction:** - Click on the **Product Name** text input field near the top of the New Product form. ![Focus the Product Name field](/img/turboquote/product-step-03.png) ## Step 4: Select or Enter a Product Category Identify or create a category for the new product to organize it properly within the catalog for easier selection and reporting. **Instruction:** - Click on the **Category** text field in the New Product form to begin searching or entering a category. ![Select or enter a product category](/img/turboquote/product-step-04.png) ## Step 5: Choose a Category from the Dropdown Assign the new product to a category to align it with similar catalog items and facilitate proper organization. **Instruction:** - Click the desired category option (e.g. **Hardware (Default)**) from the Category dropdown list. ![Choose a category](/img/turboquote/product-step-05.png) ## Step 6: Open the Billing Frequency Dropdown Review or change the billing frequency to ensure it matches the intended sales and revenue model for the new product. **Instruction:** - Click on the **Billing Frequency** dropdown field currently showing **Monthly Recurring**. ![Open the Billing Frequency dropdown](/img/turboquote/product-step-06.png) ## Step 7: Select a Billing Frequency Switch the billing schedule to correspond with the product's sales type. **Instruction:** - Click on the desired option (e.g. **One-Time**) in the Billing Frequency dropdown list. ![Select billing frequency](/img/turboquote/product-step-07.png) ## Step 8: Navigate to the Pricing & Costs Tab Access the section to configure pricing details of the new product after setting general information. **Instruction:** - Click on the **Pricing & Costs** tab in the New Product dialog's tab bar. ![Navigate to Pricing & Costs tab](/img/turboquote/product-step-08.png) ## Step 9: Enter the List Price Focus on the List Price field to input or modify the product's list price amount for quoting. **Instruction:** - Click inside the **List Price** text input field in the Pricing & Costs tab. ![Enter the list price](/img/turboquote/product-step-09.png) ## Step 10: Enter the Optional Cost Value Specify the internal cost of the product to enable accurate margin tracking, if applicable. **Instruction:** - Click on the **Cost (Optional)** input field in the Pricing & Costs tab. ![Enter the optional cost value](/img/turboquote/product-step-10.png) ## Step 11: Submit the New Product Finalize and save the new product record to update the product catalog and make it available to add to the quote. **Instruction:** - Click the blue **Create Product** button at the bottom-right of the New Product dialog. ![Submit the new product](/img/turboquote/product-step-11.png) ## Step 12: Open Additional Options for the Product Access extra settings or detailed information about the new product within the catalog. **Instruction:** - Click on the circular icon button in the top-right corner of the product card. ![Open additional options](/img/turboquote/product-step-12.png) ## Step 13: Insert the Product into the Quote Complete the process by adding the created product as a line item in the active quote. **Instruction:** - Click the blue **+ Insert 1 Product** button at the bottom-right of the Product Catalog dialog. ![Insert the product into the quote](/img/turboquote/product-step-13.png) ## Summary You've successfully added a new custom product to your quote in TurboQuote! Here's a recap of what you accomplished: 1. Opened the product catalog from the quote interface 2. Created a new product with a name and category 3. Configured billing frequency and pricing details 4. Inserted the product as a line item in your active quote Your product is now saved in the catalog for future use and has been added to the current quote. You can continue adding more products or proceed to finalize your quote. --- # Bulk Importing from a Spreadsheet Instead of creating catalog and CRM records one at a time, you can import them in bulk from a CSV or XLSX spreadsheet. This is the fastest way to load an existing catalog exported from your CRM, ERP, or a previous tool. ## What You'll Accomplish By the end of this guide, you will have: - 📤 **Uploaded** a CSV or XLSX file to a TurboQuote import dialog - 🔗 **Mapped** your spreadsheet columns to TurboQuote fields - ✅ **Reviewed** validation results and imported your records - 📥 **Downloaded an error report** for any rows that need fixing :::tip Quick Start Promise Already have your data in a spreadsheet? You can be importing in under a minute. Start by downloading the template so your columns line up automatically. 🚀 ::: ## How Records Link Together Related records connect using the names and codes you already use, so you can build your spreadsheet straight from data you already have: - **Products** point at a category by its **name**. If the category doesn't exist yet, TurboQuote creates it for you. - **Bundles** and **price books** point at products by their **SKU**. - **Companies** carry their **contacts inline**, on the same rows. - **Contacts** are added to the company whose page you opened the importer from, so there is **no company column** to fill in. ## What You Can Import Bulk import is available for each of these record types, from its own list page: | Record type | Where to find it | Button | | --- | --- | --- | | **Products** | TurboQuote → Products | **Import from Spreadsheet** | | **Companies** (with their contacts) | TurboQuote → Companies | **Import from Spreadsheet** | | **Contacts** | A company's Contacts page | **Import from Spreadsheet** | | **Bundles** | TurboQuote → Bundles | **Import from Spreadsheet** | | **Price Books** | TurboQuote → Price Books | **Bulk Create** | | **Categories** | TurboQuote → Categories | **Bulk Create** | The button to open the importer sits next to **New …** on each page. ![The Products page with the Import From Spreadsheet button highlighted in the top-right toolbar](/img/turboquote_bulk_import/01_products_import_button.png) ## Step 1: Upload Your File This walkthrough uses **Products** as the example. Companies, contacts, bundles, price books, and categories follow the same three steps; only the column names differ (see the [Column Reference](#column-reference)). For price books and categories, the button is labeled **Bulk Create** instead of **Import from Spreadsheet**. 1. Click **Import from Spreadsheet** (or **Bulk Create**, for price books and categories) to open the import dialog. 2. (Recommended) Click **Download template** in the top-right of the **Columns** box to get a correctly formatted CSV with the right column headers and an example row. 3. Drag and drop your **CSV** or **XLSX** file onto the upload area, or click to browse. The dialog parses your file and shows the row and column counts plus a preview. - 📦 **Maximum 500 rows** per import. A larger file is rejected before anything is created, with a message like _"Spreadsheet contains 750 rows, which exceeds the maximum of 500. Please reduce the number of rows."_ Split the file into batches and import each one. - 📅 Date columns (for example, a price book's valid-from and valid-to) are read in `YYYY-MM-DD` format. ![The Upload step with the drag-and-drop upload area highlighted; above it, the Columns box lists the required and optional fields and the Download Template button](/img/turboquote_bulk_import/02_upload_step.png) ## Step 2: Map Your Columns TurboQuote auto-detects common column names (for example, `product_name` maps to **Product Name**). Header matching ignores case, spaces, and underscores, so `Product Name`, `product_name`, and `PRODUCTNAME` are all recognized. On the **Map Columns** step you can confirm or change each mapping: - **Required fields** are marked with an asterisk and must be mapped before you can continue. - **Optional fields** can be left unmapped. Sensible defaults are applied (for example, currency defaults to USD). If you used the downloaded template, every column maps automatically. ![The Map Columns step with the Required Fields group highlighted, each required field auto-mapped to a spreadsheet column](/img/turboquote_bulk_import/03_map_columns.png) ## Step 3: Review and Import The **Review & Import** step checks every row **before anything is created**, then sorts your rows into three groups, shown as a colored bar and a summary headline (for example, **"4 of 5 rows will import"**): - ✅ **Import as-is.** The row is clean and imports unchanged. - ⚠️ **Import with changes.** The row still imports, but a bad **optional** value was cleared. For example, a non-numeric `cost`, an unrecognized `currency`, or an out-of-range minimum order quantity is dropped and the rest of the row is kept (currency falls back to the default, USD). - ❌ **Won't import.** A **required** value is missing or invalid, so the whole row is skipped. For example, a product with no name or an invalid billing frequency, or a bundle row missing its unit price. Expand any group to see the affected rows with their row number and the exact reason. When you're happy with the split, click **Import _N_ rows**. The button shows how many will import. TurboQuote creates any missing categories or price-book types first, then imports the eligible rows. ![The Review and Import step showing the three colored groups (import as-is, import with changes, won't import) and the Import 4 Rows button highlighted](/img/turboquote_bulk_import/04_review_import.png) :::tip Importing companies with contacts? A company can span several rows, one per contact, so it works a little differently. See the [worked example](#example-importing-companies-and-contacts) below. ::: ### Adjustments and Error Reports After importing, the headline updates to something like **"4 of 5 rows imported"**, keeping the same three groups. Two downloads help you follow up: - **Download changes.** A report of every row that imported **with changes** (which value was cleared, and why), so you can fill them back in later. - **Download errors.** A report of every row that **didn't import**. Fix those rows in your spreadsheet and re-upload just them. :::tip Why your file rarely fails outright A bad **optional** value never blocks a row. TurboQuote clears just that value and keeps the rest. Only a missing or invalid **required** value skips a row. So even a messy export gets most of your data in, and the change and error reports tell you exactly what to clean up. ::: ## Example: Importing Companies and Contacts The companies importer works the same way as the products importer, but with one key difference: a company can span **several rows**, one per contact. You open it from **TurboQuote → Companies** with the **Import From Spreadsheet** button. ![The Companies page with the Import From Spreadsheet button highlighted in the top-right toolbar](/img/turboquote_bulk_import/05_companies_import_button.png) On the **Map Columns** step, the fields are split into two groups: **Company Fields** (the company-level columns like name, phone, and city) and **Contact Fields** (the per-contact columns like contact name and email). Rows that share the same company name are grouped into one company, and each row adds one contact. ![The Companies Map Columns step with the Company Fields and Contact Fields groups highlighted](/img/turboquote_bulk_import/06_companies_map.png) The **Review & Import** step groups the rows by company before counting, so the headline counts **companies**, not spreadsheet rows. In this example, three spreadsheet rows (two contacts for one company, one for another) become **two companies** — the review reads **"2 of 2 rows will import"** (one per company), and the **Import 2 Companies** button confirms the count before anything is created. ![The Companies Review and Import step showing 2 of 2 rows will import and the Import 2 Companies button highlighted](/img/turboquote_bulk_import/07_companies_review.png) ## Column Reference You only need this section if you're building a spreadsheet by hand. If you started from **Download template**, your headers already match and every column maps automatically, so you can skip ahead to [Tips and Limits](#tips-and-limits). The columns below match what **Download template** produces, so the safest path is always to start from the template. Header names are matched flexibly (case, spaces, and underscores are ignored), and a few common alternates are noted. Anything marked optional can be left out entirely. ### Products (TurboQuote → Products) | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `product_name` | **Required** | Example Product | Also accepts `product`, `name`. | | `billing_frequency` | **Required** | monthly | One of `monthly`, `quarterly`, `annual`, `one-time`. | | `list_price` | **Required** | 99.00 | Number, 0 or greater. | | `category` | **Required** | Software Licenses | Category name. Created automatically if it does not exist. | | `sku` | Optional | SKU-001 | | | `description` | Optional | Product description | | | `internal_notes` | Optional | Internal note | Not shown to customers. | | `cost` | Optional | 50.00 | Number, 0 or greater. Cleared if invalid. | | `min_order_qty` | Optional | 1 | Whole number, 1 or greater. Defaults to 1. | | `currency` | Optional | USD | One of `USD`, `EUR`, `GBP`, `CAD`, `INR`, `AUD`. Defaults to USD. | | `show_in_catalog` | Optional | true | `true` or `false`. Defaults to true. | ### Bundles (TurboQuote → Bundles) Bundle-level columns (repeat the same values on every row of the bundle): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `bundle_name` | **Required** | Starter Pack | Also accepts `bundle`, `name`. Groups the rows of one bundle. | | `category` | **Required** | Bundles | Created automatically if it does not exist. | | `description` | Optional | Everything to get started | | | `bundle_sku` | Optional | BNDL-01 | | | `bundle_discount_type` | Optional | percent | `percent` or `amount`. | | `bundle_discount_percent` | Optional | 10 | Used when the type is `percent`. | | `bundle_discount_amount` | Optional | 50 | Used when the type is `amount`. | | `currency` | Optional | USD | One of `USD`, `EUR`, `GBP`, `CAD`, `INR`, `AUD`. Defaults to USD. | | `show_items_to_end_user` | Optional | false | Defaults to false. | | `show_in_catalog` | Optional | true | Defaults to true. | | `sync_with_products` | Optional | false | Defaults to false. | Per-item columns (one row per bundle item): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `product_sku` | **Required** | SKU-001 | SKU of an existing product. Also accepts `product`. | | `item_unit_price` | **Required** | 99.00 | Number, 0 or greater. | | `item_billing_frequency` | **Required** | monthly | One of `monthly`, `quarterly`, `annual`, `one-time`. | | `item_quantity` | Optional | 1 | Whole number. Defaults to 1. | | `item_discount_type` | Optional | percent | `percent` or `amount`. | | `item_discount_percent` | Optional | 5 | | | `item_discount_amount` | Optional | 10 | | | `item_cost` | Optional | 40.00 | | ### Price Books (TurboQuote → Price Books) Price-book-level columns (repeat the same values on every row of the price book): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `price_book_name` | **Required** | Q1 Enterprise | Also accepts `price_book`, `name`. Groups the rows of one price book. | | `type` | **Required** | Enterprise | Price book type. Created automatically if it does not exist. | | `valid_from` | **Required** | 2026-01-01 | `YYYY-MM-DD`. | | `description` | Optional | First quarter rates | | | `discount_percent` | Optional | 10 | 0 to 100. Defaults to 0. | | `valid_to` | Optional | 2026-12-31 | `YYYY-MM-DD`. Must be after `valid_from`. | | `is_default` | Optional | false | Defaults to false. | | `show_in_quote_builder` | Optional | true | Defaults to true. | Per-product override columns (one row per product): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `product_sku` | **Required** | SKU-001 | SKU of an existing product. Also accepts `product`. | | `product_discount_type` | Optional | percent | `percent` or `amount`. | | `product_discount_percent` | Optional | 15 | | | `product_discount_amount` | Optional | 20 | | ### Categories (TurboQuote → Categories) | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `category_name` | **Required** | Software Licenses | Also accepts `name`, `category`. | The category **type** (Product, Bundle, Price Book, or Industry) comes from which **Bulk Create** button you click on the Categories page, not from a column. Open the importer under the heading you want, and every row is created as that type. ### Companies (TurboQuote → Companies) Company-level columns (repeat the same values on every row of the company): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `company_name` | **Required** | Acme Corp | Also accepts `company`, `name`. Groups the rows of one company. | | `phone` | Optional | +1 555-123-4567 | | | `city` | Optional | New York | | | `state` | Optional | NY | Also accepts `province`. | | `country` | Optional | United States | | | `industry` | Optional | Technology | Must be an existing industry name. See the note below. | Per-contact columns (one row per contact; each company needs at least one): | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `contact_name` | **Required** | Jane Smith | | | `contact_email` | **Required** | jane.smith@acme.com | | | `contact_phone` | Optional | +1 555-987-6543 | | | `contact_title` | Optional | VP of Sales | Also accepts `role`, `position`. | ### Contacts (a company's Contacts page) | Column | Required? | Example | Notes | | --- | --- | --- | --- | | `contact_name` | **Required** | Jane Smith | Also accepts `name`. | | `contact_email` | **Required** | jane.smith@example.com | | | `contact_phone` | Optional | +1 555-123-4567 | Also accepts `tel`. | | `contact_title` | Optional | VP of Sales | Also accepts `role`, `position`. | :::note No company column for contacts The contact importer opens from one company's Contacts page, and every row is added to **that** company. Do not include a company name or ID column. TurboQuote supplies the company for you. ::: ## Grouped Records (Companies, Bundles, Price Books) Some records span **multiple rows** in your spreadsheet: - A **company** with several contacts: repeat the company on one row per contact. - A **bundle** with several items, or a **price book** with several product overrides: repeat the record on one row per item. For these, all rows that share the same record name must repeat the same record-level values (company industry, bundle discount, price book type, and so on). **Only the per-item columns may differ between rows.** If two rows disagree on a record-level value, the import flags a conflict so nothing is created with the wrong value. :::note Categories and types are created for you When you import products, bundles, or price books, any category or price-book type that doesn't exist yet is created automatically before the records are imported. Company **industries** are the exception: they must already exist, so use a recognized industry name. You can pre-create industries in bulk from the **Industry Categories** section of the Categories page. ::: ## Referencing Products by SKU (Bundles and Price Books) Bundle items and price-book overrides each point at a product by its **SKU**, the code shown on each product in your catalog. Put that SKU in the `product_sku` column. TurboQuote resolves the reference for you: - **One match.** That product is used. - **Several products share the SKU.** The **earliest-created** one is used, and the row is flagged under **import with changes** so you can confirm it's the right product. - **No match.** Just that one item or override is dropped (also logged under **import with changes**); the rest of the bundle or price book still imports. :::tip Import your **products first**, then reference them by SKU in your bundle and price-book sheets. The downloaded template's product column is already named `product_sku`. ::: ## Tips and Limits - 📦 **500 rows** per import. Split a larger file into batches. - 🖼️ **Product images** aren't supported during bulk import. Add them to individual products afterward. - 🔖 **Bundles and price books reference products by SKU** in the `product_sku` column. See [Referencing Products by SKU](#referencing-products-by-sku-bundles-and-price-books). Import your products first. - 🧾 Start from **Download template** whenever possible. It guarantees your headers match and every column maps automatically. ## Related - [Adding a New Product](./Adding-a-New-Product.md) - [Creating a New Quote](./Creating-a-New-Quote.md) --- # Creating a New Quote This guide walks you through the complete process of creating a new professional quote using TurboQuote. You'll learn how to initiate a quote, set up a company with contact information, and finalize the quote for your client. ## What You'll Accomplish By the end of this guide, you will have: - 🧾 **Created a new quote** from the TurboDocx home screen - 🏢 **Added a new company record** with industry classification - 👤 **Set up contact details** for the company - ✅ **Named and submitted** your completed quote :::tip Quick Start Promise Creating your first quote takes just a few minutes. Follow along and you'll have a professional, ready-to-send quote in no time! 🚀 ::: ## Step 1: Start Creating a New Quote from the Main TurboDocx Home Screen Begin the process of creating and sending a new professional quote using TurboQuote. The main options section provides the entry point to initiate this workflow, setting up the workspace to add client details, products, and pricing. **Instruction:** - Click on the **Create a Quote** card in the main options section to start building a new quote. ![Start creating a new quote from the home screen](/img/turboquote/step-01.png) ## Step 2: Initiate Quote Creation from the TurboQuote Quotes List Page Access the TurboQuote module directly to begin creating a new quote. This will transition to the quote creation interface where detailed quote information can be provided. **Instruction:** - Click the **+ New Quote** button located in the top-right toolbar of the quotes page. ![Initiate quote creation from the quotes list](/img/turboquote/step-02.png) ## Step 3: Open the Add New Menu on the Quote Creation Page Access options to add new contacts or related items while building the quote. This menu exposes additional creation flows necessary for completing the quote setup. **Instruction:** - Click the purple **Add New** button on the quote page to reveal more creation options. ![Open the Add New menu](/img/turboquote/step-03.png) ## Step 4: Select New Company from the Add New Dropdown Create a new company record to associate with the quote. This ensures the quote is linked to an up-to-date company profile for accurate tracking and communication. **Instruction:** - Click the **New Company** option in the dropdown menu under the **Add New** button. ![Select New Company from dropdown](/img/turboquote/step-04.png) ## Step 5: Focus the Company Name Field in the Create Company Form Begin entering the company's name, which is the primary identifier for the new company record. **Instruction:** - Click on the **Company Name** text input field at the top of the Create Company form to activate text entry. ![Focus the Company Name field](/img/turboquote/step-05.png) ## Step 6: Open Industry Selection Options in Create Company Form Access the interface to select an industry or create a new one, specifying the business category associated with the company. **Instruction:** - Click the icon button at the right side of the **Select or create industry...** field within the Create Company form. ![Open industry selection options](/img/turboquote/step-06.png) ## Step 7: Choose an Industry from the Dropdown Define the company's industry by selecting the appropriate preset industry from the dropdown list. **Instruction:** - Click on the desired industry option (e.g. **Construction (Default)**) in the Industry dropdown list to select it for the company. ![Choose industry from dropdown](/img/turboquote/step-07.png) ## Step 8: Edit the Contact Name Field in the Create Company Form Focus on the primary contact's name field to enter or modify the contact person's full name. **Instruction:** - Click on the **Name** text input field under the **Contact 1** section in the Create Company form to activate the text cursor. ![Focus the contact name field](/img/turboquote/step-08a.png) ![Enter the contact name](/img/turboquote/step-08b.png) ## Step 9: Enter the Contact Email Address Prepare to enter or update the email address for the primary contact, which is essential for communication related to the quote. **Instruction:** - Click on the email address text input field under **Contact 1** in the Create Company form to focus it for editing. ![Enter the contact email address](/img/turboquote/step-09.png) ## Step 10: Submit the Create Company Form Complete the creation of the new company record along with its associated contact details by submitting the form. **Instruction:** - Click the **Create Company** button located at the bottom-right of the modal to submit the form. ![Submit the Create Company form](/img/turboquote/step-10.png) ## Step 11: Select the Contact Card for the New Quote Associate the newly created contact with the quote by selecting their contact card. **Instruction:** - Click on the contact card labeled with the contact's name to select this contact for the quote. ![Select the contact card](/img/turboquote/step-11.png) ## Step 12: Submit the Completed Quote Finalize and create the new quote record by submitting the detailed quote information after linking company and contact. **Instruction:** - Click the blue **Create Quote** button at the bottom-right of the Quote Details dialog to submit and create the quote. ![Submit the completed quote](/img/turboquote/step-12.png) ## Step 13: Name Your Quote Name the quote appropriately to identify it clearly for future reference and client communication. **Instruction:** - Click on the **Quote Name** text input field at the top of the Quote Details modal to begin typing the quote name. ![Focus the quote name field](/img/turboquote/step-13a.png) ![Enter the quote name](/img/turboquote/step-13b.png) ## Summary You've successfully created a new quote in TurboQuote! Here's a recap of what you accomplished: 1. Initiated a new quote from the TurboDocx home screen or TurboQuote quotes list 2. Created a new company record with industry classification 3. Added contact details including name and email 4. Selected the contact for the quote and submitted the final quote Your quote is now ready for you to add line items, pricing, and any additional details before sending it to your client. --- # How to Configure Quote Numbering This guide walks you through setting up a custom quote number for your organization. You choose the format (prefix, year, month, and how many digits), pick the starting number, and decide when the count resets. A live preview shows the exact number your next quote will receive. ## What You'll Accomplish By the end of this guide, you will have: - ⚙️ **Opened Quote numbering** in your organization Settings - 🔤 **Built a quote number format** with a prefix, year, month, and digit padding - 🔢 **Set the starting number** and chosen when the count resets - 👀 **Previewed** the next quote number live - 💾 **Saved** your new numbering scheme :::tip Who can do this Quote numbering is an organization-wide setting, so you need an **Administrator** role. Changes apply to every **new** quote created after you save — existing quotes keep their numbers. ::: ## Step 1: Open your organization Settings From anywhere in TurboDocx, open the user menu from your avatar at the bottom of the left sidebar, then select **Settings**. **Instruction:** Click your **User Avatar**, then click **Settings**. ![User menu open with the Settings option highlighted](/img/quote-numbering/01-open-settings.png) ## Step 2: Open Quote numbering The Settings page has a menu down its left side, grouped into sections. The quote number options live on the **Quote numbering** page, under **Quotes**. **Instruction:** In the left menu, click **Quote numbering**. ![Settings menu with Quote numbering under Quotes highlighted](/img/quote-numbering/02-quote-numbering-link.png) ## Step 3: Build your number format The **Format** section controls what a quote number looks like. Fill in the parts you want: - **Prefix** — text that starts every number (for example, `Q`). - **Separator** — the character placed between each part (for example, `-`). - **Suffix** — optional text added to the end. - **Year** — choose **None**, **2-digit** (`26`), or **4-digit** (`2026`). - **Month** — turn on **Include month (2-digit)** to add the month (for example, `07`). - **Number digits** — how many digits the running count is padded to (for example, **5 digits** shows `00001`). **Instruction:** Set the **Prefix**, **Separator**, and **Suffix**, then choose your **Year**, **Month**, and **Number digits** options. ![The Format section of Quote Numbering highlighted, showing prefix, separator, suffix, year, month, and number digits](/img/quote-numbering/03-format-section.png) ## Step 4: Set the starting number and reset cadence The **Numbering** section controls the running count. - **Start number** — the first sequence number to use (for example, `1`). - **Reset count** — choose **Never** (the count keeps climbing forever), **Yearly** (it restarts at your start number each new year), or **Monthly** (it restarts each new month). **Instruction:** Enter a **Start number**, then choose a **Reset count** of Never, Yearly, or Monthly. ![The Numbering section with the start number and reset count options highlighted](/img/quote-numbering/04-numbering-section.png) :::note Match the reset to your format If you reset **Monthly**, include the **Month** in your format (Step 3) so numbers stay unique across months — otherwise a fresh `00001` could repeat each month. ::: ## Step 5: Check the live preview As you change the format, the **Preview · Next Quote Number** box updates instantly. It shows the exact number your next quote will get, followed by the two after it. **Instruction:** Read the **Preview** to confirm the next quote number looks the way you want. ![The live preview showing the next quote number QT-2026-00001 highlighted](/img/quote-numbering/05-live-preview.png) ## Step 6: Save your settings When the preview looks right, save. The **Save** button becomes active as soon as you make a change. **Instruction:** Click **Save**. ![The Save button highlighted](/img/quote-numbering/06-save.png) :::tip That's it Every new quote your organization creates from now on uses this format and picks up the next number in the sequence automatically. ::: --- # How to Set Your Quote Sender Information Every quote shows a **"Prepared by"** block with a name, phone, and email so your customer knows who sent it. This guide walks you through setting that information once for your whole organization, so it appears consistently on every quote — no matter who creates or sends it. ## What You'll Accomplish By the end of this guide, you will have: - ⚙️ **Opened your organization's quote template settings** - ✍️ **Set a Sender Name, Sender Phone, and Sender Email** - 💾 **Saved your changes** - 👀 **Confirmed** the new information appears in the "Prepared by" preview :::tip Who can do this Quote sender information is an organization-wide setting, so you need an **Administrator** role. Once saved, it applies to every quote — including quotes created through the API, SDKs, or n8n, which have no mailbox of their own and rely entirely on this setting. ::: ## Step 1: Open your organization Settings From anywhere in TurboDocx, open the user menu from your avatar at the bottom of the left sidebar, then select **Settings**. **Instruction:** Click your **User Avatar**, then click **Settings**. ![User menu open with the Settings option highlighted](/img/quote-sender-settings/01-user-avatar.png) ![Settings option highlighted in the open user menu](/img/quote-sender-settings/02-settings-menu-item.png) ## Step 2: Open Branding and formatting The Settings page has a menu down its left side, grouped into sections. Sender information lives on the **Branding and formatting** page, under **Documents**. **Instruction:** In the left menu, click **Branding and formatting**. ![Settings menu with Branding and formatting under Documents highlighted](/img/quote-sender-settings/03-branding-and-formatting-link.png) ## Step 3: Switch to the TurboQuote tab Branding and formatting has two tabs at the top — one for TurboDocx documents, one for TurboQuote. Sender information for quotes lives under the TurboQuote tab. **Instruction:** Click the **TurboQuote** tab. ![The TurboQuote tab highlighted on the Branding and formatting page](/img/quote-sender-settings/04-turboquote-settings-tab.png) ## Step 4: Fill in your Sender Information Scroll down to the **Sender Information** section. This is exactly what appears as "Prepared by" on your quotes, shown live in the **Quote Preview** — beside the form on wide screens, or further down the page on smaller ones. - **Sender Name** — the name your customers see (for example, your own name or a team name). - **Sender Phone** — an optional contact number. - **Sender Email** — the reply-to address customers use to reach you. **Instruction:** Enter your **Sender Name**, **Sender Phone**, and **Sender Email**. ![Sender Name, Sender Phone, and Sender Email fields highlighted](/img/quote-sender-settings/05-sender-information-fields.png) :::note API and integration quotes need this too A quote created through the API, an SDK, or an n8n workflow has no mailbox of its own — its "Prepared by" **email** can only come from this template. If it's not set, those calls are rejected with `400 SenderEmailRequired` until you configure it here. ::: ## Step 5: Save your changes The **Save Changes** button for this section becomes active as soon as you make a change. **Instruction:** Click **Save Changes**. ![Save Changes button highlighted under Sender Information](/img/quote-sender-settings/06-save-sender-info.png) Once saved, the button greys out again and the **Quote Preview** reflects your new Sender Name, Phone, and Email under "Prepared by." ![Sender Information saved, reflected in the Prepared by preview](/img/quote-sender-settings/07-saved-confirmation.png) :::tip That's it Every quote — created in the app, through the API, an SDK, or n8n — now shows this Sender Information as "Prepared by." See [Prepared By & Sender Identity](/docs/TurboQuote/Prepared-By-and-Sender-Identity) for exactly how that resolution works and what happens for API-created quotes. ::: --- ## Prepared By & Sender Identity # "Prepared By" & Sender Identity Every quote shows a **"Prepared by"** block — a name and email that tells your customer who the quote is from. This page explains where those values come from, and how to make sure they are correct for quotes created in the UI **and** through the API, SDKs, or n8n. ## Where "Prepared by" comes from TurboQuote resolves the "Prepared by" name and email in this order: 1. **Your organization's quote template.** If the template has a **Sender Name** and **Sender Email** configured, those are used on every quote — this is the recommended setup, because it keeps every quote consistent regardless of who created it. 2. **The quote's creator.** If the template has no sender fields set, the quote falls back to the name and email of the person (or integration) that created the quote. The name and email always come from the **same source**, so they can never disagree — the person downloading or sending the quote never changes what the customer sees. :::tip Set it once on your quote template Configure a **Sender Name**, **Sender Email**, and optional **Sender Phone** on your organization's quote template. These appear as the "Prepared by" block on every quote and give you one consistent, branded sender identity across your whole team. See [How to Set Your Quote Sender Information](/docs/TurboQuote/How-to-Set-Your-Quote-Sender-Information) for the step-by-step walkthrough. ::: ## Quotes created through the API, SDKs, or n8n When a quote is created by an **API key** — whether directly via the API, through one of the [SDKs](../SDKs/index.md), or from an n8n workflow — there is an important difference: **an API key has no mailbox of its own.** - The **"Prepared by" name** for an API-created quote resolves to the **name of the API key** (e.g. "Acme Billing Integration"), so it is meaningful and never a generic placeholder such as "API Service User". - The **"Prepared by" email** can only come from your **quote template**, because the API key has no email address to fall back to. :::note There is no per-request sender field on a quote Unlike TurboSign — where `senderEmail` is a **required field on every signature request body** — the TurboQuote API has **no `senderEmail` field** on create, update, or send. The sender is always resolved server-side from your organization's quote template (Quote Settings). This is the one place to change it. ::: Because of this, **an API-key call that needs a sender requires a sender email on your quote template.** If the template has no sender email set, the request is rejected: ``` HTTP 400 — SenderEmailRequired ``` This applies to all four sender-resolving operations: | Operation | Endpoint | | :--- | :--- | | Create a quote | `POST /v1/quotes` | | Duplicate a quote | `POST /v1/quotes/:id/duplicate` | | Send a quote | `POST /v1/quotes/:id/send` and `POST /v1/quotes/:id/send-with-deliverable` | | Handle an expired sent quote | `POST /v1/quotes/:id/handle-expired-sent` | A companion error, **`SenderNameRequired` (HTTP 400)**, is returned when no sender *name* can be resolved — set a **Sender Name** on the quote template (or give the API key a name). The error message tells you exactly how to fix it: set a sender email on your organization's quote template. Once configured, every subsequent create, duplicate, send, and expiry-handling call resolves cleanly. :::info Duplicating attributes the copy to whoever duplicated it When you duplicate a quote, the copy is attributed to **the caller who ran the duplicate**, not to the original quote's creator. Duplicating with an API key therefore produces a quote whose "Prepared by" resolves through that API key (and your template), not through the original author. ::: :::info This only applies to API-key callers Quotes you create in the **TurboDocx web app** are never blocked — your own name and email are the fallback when the template has no sender fields. The requirement above exists only for API keys, which have no mailbox to fall back to. ::: ## Reading the resolved identity in code When you fetch a **single quote** — `GET /v1/quotes/:id` — the resolved "Prepared by" identity is returned as a **`preparedBy`** object alongside the quote: ```json { "data": { "result": { "id": "…", "name": "Acme Q3 Proposal", "…": "…" }, "preparedBy": { "name": "Acme Billing Integration", "email": "billing@acme.com" } } } ``` Each SDK's `getQuote` folds `preparedBy` onto the returned quote for you (see the [SDK guides](../SDKs/index.md)). Both fields are optional — `email` may be absent for an API-created quote whose template has no sender email — so render a placeholder for a missing value. :::caution `preparedBy` is only on the single-quote fetch `preparedBy` is returned by **`GET /v1/quotes/:id` only**. It is **not** included on `POST /v1/quotes` (create), `POST /v1/quotes/:id/duplicate`, or `GET /v1/quotes` (list). If you create a quote and need the resolved identity for display, fetch the quote by ID afterwards. ::: **Prefer `preparedBy` over the quote's `creator` field for any customer-facing display.** The `creator` is the raw authorship row for the quote, which for an API-created quote is the synthetic service account backing the API key — `preparedBy` is the resolved, customer-safe identity. --- # Renaming a Quote A quote's name is not just a label in your quotes list. When you send a quote for signature, that name is **copied onto the signature document** — it is what your signer sees in the request email and on the signed PDF. This guide shows you where to rename a quote, and when you can still do it. ## What You'll Accomplish By the end of this guide, you will know how to: - ✏️ **Rename a quote in place** from the quote page - 📋 **Rename a quote from the quotes list**, without opening it - 🧾 **Clean up a duplicated quote's name** before it reaches a client - ⏱️ **Recognise when it is too late** to rename :::caution Rename before you send Renaming is only available while a quote is a **draft**. Once you send a quote, its name is locked — the signature request has already gone out under that name. If you spot a wrong name after sending, void the request and send a new one. ::: ## Why the name matters Two ordinary actions produce a name you probably don't want a client to read: | How you got here | The name you end up with | |---|---| | You **duplicated** a quote to reuse its line items | `Copy of Acme Q3 Proposal` | | You duplicated the copy again | `Copy of Copy of Acme Q3 Proposal` | If you send either of those without renaming, `Copy of Acme Q3 Proposal` is the title on the signature request your client receives. Renaming takes a couple of seconds and happens before the send, so it never reaches them. :::tip Renewals don't do this Renewing an expired quote is different from duplicating one. A renewal is the **same deal re-issued**, so it keeps the original quote's name — no `Copy of` prefix, no matter how many times you renew it. Only an explicit duplicate adds the prefix. ::: ## Option 1: Rename from the quote page Use this when you already have the quote open — for example, right after duplicating it. **Instruction:** - Open the quote you want to rename. - Click the **quote name** in the page header. It turns into an editable text field. - Type the new name. - Press **Enter**, or click anywhere outside the field, to save. The name saves immediately — there is no separate Save button. If the save fails, the field stays open with your text still in it so you can try again without retyping. ## Option 2: Rename from the quotes list Use this when you want to fix a name without opening the quote. **Instruction:** - Go to your **quotes list**. - Find the quote you want to rename. - Click the **⋮ (three dots)** action menu on that quote's card or row. - Choose **Rename**. - Type the new name in the dialog and click **Rename**. ## Naming rules A few rules apply wherever you rename: | Rule | What happens | |---|---| | Blank names are not allowed | A name of only spaces is rejected — a quote must have a real name | | Extra spaces are removed | ` Acme Q3 ` is saved as `Acme Q3` | | Spaces inside the name are kept | `Acme Corp` keeps both spaces — only the ends are trimmed | | Maximum length is 255 characters | Longer names are rejected; the check runs after trimming | ## When renaming is unavailable The **Rename** option is visible but greyed out once a quote leaves draft status — after it has been sent, accepted, declined, or voided. This is deliberate: the signature request has already been delivered under the current name, and quietly changing the title of a document someone has already received would be misleading. If you need a different name on a quote you have already sent: 1. **Void** the existing signature request. 2. **Duplicate** the quote. 3. **Rename** the new draft. 4. **Send** the renamed draft. ## Summary Here's what to remember: 1. A quote's name becomes the **signature document's name** when you send it 2. Rename from the **quote page header** or the **⋮ menu** in the quotes list 3. Duplicates arrive as `Copy of …` — rename them before sending 4. Renewals keep the original name and never accumulate `Copy of` prefixes 5. Renaming is **draft-only**; after sending, void and re-send instead Your quote now carries a name you're happy for a client to read. --- # TurboSign Bulk API Integration Send documents for electronic signature at scale. The TurboSign Bulk API allows you to process hundreds or thousands of signature requests in organized batches, with comprehensive tracking and management capabilities. ![TurboSign Single-Step Workflow](/img/turbosign/api/bulk-api.jpg) ## Overview The Bulk API is designed for high-volume signature collection scenarios. Instead of sending documents one at a time, you can create batches that process multiple signature requests efficiently. ### What is Bulk Sending? Bulk sending allows you to send the **same document to multiple recipients** in a single batch operation. Each recipient gets their own personalized copy with customized signature fields and recipient information. **Common Use Cases:** - **Employment Contracts**: Send offer letters or contracts to multiple new hires - **Client Agreements**: Distribute service agreements to hundreds of clients - **Policy Updates**: Send updated terms or policies requiring acknowledgment - **Event Registrations**: Collect signatures from event participants - **Real Estate**: Send disclosure forms to multiple buyers or sellers - **Insurance**: Distribute policy documents requiring signatures ### Key Benefits - **Efficiency**: Create one batch instead of hundreds of individual requests - **Cost Effective**: Batch processing optimizes credit usage - **Tracking**: Monitor progress across all jobs in a batch - **Management**: Cancel, retry, or review jobs as needed - **Scalability**: Handle thousands of signature requests seamlessly - **Organization**: Group related requests together logically ## Key Concepts Understanding these core concepts will help you effectively use the Bulk API. ### Batches A **batch** is a container for multiple document send operations. All documents in a batch share the same source file (PDF, template, or deliverable) but can have different recipients and field configurations. ### Jobs A **job** represents a single document send operation within a batch. Each job corresponds to one set of recipients receiving the shared document. ### Source Types The Bulk API supports four source types for your documents: | Source Type | Description | When to Use | | --------------- | ------------------------------ | ---------------------------------- | | `file` | Upload PDF directly | One-time documents, custom content | | `deliverableId` | Reference existing deliverable | Documents generated from templates | | `templateId` | Use template directly | Reusable document structures | | `fileLink` | URL to document | Documents hosted externally | ### Batch-Level vs Document-Level Configuration **Batch-Level:** - Source file (all jobs use same document) - Default metadata (documentName, documentDescription) - Sender information (`senderEmail` is required; `senderName` defaults to your API key's name) **Document-Level:** - Recipients (unique per job) - Signature fields (customized per job) - Optional metadata overrides per job ## Prerequisites Before using the Bulk API, ensure you have: - **API Access Token**: Bearer token for authentication - **Organization ID**: Your organization identifier - **Bulk Sending Enabled**: Feature must be enabled for your organization - **Sufficient Credits**: Credits will be reserved when creating batches ### Getting Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or retrieve your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ![TurboSign API Key](/img/turbosign/api/api-key.png) ![TurboSign Organization ID](/img/turbosign/api/org-id.png) ## Authentication All TurboSign Bulk API requests require authentication using a Bearer token: ```http Authorization: Bearer YOUR_API_TOKEN ``` Additional required headers for all requests: ```http x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` :::tip Authentication These authentication headers are identical to the single-step API. If you're already using the single-step API, you can use the same credentials for bulk operations. ::: ## API Endpoints The Bulk API provides four endpoints for complete batch management: | Endpoint | Method | Purpose | | --------------------------------------- | ------ | --------------------------------- | | `/turbosign/bulk/ingest` | POST | Create and ingest a new batch | | `/turbosign/bulk/batches` | GET | List all batches for organization | | `/turbosign/bulk/batch/:batchId/jobs` | GET | List jobs within a specific batch | | `/turbosign/bulk/batch/:batchId/cancel` | POST | Cancel a batch and pending jobs | --- ## Endpoint 1: Ingest Bulk Batch Create a new batch and ingest multiple document send operations. This is the primary endpoint for initiating bulk sending. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/bulk/ingest ``` ### Headers ```http Content-Type: multipart/form-data Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Request Body (multipart/form-data) ⚠️ **Important**: `documents` must be sent as a JSON string in form-data | Field | Type | Required | Description | | ------------------- | ----------------- | ------------- | ----------------------------------------------------------------- | | sourceType | String | **Yes** | Source type: `file`, `deliverableId`, `templateId`, or `fileLink` | | file | File | Conditional\* | PDF file upload (required when sourceType is `file`) | | sourceValue | String (UUID/URL) | Conditional\* | UUID or URL (required for deliverableId/templateId/fileLink) | | batchName | String | **Yes** | Name for this batch (max 255 chars) | | documentName | String | No | Default document name for all jobs (max 255 chars) | | documentDescription | String | No | Default description for all jobs (max 1000 chars) | | senderName | String | No | Name of sender (max 255 chars). Defaults to your API key's name. | | senderEmail | String (email) | **Yes** | Batch-level reply-to address and the sender recorded in the audit trail | | documents | String (JSON) | **Yes** | JSON string array of document objects | :::warning senderEmail is required `senderEmail` is required at the batch level for API/SDK bulk sends — an API key has no mailbox of its own, so TurboDocx **rejects the batch (HTTP 400)** without it rather than sending from an unmonitored address. `senderName` is optional and defaults to your API key's name. ::: \* **File Source**: Must provide exactly ONE of: - `file` (upload) when sourceType is `file` - `sourceValue` (UUID) when sourceType is `deliverableId` or `templateId` - `sourceValue` (URL) when sourceType is `fileLink` ### Documents Array Format The `documents` parameter must be a JSON string containing an array of document objects: ```json [ { "recipients": [ { "name": "John Smith", "email": "john.smith@company.com", "signingOrder": 1 } ], "fields": [ { "recipientEmail": "john.smith@company.com", "type": "signature", "page": 1, "x": 100, "y": 200, "width": 200, "height": 80, "required": true }, { "recipientEmail": "john.smith@company.com", "type": "date", "page": 1, "x": 100, "y": 300, "width": 150, "height": 30, "required": true } ], "documentName": "Employment Contract - John Smith", "documentDescription": "Please review and sign your employment contract" }, { "recipients": [ { "name": "Jane Doe", "email": "jane.doe@company.com", "signingOrder": 1 } ], "fields": [ { "recipientEmail": "jane.doe@company.com", "type": "signature", "page": 1, "x": 100, "y": 200, "width": 200, "height": 80, "required": true } ], "documentName": "Employment Contract - Jane Doe" } ] ``` **Document Object Properties:** | Property | Type | Required | Description | | ------------------- | ------ | -------- | ----------------------------------------------------------- | | recipients | Array | **Yes** | Array of recipient objects (same format as single-step API) | | fields | Array | **Yes** | Array of field objects (same format as single-step API) | | documentName | String | No | Override batch-level document name for this job | | documentDescription | String | No | Override batch-level description for this job | :::tip Field Types All field types from the single-step API are supported: `signature`, `initial`, `date`, `full_name`, `first_name`, `last_name`, `title`, `company`, `email`, `text`, `checkbox`. See the [single-step API documentation](/docs/TurboSign/API-Signatures) for details. ::: :::note Conditional (IF/THEN) fields are supported Bulk documents use the same field format as the single-step API, so **conditional fields work in bulk batches too**. Add a `metadata.fieldKey` to a controlling checkbox and a `metadata.conditional` rule (`controllingFieldKey`, `operator`, `action`) to each dependent field within the same job's `fields` array. See [Conditional (IF/THEN) Fields](/docs/TurboSign/Conditional-Fields) for the full contract, the `show` vs. `unlock` behavior, and the `InvalidConditionalRule` / fail-open rules. ::: :::note Optional fields are supported Set `required: false` on a field in `documents[].fields` to let the signer leave it blank. The same rules as the single-step API apply: `signature` and `initial` fields cannot be optional, `required` must be a JSON boolean (`null` counts as not set, so the field stays required), and each recipient needs at least one required, editable field. A document that breaks a rule fails the batch with `BulkValidationFailed`, and the failing document is listed in the error details with its specific code (`OptionalNotSupported`, `InvalidFieldRequired`, or `NoEditableFieldsForRecipient`). See [Optional Fields](/docs/TurboSign/API-Signatures#optional-fields) for details. ::: ### Response (Success) ```json { "success": true, "batchId": "550e8400-e29b-41d4-a716-446655440000", "batchName": "Q4 Employment Contracts", "totalJobs": 50, "status": "pending", "message": "Batch created successfully with 50 jobs. Processing will begin shortly." } ``` ### Response (Error) ```json { "error": "Validation failed for 3 documents", "code": "BulkValidationFailed", "data": { "invalidDocuments": [ { "index": 0, "errors": ["recipients[0].email is required"] }, { "index": 5, "errors": ["fields[0].recipientEmail does not match any recipient"] } ] } } ``` ### Code Example --- ## Endpoint 2: List All Batches Retrieve all batches for your organization with pagination and filtering capabilities. ### Endpoint ```http GET https://api.turbodocx.com/turbosign/bulk/batches ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Query Parameters | Parameter | Type | Required | Description | | --------- | ------------------ | -------- | ----------------------------------------------------------------------------- | | limit | Number | No | Number of batches to return (default: 20, max: 100) | | offset | Number | No | Number of batches to skip (default: 0) | | query | String | No | Search query to filter batches by name | | status | String or String[] | No | Filter by status: `pending`, `processing`, `completed`, `failed`, `cancelled` | | startDate | String (ISO 8601) | No | Filter batches created on or after this date | | endDate | String (ISO 8601) | No | Filter batches created on or before this date | ### Response ```json { "data": { "batches": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Q4 Employment Contracts", "status": "completed", "totalJobs": 50, "succeededJobs": 48, "failedJobs": 2, "pendingJobs": 0, "createdOn": "2025-01-15T10:30:00Z", "updatedOn": "2025-01-15T14:25:00Z", "metadata": {} }, { "id": "660e8400-e29b-41d4-a716-446655440001", "name": "Client Agreements - January 2025", "status": "processing", "totalJobs": 100, "succeededJobs": 75, "failedJobs": 5, "pendingJobs": 20, "createdOn": "2025-01-16T09:00:00Z", "updatedOn": "2025-01-16T12:30:00Z", "metadata": {} } ], "totalRecords": 2 } } ``` ### Code Example --- ## Endpoint 3: List Jobs in Batch Retrieve all jobs within a specific batch with detailed status information. ### Endpoint ```http GET https://api.turbodocx.com/turbosign/bulk/batch/:batchId/jobs ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | --------- | ------------- | -------- | --------------- | | batchId | String (UUID) | **Yes** | ID of the batch | ### Query Parameters | Parameter | Type | Required | Description | | --------- | ------------------ | -------- | ------------------------------------------------ | | limit | Number | No | Number of jobs to return (default: 20, max: 100) | | offset | Number | No | Number of jobs to skip (default: 0) | | status | String or String[] | No | Filter by job status | | query | String | No | Search query to filter jobs | ### Response ```json { "data": { "batchId": "550e8400-e29b-41d4-a716-446655440000", "batchName": "Q4 Employment Contracts", "batchStatus": "completed", "jobs": [ { "id": "770e8400-e29b-41d4-a716-446655440000", "batchId": "550e8400-e29b-41d4-a716-446655440000", "documentId": "880e8400-e29b-41d4-a716-446655440000", "documentName": "Employment Contract - John Smith", "status": "SUCCEEDED", "recipientEmails": ["john.smith@company.com"], "attempts": 0, "errorCode": null, "errorMessage": null, "createdOn": "2025-01-15T10:30:00Z", "updatedOn": "2025-01-15T11:45:00Z", "lastAttemptedAt": "2025-01-15T11:45:00Z" }, { "id": "770e8400-e29b-41d4-a716-446655440001", "batchId": "550e8400-e29b-41d4-a716-446655440000", "documentId": null, "documentName": "Employment Contract - Jane Doe", "status": "FAILED", "recipientEmails": ["jane.doe@company.com"], "attempts": 1, "errorCode": "INVALID_EMAIL", "errorMessage": "Invalid recipient email address", "createdOn": "2025-01-15T10:30:00Z", "updatedOn": "2025-01-15T10:31:00Z", "lastAttemptedAt": "2025-01-15T10:31:00Z" } ], "totalJobs": 50, "totalRecords": 50, "succeededJobs": 48, "failedJobs": 2, "pendingJobs": 0 } } ``` ### Code Example --- ## Endpoint 4: Cancel Batch Cancel a batch and stop all pending jobs. Already completed jobs are not affected. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/bulk/batch/:batchId/cancel ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | --------- | ------------- | -------- | ------------------------- | | batchId | String (UUID) | **Yes** | ID of the batch to cancel | ### Response ```json { "success": true, "message": "Batch cancelled. 20 job(s) stopped, 20 credit(s) refunded.", "batchId": "550e8400-e29b-41d4-a716-446655440000", "cancelledJobs": 20, "succeededJobs": 30, "refundedCredits": 20, "status": "cancelled" } ``` ### Code Example :::warning Cancellation Notice - Only pending and processing jobs will be cancelled - Already succeeded jobs are not affected - Credits are refunded only for cancelled jobs - Cancellation cannot be undone ::: --- ## Best Practices ### Batch Size **Recommended batch sizes:** - Small batches (10-50 jobs): Faster processing, easier to monitor - Medium batches (50-200 jobs): Balanced efficiency - Large batches (200-1000 jobs): Maximum efficiency for large-scale operations :::tip Batch Size Strategy For first-time use, start with smaller batches (10-20 jobs) to validate your configuration. Once confident, scale up to larger batches. ::: ### Error Handling **Always check for validation errors:** ```javascript if (response.data.code === "BulkValidationFailed") { const errors = response.data.data.invalidDocuments; errors.forEach((err) => { console.log(`Document ${err.index}: ${err.errors.join(", ")}`); }); } ``` **Monitor job failures:** - Regularly check job status - Review error messages for failed jobs - Fix issues and retry with corrected data ### Credit Management **Credits are reserved when batch is created:** - 1 credit per recipient per job - Credits are consumed as jobs succeed - Credits are refunded for failed or cancelled jobs **Example**: Batch with 50 jobs, 1 recipient each = 50 credits reserved ### Performance Tips 1. **Use templateId or deliverableId**: Faster than file uploads 2. **Batch similar documents**: Group documents with similar configurations 3. **Validate data before submission**: Prevent validation errors 4. **Use pagination**: When listing large result sets 5. **Implement webhooks**: For real-time status updates (see [Webhooks documentation](/docs/TurboSign/Webhooks)) ### Data Validation **Before creating a batch, validate:** - All email addresses are valid - Field positioning doesn't overlap - RecipientEmail in fields matches actual recipients - Required fields are present --- ## Error Codes Common error codes you may encounter: | Code | Description | Solution | | ---------------------- | ------------------------------------------- | --------------------------------------- | | `FileUploadRequired` | No file provided when sourceType is 'file' | Include file in form-data | | `SourceValueRequired` | No sourceValue for deliverableId/templateId | Provide valid UUID | | `InvalidSourceValue` | sourceValue provided with file upload | Remove sourceValue when using file | | `InvalidDocumentsJSON` | Documents JSON is malformed | Validate JSON structure | | `BulkValidationFailed` | One or more documents failed validation | Check data.invalidDocuments for details | | `DeliverableNotFound` | deliverableId doesn't exist | Verify deliverable exists | | `TemplateNotFound` | templateId doesn't exist | Verify template exists | | `BatchNotFound` | batchId doesn't exist | Check batch ID | | `BatchNotCancellable` | Batch already completed | Cannot cancel completed batches | | `InsufficientCredits` | Not enough credits for batch | Add credits to organization | | `OptionalNotSupported` | Per document, inside `BulkValidationFailed`: `required: false` on a signature or initial field | Remove `required` or set it to `true` | | `InvalidFieldRequired` | Per document, inside `BulkValidationFailed`: `required` is not a boolean or `null` (e.g. `"false"`) | Send `true` or `false` as a JSON boolean | | `NoEditableFieldsForRecipient` | Per document, inside `BulkValidationFailed`: a recipient has only optional or read-only fields | Give the recipient a required, editable field such as a signature | --- ## Limits and Quotas ### Batch Limits | Limit | Value | | -------------------------- | -------------- | | Maximum jobs per batch | 1,000 | | Maximum document size | 25 MB | | Maximum recipients per job | 50 | | Maximum fields per job | 100 | | Maximum batch name length | 255 characters | ### Rate Limits | Endpoint | Rate Limit | | ------------ | ------------------ | | Ingest batch | 10 requests/minute | | List batches | 60 requests/minute | | List jobs | 60 requests/minute | | Cancel batch | 10 requests/minute | :::warning Rate Limiting If you exceed rate limits, you'll receive a `429 Too Many Requests` response. Implement exponential backoff in your retry logic. ::: ### Credit Consumption - **1 credit per recipient**: Each recipient in each job consumes 1 credit - **Reserved on creation**: Credits are reserved when batch is created - **Consumed on success**: Credits are consumed when job succeeds - **Refunded on failure/cancellation**: Credits are refunded for failed or cancelled jobs **Example Calculations:** - 100 jobs × 1 recipient = 100 credits - 50 jobs × 2 recipients = 100 credits - 200 jobs × 1 recipient = 200 credits --- ## Troubleshooting ### Batch Stuck in "Processing" **Possible causes:** - High system load (normal during peak times) - Large batch size - Complex document processing **Solutions:** - Wait for processing to complete (usually 1 hour ### Jobs Failing with "Invalid Email" **Cause**: Email validation failed **Solutions:** - Verify email format - Check for typos - Ensure no special characters in local part ### Credits Not Refunded **Cause**: Jobs succeeded before cancellation **Solution**: Credits are only refunded for cancelled jobs. Already succeeded jobs consume credits. ### "DeliverableNotFound" Error **Possible causes:** - Wrong organization ID - Deliverable deleted - Incorrect deliverable ID **Solutions:** - Verify deliverable exists in your organization - Check organization ID matches - Generate new deliverable if needed --- ## Next Steps Now that you understand the Bulk API, you might want to: - **Set up webhooks**: Get real-time notifications for batch and job status changes. See [Webhooks documentation](/docs/TurboSign/Webhooks) - **Explore single-step API**: For individual document sending. See [Single-Step API documentation](/docs/TurboSign/API-Signatures) - **Review field types**: Learn about all available signature field types - **Integrate with your app**: Build bulk sending into your application workflow --- _Have questions? Contact our support team at support@turbodocx.com_ --- # TurboSign API Integration This comprehensive guide walks you through the TurboSign single-step API integration. Learn how to programmatically upload documents, configure recipients, set up signature fields, and send documents for electronic signatures using a single, streamlined API call. ![TurboSign API Integration Overview](/img/turbosign/api/api-illustration.png) ## Overview The TurboSign API provides a simplified single-step process to prepare documents for electronic signatures. Instead of multiple API calls, you can now accomplish everything in one request. ### Two Endpoint Options TurboSign offers two single-step endpoints to fit different workflows: 1. **Prepare for Review** - Upload and get preview URL (no emails sent) 2. **Prepare for Signing** - Upload and send immediately (emails sent) ![TurboSign Single-Step Workflow](/img/turbosign/api/types.svg) ### Key Features - **Single API Call**: Upload document, add recipients, and configure fields in one request - **RESTful API**: Standard HTTP methods with multipart/form-data - **Bearer Token Authentication**: Secure API access using JWT tokens - **Multiple Recipients**: Support for multiple signers with custom signing order - **Flexible Field Placement**: Position signature fields using anchors or coordinates - **Multiple File Sources**: Upload file, or reference deliverableId, templateId, or fileLink - **Real-time Status Updates**: Track document status throughout the signing process - **Webhook Integration**: Receive notifications when signing is complete :::tip Prefer using an SDK? We offer official SDKs that handle authentication, error handling, and type safety for you. [View all SDKs →](/docs/SDKs) ::: ## TLDR; Complete Working Example 🚀 Don't want to read all the details? Here's what you need to know: ### Available Field Types | Type | Description | Use Case | | ------------ | -------------------------- | -------------------------------------------- | | `signature` | Electronic signature field | Legal signatures | | `initial` | Initial field | Document initials, paragraph acknowledgments | | `date` | Date picker field | Signing date, agreement date | | `full_name` | Full name field | Automatically fills signer's complete name | | `first_name` | First name field | Automatically fills signer's first name | | `last_name` | Last name field | Automatically fills signer's last name | | `title` | Title/job title field | Professional title or position | | `company` | Company name field | Organization or company name | | `email` | Email address field | Signer's email address | | `text` | Generic text input field | Custom text, notes, or any other text input | | `checkbox` | Checkbox field | Acknowledgments, consent, agreements | ### Quick Start: Prepare for Signing (Most Common) Use this endpoint to send documents immediately for signing: ### Alternative: Prepare for Review Use this endpoint when you need a preview URL to verify field placement: ### Quick Comparison | Feature | prepare-for-review | prepare-for-signing | | -------------------- | ------------------------------ | ------------------------- | | Sends emails? | ❌ No | ✅ Yes | | Returns preview URL? | ✅ Yes | ❌ No | | Returns recipients? | ✅ Yes | ✅ Yes | | Final status | REVIEW_READY | UNDER_REVIEW | | Use when | Need to verify field placement | Ready to send immediately | Now that you've seen the whole thing, let's dive into the details... ## Prerequisites Before you begin, ensure you have: - **API Access Token**: Bearer token for authentication - **Organization ID**: Your organization identifier - **PDF Document**: Document ready for signature collection ### Getting Your Credentials 1. **Login to TurboDocx**: Visit [https://www.turbodocx.com](https://www.turbodocx.com) 2. **Navigate to Settings**: Click your account avatar, then click **Settings** 3. **API Keys Section**: Open **API keys** (under **Developers**) to generate or retrieve your API access token 4. **Organization ID**: Copy your organization ID from the **Features and integrations** page ![TurboSign API Key](/img/turbosign/api/api-key.png) ![TurboSign Organization ID](/img/turbosign/api/org-id.png) ## Authentication All TurboSign API requests require authentication using a Bearer token in the Authorization header: ```http Authorization: Bearer YOUR_API_TOKEN ``` Additional required headers for all requests: ```http x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ## Choosing Your Endpoint TurboSign offers two single-step endpoints to fit different workflows. Choose the one that best matches your use case. ### When to Use prepare-for-review ✅ **Use this endpoint when you want to:** - Verify field placement before sending to recipients - Get a preview URL to review the document in TurboSign's interface - Manually trigger email sending after verifying field placement - Ensure correct field positioning before recipients receive emails **Workflow**: Upload → Get preview URL → Review in browser → Manually send when ready ### When to Use prepare-for-signing ✅ **Use this endpoint when you want to:** - Send documents immediately without preview step - Automate the entire signature process end-to-end - Use with verified templates or confident field positioning - Skip manual review and send directly to recipients **Workflow**: Upload → Emails sent automatically → Recipients sign ## Endpoint 1: Prepare for Review Creates a signature request and returns a preview URL. No emails are sent to recipients. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/single/prepare-for-review ``` ### Headers ```http Content-Type: multipart/form-data Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Request Body (multipart/form-data) ⚠️ **Important**: Recipients and fields must be sent as JSON strings in form-data | Field | Type | Required | Description | | ------------------- | -------------- | ------------- | ------------------------------------------ | | file | File | Conditional\* | PDF, DOCX, or PPTX file to upload | | deliverableId | String (UUID) | Conditional\* | Reference to existing deliverable | | templateId | String (UUID) | Conditional\* | Reference to existing template | | fileLink | String (URL) | Conditional\* | URL to download file from | | documentName | String | No | Document name in TurboSign (max 255 chars) | | documentDescription | String | No | Document description (max 1000 chars) | | recipients | String (JSON) | **Yes** | JSON string array of recipient objects | | fields | String (JSON) | **Yes** | JSON string array of field objects | | senderName | String | No | Name of sender (max 255 chars). Defaults to your API key's name. | | senderEmail | String (email) | **Yes** | Reply-to address on the signature email and the sender recorded in the audit trail | | ccEmails | String (JSON) | No | JSON string array of CC email addresses | | remindersEnabled | Boolean | No | Send reminder emails to signers who haven't signed | | reminderDelay | String (JSON) | No | Time to the FIRST reminder, `{"value":3,"unit":"days"}` | | reminderInterval | String (JSON) | No | Gap between later reminders | | maxReminders | Number | No | Cap per signer, max `50`. `-1` unlimited, `0` none | | expirationEnabled | Boolean | No | Close the signing window after `expireAfter` | | expireAfter | String (JSON) | No | How long the document stays signable | | expirationWarning | String (JSON) | No | How far before expiry warnings start. `0` = never warn | | expirationWarningInterval | String (JSON) | No | Gap between warnings once they start | \* **File Source**: Must provide exactly ONE of: file, deliverableId, templateId, or fileLink :::tip Reminders & expiration are optional The eight schedule fields are **per-document overrides**. Omit any of them and that setting is inherited from your organization's E-Signature defaults; omit all of them and the document simply follows the org policy as it stands at send time. Both reminders and expiration ship **off** by default, so leaving these out preserves today's behaviour exactly. The resolved schedule is **frozen onto the document when it is sent** — changing your org defaults later never alters a document already out for signature. **Durations are `{value, unit}` objects sent as JSON strings**, because `multipart/form-data` cannot carry a nested value: ``` reminderDelay={"value":3,"unit":"days"} expireAfter={"value":30,"unit":"hours"} ``` `unit` is `"hours"` or `"days"`. `value` is a whole number, minimum 1 and at most **999 days (23976 hours)** — except `expirationWarning`, where `0` means "never send a warning". See [Reminders & Expiration](#reminders--expiration) for the full behaviour. ::: :::warning senderEmail is required `senderEmail` must be supplied on every API/SDK signature request. A request authenticated with an API key has no mailbox of its own, so TurboDocx **rejects the request with HTTP 400 and the error code `SenderEmailRequired`** rather than sending from an unmonitored address. `senderName` is optional — it defaults to the name of your API key (shown in the recipient's email and the audit trail); if no name can be resolved at all the API returns `400 SenderNameRequired`. TurboQuote works differently: quotes have **no `senderEmail` request field** — the sender is resolved from the org quote template. See [Prepared By & Sender Identity](/docs/TurboQuote/Prepared-By-and-Sender-Identity). ::: ### Recipients JSON Format Recipients must be stringified before adding to form-data: ```javascript const recipients = JSON.stringify([ { name: "John Smith", email: "john.smith@company.com", signingOrder: 1, metadata: { color: "hsl(200, 75%, 50%)", lightColor: "hsl(200, 75%, 93%)", }, }, { name: "Jane Doe", email: "jane.doe@partner.com", signingOrder: 2, metadata: { color: "hsl(270, 75%, 50%)", lightColor: "hsl(270, 75%, 93%)", }, }, ]); formData.append("recipients", recipients); ``` ### Fields JSON Format Fields reference recipients by **email** (not recipientId) and must be stringified: #### Template-based (recommended): ```javascript const fields = JSON.stringify([ { recipientEmail: "john.smith@company.com", type: "signature", template: { anchor: "{Signature1}", placement: "replace", size: { width: 200, height: 80 }, offset: { x: 0, y: 0 }, }, required: true, }, { recipientEmail: "john.smith@company.com", type: "date", template: { anchor: "{Date1}", placement: "replace", size: { width: 150, height: 30 }, }, required: true, }, ]); formData.append("fields", fields); ``` #### Coordinate-based: ```javascript const fields = JSON.stringify([ { recipientEmail: "john.smith@company.com", type: "signature", page: 1, x: 100, y: 200, width: 200, height: 80, pageWidth: 612, pageHeight: 792, required: true, }, ]); formData.append("fields", fields); ``` ### Response ```json { "success": true, "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "status": "REVIEW_READY", "previewUrl": "https://www.turbodocx.com/sign/preview/abc123...", "recipients": [ { "id": "5f673f37-9912-4e72-85aa-8f3649760f6b", "name": "John Smith", "email": "john.smith@company.com", "signingOrder": 1, "metadata": { "color": "hsl(200, 75%, 50%)", "lightColor": "hsl(200, 75%, 93%)" } } ], "message": "Document prepared for review. Use the preview URL to review and assign fields." } ``` ### Response Fields | Field | Type | Description | | ---------- | ------------- | ---------------------------------------------- | | success | Boolean | Request success status | | documentId | String (UUID) | Unique document identifier - save for tracking | | status | String | Document status (REVIEW_READY) | | previewUrl | String (URL) | URL to preview and verify document | | recipients | Array | Array of recipient objects with generated IDs | | message | String | Human-readable success message | ### Code Examples ### Next Steps After Review Once you've reviewed the document via the preview URL click "Send for Signing" button on the preview page to send emails to recipients ## Endpoint 2: Prepare for Signing Creates a signature request and immediately sends emails to recipients. Use this for production workflows when you're confident in your field positioning. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/single/prepare-for-signing ``` ### Headers ```http Content-Type: multipart/form-data Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Request Body (multipart/form-data) The request format is **identical** to prepare-for-review. See the "Endpoint 1: Prepare for Review" section above for detailed field documentation. ### Response ```json { "success": true, "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "status": "UNDER_REVIEW", "recipients": [ { "id": "5f673f37-9912-4e72-85aa-8f3649760f6b", "name": "John Smith", "email": "john.smith@company.com", "signingOrder": 1, "metadata": { "color": "hsl(200, 75%, 50%)", "lightColor": "hsl(200, 75%, 93%)" } } ], "message": "Document sent for signing. Emails are being sent to recipients." } ``` ### Response Fields | Field | Type | Description | | ---------- | ------------- | ---------------------------------------------- | | success | Boolean | Request success status | | documentId | String (UUID) | Unique document identifier - save for tracking | | status | String | Document status (UNDER_REVIEW) | | recipients | Array | Array of recipient objects with generated IDs | | message | String | Human-readable success message | ⚠️ **Note**: This endpoint returns immediately after creating the document. Email sending happens asynchronously in the background. Use webhooks to receive notification when the document is fully signed. Documents sent with an API key don't email a "fully signed" copy to the sender; signers and CC recipients still get their completion emails. ### Code Examples :::tip Need to resend emails? If a recipient hasn't received or has lost their signing email, you can resend it using the [Resend Email endpoint](#endpoint-6-resend-email). You'll need the `recipientIds` from the response of this endpoint. ::: ## Endpoint 3: Download Signed Document After a document has been signed by all recipients (status: `COMPLETED`), you can download the final signed PDF document. ### Endpoint ```http GET https://api.turbodocx.com/turbosign/documents/{documentId}/download ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | ---------- | ------------- | -------- | -------------------------------- | | documentId | String (UUID) | Yes | The unique identifier of the document | ### Response ```json { "downloadUrl": "https://s3.amazonaws.com/bucket/path/to/document.pdf?X-Amz-...", "fileName": "Signed_Contract_2024.pdf" } ``` ### Response Fields | Field | Type | Description | | ----------- | ------ | ---------------------------------------------------------- | | downloadUrl | String | Presigned S3 URL to download the signed PDF (expires in 1 hour) | | fileName | String | Original filename of the signed document | :::warning Document Status Requirement This endpoint only returns a download URL when the document status is `COMPLETED`. If the document is still pending signatures, you will receive an error response. ::: ### Usage Notes - The presigned URL expires after **1 hour**. Request a new URL if the previous one has expired. - The downloaded PDF includes all signatures embedded and is legally binding. - For large documents, consider streaming the download rather than loading the entire file into memory. ## Endpoint 4: Get Audit Trail Retrieve the complete audit trail for a document, including all events and timestamps. The audit trail provides a tamper-evident record of all actions taken on the document using a cryptographic hash chain. ### Endpoint ```http GET https://api.turbodocx.com/turbosign/documents/{documentId}/audit-trail ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | ---------- | ------------- | -------- | -------------------------------- | | documentId | String (UUID) | Yes | The unique identifier of the document | ### Response ```json { "data": { "document": { "id": "4a20eca5-7944-430c-97d5-fcce4be24296", "name": "Service Agreement 2024" }, "auditTrail": [ { "id": "entry-uuid-1", "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "actionType": "prepared_for_review", "timestamp": "2024-01-15T10:30:00.000Z", "previousHash": null, "currentHash": "a1b2c3d4e5f6...", "createdOn": "2024-01-15T10:30:00.000Z", "details": { "ipAddress": "192.168.1.100", "userAgent": "Mozilla/5.0..." }, "user": { "name": "Admin User", "email": "admin@company.com" }, "userId": "user-uuid-1" }, { "id": "entry-uuid-2", "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "actionType": "document_sent", "timestamp": "2024-01-15T10:31:00.000Z", "previousHash": "a1b2c3d4e5f6...", "currentHash": "b2c3d4e5f6g7...", "createdOn": "2024-01-15T10:31:00.000Z", "details": { "recipientCount": 2 }, "user": { "name": "Admin User", "email": "admin@company.com" }, "userId": "user-uuid-1" }, { "id": "entry-uuid-3", "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "actionType": "document_viewed", "timestamp": "2024-01-15T11:00:00.000Z", "previousHash": "b2c3d4e5f6g7...", "currentHash": "c3d4e5f6g7h8...", "createdOn": "2024-01-15T11:00:00.000Z", "details": { "ipAddress": "10.0.0.50" }, "recipient": { "name": "John Smith", "email": "john.smith@company.com" }, "recipientId": "recipient-uuid-1" }, { "id": "entry-uuid-4", "documentId": "4a20eca5-7944-430c-97d5-fcce4be24296", "actionType": "document_signed", "timestamp": "2024-01-15T11:05:00.000Z", "previousHash": "c3d4e5f6g7h8...", "currentHash": "d4e5f6g7h8i9...", "createdOn": "2024-01-15T11:05:00.000Z", "details": { "signatureType": "electronic", "ipAddress": "10.0.0.50" }, "recipient": { "name": "John Smith", "email": "john.smith@company.com" }, "recipientId": "recipient-uuid-1" } ] } } ``` ### Response Fields #### Document Object | Field | Type | Description | | ----- | ------------- | --------------------------- | | id | String (UUID) | Document identifier | | name | String | Document name | #### Audit Trail Entry Object | Field | Type | Description | | ------------ | ------------- | ----------------------------------------------------- | | id | String (UUID) | Unique identifier for the audit entry | | documentId | String (UUID) | Document this entry belongs to | | actionType | String | Type of action (see Action Types below) | | timestamp | String (ISO) | When the action occurred | | previousHash | String | Hash of the previous entry (null for first entry) | | currentHash | String | Hash of this entry (forms hash chain) | | createdOn | String (ISO) | When the entry was created | | details | Object | Additional action-specific details | | user | Object | User who performed action (for sender actions) | | userId | String (UUID) | User ID (when applicable) | | recipient | Object | Recipient who performed action (for signer actions) | | recipientId | String (UUID) | Recipient ID (when applicable) | #### Action Types | Action Type | Description | | ----------------------------- | --------------------------------------------------- | | `prepared_for_review` | Document was uploaded and prepared for review | | `document_sent` | Document was sent to recipients for signing | | `document_viewed` | Recipient opened/viewed the document | | `document_signed` | Recipient signed the document | | `document_voided` | Document was voided/cancelled | | `document_resent` | Reminder/resend email was sent to recipient | | `email_notification_sent` | Signing invitation email was sent | | `cc_email_notification_sent` | CC notification email was sent | ### Hash Chain Verification The audit trail uses a cryptographic hash chain for tamper-evidence: - Each entry's `currentHash` is computed from the entry data plus the `previousHash` - The first entry has `previousHash: null` - To verify integrity, recompute each hash and compare - Any modification to historical entries would break the chain This provides strong evidence that the audit trail has not been tampered with after creation. ### Usage Notes - The audit trail is available at any document status (not just completed documents) - All timestamps are in ISO 8601 format (UTC timezone) - The `details` object varies by action type and may contain IP addresses, user agents, and other contextual information - Audit trail entries are immutable and cannot be modified or deleted ## Endpoint 5: Get Recipients Retrieve every recipient on a document with their signing status, their email history, and who sent the document. This is the endpoint to poll when you want to know **who has signed and who you are still waiting on**. ### Endpoint ```http GET https://api.turbodocx.com/turbosign/documents/{documentId}/recipients ``` ### Headers ```http Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | ---------- | ------------- | -------- | ------------------------------------- | | documentId | String (UUID) | Yes | The unique identifier of the document | ### Response ```json { "data": { "document": { "id": "4a20eca5-7944-430c-97d5-fcce4be24296", "name": "Service Agreement 2024", "status": "under_review", "createdOn": "2024-01-15T10:30:00.000Z", "sentOn": "2024-01-15T10:35:00.000Z", "expiresAt": null, "sentBy": { "name": "Jane Sender", "email": "jane@acme.com" } }, "recipients": [ { "id": "recipient-uuid-1", "name": "John Doe", "email": "john@example.com", "status": "completed", "effectiveStatus": "completed", "signedOn": "2024-01-16T14:02:00.000Z", "signingOrder": 1, "delivery": { "firstSentOn": "2024-01-15T10:35:00.000Z", "lastSentOn": "2024-01-15T10:35:00.000Z", "totalSent": 1, "reminderCount": 0, "lastRemindedAt": "2024-01-15T10:35:00.000Z", "warningCount": 0, "lastWarningAt": null } }, { "id": "recipient-uuid-2", "name": "Jane Smith", "email": "jane@example.com", "status": "pending", "effectiveStatus": "pending", "signedOn": null, "signingOrder": 2, "delivery": { "firstSentOn": "2024-01-16T14:02:05.000Z", "lastSentOn": "2024-01-20T09:00:00.000Z", "totalSent": 2, "reminderCount": 1, "lastRemindedAt": "2024-01-20T09:00:00.000Z", "warningCount": 0, "lastWarningAt": null } } ], "summary": { "total": 2, "pending": 1, "viewed": 0, "completed": 1, "voided": 0, "expired": 0, "waitingOn": 1 } } } ``` ### Response Fields #### `document` | Field | Type | Description | | --------- | ------ | ------------------------------------------------------------------------------ | | status | String | Document-level status (`draft`, `under_review`, `completed`, `voided`, `expired`, …) | | sentOn | String \| null | When the document was dispatched to recipients; `null` while it is still a draft | | expiresAt | String \| null | When the signing window closes; `null` if it never expires | | sentBy | Object | `{ name, email }` — the real sender, never the internal service account | #### `recipients[]` | Field | Type | Description | | --------------- | -------------- | ----------------------------------------------------------------- | | status | String | Raw status: `pending`, `viewed` or `completed` | | effectiveStatus | String | `pending`, `viewed`, `completed`, `voided` or `expired` — **use this for display** | | signedOn | String \| null | When this recipient signed; `null` while pending or viewed | | signingOrder | Number | Position in the signing sequence | | delivery | Object | This recipient's email history (see below) | #### `recipients[].delivery` | Field | Type | Description | | -------------- | -------------- | ------------------------------------------------------------------------ | | firstSentOn | String \| null | First email of any kind; `null` if this recipient has never been emailed | | lastSentOn | String \| null | Most recent email of any kind | | totalSent | Number | Signature request + resends + reminders + expiry warnings + terminal notices | | reminderCount | Number | **Automatic (scheduled) reminders only** — see the note below | | lastRemindedAt | String \| null | **When the reminder cadence clock was last reset** — see the note below | | warningCount | Number | Expiry warnings sent. Only a warning touches this | | lastWarningAt | String \| null | When the last expiry warning went out. Only a warning touches this | :::warning `reminderCount` and `lastRemindedAt` do not mean what their names suggest **`reminderCount` counts automatic (scheduled) reminders only.** It is the counter that the `maxReminders` schedule setting caps. A manual "remind now" (the Send Reminder action) is a standalone nudge that deliberately does **not** consume the cap budget, so it does **not** increment this — even though the email it sends *does* appear in `totalSent`. A recipient can therefore read `reminderCount: 0` while reminder emails have genuinely been sent. **`lastRemindedAt` is a cadence clock, not a record of a reminder.** Four things stamp it: the initial signature-request send (so the first reminder is measured from the invitation), each scheduled reminder, each manual "remind now", and each expiry warning (a deliberate cross-reset so a reminder never lands adjacent to a warning). Only the second of those bumps `reminderCount`. The common consequence: a freshly-sent document returns a **non-null `lastRemindedAt` equal to the invitation timestamp, alongside `reminderCount: 0`**. That is correct — nobody has been reminded. To answer "have we actually chased this person", read `totalSent` (all emails) or the audit trail's `reminder_sent` entries, not `reminderCount`. `warningCount` / `lastWarningAt` have no such caveat — only an expiry warning touches them. ::: #### `summary` Counts by `effectiveStatus`, plus `waitingOn` — the recipients who have not finished (`pending` + `viewed`). `waitingOn` drops to zero once the document reaches a terminal state. ### Two status fields, and they differ on purpose There is no per-recipient declined, voided or expired state in the system — a recipient row only ever holds `pending`, `viewed` or `completed`. That means on a voided or expired document, an unsigned signer **still reads `pending`** in `status`. `effectiveStatus` layers the document's outcome on top, so that same signer reads `voided` or `expired`. Branch on `effectiveStatus`, not `status`, or you will chase people whose signing links are already dead. A completed signature is never revoked: someone who signed before the document was voided still reads `completed` in both fields. `effectiveStatus` also reflects a **lapsed deadline immediately**. A document past its `expiresAt` keeps its old status until a background sweep updates it, but the signing links are already refused at that moment — so `effectiveStatus` reports `expired` right away rather than waiting for the row to change. ### Usage Notes - Available at any document status, including drafts (a draft returns its recipients with `sentOn: null` and `delivery.totalSent: 0`) - Recipients are returned ordered by `signingOrder` - `delivery` counts emails to that signer only — CC notifications are excluded, since a CC address is not a signer - On a document with a signing order, only the current turn has been emailed; `delivery.totalSent === 0` distinguishes "not yet invited" from "invited and not acted" - Returns `404` if the document does not exist or belongs to another organization - All timestamps are ISO 8601 (UTC) ## Endpoint 6: Resend Email Resend signature request emails to one or more recipients who haven't yet completed signing. Only recipients at the current signing order who haven't completed are eligible for resend. This applies to documents that have already been **sent for signing** — there must be an original invitation to resend. A document still in review (no signing emails sent yet) has no eligible recipients. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/documents/{documentId}/resend-email ``` ### Headers ```http Content-Type: application/json Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | ---------- | ------------- | -------- | ------------------------------------- | | documentId | String (UUID) | Yes | The unique identifier of the document | ### Request Body (JSON) | Field | Type | Required | Description | | ------------ | -------------- | -------- | ---------------------------------------------------------------- | | recipientIds | Array (UUID[]) | **Yes** | Array of recipient UUIDs to resend emails to (min 1, unique) | ```json { "recipientIds": [ "5f673f37-9912-4e72-85aa-8f3649760f6b", "7a891c23-4d56-4e78-9abc-def012345678" ] } ``` :::info Where do I get recipient IDs? Recipient IDs are returned in the response of the **Prepare for Review** ([Endpoint 1](#endpoint-1-prepare-for-review)) and **Prepare for Signing** ([Endpoint 2](#endpoint-2-prepare-for-signing)) endpoints. Save these IDs when creating your signature request. ::: ### Response ```json { "data": { "success": true, "recipientCount": 2 } } ``` ### Response Fields | Field | Type | Description | | ------------------- | ------- | ------------------------------------------- | | data.success | Boolean | Whether the resend was successful | | data.recipientCount | Number | Number of recipients who received the email | ### Error Responses | Status | Error Message | Cause | | ------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | | 404 | `"Document not found"` | Document doesn't exist, has been deleted, or belongs to another organization | | 400 | `"All recipients have already completed signing"` | No eligible recipients remain | | 400 | `"Some recipients are not eligible for email resend at this time"` | Requested recipients aren't at the current signing order or already completed | The 400 "not eligible" error also returns an `invalidRecipientIds` array showing which IDs were rejected: ```json { "error": "Some recipients are not eligible for email resend at this time", "invalidRecipientIds": ["7a891c23-4d56-4e78-9abc-def012345678"] } ``` To recover, retry the request without the listed IDs, or wait until those recipients reach the current signing order before resending to them. ### Usage Notes - Only recipients at the **current signing order** who haven't completed signing are eligible for resend. If your document uses sequential signing (signingOrder 1, 2, 3...), that means the current active step only. - The request is **all-or-nothing**: if any requested ID is ineligible, the whole request is rejected with a 400 and **no** emails are sent. Fix the `recipientIds` and retry. - Each resend creates a `document_resent` entry in the [audit trail](#endpoint-4-get-audit-trail) for tracking - The `recipientIds` array must contain at least one ID and all IDs must be unique UUIDs ## Endpoint 7: Send Reminder Send a reminder email to a document's outstanding signers. This is a **standalone nudge**, deliberately decoupled from the automatic reminder schedule: it ignores the configured cadence, works even when reminders are disabled or the per-signer cap is already spent, and does **not** consume that cap. Use it when someone asks you to "chase them again" outside the normal rhythm. Distinct from [Resend Email](#endpoint-6-resend-email): **resend** re-sends the original invitation; **remind** sends the reminder copy. ### Endpoint ```http POST https://api.turbodocx.com/turbosign/documents/{documentId}/send-reminder ``` ### Headers ```http Content-Type: application/json Authorization: Bearer YOUR_API_TOKEN x-rapiddocx-org-id: YOUR_ORGANIZATION_ID User-Agent: TurboDocx API Client ``` ### Path Parameters | Parameter | Type | Required | Description | | ---------- | ------------- | -------- | ------------------------------------- | | documentId | String (UUID) | Yes | The unique identifier of the document | ### Request Body (JSON) | Field | Type | Required | Description | | ------------ | -------------- | -------- | ------------------------------------------------------------------------ | | recipientIds | Array (UUID[]) | No | Subset to remind. **Omit to remind every eligible signer.** Min 1, unique | Omit the body entirely (or send `{}`) to remind everyone whose turn it is: ```json {} ``` Or name specific signers: ```json { "recipientIds": ["5f673f37-9912-4e72-85aa-8f3649760f6b"] } ``` :::warning Don't send an empty array `recipientIds` must contain at least one ID **when the key is present**. Sending `{"recipientIds": []}` is rejected with a 400. To remind everyone, omit the key. ::: ### Response ```json { "data": { "results": [ { "recipientId": "5f673f37-9912-4e72-85aa-8f3649760f6b", "status": "sent", "reminderCount": 2, "phase": "reminder" }, { "recipientId": "7a891c23-4d56-4e78-9abc-def012345678", "status": "skipped_wrong_order" } ] } } ``` ### Response Fields | Field | Type | Description | | -------------------- | ------ | ----------------------------------------------------------------- | | data.results | Array | One entry per recipient considered — including those skipped | | results[].recipientId | String | The recipient this outcome refers to | | results[].status | String | What happened — see the table below | | results[].reminderCount | Number | Reminder count after the send. Only meaningful when `sent` | | results[].phase | String | `reminder` or `expiring` — which email copy was used | A recipient who was **not** emailed is reported rather than silently dropped, so you can tell nobody received anything: | `status` | Meaning | | --------------------- | -------------------------------------------------------------- | | `sent` | The reminder email was sent | | `failed` | The send was attempted and the mail transport rejected it | | `skipped_completed` | That recipient has already signed | | `skipped_wrong_order` | They're at a later signing order — it isn't their turn yet | | `skipped_not_due` | Scheduled path only: their next reminder isn't due | | `skipped_max_reached` | Scheduled path only: their reminder cap is spent | | `skipped_disabled` | Scheduled path only: reminders are off for this document | | `skipped_claim_lost` | A concurrent request won the send; no duplicate was sent | ### Error Responses | Status | Error Message | Cause | | ------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | | 404 | `"Document not found"` | Document doesn't exist, has been deleted, or belongs to another organization | | 409 | `"This document has expired, so its signers can no longer be reminded."` | The signing window has closed — the link is dead, so a reminder would be useless | | 409 | `"This document has been voided, so its signers can no longer be reminded."` | The document was voided | | 400 | `"Some recipients are not eligible for a reminder at this time"` | A named recipient isn't a current-order pending signer | The 409 responses carry `code: "DocumentNotActionable"` plus the document's `status`, so a client can refresh a stale row rather than guessing. The 400 returns an `invalidRecipientIds` array showing which IDs were rejected. The request is **all-or-nothing** — if any named ID is ineligible, **no** emails are sent: ```json { "error": "Some recipients are not eligible for a reminder at this time", "invalidRecipientIds": ["7a891c23-4d56-4e78-9abc-def012345678"] } ``` ### Usage Notes - Only recipients at the **current signing order** who haven't completed are eligible. - Unlike the automatic schedule, this is **not** gated by `remindersEnabled` or `maxReminders`, and it does **not** increment the reminder count — so it works on a document that never turned reminders on, or one whose cap is already spent. - Each send creates a `reminder_sent` entry in the [audit trail](#endpoint-4-get-audit-trail), recorded against the user who triggered it. --- ## Reminders & Expiration Two independent, opt-in features you can configure organization-wide in **E-Signature Settings → Reminders & Expiration**, or per-document via the [schedule fields](#endpoint-1-prepare-for-review) on either send endpoint. **Both ship off by default.** A document sent without any schedule fields behaves exactly as it always has: no reminders, no expiry. ### The schedule | Field | Default | Meaning | | --- | --- | --- | | `remindersEnabled` | `false` | Send reminder emails at all | | `reminderDelay` | 3 days | Time to the **first** reminder, measured from that signer's invitation | | `reminderInterval` | 3 days | Gap between **subsequent** reminders | | `maxReminders` | 5 | Cap per signer, max `50`. `-1` unlimited, `0` none. **Never caps expiry warnings** | | `expirationEnabled` | `false` | Expire the document at all | | `expireAfter` | 120 days | How long the document stays signable, counted from **sending** | | `expirationWarning` | 3 days | How far **before** expiry warnings start. `0` = never warn | | `expirationWarningInterval` | 1 day | Gap between warnings once they start | `reminderDelay` and `reminderInterval` are separate because a single interval can't express "first at 3 days, then every 5". `expirationWarning` / `expirationWarningInterval` mirror that split exactly. ### Two independent tracks Reminders and expiry warnings run as **two separate clocks**, so a signer gets the full escalating stream — reminders keep coming even after the warning window opens. The two are coordinated so they never collide: on any given tick a due warning takes priority and restarts the reminder clock, so a signer never receives a reminder and a warning at the same moment. Expiry warnings also still fire when reminders are **off** or **capped** — the deadline notice belongs to expiration, not to reminders. ### Expiry When `expirationEnabled` is on, the document carries an absolute deadline: - `expiresAt` — an ISO timestamp on the document, absent when it has no deadline. - Once that instant passes, every signing link returns **HTTP 410** with `code: "DocumentExpired"`, and the document moves to the terminal status `expired`. - Expiry is enforced **in real time** on every signing request, so a link stops working exactly on time regardless of background processing. A document that was already **completed** keeps working — the deadline exists to stop signing, and signing is already finished. ### Validation The API rejects contradictory combinations with `400` and `error: "InvalidSignatureSchedule"`, plus a machine-readable `data.code` and the conflicting `data.fields`: | Code | Rule | | --- | --- | | `ExpiryWindowTooShort` | `expireAfter` must be at least one hour | | `WarningExceedsExpiry` | `expirationWarning` must be **less than** `expireAfter` | | `WarningIntervalTooShort` | `expirationWarningInterval` must be at least one hour when warnings are on | | `ReminderDelayExceedsExpiry` | The first reminder must fall inside the expiry window, or it would never send | | `ReminderIntervalExceedsExpiry` | Follow-up reminders must fit inside the window | The canonical mistake these prevent: *"remind every 4 days"* with *"expire in 2 days"* — the document would die before the first nudge was ever due, and you'd believe reminders were on while no email ever sent. --- ## Recipients Reference ### Recipient Properties Each recipient object in the `recipients` array should contain the following properties: | Property | Type | Required | Description | | ------------ | -------------- | -------- | ---------------------------------------------------------- | | name | String | Yes | Full name of the recipient/signer | | email | String (email) | Yes | Email address of the recipient (must be unique) | | signingOrder | Number | Yes | Order in which recipient should sign (starts at 1) | | metadata | Object | No | Optional metadata for UI customization (color, lightColor) | ### Metadata Object (Optional) The `metadata` object allows you to customize the recipient's UI appearance: | Property | Type | Description | Example | | ---------- | ------ | -------------------------------------------------- | ---------------------- | | color | String | Primary color for recipient in HSL format | `"hsl(200, 75%, 50%)"` | | lightColor | String | Light background color for recipient in HSL format | `"hsl(200, 75%, 93%)"` | ### Example Recipients Array ```json [ { "name": "John Smith", "email": "john.smith@company.com", "signingOrder": 1 }, { "name": "Jane Doe", "email": "jane.doe@partner.com", "signingOrder": 2 } ] ``` ### With Optional Metadata ```json [ { "name": "John Smith", "email": "john.smith@company.com", "signingOrder": 1, "metadata": { "color": "hsl(200, 75%, 50%)", "lightColor": "hsl(200, 75%, 93%)" } } ] ``` ## Field Types Reference ### Complete Field Type List | Type | Description | Auto-filled | Use Case | | ------------ | -------------------------- | ----------- | -------------------------------------------- | | `signature` | Electronic signature field | No | Legal signatures, agreements | | `initial` | Initial field | No | Document initials, paragraph acknowledgments | | `date` | Date picker field | No | Signing date, agreement date | | `full_name` | Full name field | Yes | Automatically fills signer's complete name | | `first_name` | First name field | Yes | Automatically fills signer's first name | | `last_name` | Last name field | Yes | Automatically fills signer's last name | | `title` | Title/job title field | No | Professional title or position | | `company` | Company name field | No | Organization or company name | | `email` | Email address field | Yes | Signer's email address | | `text` | Generic text input field | No | Custom text, notes, or any other text input | | `checkbox` | Checkbox field | No | Acknowledgments, consent, agreements | ### Field Configuration Properties #### Common Properties (All Field Types) | Property | Type | Required | Description | | --------------- | ------- | -------- | -------------------------------------------------------------- | | recipientEmail | String | Yes | Email address of recipient (matches email in recipients array) | | type | String | Yes | Field type (see table above) | | required | Boolean | No | Whether the signer must fill the field (default: `true`). Send `false` to let the signer leave it blank and still finish. Must be a JSON boolean, not the string `"false"`; `null` is treated as not set (required). Cannot be `false` on `signature` or `initial` fields. See [Optional Fields](#optional-fields) | | defaultValue | String | No | Pre-filled value for the field (max 600 characters). This is a real value that is submitted with the signature, not placeholder hint text | | isReadonly | Boolean | No | Makes field non-editable (for prefilled values) | | backgroundColor | String | No | Custom background color (hex or rgba) | | metadata | Object | No | Optional field metadata. Carries `fieldKey` (on a controlling checkbox) and/or a `conditional` rule (on a dependent field). See [Conditional (IF/THEN) Fields](#conditional-if-then-fields). | #### Template-based Properties | Property | Type | Required | Description | | ---------------------- | ------- | -------- | --------------------------------------------------------------- | | template.anchor | String | Yes | Text anchor to find in document (e.g., `{Signature1}`) | | template.placement | String | Yes | How to place field: "replace", "before", "after" | | template.size | Object | Yes | Field dimensions: { width: number, height: number } | | template.offset | Object | No | Position offset: { x: number, y: number } (default: {x:0, y:0}) | | template.caseSensitive | Boolean | No | Whether anchor search is case-sensitive (default: true) | | template.useRegex | Boolean | No | Whether to treat anchor as regex pattern (default: false) | | template.alignment | String | No | Where the signer's entry sits: `"left"`, `"center"` or `"right"`. See [Field Alignment](#field-alignment) | #### Coordinate-based Properties | Property | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------------ | | page | Number | Yes | Page number (starts at 1) | | x | Number | Yes | Horizontal position from left edge (pixels) | | y | Number | Yes | Vertical position from top edge (pixels) | | width | Number | Yes | Field width in pixels | | height | Number | Yes | Field height in pixels | | pageWidth | Number | No | Total page width in pixels (optional, for responsive positioning) | | pageHeight | Number | No | Total page height in pixels (optional, for responsive positioning) | | alignment | String | No | Where the signer's entry sits: `"left"`, `"center"` or `"right"`. See [Field Alignment](#field-alignment) | ### Optional Fields {#optional-fields} Every field is required by default: the signer cannot finish until it is filled. Set `required: false` on a field to make it optional. The signer can then leave it blank and still complete the document, and a blank optional field stays empty on the signed PDF. ```javascript const fields = JSON.stringify([ { recipientEmail: "john.smith@company.com", type: "signature", template: { anchor: "{Signature1}", placement: "replace", size: { width: 200, height: 80 } }, }, // Optional: the signer may leave this blank { recipientEmail: "john.smith@company.com", type: "text", template: { anchor: "{Notes}", placement: "replace", size: { width: 300, height: 30 } }, required: false, }, ]); formData.append("fields", fields); ``` Rules: - **Signature and initial fields are always required.** Sending `required: false` on a `signature` or `initial` field is rejected with `400 OptionalNotSupported`. - **`required` must be a JSON boolean.** Any other value, including the string `"false"`, is rejected with `400 InvalidFieldRequired`. Omitting `required`, or sending `null`, is the same as `true`. - **Each recipient needs at least one required, editable field.** A field counts when it is not `isReadonly` and its `required` is not `false`. A signature field always counts. A recipient whose fields are all optional or read-only is rejected with `400 NoEditableFieldsForRecipient`. - **Date and checkbox fields are always filled on the signing page**, so making them optional does not change what the signer sees. It does change one check: an optional date or checkbox does not count as the recipient's required field, so a recipient whose only other fields are optional is still rejected with `400 NoEditableFieldsForRecipient`. - **`defaultValue` is a real value, not hint text.** If you put instructions such as "Explain any changes here" in `defaultValue`, that text is submitted and lands on the signed PDF unless the signer clears it. Leave `defaultValue` empty on an optional field you want to stay blank. - **`isReadonly` and `required: false` can be combined.** A read-only field is pre-filled and the signer can't change it, so it never holds up signing, whether it is required or not. A field that a conditional rule still hides or locks never holds up signing either. On the signing page, an empty optional field keeps its normal label (for example "Text") and shows a small **Optional** tag on its top-right corner. The "N of M required" progress counter leaves optional fields out. A locked field never shows the tag: that covers a read-only field and a field that a conditional `unlock` rule still holds locked, and the tag appears once the rule unlocks it. A locked field shows its lock icon instead when your organization draws locked fields as grey boxes (see [Locked Fields Rendering](/docs/TurboSign/How-to-Configure-Signature-Appearance)). If the signer fills in an optional field and changes their mind, they can open it and click **Clear field**. `required` works the same way on prepare-for-signing, prepare-for-review, and in each `documents[].fields` array of the [Bulk API](/docs/TurboSign/API-Bulk-Signatures). :::warning Behavior change for existing integrations Earlier, the single-step endpoints accepted `required` but ignored it, so every field was treated as required. `required: false` is now honored. If your integration already sends `required: false`, those fields become optional for the signer. Requests that send `required: false` on a signature or initial field, send a non-boolean `required`, or leave a recipient with no required editable field now return `400` (`OptionalNotSupported`, `InvalidFieldRequired`, or `NoEditableFieldsForRecipient`). ::: ### Conditional (IF/THEN) Fields {#conditional-if-then-fields} Any field can carry an optional `metadata` object. It is used to build **conditional (IF/THEN) relationships** between fields: a **controlling checkbox** decides whether one or more **dependent fields** are shown or unlocked. This works with both the **prepare-for-signing** and **prepare-for-review** single-step routes (and with the [Bulk API](/docs/TurboSign/API-Bulk-Signatures), which uses the same field format). The relationship has two halves: 1. **The controlling checkbox** gets a stable identifier via `metadata.fieldKey`. It must be a field of `type: "checkbox"`. 2. **Each dependent field** points back at that checkbox with a `metadata.conditional` rule whose `controllingFieldKey` equals the checkbox's `fieldKey`. #### metadata Object | Property | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------------------------------------- | | `fieldKey` | String | No | Stable identifier for a **controlling checkbox**. Referenced by dependent fields' `controllingFieldKey`. Set this on the checkbox (`type: "checkbox"`). | | `conditional` | Object | No | Rule placed on a **dependent field** that makes its visibility/editability depend on a controlling checkbox. See below. | #### metadata.conditional Object | Property | Type | Required | Description | | --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------- | | `controllingFieldKey` | String | **Yes** | The `metadata.fieldKey` of the controlling checkbox this field depends on. Must be non-empty. | | `operator` | String | **Yes** | Condition to evaluate against the checkbox: `"is_checked"` or `"is_not_checked"`. | | `action` | String | **Yes** | What happens to this field when the condition is met: `"show"` or `"unlock"`. | #### show vs. unlock The `action` controls the dependent field's starting state and what the condition changes: - **`show`** — the dependent field is **hidden** until the condition is met. When the checkbox matches the `operator`, the field appears. - **`unlock`** — the dependent field is **visible but read-only** until the condition is met. When the checkbox matches the `operator`, the field becomes editable. #### Worked Example A recipient checks "Request changes" and a text box appears asking them to explain. The checkbox is the controller (`fieldKey: "request_changes"`); the text field shows only when the box is checked. ```javascript const fields = JSON.stringify([ // The recipient's signature. Every recipient needs at least one required field. { recipientEmail: "john.smith@company.com", type: "signature", page: 1, x: 100, y: 600, width: 200, height: 80, }, // Controlling checkbox — gets a stable fieldKey { recipientEmail: "john.smith@company.com", type: "checkbox", page: 1, x: 100, y: 400, width: 20, height: 20, required: false, metadata: { fieldKey: "request_changes", }, }, // Dependent text field — hidden until the checkbox above is checked { recipientEmail: "john.smith@company.com", type: "text", page: 1, x: 130, y: 400, width: 300, height: 60, required: false, defaultValue: "", metadata: { conditional: { controllingFieldKey: "request_changes", operator: "is_checked", action: "show", }, }, }, ]); formData.append("fields", fields); ``` To instead keep the field visible but locked until the box is checked, change the dependent field's `action` to `"unlock"`. To reveal a field when a box is **cleared** (for example, an "I do not consent — explain why" note), use `operator: "is_not_checked"`. #### Validation and Fail-Open Behavior The API **validates the shape** of every `conditional` rule. A malformed rule is rejected with HTTP **400** and the `type` **`InvalidConditionalRule`**. A rule is malformed when: - `operator` is anything other than `"is_checked"` or `"is_not_checked"`, or - `action` is anything other than `"show"` or `"unlock"`, or - `controllingFieldKey` is missing or empty. ```json { "message": "Field 2: metadata.conditional.operator must be one of: is_checked, is_not_checked", "type": "InvalidConditionalRule" } ``` A **well-formed** rule whose `controllingFieldKey` does not match any checkbox's `fieldKey` in the same request does **not** error — it **fails open**. The dependent field stays visible and editable as if it had no rule, so a typo in `controllingFieldKey` silently disables the condition rather than blocking the send. Double-check that the `controllingFieldKey` on each dependent field exactly matches the `fieldKey` of an existing checkbox. ### Field Type Special Behaviors **signature & initial** - Draws a signature pad for user input - Can be text-based or drawn - Cryptographically signed and hashed for legal validity - Cannot carry a `defaultValue` — sending one is rejected - Always required: `required: false` is rejected with `400 OptionalNotSupported` **date** - Shows date picker interface - Format: MM/DD/YYYY (US) or DD/MM/YYYY (configurable) - Fills automatically with the date the recipient signs - To pin a specific date instead, set `defaultValue` to that date in `MM/DD/YYYY` format (e.g. `"12/31/2026"`). Omit `defaultValue` (or send `""`) to keep the signing-date behavior - `defaultValue` must be a **real calendar date** in `MM/DD/YYYY` — a non-existent date such as `"02/31/2026"` (or any malformed value) is rejected with `400 InvalidDateValue`. There is no `"today"` keyword - A date field cannot be `isReadonly` — a pinned date still shows to the signer, it is not locked - Always filled on the signing page, so `required: false` does not change what the signer sees, but an optional date does not count as the recipient's required field **full_name, first_name, last_name, email** - Auto-populated from recipient profile - Can be overridden by recipient if needed - Useful for legal compliance and form filling **text** - Single-line text input by default - Supports defaultValue for prefilled content - Set `required: false` to let the signer leave it blank (see [Optional Fields](#optional-fields)) - Use for titles, company names, custom fields **checkbox** - Boolean true/false value - Useful for acknowledgments and consent - Can have label text next to checkbox - Always has a value (checked or unchecked) when the signer finishes, so `required: false` does not change what the signer sees, but an optional checkbox does not count as the recipient's required field ## Field Positioning Methods TurboSign supports two methods for positioning signature fields in your documents. ### Method 1: Template-based Positioning (Recommended) Uses text anchors in your PDF as placeholders. TurboSign searches for these anchors and places fields accordingly. #### Advantages ✅ Easy to update field positions (just edit the PDF) ✅ No need to measure exact coordinates ✅ Works across different page sizes ✅ More maintainable for non-technical users ✅ Handles document variations gracefully #### How it Works 1. **Add anchor text to your PDF**: Place text like `{Signature1}`, `{Date1}`, `{Initial1}` where you want fields 2. **Configure fields with anchor references**: Tell TurboSign what to search for 3. **TurboSign finds and replaces**: Anchors are found and replaced with interactive fields #### Anchor Configuration Example ```json { "recipientEmail": "john.smith@company.com", "type": "signature", "template": { "anchor": "{Signature1}", "placement": "replace", "size": { "width": 200, "height": 80 }, "offset": { "x": 0, "y": 0 }, "caseSensitive": true, "useRegex": false }, "required": true } ``` #### Placement Options - **replace**: Removes the anchor text and places the field in its position - **before**: Places field before the anchor text (anchor remains visible) - **after**: Places field after the anchor text (anchor remains visible) #### Field Alignment {#field-alignment} Use `alignment` to choose where the signer's entry (a typed or drawn signature, initials, a date, or any text value) sits inside its field: `"left"`, `"center"` or `"right"`. - **Anchor fields:** set it on the template as `template.alignment`. With `"placement": "replace"`, it also pins the field to that side of the anchor text: `"left"` starts the field where the anchor starts, `"right"` ends it where the anchor ends. - **Coordinate fields:** set it at the top level of the field as `alignment`. - **Leave it out (or send `null`)** to keep the standard placement. Existing integrations don't change. - Any other value is rejected with a `400` error: `alignment must be one of: left, center, right`. - Checkbox fields ignore it. ```json { "recipientEmail": "master@shipping.com", "type": "signature", "template": { "anchor": "{MasterSignature}", "placement": "replace", "size": { "width": 180, "height": 40 }, "alignment": "left" } } ``` The signing page shows the entry with the same alignment, and the signed PDF matches it. #### Offset Usage Offset allows fine-tuning field position relative to the anchor: - `x`: Positive moves right, negative moves left (pixels) - `y`: Positive moves down, negative moves up (pixels) ```json { "anchor": "{Signature1}", "placement": "replace", "size": { "width": 200, "height": 80 }, "offset": { "x": 10, "y": -5 } // 10px right, 5px up from anchor } ``` ### Method 2: Coordinate-based Positioning Uses exact pixel coordinates to position fields on specific pages. Best for precise control or when anchors aren't feasible. #### Advantages ✅ Pixel-perfect precision ✅ Works with PDFs that can't be edited ✅ Programmatically generated positions ✅ Useful for form-filling scenarios ✅ Consistent placement across documents #### How it Works 1. **Measure exact x,y coordinates** in your PDF (using PDF editor or viewer) 2. **Provide page number, coordinates, and dimensions** 3. **TurboSign places fields at exact positions** #### Coordinate Configuration Example ```json { "recipientEmail": "john.smith@company.com", "type": "signature", "page": 1, "x": 100, "y": 200, "width": 200, "height": 80, "pageWidth": 612, "pageHeight": 792, "required": true } ``` #### Coordinate System Reference - **Origin (0,0)**: Top-left corner of the page - **X-axis**: Increases from left to right - **Y-axis**: Increases from top to bottom - **Standard US Letter**: 612 x 792 pixels (8.5" x 11" at 72 DPI) - **Standard A4**: 595 x 842 pixels (210mm x 297mm at 72 DPI) #### Coordinate Validation Fields must stay within page boundaries: - `x ≥ 0` - `y ≥ 0` - `x + width ≤ pageWidth` - `y + height ≤ pageHeight` #### Measuring Coordinates **Adobe Acrobat Pro**: 1. View → Show/Hide → Rulers & Grids → Rulers 2. Hover over location to see coordinates **Browser Developer Tools**: 1. Open PDF in browser 2. Right-click → Inspect 3. Use element inspector to measure positions **PDF Editing Software**: - Use built-in coordinate display - Draw rectangles to measure dimensions #### Quick Coordinate Example Position a signature field at bottom-right of a US Letter page: ```json { "recipientEmail": "john@example.com", "type": "signature", "page": 1, "x": 362, // 612 - 250 = 362 (right aligned with 50px margin) "y": 662, // 792 - 130 = 662 (bottom aligned with 50px margin) "width": 200, "height": 80, "pageWidth": 612, "pageHeight": 792 } ``` ## Best Practices ### Workflow Selection **When You Need Field Verification**: - ✅ Use `prepare-for-review` to get preview URLs - ✅ Verify field placement in browser before sending - ✅ Manually trigger sending after review - ✅ Useful for new document templates or complex field layouts **When Field Placement Is Verified**: - ✅ Use `prepare-for-signing` to send immediately - ✅ Implement webhook handlers for completion notifications - ✅ Use proper error handling and retry logic - ✅ Monitor API rate limits - ✅ Log all document IDs for tracking **General Tips**: - ✅ Use deliverableId or templateId to avoid repeated uploads - ✅ Test with your own email addresses first - ✅ Both endpoints are production-ready ### Security - **Never expose API tokens**: Store tokens securely in environment variables or secrets management - **Use HTTPS only**: All API calls must use HTTPS in production (API enforces this) - **Validate inputs**: Always validate recipient emails and document names before submission - **Implement rate limiting**: Respect API rate limits to avoid throttling - **Rotate tokens regularly**: Generate new API tokens periodically - **Use webhook signatures**: Verify webhook payloads using HMAC signatures - **Sanitize user inputs**: Validate and sanitize all user-provided data ### Error Handling - **Check HTTP status codes**: Always verify response status before processing - **Handle timeouts**: Implement retry logic with exponential backoff for network failures - **Log API responses**: Keep detailed logs for debugging and monitoring - **Validate responses**: Check response structure before accessing data - **Graceful degradation**: Have fallback behavior for API failures - **User-friendly errors**: Display helpful error messages to end users ### Performance **File Upload Optimization**: - Compress PDFs when possible (aim for <5MB) - Use fileLink for files already in cloud storage (S3, GCS, etc.) - Use deliverableId/templateId to reference existing documents - Avoid uploading the same document multiple times **API Efficiency**: - Single-step endpoints reduce API calls from 3 to 1 (3x faster) - Batch multiple documents in parallel requests when possible - Use connection pooling for multiple requests - Implement exponential backoff for retries - Cache responses when appropriate **Network Optimization**: - Use CDN for document hosting when using fileLink - Enable gzip compression for API requests - Minimize payload sizes by only including required fields ### Document Preparation **Text Anchors (Template-based)**: - Use consistent anchor naming: `{FieldType}{Number}` (e.g., `{Signature1}`, `{Date1}`) - Place anchors exactly where you want fields - Use unique anchors (avoid duplicates) - Test anchor placement with prepare-for-review first - Document your anchor naming convention **Coordinate-based**: - Verify coordinates work across different PDF viewers - Account for page margins and headers/footers - Use standard page sizes when possible - Test on actual page dimensions (don't assume) - Validate boundaries before submission **Document Validation**: - Ensure PDFs are not password-protected or corrupted - Verify all pages are readable - Test with actual documents before production - Keep backup copies of source documents ### JSON String Formatting ⚠️ **Critical**: Recipients and fields must be valid JSON strings when added to form-data. **Correct**: ```javascript const recipients = JSON.stringify([ { name: "John", email: "john@example.com", signingOrder: 1 }, ]); formData.append("recipients", recipients); ``` **Incorrect**: ```javascript // Don't send object/array directly! formData.append("recipients", recipientsArray); // ❌ Wrong formData.append("recipients", "[{...}]"); // ❌ Wrong (string literal, not stringified) ``` **Python Example**: ```python recipients = json.dumps([ {"name": "John", "email": "john@example.com", "signingOrder": 1} ]) form_data['recipients'] = recipients ``` **C# Example**: ```csharp using System.Text.Json; var recipients = JsonSerializer.Serialize(new[] { new { name = "John", email = "john@example.com", signingOrder = 1 } }); formData.Add(new StringContent(recipients), "recipients"); ``` ## Error Handling & Troubleshooting ### Common HTTP Status Codes | Status Code | Description | Solution | | ----------- | --------------------- | --------------------------------------------- | | `200` | Success | Request completed successfully | | `400` | Bad Request | Check request body format and required fields | | `401` | Unauthorized | Verify API token and headers | | `403` | Forbidden | Check organization ID and permissions | | `404` | Not Found | Verify endpoint URLs are correct | | `422` | Unprocessable Entity | Validate field values and constraints | | `429` | Too Many Requests | Implement rate limiting and retry logic | | `500` | Internal Server Error | Contact support if persistent | ### Common Issues #### JSON String Formatting Errors **Symptoms**: 400 Bad Request with message "Invalid JSON in recipients/fields" **Solutions**: - ✅ Verify JSON.stringify() or equivalent is used for recipients, fields, ccEmails - ✅ Check JSON is valid using JSONLint or similar validator - ✅ Ensure proper escaping of quotes in JSON strings - ✅ Test with minimal example first (1 recipient, 1 field) **Example Error Response**: ```json { "error": "Invalid JSON string in recipients field", "code": "JSONParseError", "details": "Unexpected token at position 45" } ``` **Debug Steps**: 1. Log the JSON string before sending 2. Validate JSON with online validator 3. Check for special characters or unescaped quotes 4. Test with hardcoded valid JSON first #### File Source Errors **Symptoms**: 400 Bad Request with message about file source **Solutions**: - ✅ Provide exactly ONE of: file, deliverableId, templateId, fileId, fileLink - ✅ Verify UUIDs are valid format (8-4-4-4-12 characters) - ✅ Check file upload isn't corrupted or empty - ✅ Ensure fileLink is accessible (not behind auth) **Example Error Response**: ```json { "error": "Must provide exactly one file source", "code": "InvalidFileSource" } ``` #### Recipients/Fields Mismatch **Symptoms**: 400 Bad Request about missing recipient or email mismatch **Solutions**: - ✅ Verify recipientEmail in fields matches email in recipients array **exactly** - ✅ Check for typos in email addresses - ✅ Ensure all fields reference valid recipients - ✅ Email matching is case-sensitive **Example**: ```javascript // Recipients array [{ email: "john.smith@company.com", ... }] // Fields array - must match exactly [{ recipientEmail: "john.smith@company.com", ... }] // ✅ Correct [{ recipientEmail: "John.Smith@company.com", ... }] // ❌ Wrong (case mismatch) ``` #### Optional Field Errors **Symptoms**: 400 Bad Request with code `OptionalNotSupported`, `InvalidFieldRequired`, or `NoEditableFieldsForRecipient` | Code | Cause | Fix | | ------------------------------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `OptionalNotSupported` | `required: false` on a `signature` or `initial` field | Remove `required` from that field, or set it to `true` | | `InvalidFieldRequired` | `required` is not a boolean or `null` (for example the string `"false"`) | Send a JSON boolean: `true` or `false` | | `NoEditableFieldsForRecipient` | A recipient has only optional or read-only fields | Give that recipient at least one required, editable field, such as a signature | **Example Error Response**: ```json { "error": "Field 1: SIGNATURE and INITIAL field types cannot be optional. Field type \"signature\" must be completed by the signer.", "code": "OptionalNotSupported" } ``` See [Optional Fields](#optional-fields) for the full rules. #### Authentication Failures **Symptoms**: 401 Unauthorized responses **Solutions**: - ✅ Verify API token is correct and not expired - ✅ Check that `x-rapiddocx-org-id` header matches your organization - ✅ Ensure Bearer token format: `Bearer YOUR_TOKEN` (with space) - ✅ Confirm token has necessary permissions **Example Correct Headers**: ```http Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... x-rapiddocx-org-id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` #### Document Upload Failures **Symptoms**: Upload returns error or times out **Solutions**: - ✅ Verify PDF file is not corrupted or password-protected - ✅ Check file size is under maximum limit (typically 10MB) - ✅ Ensure file is actually a PDF (check MIME type) - ✅ Verify network connection and try again - ✅ For fileLink, ensure URL is accessible #### Field Positioning Problems **Symptoms**: Signature fields appear in wrong locations or not at all **Template-based Solutions**: - ✅ Verify anchor text exists in the PDF document - ✅ Check anchor text matches exactly (case-sensitive by default) - ✅ Test with `caseSensitive: false` if having matching issues - ✅ Try different placement options (replace, before, after) - ✅ Use prepare-for-review to visually verify placement **Coordinate-based Solutions**: - ✅ Verify page dimensions match your PDF's actual size - ✅ Check that x,y coordinates are within page boundaries - ✅ Ensure coordinates account for any PDF margins or headers - ✅ Test with different page numbers if multi-page document - ✅ Validate that `x + width ≤ pageWidth` and `y + height ≤ pageHeight` #### Webhook Integration Issues **Symptoms**: Not receiving completion notifications **Solutions**: - ✅ Verify webhook URLs are accessible and return 200 OK - ✅ Check webhook configuration in organization settings - ✅ Review webhook delivery history for error details - ✅ Test webhook endpoints with external tools (webhook.site, ngrok) - ✅ Implement HMAC signature verification ### Debugging Tips 1. **Test with prepare-for-review first**: Visual confirmation before sending emails 2. **Use preview URLs**: Verify field placement and document appearance 3. **Check response documentId**: Save this for tracking and debugging 4. **Enable request logging**: Log all requests and responses with timestamps 5. **Test with minimal payloads**: Start simple (1 recipient, 1 field), add complexity incrementally 6. **Validate JSON before sending**: Use JSON validators to check format 7. **Use Postman/Insomnia**: Test manually before writing code 8. **Check API status page**: Verify TurboDocx services are operational 9. **Review error messages carefully**: Error responses include specific details 10. **Monitor rate limits**: Track API usage to avoid throttling ### Example Debug Request ```bash # Test with curl to isolate issues curl -X POST https://api.turbodocx.com/turbosign/single/prepare-for-review \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "x-rapiddocx-org-id: YOUR_ORG_ID" \ -F "file=@document.pdf" \ -F "documentName=Test Document" \ -F "senderEmail=you@yourcompany.com" \ -F 'recipients=[{"name":"Test User","email":"test@example.com","signingOrder":1}]' \ -F 'fields=[{"recipientEmail":"test@example.com","type":"signature","page":1,"x":100,"y":200,"width":200,"height":80,"pageWidth":612,"pageHeight":792}]' \ -v ``` ## Next Steps ### Webhooks - The Next Logical Step Now that you've integrated the single-step signing flow, the next step is setting up webhooks to receive real-time notifications when documents are signed. This eliminates the need for polling and provides instant updates about document status changes. 📖 **[Learn how to configure Webhooks →](/docs/TurboSign/Webhooks)** ### Related Documentation - [TurboSign Setup Guide](/docs/TurboSign/Setting-up-TurboSign) - [Webhook Configuration](/docs/TurboSign/Webhooks) - [API Authentication](/docs/API/turbodocx-api-documentation) ## Support Need help with your integration? - **Discord Community**: [Join our Discord server](https://discord.gg/NYKwz4BcpX) for real-time support and discussions - **Documentation**: [https://docs.turbodocx.com](https://docs.turbodocx.com) --- Ready to get started? Follow the guide above to integrate TurboSign single-step API into your application and start collecting electronic signatures programmatically with a single API call! --- # Bulk Signature Sending Need to send the same document to dozens — or hundreds — of recipients, each with their own personalized details and signature fields? The **Bulk Send** wizard lets you do exactly that, right from the TurboDocx UI. Upload a spreadsheet, map your columns, preview the results, and submit the entire batch in minutes. ## What You'll Learn - 📊 **Upload a spreadsheet** (CSV or XLSX) with recipient data - 🔗 **Map columns** to template variables automatically or manually - ✏️ **Configure signature fields** with types, defaults, and recipient assignments - 👁️ **Preview merged documents** before sending - 📤 **Submit batches** and monitor progress - 📈 **Track batch status** from the Batches dashboard :::info Pro Plan Required Bulk Signature Sending is available exclusively on the **Pro plan**. If you're on a free or starter plan, clicking the Bulk Send button will prompt you to upgrade. [Learn more about Pro plan features](https://www.turbodocx.com/pricing). ::: ## Before You Begin To use Bulk Signature Sending, you'll need: - **Pro plan** — the feature is gated to Pro subscribers - **A TurboDocx template** — with variables (placeholders) that correspond to columns in your spreadsheet - **A spreadsheet** — CSV (`.csv`) or Excel (`.xlsx`) file with one row per recipient/document - **Sufficient signature credits** — each recipient in each document consumes 1 credit :::tip Prepare Your Spreadsheet Make sure your spreadsheet has clear column headers that match (or are close to) your template's placeholder names. The wizard will try to auto-match them, but clean headers make the process much faster. ::: --- ## Step 1: Open the Bulk Send Wizard Navigate to the **Edit Template & Preferences** page for the template you want to send in bulk. You'll see a **"Bulk Send"** button alongside the other template actions. **Click "Bulk Send"** to open the wizard. ![Bulk Send button on Edit Template & Preferences page](/img/turbosign/bulk/bulk-send-button.jpeg) :::note Non-Pro Users If your organization doesn't have the Pro plan, the Bulk Send button displays an upgrade badge. Clicking it opens the upgrade dialog instead of the wizard. ::: --- ## Step 2: Upload Your Spreadsheet The first step of the wizard is uploading your recipient data. ### How to Upload - **Drag and drop** your file onto the upload area, or - **Click to browse** and select a file from your computer ### Supported Formats | Format | Extension | Notes | |--------|-----------|-------| | CSV | `.csv` | Comma-separated values | | Excel | `.xlsx` | Multi-sheet support — select which sheet to use | ### What You'll See After uploading, the wizard displays: - **File name** and format confirmation - **Sheet selector** (for Excel files with multiple sheets) - **Preview table** showing the first 5 rows of your data with column headers ![Drag and drop or browse to upload your spreadsheet](/img/turbosign/bulk/spreadsheet-upload.jpeg) Review the preview table to confirm your data looks correct, then click **Next**. ![Spreadsheet preview showing column headers and first rows](/img/turbosign/bulk/spreadsheet-preview.jpeg) :::tip File Tips - **Headers matter** — the first row of your spreadsheet should contain column headers, not data - **Keep it clean** — remove empty rows or columns before uploading - **Check encoding** — for CSV files, use UTF-8 encoding to avoid character issues ::: --- ## Step 3: Map Columns & Configure Recipients This is the most important step. Here you'll connect your spreadsheet data to your template's placeholders, set up signature fields, and define who receives each document. ### 3a. Map Template Variables Each template variable (placeholder) needs to be resolved — either by mapping it to a spreadsheet column or by designating it as a signature field. ![Template variable mapping with auto-match](/img/turbosign/bulk/column-mapping.jpeg) **Auto-Matching:** The wizard automatically attempts to match your column headers to template placeholders using fuzzy matching. For example, a column named `client_email` would auto-match to a `{clientEmail}` placeholder. Auto-matched mappings display an **"Auto-matched"** chip. **Manual Override:** You can always override an auto-match by selecting a different column from the dropdown. **Signature Field Option:** Instead of mapping a variable to a column, you can click the **"Sign"** button next to a variable to designate it as a **signature field** — meaning it becomes a field that the recipient fills in or signs during the signing process. ![Click the Sign button to designate a variable as a signature field](/img/turbosign/bulk/signature-field-toggle.jpeg) ### 3b. Configure Signature Fields For any variable designated as a signature field, you'll configure: **Field Type** — choose from the following: | Field Type | Description | |------------|-------------| | **Signature** | Full signature capture | | **Initial** | Initials field | | **Date** | Date picker | | **Name** | Full name text field | | **First Name** | First name only | | **Last Name** | Last name only | | **Email** | Email address field | | **Title** | Job title field | | **Company** | Company name field | | **Text Input** | Free-form text entry | | **Checkbox** | Checkbox confirmation | **Recipient Assignment** — assign the field to a specific recipient (signer). **Optional Settings:** - **Default Value** — pre-fill the field with a value the signer can review or edit - **Read Only** — lock the field so the signer can see it but not change it (requires a default value) - **Multiline** — for text fields, allow multi-line input ### 3c. Add Recipients Define who signs each document by mapping spreadsheet columns to recipient slots. **For each recipient, configure:** - **Email Column** — which spreadsheet column contains their email address - **Name Column** — which spreadsheet column contains their name **Additional options:** - **Multiple Recipients** — click **"Add Recipient"** to add up to **10 signers** per document. Each recipient is color-coded for easy visual identification - **CC Email Column** — optionally select a column containing CC email addresses (supports comma or semicolon-separated lists for multiple CCs per row) ![Recipient configuration with CC email mapping](/img/turbosign/bulk/recipient-config.jpeg) ### 3d. Document Naming (Optional) Build dynamic document names for each generated document using the name builder toolbar: - **Insert Column** — pull in a value from your spreadsheet (e.g., a "DocumentTitle" column) - **Insert Date** — append the current date - **Add Separator** — insert a separator character (hyphen, underscore, slash, etc.) - **Static text** — type fixed text directly Drag segments to reorder them. The wizard shows a live preview of the resulting document name using data from your first row. ![Document name builder with Insert Column, Insert Date, and Add Separator controls](/img/turbosign/bulk/document-naming.jpeg) ### 3e. Validation The wizard validates your configuration before allowing you to proceed. If there are issues, you'll see red alert banners explaining what needs to be fixed. **Common validation requirements:** | Requirement | What It Means | |-------------|---------------| | At least one signature field | You need at least one variable designated as a signature field | | All variables resolved | Every template variable must either map to a column, be a signature field, or explicitly set to empty | | Recipient email & name mapped | Each recipient slot needs both an email and name column | | Valid email addresses | All emails in mapped email columns must be valid | | Each recipient has a signature field | Every recipient needs at least one field assigned to them | | Editable fields per recipient | Each recipient must have at least one field that isn't read-only | | Read-only fields need defaults | Any read-only field must have a default value set | :::tip Fixing Validation Errors Read each alert carefully — they tell you exactly which recipient or field has the issue. Most problems are resolved by assigning a missing column mapping or adding a signature field to a recipient. ::: --- ## Step 4: Preview Before submitting, you can preview exactly what each merged document will look like. ### What You'll See - **PDF Preview** — a rendered preview of the document with your spreadsheet data merged in - **Variable Table** — a side-by-side table showing which values were inserted for the current row - **Row Navigator** — **Previous** and **Next** buttons to cycle through rows and check different recipients' documents Use this step to verify that: - Variables are merging correctly - The right data appears in the right places - The document looks as expected ![Document preview showing merged variable values](/img/turbosign/bulk/preview.jpeg) :::tip Preview Tips - **Check multiple rows** — don't just preview the first row. Navigate through several rows to spot formatting issues or empty fields - **Watch for long text** — verify that longer values don't overflow or break the document layout ::: --- ## Step 5: Review & Submit The final step before sending your batch. ### What to Review - **Batch Name** — give your batch a descriptive name for easy tracking (defaults to the template name) - **Recipient Summary** — review the first several rows showing recipient names and emails - **Document Count** — confirms how many documents will be generated and sent ### Submitting Click **"Submit"** to initiate batch processing. The wizard shows a loading state while submitting. **On success:** the dialog closes and your batch begins processing in the background. You can monitor progress from the Batches dashboard. ![Submit Batch button on the review screen](/img/turbosign/bulk/submit-batch.jpeg) --- ## Monitoring Your Batch After submitting, navigate to the **Batches** dashboard to track your batch. ### How to Get There Go to **E-Signatures** in the sidebar, then click the **"Bulk Batches"** tab to navigate to the batch management page. ![Navigate to E-Signatures and click Bulk Batches](/img/turbosign/bulk/bulk-batches-nav.jpeg) ### Batch Dashboard The dashboard shows all your batches with: | Column | Description | |--------|-------------| | **Batch Name** | The name you assigned during submission | | **Status** | Current batch status (see below) | | **Jobs** | Total / Succeeded / Failed / Pending counts | | **Created** | When the batch was submitted | | **Updated** | When the batch was last updated | ### Batch Statuses | Status | Icon | Description | |--------|------|-------------| | **Pending** | ⏳ | Batch is queued and waiting to start processing | | **Processing** | ⏳ | Documents are being generated and sent | | **Completed** | ✅ | All jobs finished (some may have failed) | | **Failed** | ❌ | Batch encountered a critical error | | **Cancelled** | ❌ | Batch was cancelled by user | ### Filtering & Search - **Search** by batch name - **Filter** by status group and time period - **Sort** by any column - **Paginate** through large result sets ![Batches dashboard showing batch status and job counts](/img/turbosign/bulk/batches-dashboard.jpeg) --- ## Tips & Best Practices ### Spreadsheet Preparation - **Use clear column headers** that closely match your template placeholder names — this maximizes auto-matching accuracy - **Validate email addresses** before uploading — invalid emails cause individual job failures - **Remove empty rows** at the bottom of your spreadsheet - **Keep one row per document** — each row generates one document with one set of recipients ### Start Small - **Test with 5-10 rows first** to verify your configuration before scaling up - **Preview multiple rows** to catch edge cases - **Check the Batches dashboard** after your test batch to confirm all jobs succeeded ### Signature Fields - **Each recipient needs at least one editable signature field** — the system enforces this to ensure every signer has something to complete - **Use read-only fields** for information the signer should see but not change (like a pre-filled address) - **Set sensible defaults** for text fields when you already know the value ### Credit Management - **1 credit per document** — a batch of 100 documents uses 100 credits, regardless of how many recipients each document has - **Credits are reserved** when you submit and **refunded** for any failed or cancelled jobs - **Check your credit balance** before submitting large batches --- ## Troubleshooting ### "The Next Button Is Disabled on Step 3" **Cause:** One or more validation requirements aren't met. **Solution:** Scroll through the step and look for red alert banners. They describe exactly what's missing — usually an unmapped variable, a recipient without a signature field, or an invalid email address. ### Preview PDF Isn't Loading **Cause:** The preview generation may be taking time for complex templates. **Solution:** Wait a few moments. If it persists, try navigating to a different row and back. Ensure your template doesn't have errors that would prevent document generation. ### Batch Stuck in "Processing" **Cause:** Large batches take time to process. Each document goes through generation, PDF conversion, and signature request creation. **Solution:** Wait for processing to complete — most batches finish within 30 minutes. Check individual job statuses in the Batches dashboard. If stuck for over an hour, contact support. ### Some Jobs Failed in My Batch **Cause:** Individual jobs can fail due to invalid email addresses, template errors for specific data, or transient issues. **Solution:** Check the failed job's error message in the Batches dashboard. Fix the data issue and submit a new batch with just the corrected rows. --- ## What's Next? Now that you know how to send bulk signatures from the UI, explore these related features: - **[How to Get a Document Signed](/docs/TurboSign/Setting-up-TurboSign)** — the single-document signing flow for one-off requests - **[TurboSign Bulk API Integration](/docs/TurboSign/API-Bulk-Signatures)** — send bulk signatures programmatically via API - **[Webhooks](/docs/TurboSign/Webhooks)** — get real-time notifications when documents are signed - **[Managing Your Signatures](/docs/TurboSign/Managing-Your-Signatures)** — track, resend, void, and download signed documents --- # Conditional (IF/THEN) Fields Conditional fields let a document react to what the signer does. A **controlling checkbox** decides whether one or more **dependent fields** are shown or unlocked, so signers only fill in the fields that actually apply to them. A classic example: a reviewer ticks **"Request changes"**, and only then does a text box appear asking them to explain what to change. If they leave the box unticked, the text box never gets in the way. ## Two ways a field can react Every conditional field starts in one of two states and changes when the condition is met: | Behavior | Starting state of the dependent field | When the condition is met | | -------- | ------------------------------------- | ------------------------- | | **Show** | **Hidden** (left out of the signed PDF entirely) | The field appears | | **Unlock** | **Visible but read-only** | The field becomes fillable | Use **Show** when the field is irrelevant unless the box is set (a reason box that only matters if changes are requested). Use **Unlock** when the field should always be visible for context but must not be edited until the signer opts in (an amount field that stays greyed out until "Override default amount" is ticked). :::tip Which section do you need? - **Setting this up by hand in the TurboSign app?** → [In the app](#in-the-app) - **Building it through the API or an SDK?** → [With the API and SDKs](#with-the-api-and-sdks) Both produce the same result — [the app's controls map directly onto the API fields](#how-the-app-maps-to-the-api). ::: ## In the app Set up conditional fields while you place fields on a document, on the **Assign Fields** step of the signature flow. This example rebuilds the reviewer scenario: a checkbox that reveals a "please explain" text box. ### Step 1: Open the field editor Start a new signature request, upload your document, add your recipient, and continue to the **Assign Fields** step. The field palette is on the right; the document is in the middle. ![The TurboSign Assign Fields editor with the field palette on the right](/img/turbosign/conditional-fields/01-field-editor.png) ### Step 2: Add the checkbox and the field it controls From the palette, drag a **Checkbox** field onto the document — this is the controlling box. Then drag the field it will control (here, a **Text** field) onto the document. ![The field palette with the Checkbox and Text fields highlighted](/img/turbosign/conditional-fields/02-add-fields.png) ### Step 3: Turn on the rule Click the dependent field (the text box) to open its settings, then open **Only show this field sometimes** and set the rule: - **Which checkbox?** — pick the controlling checkbox you just added. - **Show it when the box is** — choose **Ticked** or **Not ticked**. - **Until then, this field is** — choose **Hidden** (the field is left out until the condition is met) or **Visible, but not fillable** (the field shows but can't be typed in yet). A plain-English summary underneath confirms the rule, for example *"This field stays hidden until Checkbox 1 is ticked."* ![The "Only show this field sometimes" rule builder with the checkbox, condition, and starting-state controls highlighted](/img/turbosign/conditional-fields/03-rule-builder.png) ### Step 4: Send Finish placing any other fields and send the document. The signer sees the dependent field appear (or unlock) only when they set the controlling checkbox the way your rule specifies. :::note Keep the checkbox and its dependent fields with the same signer The controlling checkbox and the fields it controls should belong to the **same recipient**, so the person ticking the box is the same person who fills in the revealed field. ::: ## With the API and SDKs Conditional logic is expressed entirely through the optional `metadata` object on a field, so it works anywhere fields are accepted — the single-step [Prepare for Signing and Prepare for Review](/docs/TurboSign/API-Signatures) routes and the [Bulk API](/docs/TurboSign/API-Bulk-Signatures). ### How it works A conditional relationship always has two halves: 1. **A controlling checkbox** — a field of `type: "checkbox"` that carries a stable identifier, `metadata.fieldKey`. 2. **One or more dependent fields** — each carries a `metadata.conditional` rule whose `controllingFieldKey` matches the checkbox's `fieldKey`. The rule names the condition (`operator`) and what happens when it is met (`action`). One checkbox can control any number of dependent fields — give them all the same `controllingFieldKey`. ### The metadata contract ```jsonc metadata: { // On a CONTROLLING checkbox (type: "checkbox"): fieldKey: "request_changes", // On a DEPENDENT field: conditional: { controllingFieldKey: "request_changes", // = the checkbox's fieldKey operator: "is_checked", // "is_checked" | "is_not_checked" action: "show" // "show" | "unlock" } } ``` | Property | Values | Meaning | | ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------- | | `fieldKey` | any non-empty string | Stable id on the controlling checkbox. Dependent fields point at this. | | `conditional.controllingFieldKey` | a checkbox's `fieldKey` | Which checkbox this field depends on. Required and must be non-empty. | | `conditional.operator` | `is_checked`, `is_not_checked` | The condition evaluated against that checkbox. | | `conditional.action` | `show`, `unlock` | What happens to this field when the condition is met. | The `action` values map onto the two behaviors above: `show` starts the field hidden, `unlock` starts it visible but read-only. ### Worked example: a checkbox reveals a text field The reviewer sees a **"Request changes"** checkbox. Only if they check it does the **"Please explain"** text field appear. ```javascript const fields = JSON.stringify([ // The recipient's signature. Every recipient needs at least one required field. { recipientEmail: "reviewer@acme.com", type: "signature", page: 1, x: 100, y: 600, width: 200, height: 80, }, // 1) Controlling checkbox — gets a stable fieldKey { recipientEmail: "reviewer@acme.com", type: "checkbox", page: 1, x: 100, y: 400, width: 20, height: 20, required: false, metadata: { fieldKey: "request_changes", }, }, // 2) Dependent text field — HIDDEN until "request_changes" is checked { recipientEmail: "reviewer@acme.com", type: "text", page: 1, x: 130, y: 400, width: 300, height: 60, required: false, defaultValue: "", metadata: { conditional: { controllingFieldKey: "request_changes", operator: "is_checked", action: "show", }, }, }, ]); formData.append("fields", fields); ``` When the reviewer checks the box, the text field appears. Uncheck it and the field disappears again. ### Worked example: a checkbox unlocks a locked field Here the amount field is always visible so the signer can see the default, but it stays read-only until they check **"Override default amount"**. ```javascript const fields = JSON.stringify([ // The recipient's signature. Every recipient needs at least one required field. { recipientEmail: "signer@acme.com", type: "signature", page: 1, x: 100, y: 600, width: 200, height: 80, }, // Controlling checkbox { recipientEmail: "signer@acme.com", type: "checkbox", page: 1, x: 100, y: 500, width: 20, height: 20, required: false, metadata: { fieldKey: "override_amount", }, }, // Dependent amount field — VISIBLE but LOCKED until the box is checked { recipientEmail: "signer@acme.com", type: "text", page: 1, x: 130, y: 500, width: 150, height: 30, required: false, defaultValue: "1000.00", metadata: { conditional: { controllingFieldKey: "override_amount", operator: "is_checked", action: "unlock", }, }, }, ]); formData.append("fields", fields); ``` ### Reveal a field when a box is cleared Set `operator: "is_not_checked"` to invert the logic. For example, an **"I do not consent"** checkbox can reveal a text field asking the signer to explain, only when consent is *not* given: ```javascript { metadata: { conditional: { controllingFieldKey: "consent", operator: "is_not_checked", action: "show", }, }, } ``` ### Validation and fail-open behavior The API **validates the shape** of every `conditional` rule before creating the document. A malformed rule is rejected with HTTP **400** and the `type` **`InvalidConditionalRule`**. A rule is malformed when: - `operator` is anything other than `"is_checked"` or `"is_not_checked"`, or - `action` is anything other than `"show"` or `"unlock"`, or - `controllingFieldKey` is missing or empty. The response body names the offending field and the exact problem: ```json { "message": "Field 2: metadata.conditional.operator must be one of: is_checked, is_not_checked", "type": "InvalidConditionalRule" } ``` :::warning A well-formed rule with a wrong key fails open The API does **not** verify that a rule's `controllingFieldKey` actually matches a checkbox in the request. If it doesn't match any checkbox's `fieldKey`, the rule **fails open**: the dependent field stays fully visible and editable, exactly as if it had no rule, and **no error is returned**. A typo in `controllingFieldKey` therefore silently disables the condition instead of blocking the send. Always confirm each dependent field's `controllingFieldKey` exactly matches an existing checkbox's `fieldKey`. ::: ### Tips - The controlling field must be a **checkbox** (`type: "checkbox"`). Only checkboxes can hold a controlling `fieldKey`. - Keep `fieldKey` values short, stable, and unique within a document (`request_changes`, `override_amount`, `consent`). - A dependent field that is `required: true` but hidden via `action: "show"` is only enforced once it becomes visible — don't rely on a hidden required field to block completion. The same goes for a field held read-only by `action: "unlock"`: it is only enforced once the box unlocks it. - Set `required: false` on a dependent field if the signer may leave it blank even after it appears or unlocks. See [Optional Fields](/docs/TurboSign/API-Signatures#optional-fields). - The controlling checkbox and its dependent fields should generally belong to the **same recipient** so the same signer both toggles the box and fills the revealed field. ## How the app maps to the API The app's rule builder and the API `metadata.conditional` object are the same thing in two forms: | In the app | In the API | | ----------------------------------- | ------------------------------------------------------- | | **Which checkbox?** | `controllingFieldKey` (the checkbox's `fieldKey`) | | **Show it when the box is → Ticked** | `operator: "is_checked"` | | **Show it when the box is → Not ticked** | `operator: "is_not_checked"` | | **Until then, this field is → Hidden** | `action: "show"` | | **Until then, this field is → Visible, but not fillable** | `action: "unlock"` | ## Related - **[TurboSign API Integration](/docs/TurboSign/API-Signatures#conditional-if-then-fields)** — the full field reference, including the `metadata` object. - **[Bulk API Integration](/docs/TurboSign/API-Bulk-Signatures)** — conditional metadata works in bulk batches too. --- # Email Deliverability (DKIM / DMARC / SPF) TurboDocx emails, including TurboSign signature requests and reminders, are sent from an authenticated domain with DKIM, SPF, and DMARC in place. If a message shows a DKIM, DMARC, or SPF failure, this page walks you through what to check and how to resolve it. The most common cause is something modifying the email **in transit, after it was signed**, usually a security gateway between the sender and the inbox. The steps below help you confirm where it's happening and what to do about it. ## What's happening A DKIM signature includes a cryptographic hash of the message body. The email is signed before it leaves our infrastructure. If anything along the delivery path changes the body, even slightly, the hash no longer matches and DKIM fails. Because DMARC passes only when SPF or DKIM is aligned, DMARC can fail as a result. Email security products often modify messages as they inspect them: - Rewriting links for click-time protection (the most common trigger) - Adding external-sender banners, warnings, or disclaimers - Sanitizing or re-encoding HTML and MIME - Inspecting or re-packaging attachments - Normalizing whitespace or line endings Products that do this include secure email gateways and anti-phishing platforms such as Avanan (Check Point), Mimecast, Proofpoint, and Microsoft Safe Links. The behavior depends on each organization's configured policy, so the same message can pass for one recipient and fail for another. ## Symptoms you might see - A message marked as failing DKIM or DMARC - A message quarantined, delayed, or flagged as suspicious - Headers containing `body hash did not verify` - SPF passing on first arrival but failing after the message is relayed - A message that delivers fine to one mailbox (for example Gmail) but fails at another A failing DKIM result in the headers typically looks like this: ``` dkim=fail (body hash did not verify) header.d=sign.turbodocx.com ``` A message can carry more than one DKIM signature, and when the body is modified in transit they tend to fail together, so seeing two failures at once is expected and points to the same cause. ## Why SPF can also "softfail" When a gateway inspects a message and then relays it onward, the receiving server runs SPF again. At that point the connecting IP belongs to the **gateway**, not the original sending infrastructure. Since that IP is not in the TurboDocx SPF record, SPF can report a soft failure: ``` spf=softfail smtp.mailfrom=e.sign.turbodocx.com sender IP= ``` This is a side effect of the relay, not a DNS problem on the sending side. Don't add the gateway's IP to the TurboDocx SPF record. It isn't a TurboDocx sender, and adding it won't fix the underlying body-hash mismatch. ## Troubleshooting steps Work through these in order to pinpoint where the failure is introduced. 1. **Send the same email to a directly delivered mailbox.** Use a mailbox that receives mail without a gateway in front of it (a personal Gmail address works well). A healthy result looks like: ``` dkim=pass header.d=sign.turbodocx.com spf=pass dmarc=pass ``` If it passes here but fails at the affected mailbox, the difference is something in that mailbox's delivery path, typically a security gateway. 2. **Compare the authentication results before and after the gateway.** Many platforms record the original result in a header such as `Authentication-Results-Original` (or an ARC chain). If SPF, DKIM, and DMARC passed *before* the gateway processed the message, the message was authenticated correctly and was altered afterward. 3. **Test with the modifying policy temporarily off.** Ask the administrator of the affected environment to exclude a test mailbox (or the TurboDocx application URL) from URL rewriting and content modification, then resend with a unique subject. If DKIM and DMARC now pass, you've confirmed the policy as the cause. ## How to resolve it If the failure only appears after a gateway modifies the message, the sending configuration is working and the change is made on the receiving side. The administrator of the affected email environment has a few options: - **Rely on ARC.** ARC is designed to carry the original authentication results across an intermediary that modifies a message, so a receiver that trusts the gateway's ARC seal can still treat the message as authenticated. Confirm the receiving platform is configured to trust that seal. - **Exclude the TurboDocx URLs from link rewriting.** For most gateways this means adding both `app.turbodocx.com` and `sign.turbodocx.com` to the link-rewriting exclusion list. TurboSign signature requests link to `sign.turbodocx.com`, so excluding only `app.turbodocx.com` won't stop those messages from being modified. This stops the body from being changed for those links. Because it also skips click-time inspection for them, treat it as a scoped exception rather than a blanket change. - **Create a narrowly scoped exception for TurboDocx messages** instead of disabling protection broadly. - **Contact the gateway vendor** if messages are being incorrectly quarantined or rejected despite a valid ARC chain. ## What not to do - Don't loosen or disable DKIM and DMARC enforcement on the receiving side just to make the failure disappear. That hides the problem and weakens protection for all of your inbound mail, not just TurboDocx. - Don't read the domain in an SPF result as the domain being authenticated. After a relay it reflects the gateway, not the original sender. If the message passes at a directly delivered mailbox in step 1, the TurboDocx sending configuration (SPF and DKIM) is already correct. The fix for a gateway-modified message is on the receiving side, so there's no change to make on the TurboDocx side. ## What to share if you contact support If you've worked through the steps and still need help, include as much of the following as you can. It lets us pinpoint the cause quickly: - The complete message headers - The email platform and any security product in the delivery path - Whether link rewriting or external banners are enabled - The final DKIM, SPF, DMARC, and ARC results - The result of the same email sent to a directly delivered mailbox (step 1) - The result with the modifying policy temporarily off (step 3) If a TurboDocx email passes DKIM, SPF, and DMARC when delivered directly but fails only after a gateway modifies it, the cause is that in-transit change rather than the sending configuration, and the fix lives with the gateway or the receiving platform's handling of it. --- # How to Configure Signature Appearance TurboSign stamps each signed field onto the finished PDF with a thin outline, a small label, and a verification hash. It also decides how **locked fields** — read-only values imported onto a document that the signer can't change — appear while someone is signing. You can control all of this for your whole organization from one settings screen. This guide shows you how to find and change the **Signature Appearance** and **Locked Fields Rendering** options. :::note What these options do **Signature Appearance** — how each signed field looks on the finished PDF: - **Show signature outline and label** — the thin border and caption drawn around each signed field on the PDF. - **Show verification hash** — the short verification code printed beneath a signature. **Locked Fields Rendering** — how locked (read-only) fields look to the signer in the signing portal: - **Show locked fields with grey background** — draw each locked field as a grey box, or, when turned off, render its value as plain document text (and hide blank locked fields entirely) for a cleaner page. All three are **on** by default, so your documents look the same until you change them. These settings apply to every new signature request in your organization. ::: ## Step 1: Open the account menu In the left sidebar, click your **account avatar** at the bottom. ![Left sidebar with the account avatar at the bottom highlighted](/img/signature-appearance/01-user-avatar.png) ## Step 2: Open Settings In the menu that appears, click **Settings**. ![Account menu with the Settings item highlighted](/img/signature-appearance/02-settings-menu-item.png) ## Step 3: Go to Features and integrations In the menu on the left of the Settings page, click **Features and integrations**. ![Settings menu with Features and integrations highlighted](/img/signature-appearance/03-features-and-integrations-link.png) ## Step 4: Open E-Signature settings Scroll to the **Signatures** card under **Core Features** and click **Configure E-Signature**. ![Signatures card with the Configure E-Signature button highlighted](/img/signature-appearance/04-configure-esignature.png) ## Step 5: Set the signature appearance options In the **E-Signature Settings** dialog, find the **Signature Appearance** section. Select or clear each checkbox to turn the option on or off: - **Show signature outline and label** - **Show verification hash** ![E-Signature Settings dialog with the Signature Appearance toggles highlighted](/img/signature-appearance/05-signature-appearance-toggles.png) Your choice is saved for the organization and applies to future signature requests. ## Step 6: Choose how locked fields appear In the same **E-Signature Settings** dialog, find the **Locked Fields Rendering** section below the appearance options. - Leave **Show locked fields with grey background** **on** (the default) to keep drawing each locked field as a grey box. - Turn it **off** to render locked values as plain document text instead. Blank locked fields are then hidden completely, which keeps the page clean and stops an empty box from sitting over the text beneath it. ![E-Signature Settings dialog with the Show locked fields with grey background checkbox highlighted](/img/signature-appearance/06-locked-fields-rendering.png) This setting changes what signers see in the **signing portal**; it does not change the signature stamp on the finished PDF. :::tip Turning the two **Signature Appearance** options off gives a cleaner, minimal signature stamp — useful when you want the signature to blend into a formal document layout. Turning **Show locked fields with grey background** off similarly declutters the signing page for the recipient. Signed PDFs still carry their full audit trail either way. ::: --- # Managing Your Signatures Once you've sent your document for signature, your work isn't done! This guide covers everything you need to know about managing your signature requests, tracking progress, and handling any issues that come up. ## What You'll Learn - 📊 **Track signature progress** and see who's signed - 📧 **Resend signature emails** when needed - ❌ **Void documents** that need to be cancelled - 🔍 **View audit trails** for complete signature history - 📄 **Download completed documents** when all signatures are collected ## Quick Reference: Common Actions **Access all document actions through the context menu (three-dot menu ⋮):** | Action | Purpose | |--------|---------| | **Void Document** | Cancel signature request | | **Resend Email** | Send reminder to recipients | | **Download PDF** | Get fully signed document | | **Audit Trail** | View complete activity history | --- ## Tracking Signature Progress & Viewing Audit Trails ### Automatic Email Delivery **Good news!** TurboSign automatically sends a copy of the completed document and the user-facing audit trail to all participants via email when the signature process is complete. This means: - **All signers** get their own copy of the completed document - **CC recipients** get a copy too - **You, the sender**, get a "Your document is fully signed" email with the signed document, even if you weren't a signer or CC - **Email delivery** happens instantly when the last signature is completed - **Audit trail** is included for compliance and record-keeping Nobody gets the same document twice: if you're also a signer or CC on the document, you receive just that one copy. So while you can always redownload documents from your dashboard, you likely already have the completed document in your email inbox! :::note When the sender doesn't get a copy The sender's copy isn't sent for documents sent through **Bulk Signature Sending** (that would be one email per document), or for documents sent with an **API key** (use [webhooks](./Webhooks.md) to get notified instead). ::: #### Turn off the sender's copy Organization admins can turn the sender's email off for everyone in the organization: 1. Click your **account avatar** at the bottom of the left sidebar, then click **Settings**. 2. In the menu on the left, click **Features and integrations**. 3. On the **Signatures** card, click **Configure E-Signature**. 4. In the **General** section, scroll to **Sender Notifications** and clear **Email the sender when a document is fully signed**. ![E-Signature Settings dialog with the Email the sender when a document is fully signed checkbox highlighted](/img/turbosign/SenderNotificationsSetting.png) The change saves immediately. Signers and CC recipients still get their copies. The setting is checked when a document is completed, so it also applies to documents that are already out for signature. --- ## How to Resend Signature Emails Need to send a gentle reminder to recipients who haven't signed yet? TurboSign makes it easy to resend signature requests to the next person in your signing order. ### Step-by-Step: Resending Signature Emails **Step 1: Access the Document Actions** 1. Navigate to your **TurboSign Dashboard** 2. Find the document with pending signatures 3. Click the **three-dot menu (⋮)** next to the document ![Document context menu showing resend email option](/img/turbosign/ResendEmail.png) **Step 2: Send the Reminder** 4. Click **"Resend Email"** from the context menu 5. The resend modal will open showing available recipients ![Resend email modal showing recipient selection](/img/turbosign/ResendEmailHighlighted.png) 6. Click **"Send Email"** to dispatch the email 7. The dialog will close and the recipient will receive a reminder email with their signing link --- ## How to Void a Document Need to cancel a signature request that's already been sent? TurboSign makes it easy to void documents and notify all recipients. ### Step-by-Step: Voiding a Document **Step 1: Access the Document Actions** 1. Navigate to your **TurboSign Dashboard** 2. Locate the document you want to void 3. Click the **three-dot menu (⋮)** next to the document ![Document context menu showing void option](/img/turbosign/VoidDocument.png) **Step 2: Enter Void Reason** 4. Enter a void reason that explains why you are voiding the document. ![Void dialog with reason field](/img/turbosign/VoidDialogReasonsForVoiding.png) **Step 3: Confirm Void** 5. Click the **"Void Document"** button in the bottom right corner to confirm ![Clicking void document confirmation button](/img/turbosign/ClickVoidDocument.png) 6. All recipients receive automatic notifications and the document status changes to "Voided" --- ## How to Redownload Completed Documents Need to download your fully signed document again? TurboSign makes it easy to redownload the completed PDF with all signatures whenever you need it. ### Step-by-Step: Redownloading Your Signed Document **Step 1: Access the Document Actions** 1. Navigate to your **TurboSign Dashboard** 2. Look for documents with **green "Completed" status** 3. Click the **three-dot menu (⋮)** next to your completed document ![Document context menu showing download PDF option](/img/turbosign/DownloadPDF.png) **Step 2: Download the PDF** 4. Click **"Download PDF"** from the context menu 5. Your browser will automatically start downloading the file with all signatures --- ## How to View Audit Trails Need to see the complete history of your document? TurboSign maintains a detailed audit trail of every action taken. ### Step-by-Step: Accessing Audit Trails **Step 1: Access the Document Actions** 1. Open your **TurboSign Dashboard** 2. Find any document (completed or in-progress) 3. Click the **three-dot menu (⋮)** next to the document ![Document context menu showing audit trail option](/img/turbosign/AuditTrail.png) **Step 2: View the Timeline** 4. Click **"Audit Trail"** from the context menu 5. The audit trail modal opens showing a chronological timeline ![Audit trail overview showing complete timeline](/img/turbosign/AuditTrailOverview.png) 6. Review all events with timestamps, participants, and security details. **Understanding Event Types:** The audit trail displays two main categories of events: **User Events** - Actions taken by document participants: - **Document Viewed** - When recipients open and view the document - **Document Signed** - When recipients complete their signature These are the two most important user events to monitor for tracking signature progress: ![Document viewed event details](/img/turbosign/AuditTrailDocumentViewed.png) ![Document signed event details](/img/turbosign/AuditTrailDocumentSigned.png) **System Events** - Automated actions by TurboSign: - **Email Notification Sent** - When the system sends emails to recipients - **Document Digitally Signed by TurboSign** - When TurboSign applies digital signatures - **Document Updated** - When the document is modified or updated via signature - **Document Sent** - When the document is initially distributed for signature ### What Each Event Records Alongside the timestamp and participant, every audit trail event captures the environment the action came from: | Column | What it shows | |--------|---------------| | **Device** | The device or host the action came from | | **Operating System** | The OS in use at the time of the action | | **Timezone** | The actor's IANA timezone (for example, `America/New_York`) | | **Language** | The actor's locale (for example, `en-US`) | | **Application** | What made the request — a browser, an SDK, the n8n node, or an HTTP client | **Where these values come from depends on how the action was performed:** - **In the TurboDocx web app or the signing page** — captured from the browser. - **Through a TurboDocx SDK** — the SDK sends client-context headers on every request, so the real device, operating system, timezone, and language are recorded. The application is logged as `TurboDocx SDK `. - **Through the TurboDocx n8n node** — same real environment details, logged as `TurboDocx n8n Node `. - **Through a raw API call** (curl, Postman, your own HTTP client) — TurboDocx records the **name of the HTTP library** that made the call, the action `API Request`, and `N/A` for the environment fields it has no way to know. :::tip Want richer audit entries for automated sending? Use one of the [TurboDocx SDKs](/docs/SDKs) or the n8n node instead of hand-rolled HTTP requests. They populate the device, OS, timezone, and language columns automatically — raw API calls leave those as `N/A`. ::: ### How to Redownload the Audit Trail We already send a pretty user-facing Audit Trail with User events, but if you want to download a detailed copy, click **Download Audit Trail as JSON**, --- --- *Need help with signature management? Each action includes detailed step-by-step guides, screenshots, and best practices for professional document handling.* --- # How to Get a Document Signed with TurboSign Ready to send your first document for e-signature? You're in the right place! This step-by-step guide will walk you through the entire process of preparing and sending your document using TurboSign. ## What You'll Accomplish By the end of this guide, you'll know how to: - 📄 **Upload or select documents** for signature - 👥 **Add recipients** and manage signing order - ✏️ **Place signature fields** exactly where you need them - 📤 **Send documents** for signature with confidence - 🔄 **Track the signing process** from start to finish :::tip Quick Start Promise This entire process takes less than 5 minutes once you get the hang of it. We'll have you sending professional signature requests in no time! 🚀 ::: ## Before You Begin To use TurboSign effectively, you'll need: - Access to TurboDocx - A document ready for signing (PDF, Word, or existing TurboDocx deliverable) - Email addresses for all recipients who need to sign - About 60 seconds of your time ⏰ :::tip Pro Tip Have your document ready and know exactly where signatures are needed before you start. This makes the whole process much smoother! ::: ## Step 1: Click "Get It Signed" Let's start by getting to TurboSign from your main dashboard. From the **TurboDocx homepage**, look for the **"Get It Signed"** button and click it. This will take you directly to **TurboSign** where all the magic happens. ![Get It Signed button on TurboDocx homepage](/img/turbosign/GetSigned.png) ## Step 2: Start a New Signature Request Now that you're in TurboSign, it's time to create your first signature request. In the **top right corner** of the TurboSign interface, you'll see a **"New Signature"** button. Click it to begin preparing your document for signatures. ![New Signature button on TurboSign dashboard](/img/turbosign/ClickNewSignature.png) ## Step 3: Upload or Select a Document Time to choose your document! TurboSign gives you two convenient options: ### Option A: Upload a New Document **Upload a new document** from your computer by clicking the upload area or dragging and dropping your file. **Supported formats:** - PDF files (.pdf) - Word documents (.docx) - Other common document formats ### Option B: Select an Existing Deliverable **Select an existing deliverable** you've already created within TurboDocx. This is perfect if you've already generated a document and want to get it signed. ![Document upload and selection interface](/img/turbosign/SelectOrUploadDocument.png) :::tip Document Tips - **PDF files work best** for consistent formatting across all devices - **Keep file sizes reasonable** (under 10MB) for faster loading - **Make sure your document is final** before uploading - you can't edit content after this step ::: ## Step 4: Add Document Details Now let's add some important information about your document. Fill in the required information: ### Document Name (Required) Give your document a clear, descriptive name. This will help you and your recipients identify the document easily. **Good examples:** - "Q3 Marketing Agreement - ABC Corp" - "Employment Contract - John Smith" - "Service Agreement - Project Alpha" ### Description (Optional) Add a brief description to provide context for your recipients. This appears in the signature request email. **Good examples:** - "Please review and sign this quarterly marketing agreement" - "New employee contract for review and signature" - "Service agreement for the upcoming project" ![Document details form with name and description fields](/img/turbosign/DocumentNameAndDescription.png) ## Step 5: Add Recipients Time to add the people who need to sign your document. This is where you control who signs and in what order. ### Adding Recipients **Click "Add Recipient"** to assign others to sign the document. You can add multiple recipients as needed. For each recipient, you'll need: - **Full name** (as it should appear on the signature) - **Email address** (where they'll receive the signature request) ### Including Yourself **Click "Include Me"** if you also need to sign the document. This adds you to the recipient list automatically. ### Setting Signing Order **Drag to change the signing order** if it matters for your document. The signing order determines who gets the document first. **When order matters:** - Legal documents that require witness signatures - Contracts where one party must sign before the other - Documents with approval workflows **When order doesn't matter:** - Simple agreements between equal parties - Documents where all parties can sign simultaneously ![Add recipients interface showing all options](/img/turbosign/AddRecipients.png) **CC Emails (Optional):** You can also add CC email addresses for people who should receive a copy of the document. These individuals will get the final signed copy when everyone has completed signing, but they won't need to sign the document themselves. :::tip Recipient Best Practices - **Use professional email addresses** for business documents - **Double-check email addresses** - typos mean delayed signatures - **Add yourself first** if you need to sign before others - **Keep signing order simple** unless legally required ::: ## Step 6: Continue to Field Placement Almost there! Now we'll move to the most important part - telling TurboSign exactly where signatures and other information should go. **Click "Continue"** in the bottom right corner to move to the field placement interface. This is where you'll drag and drop signature fields onto your document. ![Continue button at bottom right of recipient setup](/img/turbosign/AddRecipients.png) ## Step 7: Place Signature Fields Here's where the magic happens! You'll drag and drop the necessary fields onto your document exactly where you want them. ### Available Field Types The field editor provides several types of fields you can add: **Essential Fields:** - **Signature** - The actual signature field - **Name** - Typed name field - **Date** - Date when signed - **Initials** - For initialing pages or sections **Additional Fields:** - **Text** - For additional information - **Checkbox** - For confirmations or agreements - **Dropdown** - For selecting from options ![Field editor with available field types on the left](/img/turbosign/FieldEditorDragAndDrop.png) ### How to Place Fields 1. **Drag the field type** from the left panel 2. **Drop it onto the document** where you want it to appear 3. **Resize if needed** by dragging the corner handles 4. **Assign to the correct recipient** using the dropdown ### Assigning Fields to Recipients **Each field must be assigned to a recipient.** Make sure you: - Assign signature fields to the correct person - Place fields in logical locations on the document - Ensure all required fields are present ![Document with signature fields placed and recipient assignments](/img/turbosign/DraggingAndDroppingFields.png) :::tip Field Placement Tips - **Place signature fields near signature lines** if your document has them - **Add date fields near signatures** for legal completeness - **Use initials fields** for multi-page documents - **Test field sizes** - they should be large enough for signatures but not overwhelming - **Group related fields** together for better user experience ::: ### Aligning a Signature Inside Its Field (Optional) You can choose where the signer's entry sits inside a field: on the **left**, in the **center**, or on the **right**. This keeps signatures lined up with your document, for example starting right after a "Master's signature" label instead of floating in the middle of the line. 1. **Click the field** on the document to select it. 2. In the panel on the right, find **Alignment** (just under **Recipient**). 3. **Click Left, Center, or Right.** The field on the document moves its label to show your choice. ![The field settings panel with the Alignment options (Left, Center, Right) highlighted](/img/turbosign/field-alignment/01-alignment-control.png) The signer sees their signature placed the same way, and the signed PDF matches what they saw. ![The signing page with a typed signature sitting against the right edge of its field](/img/turbosign/field-alignment/02-signer-view-right.png) :::note Good to know - **Every field type has Alignment except Checkbox.** A checkbox always sits in the middle of its box. - **If you don't pick an option,** the field keeps its standard placement. - **Once you pick an option, there is no "none" button.** Choose **Center** if you want the entry centered. - **If a name is too long for the field,** it starts at the left edge and runs past the box. Make the field wider so it fits. - The same **Alignment** options appear when you add signature fields on a template's generate page (the **Signatures** tab). ::: ### Making a Field Optional Every field you place is required by default: the signer can't finish until it's filled in. To let the signer skip a field, such as an optional "Notes" box, turn off **Required** for that field. 1. **Click the field** on the document to select it. 2. In the panel on the right, find **Required**. 3. **Uncheck "Signer must fill this field".** The signer can now leave it blank and still finish. ![The field settings panel with the Required setting and its "Signer must fill this field" checkbox highlighted](/img/turbosign/optional-fields/01-required-setting.png) When the signer opens the document, an empty optional field keeps its usual label and shows a small **Optional** tag on its top-right corner. The "N of M required" counter only counts required fields, so the signer can submit once those are done. If the signer fills in an optional field and changes their mind, they can open it again and click **Clear field**. ![The signing page with an empty text field carrying an "Optional" tag on its top-right corner and the counter reading "0 of 1 required"](/img/turbosign/optional-fields/02-signer-optional-label.png) :::note Good to know - **Signature, Initials, Date, and Checkbox fields don't have a Required setting.** Signatures and initials are always required. Date and checkbox fields are always filled in on the signing page. - **Locked fields don't have a Required setting either.** When you check **Make field read-only** under **Lock Field**, the Required setting disappears: a locked field is pre-filled and the signer can't change it, so it never holds up signing, and signers never see the Optional tag on it. - **A blank optional field stays empty** on the signed PDF. - **A default value is a real answer, not a hint.** If you type instructions into an optional field's default value, that text ends up on the signed PDF unless the signer clears it. Leave the default value empty if the field should stay blank. - **Each recipient needs at least one required field they can fill out**, such as a signature. If a recipient only has optional or read-only fields, the editor shows "Some recipients have no required editable fields" and you can't send until you fix it. ::: ## Step 7.5: Setting Default Values (Optional) Want to save your recipients time by pre-filling some information? TurboSign lets you set default values that will automatically appear in fields when recipients open the document. This is especially useful for common information you already know about your signers. ### What Are Default Values? **Default values** are pre-filled text that appears in fields automatically. Recipients can see this information already filled in and: - **Keep it as-is** if it's correct - **Edit it** if they need to make changes - **Save time** by not typing common information from scratch :::tip When to Use Default Values Perfect for information you already know: - Shipping addresses for order forms - Client contact details from your records - Company information for regular business partners - Any information recipients can review and correct if needed ::: ### How to Set Default Values You can set default values for each recipient to pre-fill common information. :::note Field Types Without Defaults **Signature** and **Initials** fields cannot have default values because they must be drawn or typed by the recipient at signing time. **Date** fields are a special case: they fill automatically with the date the recipient signs, so they need no default. If you need a specific date instead, you can pin one — see [Setting a Date Field's Value](#setting-a-date-fields-value) below. ::: ### Setting Default Values Per Recipient Default values are set per recipient and will pre-fill their fields. There are **two places** to set them, and they edit the same value — whichever you use, the other updates to match. #### Option 1: The Default Values accordion **Step 1: Find the Default Values Accordion** Scroll down in the right sidebar until you see the **"Default values for [recipient name] (Optional)"** accordion. Click to expand it. **Step 2: Enter Default Values** Fill in any of the available fields: - **Full name** - Complete name (e.g., "John Smith") - **First Name** - Given name only (e.g., "John") - **Last Name** - Family name only (e.g., "Smith") - **Email Address** - Contact email (e.g., "john.smith@company.com") - **Title** - Job title (e.g., "Senior Manager") - **Company** - Organization name (e.g., "Acme Corporation") **Step 3: Switch Between Recipients** Use the recipient tabs at the top to set different default values for each signer. #### Option 2: Directly on the field If you have already placed a field on the document, you don't need to hunt for the accordion: 1. **Click the field** on the document. 2. The right panel opens with a **"Default Value for [field type]"** box. 3. **Type the value.** It saves as you type. ![Field properties panel showing the Default Value box for a Full name field](/img/turbosign/SetDefaultValueOnField.png) The panel tells you the scope right underneath the box — *"Applies to every [field type] field for [recipient]"* — because this is the **same** value the accordion holds, not a per-field override. Change it here and every field of that type for that recipient changes too. :::note Text fields work differently A **Text** field's default is its own — each Text field keeps a separate value, since a free-text box usually means something different in each spot. Its panel says **"Default Value for This Field"** rather than naming a recipient. ::: ### Setting a Date Field's Value A **Date** field fills in automatically with the date the recipient signs — for most documents that is exactly what you want, and you don't have to do anything. If the document needs a **specific** date instead (an effective date, a policy start date), you can pin one: 1. **Click the Date field** on the document. 2. In the right panel, under **Date Value**, choose **"Use a fixed date"**. 3. **Pick the date.** It starts on today's date so you have something valid to adjust. ![Date field panel with "Use a fixed date" selected and a date chosen](/img/turbosign/SetDateFieldValue.png) Choose **"Use the signing date"** to go back to the automatic behavior. The panel states which mode you're in underneath the control, so there's no guessing. :::tip Character Limits Each field has a maximum character limit. TurboSign will show you a character counter when you're approaching the limit so you don't lose any information. ::: ### Practical Examples **Example: Service Order** You're sending a service agreement to a client named Sarah Johnson. You already have her information from a previous order: **Default values for Sarah:** - Full name: "Sarah Johnson" - Email Address: "sarah.johnson@example.com" - Company: "Acme Corporation" - Text field for "Service Address": "123 Main Street, Suite 400, San Francisco, CA 94105" Now when Sarah receives the document, these fields will be pre-filled with this information. She can review and correct anything if needed before signing. ### Best Practices for Default Values **Do:** - ✅ **Use accurate information** - Double-check spelling and formatting - ✅ **Keep it concise** - Stay within character limits - ✅ **Save time on repeat signers** - Great for clients with existing records - ✅ **Pre-fill known facts** - Addresses, contact details, company names **Don't:** - ❌ **Don't force information** - Recipients can edit defaults if needed - ❌ **Don't use for signatures/initials** - These fields must be completed during signing - ❌ **Don't pin a date unless you mean it** - Date fields already fill with the signing date; only set a fixed date when the document needs that exact date - ❌ **Don't include sensitive data** - Avoid passwords, SSNs, or confidential information - ❌ **Don't overfill** - Only set defaults for information you truly know :::tip Time-Saving Tip If you send the same type of document to the same people regularly, setting default values can cut signing time in half! Recipients can review and submit much faster when common fields are already filled. ::: ## Step 8: Send the Document The final step! Once all fields are placed and everything looks perfect, it's time to send your document for signatures. **Click "Send Document"** in the **top right corner** to initiate the signing process. ![Send Document button in top right corner](/img/turbosign/ClickSendDocument.png) ### What Happens Next After clicking "Send Document": 1. **Email notifications** are sent to all recipients 2. **Recipients receive** a secure link to sign the document 3. **You track progress** on your TurboSign dashboard as each person signs 4. **Everyone receives** a copy of the fully signed document, including you as the sender ## Congratulations! 🎉 **That's it!** Your document is now on its way to being signed. You've successfully: - ✅ **Uploaded or selected** your document - ✅ **Added recipients** and set signing order - ✅ **Placed signature fields** exactly where needed - ✅ **Sent the document** for signatures ### What to Expect **Timeline:** - Recipients usually receive emails within minutes - Most people sign within 24-48 hours - You'll get an email with the signed document once everyone has signed **Tracking:** - Monitor progress in your TurboSign dashboard - Send gentle reminders if needed - Download the completed document when all signatures are collected ## Tips for Success ### For Faster Signatures - **Use clear document names** so recipients know what they're signing - **Add helpful descriptions** to provide context - **Send during business hours** for faster response - **Follow up politely** if signatures are delayed ### For Professional Results - **Test your document** by sending it to yourself first - **Use consistent field sizes** for a clean look - **Place fields logically** following document flow - **Keep signing order simple** unless legally required ### For Peace of Mind - **Double-check email addresses** before sending - **Review field placement** carefully - **Save templates** for documents you use frequently - **Keep records** of all signed documents ## Troubleshooting Common Issues ### "The Send Button is Disabled" **Solution:** Make sure you've placed at least one signature field for each recipient. The Send button appears only when your document is ready. If you see "Some recipients have no required editable fields", give that recipient at least one required field, or turn **Required** back on for one of their fields (see [Making a Field Optional](#making-a-field-optional)). ### "Recipient Says They Can't Haven't Received the Email" **Solution:** Check that you used the correct email address and that the signing link hasn't expired. You can resend the invitation from your dashboard. ### "I Made a Mistake in the Document" **Solution:** It's best practice to void the document and send a new version. ## What's Next? Now that you know how to send documents for signature, you might want to explore: - **Creating signature templates** for frequently used documents - **Setting up automated workflows** for recurring signature processes - **Integrating TurboSign** with your existing document workflows - **Managing completed documents** and signatures :::tip Final Thoughts You've just mastered the art of digital signatures! Every document you send with TurboSign saves time, reduces errors, and provides a professional experience for all parties involved. Welcome to the future of document signing! 🚀 ::: --- *Ready to sign your next document? Go forth and get those signatures! 📝* --- ## TurboSign Webhooks # Webhooks Webhooks enable your application to receive real-time notifications when important events occur in TurboSign. Instead of polling for changes, webhooks push event data to your specified endpoints immediately across the whole signature lifecycle — when a document is sent, viewed, signed by each recipient, completed, voided, or fails to finalize. ![Get It Signed button on TurboDocx homepage](/img/webhooks/webhook-schema.png) ## Overview TurboSign webhooks provide a robust and secure way to integrate document signature events into your existing workflows. When configured, webhooks will automatically send HTTP POST requests to your specified URLs whenever subscribed events occur. ### Key Features - **Real-time Notifications**: Receive instant updates when documents are signed or voided - **Multiple URLs**: Configure up to 5 webhook URLs per configuration - **Secure Authentication**: HMAC-SHA256 signature verification ensures webhook authenticity - **Reliable Delivery**: Automatic retry logic with up to 3 attempts per webhook - **Delivery History**: Track and replay webhook deliveries with detailed logs - **Event Filtering**: Subscribe only to the events you need ## Configuration ### Setting Up Webhooks Webhooks can be configured from the E-Signature settings on the **Features and integrations** page of your organization Settings. 1. **Open Settings** - Click your avatar at the bottom of the left sidebar, then click **Settings** ![Account menu open with the Settings item highlighted](/img/webhooks/open-settings.png) 2. **Go to Features and integrations** - In the menu on the left of the Settings page, click **Features and integrations** ![Settings menu with Features and integrations highlighted](/img/webhooks/features-and-integrations-link.png) 3. **Open E-Signature Settings and go to the Webhooks tab** - Scroll down to the **Core Features** section, find the **Signatures** card, and click **"Configure E-Signature"** ![Features and integrations page with the Configure E-Signature button on the Signatures card highlighted](/img/webhooks/core-features-section.png) - In the **E-Signature Settings** dialog that opens, select the **"Webhooks"** tab 4. **Add Webhook URLs** - Enter your webhook endpoint URL(s) - You can add up to 5 different URLs - Each URL will receive all subscribed events - URLs must use HTTPS for production environments ![E-Signature Settings Webhooks tab showing the Webhook URLs field and the Add Another URL button](/img/webhooks/signature-webhook-config.png) 5. **Select Events to Subscribe** - Choose which events should trigger webhooks: - **Document Sent**: Triggered when a document is sent out for signature (after recipients are notified) - **Document Viewed**: Triggered the first time each recipient opens and consents to view the document - **Document Progress (Partial)**: Triggered when a signer completes their part but the document is not yet fully signed - **Recipient Signed**: Triggered for every individual signer (including the final one) — use this for per-person notifications - **Signature Completed**: Triggered when all recipients have signed and the signed PDF is finalized - **Finalization Failed**: Triggered when the signed PDF fails to finalize (e.g. KMS signing error); the document is not marked Completed - **Signature Voided**: Triggered when a document is voided or cancelled ![Subscribe to Events section with all seven signature webhook events highlighted](/img/webhooks/signature-webhook-config.png) 6. **Save Configuration** - Click "Save Configuration" to activate your webhooks - Your webhook secret key will be displayed (only shown once for new configurations) - **Important**: Copy and securely store your webhook secret - it won't be shown again ![E-Signature Settings Webhooks tab with the Save Configuration button highlighted](/img/webhooks/dill-signature-webhook-config.png) ![Webhook secret key displayed after saving the configuration](/img/webhooks/copy-webhook-secret.png) ### Managing Webhook Configuration #### Viewing Delivery History The Delivery History tab shows all webhook delivery attempts with detailed information: - **Event Type**: The type of event that triggered the webhook - **HTTP Status**: Response status code from your endpoint - **Attempts**: Number of delivery attempts made - **Timestamps**: When the webhook was created and last updated - **Actions**: View details or replay failed deliveries ![Get It Signed button on TurboDocx homepage](/img/webhooks/delivery-history.png) #### Webhook Secret Management Your webhook secret is used to verify that webhooks are genuinely from TurboDocx: - **Initial Generation**: A secret is automatically generated when you create a webhook configuration - **Security**: The secret is only shown in full immediately after generation or regeneration - **Regeneration**: You can regenerate the secret at any time if compromised - **Display**: After initial viewing, only a masked version (first 3 + \*\*\* + last 3 characters) is shown ![Get It Signed button on TurboDocx homepage](/img/webhooks/regenerate-secret.png) ## Webhook Events TurboSign emits **seven** subscribable events across the signature lifecycle. Every event shares the same envelope (`event`, `event_id`, `created_at`, `version`, `data`); only the `data` object differs per event. | Event | Fires when | | ----- | ---------- | | `signature.document.sent` | The document is dispatched to recipients | | `signature.document.viewed` | A recipient opens the document for the first time | | `signature.document.recipient_signed` | Any individual signer completes their signature (fires once **per signer**) | | `signature.document.signed` | A signer signs but the document is **not yet complete** (document-level partial progress) | | `signature.document.completed` | All recipients have signed and the signed PDF is finalized | | `signature.document.finalization_failed` | The signed PDF fails to finalize (e.g. KMS signing error); the document is **not** completed | | `signature.document.voided` | The document is voided or cancelled | ### How the signing events fit together `recipient_signed`, `signed`, and `completed` are easy to confuse. The key distinction: - **`recipient_signed`** is the **per-person** event. It fires **once for every individual signer**, including the last one, and carries the signer's identity (`recipient_id`, `recipient_email`, `recipient_name`) plus **`is_final_signer`** (true only on the last signature) and `remaining_signers`. - **`signed`** is a **document-level partial-progress** event. It fires **only when a signer signs and the document is not yet complete** — it carries no `is_final_signer` and does not identify a specific recipient's final state the way `recipient_signed` does. On each signature, `recipient_signed` **always** fires first. Then exactly one of the following fires: - **`completed`** — if that was the final signature and finalization succeeded (or **`finalization_failed`** if finalization failed), **or** - **`signed`** — if signers still remain. Two consequences worth internalizing: - **`signed` never fires on the final signature.** Use `completed` (or `recipient_signed` with `is_final_signer: true`) to detect "the whole document is done" — not `signed`. - **A single-signer document never emits `signed` at all.** It emits `recipient_signed` (`is_final_signer: true`) followed immediately by `completed`. ```text Recipient signs │ ├─ signature.document.recipient_signed (always — one per signer) │ └─ more signers remaining? ├─ yes → signature.document.signed (partial progress) └─ no → signature.document.completed (finalized OK) or signature.document.finalization_failed (finalization failed) ``` ### Signature Document Sent Triggered once the document is dispatched to recipients (after signature-request emails go out). **Event Name**: `signature.document.sent` **Payload Example**: ```json { "event": "signature.document.sent", "event_id": "evt_2f8b1c0a4d5e4f6a8b9c0d1e2f3a4b5c", "created_at": "2025-08-26T11:30:00.100Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Statement of Work Example", "status": "under_review", "sent_at": "2025-08-26T11:30:00.090Z" } } ``` ### Signature Document Viewed Triggered the first time a recipient opens and consents to view the document. **Event Name**: `signature.document.viewed` **Payload Example**: ```json { "event": "signature.document.viewed", "event_id": "evt_3a9c2d1b5e6f7a8b9c0d1e2f3a4b5c6d", "created_at": "2025-08-26T11:35:12.500Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Statement of Work Example", "recipient_id": "b1e2c3d4-5678-4abc-9def-0123456789ab", "recipient_email": "signer@example.com", "viewed_at": "2025-08-26T11:35:12.480Z" } } ``` ### Signature Document Recipient Signed Triggered for **every individual signer** (including the final one). This is the per-person event — use it for per-recipient notifications. Inspect `is_final_signer` to tell whether this was the last signature. **Event Name**: `signature.document.recipient_signed` **Payload Example**: ```json { "event": "signature.document.recipient_signed", "event_id": "evt_4b0d3e2c6f7a8b9c0d1e2f3a4b5c6d7e", "created_at": "2025-08-26T11:40:05.200Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Statement of Work Example", "recipient_id": "b1e2c3d4-5678-4abc-9def-0123456789ab", "recipient_email": "signer@example.com", "recipient_name": "Jordan Signer", "signed_at": "2025-08-26T11:40:05.180Z", "remaining_signers": 1, "is_final_signer": false } } ``` ### Signature Document Signed (Partial Progress) Triggered when a signer signs but the document is **not yet complete**. This is a document-level progress event — it does **not** fire on the final signature (that path emits `completed` instead). See [How the signing events fit together](#how-the-signing-events-fit-together). **Event Name**: `signature.document.signed` **Payload Example**: ```json { "event": "signature.document.signed", "event_id": "evt_5c1e4f3d7a8b9c0d1e2f3a4b5c6d7e8f", "created_at": "2025-08-26T11:40:05.400Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Statement of Work Example", "recipient_id": "b1e2c3d4-5678-4abc-9def-0123456789ab", "recipient_email": "signer@example.com", "signed_at": "2025-08-26T11:40:05.180Z", "remaining_signers": 1 } } ``` ### Signature Document Completed Triggered when all required signers have successfully signed a document and the signed PDF has been finalized. **Event Name**: `signature.document.completed` **Payload Example**: ```json { "event": "signature.document.completed", "event_id": "evt_01daa4ba531c42938f861c5a9ce9a5f2", "created_at": "2025-08-26T11:44:30.305Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Document Name", "status": "completed", "status_enum": "SignatureDocumentStatus.COMPLETED", "completed_at": "2025-08-26T11:44:30.299Z", "document_hash": "f516c4b9de36a5c9a999ba87abbc93078fdd0c9f6b855590d883d8bfb143308f" } } ``` ### Signature Document Finalization Failed Triggered when all signers have signed but the signed PDF fails to finalize (for example, a KMS/certificate signing error). The document is **not** marked Completed — treat this as a failure path that needs attention. **Event Name**: `signature.document.finalization_failed` **Payload Example**: ```json { "event": "signature.document.finalization_failed", "event_id": "evt_6d2f5a4e8b9c0d1e2f3a4b5c6d7e8f90", "created_at": "2025-08-26T11:44:31.000Z", "version": "1.0", "data": { "document_id": "2dea093d-c38f-4898-b440-43dd9a14cd9d", "title": "Statement of Work Example", "status": "finalization_failed", "failed_at": "2025-08-26T11:44:30.980Z", "failure_step": "document_finalization" } } ``` ### Signature Document Voided Triggered when a document is voided or cancelled. **Event Name**: `signature.document.voided` **Payload Example**: ```json { "event": "signature.document.voided", "event_id": "evt_c825f202658b41ea932871ba13cc52a5", "created_at": "2025-08-26T11:42:03.622Z", "version": "1.0", "data": { "document_id": "9eee553b-28b6-4b43-b52b-4ef9957cc503", "title": "Statement of Work Example Draft", "status": "voided", "status_enum": "SignatureDocumentStatus.VOIDED", "voided_at": "2025-08-26T11:42:03.582Z", "void_reason": "signature not required", "document_hash": "b19151b93aed4f8cbcf060030a338dd414c249914eb8d2591c72390a0fa1b754" } } ``` ### Payload Fields Every event shares the top-level envelope fields. The `data` fields present depend on the event — the last column lists which events include each field. | Field | Type | Description | Present on | | -------------------- | ------ | -------------------------------------------------------- | ---------- | | `event` | string | The type of event (e.g., `signature.document.completed`) | All | | `event_id` | string | Unique identifier for this event instance | All | | `created_at` | string | ISO 8601 timestamp when the event occurred | All | | `version` | string | Webhook payload version (currently "1.0") | All | | `data.document_id` | string | Unique identifier of the signature document | All | | `data.title` | string | Document title/name | All | | `data.status` | string | Human-readable status | sent, completed, finalization_failed, voided | | `data.status_enum` | string | Programmatic status enum value | completed, voided | | `data.sent_at` | string | When the document was dispatched to recipients | sent | | `data.recipient_id` | string | Unique identifier of the recipient | viewed, recipient_signed, signed | | `data.recipient_email` | string | Recipient's email address | viewed, recipient_signed, signed | | `data.recipient_name` | string | Recipient's display name | recipient_signed | | `data.viewed_at` | string | When the recipient first viewed the document | viewed | | `data.signed_at` | string | When the signature was applied | recipient_signed, signed | | `data.remaining_signers` | number | Count of signers who have not yet completed | recipient_signed, signed | | `data.is_final_signer` | boolean | `true` only when this was the last required signature | recipient_signed | | `data.completed_at` | string | When the document was completed | completed | | `data.document_hash` | string | Document content hash for integrity verification | completed, voided | | `data.failed_at` | string | When finalization failed | finalization_failed | | `data.failure_step` | string | Which step failed (e.g. `document_finalization`) | finalization_failed | | `data.voided_at` | string | When the document was voided | voided | | `data.void_reason` | string | Reason for voiding | voided | ## Signature Verification Every webhook request includes an `x-turbodocx-signature` header that you should verify to ensure the webhook is genuinely from TurboDocx. ### How It Works 1. TurboDocx creates a signature using HMAC-SHA256 2. The signature is computed from: `timestamp + "." + request_body` 3. The signature is sent in the `x-turbodocx-signature` header 4. Your endpoint verifies this signature using your webhook secret ### Verification Headers Each webhook request includes these headers: | Header | Description | | ------------------------- | -------------------------------------------------------- | | `X-TurboDocx-Signature` | HMAC signature for verification (format: `sha256=`) | | `X-TurboDocx-Timestamp` | Unix timestamp when the webhook was sent | | `X-TurboDocx-Event` | The event type that triggered this webhook | | `X-TurboDocx-Delivery-Id` | Unique ID for this delivery attempt (for idempotency) | ### Try it Now ### SDK Verification Examples Our SDKs include built-in webhook verification. The snippets below show the minimum receiver code; for the full SDK reference (createWebhook, updateWebhook, testWebhook, listWebhookDeliveries, replayWebhookDelivery, rotateSecret, framework integration patterns, and gotchas), see the dedicated TurboWebhooks SDK pages: - [TurboWebhooks JavaScript / TypeScript SDK](/docs/SDKs/webhooks?language=js) - [TurboWebhooks Python SDK](/docs/SDKs/webhooks?language=python) - [TurboWebhooks PHP SDK](/docs/SDKs/webhooks?language=php) - [TurboWebhooks Go SDK](/docs/SDKs/webhooks?language=go) - [TurboWebhooks Java SDK](/docs/SDKs/webhooks?language=java) Here are examples for each language: ```typescript const app = express(); app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const isValid = verifyWebhookSignature({ signature: req.headers['x-turbodocx-signature'], timestamp: req.headers['x-turbodocx-timestamp'], body: req.body, secret: process.env.TURBODOCX_WEBHOOK_SECRET }); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } const event = JSON.parse(req.body.toString()); // Process event... res.status(200).json({ received: true }); }); ``` ```python from turbodocx import verify_webhook_signature from fastapi import FastAPI, Request, HTTPException app = FastAPI() @app.post("/webhook") async def handle_webhook(request: Request): body = await request.body() is_valid = verify_webhook_signature( signature=request.headers.get("x-turbodocx-signature"), timestamp=request.headers.get("x-turbodocx-timestamp"), body=body, secret=os.environ["TURBODOCX_WEBHOOK_SECRET"] ) if not is_valid: raise HTTPException(status_code=401, detail="Invalid signature") event = json.loads(body) # Process event... return {"received": True} ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) isValid := sdk.VerifyWebhookSignature( r.Header.Get("X-TurboDocx-Signature"), r.Header.Get("X-TurboDocx-Timestamp"), body, os.Getenv("TURBODOCX_WEBHOOK_SECRET"), ) if !isValid { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var event sdk.WebhookEvent json.Unmarshal(body, &event) // Process event... w.WriteHeader(http.StatusOK) } ``` ```csharp [HttpPost] public async Task HandleWebhook() { using var reader = new StreamReader(Request.Body); var body = await reader.ReadToEndAsync(); var isValid = WebhookVerifier.VerifySignature( Request.Headers["X-TurboDocx-Signature"], Request.Headers["X-TurboDocx-Timestamp"], body, _configuration["TurboDocx:WebhookSecret"] ); if (!isValid) return Unauthorized("Invalid signature"); var webhookEvent = JsonSerializer.Deserialize(body); // Process event... return Ok(new { Received = true }); } ``` **Go deeper:** the [TurboWebhooks SDK pages](/docs/SDKs#turbowebhooks-sdks) cover the full management surface (create/update/delete the webhook, test-fire it, rotate the secret, list and replay delivery history) and per-framework receiver patterns (Express, FastAPI, Spring Boot, net/http, Gin, Laravel, …). ### Security Best Practices 1. **Always verify signatures**: Never process webhooks without verifying the signature 2. **Use HTTPS**: Always use HTTPS endpoints in production 3. **Store secrets securely**: Keep webhook secrets in environment variables or secure vaults 4. **Implement timestamp validation**: Reject webhooks with timestamps older than 5 minutes 5. **Use timing-safe comparison**: Prevent timing attacks when comparing signatures 6. **Handle retries idempotently**: Use the `X-TurboDocx-Delivery-Id` to prevent duplicate processing 7. **Respond quickly**: Return 200 OK immediately and process webhooks asynchronously 8. **Log failures**: Keep logs of signature verification failures for security monitoring ## Delivery & Retries ### Delivery Behavior - **Timeout**: Each delivery attempt has a 10-second timeout - **Success Criteria**: Only HTTP 2xx status codes are considered successful - **Retry Logic**: Failed deliveries are automatically retried up to 3 times - **Retry Schedule**: Exponential backoff between retry attempts - **Delivery Order**: Webhooks are delivered to all configured URLs in parallel ### Handling Failures When a webhook delivery fails: 1. **Automatic Retries**: The system will automatically retry failed deliveries 2. **Delivery History**: All attempts are logged in the delivery history 3. **Manual Replay**: You can manually replay failed deliveries from the UI 4. **Error Details**: Response status codes and error messages are captured ### Best Practices for Your Endpoint 1. **Return 200 OK quickly**: Process webhooks asynchronously to avoid timeouts 2. **Implement idempotency**: Handle duplicate deliveries gracefully 3. **Queue for processing**: Use a message queue for reliable processing 4. **Monitor your endpoint**: Set up alerting for webhook processing failures 5. **Handle all event types**: Be prepared for new event types in the future ## Testing Webhooks ### Using the Test Feature You can test your webhook configuration before going live: 1. **Configure your webhook** with your test endpoint URL 2. **Save the configuration** to activate it 3. **Create a test signature document** and complete the signing process 4. **Check the Delivery History** to verify successful delivery 5. **Verify your endpoint** received and processed the webhook correctly ### Development Tools For local development, consider using: - **ngrok**: Expose your local server to receive webhooks - **Webhook.site**: Test webhook payloads without writing code - **RequestBin**: Inspect webhook requests in real-time - **Postman**: Simulate webhook requests for testing ### Testing Checklist - [ ] Webhook endpoint returns 200 OK status - [ ] Signature verification is working correctly - [ ] Timestamp validation is implemented - [ ] All event types are handled - [ ] Error handling is in place - [ ] Retry logic is handled idempotently - [ ] Logs capture webhook processing details - [ ] Performance under load has been tested ## Troubleshooting ### Common Issues #### Webhook Not Receiving Events **Symptoms**: Events occur but webhooks aren't triggered **Solutions**: - Verify webhook configuration is saved and active - Check that you've subscribed to the correct events - Ensure your endpoint URL is correct and accessible - Review the Delivery History for error messages #### Signature Verification Failing **Symptoms**: 401 Unauthorized responses from your endpoint **Solutions**: - Ensure you're using the raw request body (not parsed JSON) - Verify the webhook secret matches exactly - Check that header names are lowercase in your code - Confirm timestamp validation isn't too strict #### Timeouts **Symptoms**: Webhook deliveries show timeout errors **Solutions**: - Return 200 OK immediately, process asynchronously - Optimize endpoint performance - Check network connectivity and firewall rules - Consider increasing server resources #### Duplicate Deliveries **Symptoms**: Same event processed multiple times **Solutions**: - Implement idempotency using `X-TurboDocx-Delivery-Id` - Store processed event IDs temporarily - Use database constraints to prevent duplicates ### Getting Help If you encounter issues not covered here: 1. **Check the Delivery History** for detailed error messages 2. **Review your endpoint logs** for processing errors 3. **Test with a simple endpoint** to isolate issues 4. **Contact Support** with your webhook configuration details and error messages ## API Reference ### Webhook Object ```json { "id": "webhook_abc123", "orgId": "org_xyz789", "name": "signature", "urls": [ "https://api.example.com/webhooks/turbosign", "https://backup.example.com/webhooks" ], "events": [ "signature.document.sent", "signature.document.viewed", "signature.document.recipient_signed", "signature.document.signed", "signature.document.completed", "signature.document.finalization_failed", "signature.document.voided" ], "secretExists": true, "maskedSecret": "whs***f6a", "isActive": true, "createdOn": "2024-01-15T09:00:00.000Z", "updatedOn": "2024-01-15T09:00:00.000Z" } ``` ### Delivery Object ```json { "id": "delivery_def456", "webhookId": "webhook_abc123", "eventType": "signature.document.completed", "url": "https://api.example.com/webhooks/turbosign", "httpStatus": 200, "attemptCount": 1, "maxAttempts": 3, "isDelivered": true, "deliveredAt": "2024-01-15T10:30:05.000Z", "createdOn": "2024-01-15T10:30:00.000Z", "updatedOn": "2024-01-15T10:30:05.000Z" } ``` --- ## Next Steps - [Learn about TurboSign](/docs/TurboSign/Setting-up-TurboSign) - [Explore API Documentation](/docs/API/turbodocx-api-documentation) - [View Integration Guides](https://www.turbodocx.com/integrations) --- ## Call this from your POST /api/signing-session route (for example a FastAPI handler). ```typescript TurboSign.configure({ apiKey: process.env.TURBODOCX_API_KEY!, // an Administrator or Contributor key orgId: process.env.TURBODOCX_ORG_ID!, senderEmail: "contracts@yourcompany.com", senderName: "Your Company", }); // Call this from your POST /api/signing-session route. export async function startSigning({ name, email }: { name: string; email: string }) { const { documentId, recipients } = await TurboSign.createEmbeddedSignature({ file: await readFile("contract.pdf"), fileName: "contract.pdf", documentName: `Service Agreement - ${name}`, recipients: [ { name, email, auth: { emailOtp: true }, // email passcode before the document opens fields: { signature: "{signature1}", date: "{date1}" }, }, ], }); const signer = recipients[0]; if (!signer.embedUrl) throw new Error(`Cannot start signing: recipient is ${signer.status}`); return { documentId, embedUrl: signer.embedUrl }; } ``` ```python from turbodocx_sdk import TurboSign TurboSign.configure( api_key=os.environ["TURBODOCX_API_KEY"], # an Administrator or Contributor key org_id=os.environ["TURBODOCX_ORG_ID"], sender_email="contracts@yourcompany.com", sender_name="Your Company", ) # Call this from your POST /api/signing-session route (for example a FastAPI handler). async def start_signing(name: str, email: str) -> dict: with open("contract.pdf", "rb") as f: pdf = f.read() result = await TurboSign.create_embedded_signature( file=pdf, file_name="contract.pdf", document_name=f"Service Agreement - {name}", recipients=[ { "name": name, "email": email, "auth": {"email_otp": True}, # email passcode before the document opens "fields": {"signature": "{signature1}", "date": "{date1}"}, } ], ) signer = result["recipients"][0] if not signer["embedUrl"]: raise RuntimeError(f"Cannot start signing: recipient is {signer['status']}") return {"documentId": result["documentId"], "embedUrl": signer["embedUrl"]} ``` ```php recipients[0]; if ($signer->embedUrl === null) { throw new RuntimeException("Cannot start signing: recipient is {$signer->status}"); } return ['documentId' => $result->documentId, 'embedUrl' => $signer->embedUrl]; } ``` ```go package signing "context" "fmt" "os" turbodocx "github.com/TurboDocx/SDK/packages/go-sdk" ) func newClient() (*turbodocx.Client, error) { return turbodocx.NewClientWithConfig(turbodocx.ClientConfig{ APIKey: os.Getenv("TURBODOCX_API_KEY"), // an Administrator or Contributor key OrgID: os.Getenv("TURBODOCX_ORG_ID"), SenderEmail: "contracts@yourcompany.com", SenderName: "Your Company", }) } // StartSigning is called from your POST /api/signing-session handler. func StartSigning(ctx context.Context, client *turbodocx.Client, name, email string) (documentID, embedURL string, err error) { pdf, err := os.ReadFile("contract.pdf") if err != nil { return "", "", err } result, err := client.TurboSign.CreateEmbeddedSignature(ctx, &turbodocx.CreateEmbeddedSignatureRequest{ File: pdf, FileName: "contract.pdf", DocumentName: "Service Agreement - " + name, Recipients: []turbodocx.EmbeddedSignatureRecipient{ { Name: name, Email: email, Auth: &turbodocx.EmbeddedRecipientAuth{EmailOTP: true}, // email passcode before the document opens Fields: &turbodocx.EmbeddedRecipientFields{Signature: "{signature1}", Date: "{date1}"}, }, }, }) if err != nil { return "", "", err } signer := result.Recipients[0] if signer.EmbedURL == "" { // Go uses an empty string, not nil, when no URL was minted return "", "", fmt.Errorf("cannot start signing: recipient is %s", signer.Status) } return result.DocumentID, signer.EmbedURL, nil } ``` ```java public class SigningService { private final TurboDocxClient client = new TurboDocxClient.Builder() .apiKey(System.getenv("TURBODOCX_API_KEY")) // an Administrator or Contributor key .orgId(System.getenv("TURBODOCX_ORG_ID")) .senderEmail("contracts@yourcompany.com") .senderName("Your Company") .build(); // Call this from your POST /api/signing-session route. public EmbeddedSignatureRecipientResult startSigning(String name, String email) throws Exception { CreateEmbeddedSignatureResponse result = client.turboSign().createEmbeddedSignature( new CreateEmbeddedSignatureRequest.Builder() .file(Files.readAllBytes(Paths.get("contract.pdf"))) .fileName("contract.pdf") .documentName("Service Agreement - " + name) .recipients(List.of( new EmbeddedSignatureRecipient.Builder() .name(name) .email(email) .auth(EmbeddedRecipientAuth.emailOtp()) // email passcode before the document opens .fields(new EmbeddedRecipientFields.Builder() .signature("{signature1}") .date("{date1}") .build()) .build())) .build()); EmbeddedSignatureRecipientResult signer = result.getRecipients().get(0); if (signer.getEmbedUrl() == null) { throw new IllegalStateException("Cannot start signing: recipient is " + signer.getStatus()); } // Return result.getDocumentId() and signer.getEmbedUrl() to the browser. return signer; } } ``` ```ruby require "stringio" require "turbodocx_sdk" TurboDocxSdk::TurboSign.configure( api_key: ENV.fetch("TURBODOCX_API_KEY"), # an Administrator or Contributor key org_id: ENV.fetch("TURBODOCX_ORG_ID"), sender_email: "contracts@yourcompany.com", sender_name: "Your Company" ) # Call this from your POST /api/signing-session route. def start_signing(name, email) result = TurboDocxSdk::TurboSign.create_embedded_signature( file: StringIO.new(File.binread("contract.pdf")), fileName: "contract.pdf", documentName: "Service Agreement - #{name}", recipients: [ { name: name, email: email, auth: { emailOtp: true }, # email passcode before the document opens fields: { signature: "{signature1}", date: "{date1}" } } ] ) signer = result["recipients"].first raise "Cannot start signing: recipient is #{signer['status']}" if signer["embedUrl"].nil? { "documentId" => result["documentId"], "embedUrl" => signer["embedUrl"] } end ``` --- ## _setup And Verify An admin must have completed [Set up your organization](../set-up-your-organization.md): **Enable identity verification** is on (it is the master switch for embedded signing), and your app's origin is under **Allowed embedding domains**. This guide verifies each signer with an **email passcode**. Every embedded signer needs a verification method unless you use the sender override while testing. To use SMS, your own identity provider, or the override, see [Identity verification](../../identity-verification/index.md). --- # Embed Signing with Your Own Iframe (Email Passcode) In this guide your app frames the TurboSign signing page in a plain `iframe` and listens for the completion message itself. TurboSign verifies the signer with a six-digit code sent to their email. Choose this path when you want full control of the frame, or your framework is not React. If you would rather not write the listener, use the [React widget](./react-widget.md) or the [web component](./web-component.md) instead. **What you'll build:** - A server route that creates the document and returns a signing URL. - A page that shows the signing page in an iframe. - A message listener that reacts when the signer finishes, and checks where the message came from. ## Prerequisites - The setup in [Before you start](../index.md#before-you-start): embedded signing on, your origin allowed, and an **Administrator** or **Contributor** API key. - A PDF with `{signature1}` and `{date1}` text anchors. ## Step 1: Check your setup and choose how signers verify ## Step 2: Create the document and signing URL on your server Add a route to your server, for example `POST /api/signing-session`. It calls `createEmbeddedSignature`, which uploads the PDF, adds the signer with an email passcode, and returns an `embedUrl` in one call. Signing-link emails are off by default in this call (`sendEmail: false`), so the signer is not emailed a link they don't need. Before you call it, confirm that the signed-in user really is the person who should sign. Then return `embedUrl` to the browser. Never store it; request a new one each time the signer opens the page. ## Step 3: Show the signing page in an iframe In your page, ask your server for a URL and set it as the iframe's `src`. Give the iframe `allow="clipboard-write"` and enough height for the document. ```html const res = await fetch("/api/signing-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Jane Doe", email: "jane@example.com" }), }); const { embedUrl } = await res.json(); document.getElementById("turbosign").src = embedUrl; ``` The browser code is the same whichever language your server uses. ## Step 4: Listen for the completion message When the signer finishes, the signing page posts a `turbosign:completed` message to your page. Accept it only when it comes from the TurboSign origin **and** from your own iframe. ```javascript const TURBOSIGN_ORIGIN = "https://app.turbodocx.com"; const iframe = document.getElementById("turbosign"); window.addEventListener("message", async (event) => { if (event.origin !== TURBOSIGN_ORIGIN) return; // origin pinning if (event.source !== iframe.contentWindow) return; // source pinning if (event.data?.type !== "turbosign:completed") return; const { documentId, event: kind } = event.data; if (kind === "already_signed") { // The signer reopened a link they had already completed. Skip one-time side effects. } iframe.remove(); // Confirm on your server (document status or the `completed` webhook) before you // mark the agreement as signed. Then show your own "All set" screen. await fetch(`/api/agreements/${documentId}/refresh`, { method: "POST" }); }); ``` :::warning Check both origin and source Any window can post a message to your page. Checking `event.origin` rejects messages from other sites; checking `event.source` rejects messages from other frames, including ones on the TurboSign origin. If you'd rather not maintain this, `handleTurboSignMessage` from [`@turbodocx/embed`](https://www.npmjs.com/package/@turbodocx/embed) does both checks when you pass `expectedOrigin` and `expectedSource: iframe.contentWindow`. ::: ## What the signer sees Your app's page around the signing panel will look different; the panel itself is the same. 1. Your signer clicks your own button (here, **Start signing**). Your server creates the document and returns the signing URL, and your page sets it as the iframe's `src`. ![The host app form with the Start signing button highlighted](/img/embedded-signing/iframe-01-start.png) 2. Your page shows the TurboSign signing panel. The signer clicks **Send Code**, and TurboSign emails a six-digit code to the signer's address. ![The Verify your identity panel inside the host app, with the Send Code button highlighted](/img/embedded-signing/iframe-02-send-code.png) 3. The signer types the code and clicks **Verify And Continue**. ![The code entry panel with the Verify And Continue button highlighted](/img/embedded-signing/iframe-03-enter-code.png) 4. The signer ticks **I have read and agree to the TurboSign consent terms** and clicks **Continue**. ![The TurboSign Consent panel with the consent statement highlighted; the checkbox is just to its left, and Continue is below](/img/embedded-signing/iframe-04-consent.png) 5. The document opens. The signer clicks the **Signature** field, types or draws a signature, and clicks **Save**. Date fields fill in automatically. ![The document with the Signature field highlighted](/img/embedded-signing/iframe-05-document.png) 6. When every required field is done, the signer clicks **Submit Signature**. ![The signed document with the Submit Signature button highlighted](/img/embedded-signing/iframe-06-submit.png) 7. The signing page posts `turbosign:completed`. Your listener removes the iframe and shows your own confirmation. ![The host app's All set confirmation highlighted after signing](/img/embedded-signing/iframe-07-done.png) A code expires after 10 minutes, and five wrong entries require a new code. See [Passcode attempts and lockout](../reference.md#passcode-attempts-and-lockout). ## What the completion event contains ```json { "type": "turbosign:completed", "documentId": "4f1c...", "status": "completed", "event": "signing_complete", "scope": "recipient" } ``` `scope: "recipient"` means this signer finished. For the whole document, read the status on your server or wait for the `completed` webhook. Field-by-field details are in the [API reference](../reference.md#completion-event). ## Common errors | Symptom | Cause | Fix | |---|---|---| | The iframe is blank, and the browser console mentions `frame-ancestors` | Your origin is not under **Allowed embedding domains**. | Add the exact origin, including the port in development. | | HTTP `403` when the signing URL is created | The API key belongs to a **User**. | Use an **Administrator** or **Contributor** key. | | HTTP `403` `EmbeddedSigningNotEnabled` | **Enable identity verification** is off. | Ask an admin to turn it on (Step 1). | | HTTP `403` `OtpOverrideNotAllowed` | Your organization verifies every request and locked the method to a different channel. | Ask an admin to allow changing the method per recipient, or follow the [SMS guide](../passcodes.md#request-an-sms-passcode-from-your-code). | | Signing finishes but no completion message arrives (often in Firefox) | The signing page posts only to an origin it can identify, and your page or iframe sends no referrer. | Don't use `referrerpolicy="no-referrer"` on the iframe or a `no-referrer` page policy; keep the default `strict-origin-when-cross-origin`. | | Your listener never fires | The origin check uses the wrong origin, or the message came from a different frame. | Log `event.origin` once and compare it with `TURBOSIGN_ORIGIN`. | ## What's next - **Next:** [Web component](./web-component.md), a drop-in element for any framework. - [React widget](./react-widget.md): the same flow without writing the listener. - [Kiosk signing (two signers, one device)](./sequential-signers.md): extend this flow to several signers. - [API reference](../reference.md): every field, event and error. --- # Embed Signing with the React Widget (Email Passcode) In this guide your React app drops in the `TurboSignForm` component from `@turbodocx/embed`. The component renders the iframe, checks where each message comes from, and calls your `onCompleted` callback when the signer finishes. TurboSign verifies the signer with a six-digit code sent to their email. **What you'll build:** - A server route that creates the document and returns a signing URL. - A React component that shows the signing page and reacts when the signer finishes. ## Prerequisites - The setup in [Before you start](../index.md#before-you-start): embedded signing on, your origin allowed, and an **Administrator** or **Contributor** API key. - React 18 or later. - A PDF with `{signature1}` and `{date1}` text anchors. ## Step 1: Check your setup and choose how signers verify ## Step 2: Install the package ```bash npm install @turbodocx/embed ``` React is a peer dependency and is never bundled. The React component lives at the `@turbodocx/embed/react` subpath, so the package root never imports React. ## Step 3: Create the document and signing URL on your server Add a route to your server, for example `POST /api/signing-session`. It calls `createEmbeddedSignature` and returns the signer's `embedUrl`. Your API key never reaches the browser. ## Step 4: Render the widget Fetch the URL from your server and pass it to `TurboSignForm`. Set `origin` to the TurboSign origin so the component accepts messages only from the signing page. ```tsx export function SignStep({ name, email }: { name: string; email: string }) { const [embedUrl, setEmbedUrl] = useState(null); const [signed, setSigned] = useState(false); async function start() { const res = await fetch("/api/signing-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, email }), }); const data = await res.json(); setEmbedUrl(data.embedUrl); } if (signed) return All set. Your agreement is signed.; if (!embedUrl) return Sign now; return ( { // event is "signing_complete", or "already_signed" when the signer reopened a finished link. // Confirm on your server (status or `completed` webhook) before you mark the deal as signed. setSigned(true); }} /> ); } ``` ### Props | Prop | Required | Description | |---|---|---| | `embedUrl` | Yes | The per-recipient URL from your server. Use it exactly as the SDK returns it. | | `origin` | Yes, in practice | The exact TurboSign origin, `https://app.turbodocx.com`. You can also derive it from the URL you frame: `new URL(embedUrl).origin`. Without it, every message is ignored and `onCompleted` never fires. | | `onCompleted` | Yes | Called when the signer finishes. Receives `documentId`, `status`, `event` and `scope`. | | `height` | No | A CSS length, or a number of pixels. Defaults to `720px`. | | `title` | No | The iframe's accessible name. Defaults to `TurboSign signing`. | | `className`, `style` | No | Styling for the iframe. | | `onDeclined`, `onError` | No | Reserved. The signing page does not send these events yet. | | `allowAnyOrigin` | No | Development only. Accepts messages from any origin. Never ship it. | :::warning Don't leave origin empty The widget fails closed. If `origin` is missing, it ignores every message, logs a one-time warning in the console, and `onCompleted` never fires. `allowAnyOrigin` turns that off for local debugging, but then any frame on your page could fake a completion. ::: ## What the signer sees Your app's page around the signing panel will look different; the panel itself is the same. 1. Your signer clicks your own button (here, **Start signing**). Your server returns the signing URL, and you render `TurboSignForm` with it. ![The widget page form with the Start signing button highlighted](/img/embedded-signing/widget-01-start.png) 2. Your page shows the TurboSign signing panel. The signer clicks **Send Code**, and TurboSign emails a six-digit code to the signer's address. ![The Verify your identity panel inside the host app, with the Send Code button highlighted](/img/embedded-signing/widget-02-send-code.png) 3. The signer types the code and clicks **Verify And Continue**. ![The code entry panel with the Verify And Continue button highlighted](/img/embedded-signing/iframe-03-enter-code.png) 4. The signer ticks **I have read and agree to the TurboSign consent terms** and clicks **Continue**. ![The TurboSign Consent panel with the consent statement highlighted; the checkbox is just to its left, and Continue is below](/img/embedded-signing/iframe-04-consent.png) 5. The document opens. The signer clicks the **Signature** field, types or draws a signature, and clicks **Save**. Date fields fill in automatically. ![The document with the Signature field highlighted](/img/embedded-signing/iframe-05-document.png) 6. When every required field is done, the signer clicks **Submit Signature**. ![The signed document with the Submit Signature button highlighted](/img/embedded-signing/iframe-06-submit.png) 7. The widget calls `onCompleted`, and your app shows its own confirmation. ![The host app's Signed via the widget confirmation highlighted](/img/embedded-signing/widget-03-done.png) ## What the completion event contains `onCompleted` receives the fields of the `turbosign:completed` message: | Field | Value | |---|---| | `documentId` | The document's id. | | `status` | `completed`. | | `event` | `signing_complete`, or `already_signed` when the signer reopened a link they had already completed. | | `scope` | `recipient`. This signer finished; others on the document may still be pending. | ## Common errors | Symptom | Cause | Fix | |---|---|---| | The widget area is blank | Your origin is not under **Allowed embedding domains**. | Add the exact origin, including the port in development. | | Signing finishes but no completion message arrives (often in Firefox) | The signing page posts only to an origin it can identify, and your page or iframe sends no referrer. | Don't use `referrerpolicy="no-referrer"` on the iframe or a `no-referrer` page policy; keep the default `strict-origin-when-cross-origin`. | | `onCompleted` never fires, and the console warns about a missing origin | `origin` is empty or wrong. | Set `origin="https://app.turbodocx.com"`. | | HTTP `403` when your server creates the URL | The API key belongs to a **User**, or **Enable identity verification** is off (`EmbeddedSigningNotEnabled`). | Use an **Administrator** or **Contributor** key, and ask an admin to check Step 1. | | HTTP `403` `OtpOverrideNotAllowed` | Your organization verifies every request and locked the method to a different channel. | Ask an admin to let senders change the method, or [request an SMS passcode](../passcodes.md#request-an-sms-passcode-from-your-code). | ## What's next - **Next:** [Your own iframe](./own-iframe.md), to frame the page yourself and write the listener. - [Web component](./web-component.md), for the same widget outside React. - [Kiosk signing (two signers, one device)](./sequential-signers.md). - [API reference](../reference.md): every field, event and error. --- # Kiosk Signing (Two Signers, One Device) Use this when several people sign one document, in order, on the same screen. Think of a car dealership, a clinic front desk, or a tablet at a counter. Your server creates one document for everyone. It gets a signing URL for the first signer right away, and mints each later signer's URL **only when it is their turn**. Each signer verifies with their own email passcode. **What you'll build:** - A server route that creates the document for all signers in order. - A second server route that mints the next signer's URL, with a short retry. - A page that frames each signer in turn and moves on when one finishes. ## Prerequisites - The setup in [Before you start](../index.md#before-you-start): embedded signing on, your origin allowed, and an **Administrator** or **Contributor** API key. - A PDF with an anchor for each signer, for example `{signature1}` and `{signature2}`. - A working single-signer flow helps. Start with [your own iframe](./own-iframe.md) or the [React widget](./react-widget.md) if you haven't built one. ## Step 1: Check your setup and choose how signers verify ## Step 2: Create the document for every signer Give each signer a `signingOrder` (it defaults to their position in the list plus one). `createEmbeddedSignature` returns one entry per signer, in order: | `status` | `embedUrl` | What to do | |---|---|---| | `ready` | Set | It is this signer's turn. Frame the URL now. | | `pending` | Empty | An earlier signer has not signed yet. Keep the `recipientId` and mint the URL later. | | `completed` | Empty | This signer already signed. | Return the `documentId` and the list (with each `recipientId`) to your page. ## Step 3: Mint the next signer's URL when it is their turn When a signer finishes, the signing page sends `turbosign:completed` right away, but TurboSign advances the turn a moment later. A mint fired immediately can get HTTP `409` with `RecipientNotInTurn` or `NotSignersTurn`. Retry those two codes a few times with a short delay; treat every other error as real. Both server calls, Step 2 and Step 3, in each SDK language: ```typescript // Step 2: POST /api/kiosk/start export async function startKiosk(signers: Array) { const { documentId, recipients } = await TurboSign.createEmbeddedSignature({ file: await readFile("purchase-agreement.pdf"), fileName: "purchase-agreement.pdf", documentName: "Purchase Agreement", recipients: signers.map((s, i) => ({ name: s.name, email: s.email, signingOrder: i + 1, auth: { emailOtp: true }, fields: { signature: `{signature${i + 1}}` }, })), }); // recipients[0] is "ready" with an embedUrl; the rest are "pending". return { documentId, recipients }; } // Step 3: POST /api/kiosk/next const TURN_RACE = new Set(["RecipientNotInTurn", "NotSignersTurn"]); export async function mintNext(documentId: string, recipientId: string): Promise { for (let attempt = 0; ; attempt++) { try { const { url } = await TurboSign.createSigningUrl(documentId, { recipientId }); return url; } catch (err: any) { if (!TURN_RACE.has(err?.code) || attempt >= 5) throw err; await new Promise((r) => setTimeout(r, 1500)); } } } ``` ```python from turbodocx_sdk import TurboSign, ConflictError # Step 2: POST /api/kiosk/start async def start_kiosk(signers: list[dict]) -> dict: with open("purchase-agreement.pdf", "rb") as f: pdf = f.read() return await TurboSign.create_embedded_signature( file=pdf, file_name="purchase-agreement.pdf", document_name="Purchase Agreement", recipients=[ { "name": s["name"], "email": s["email"], "signing_order": i + 1, "auth": {"email_otp": True}, "fields": {"signature": f"{{signature{i + 1}}}"}, } for i, s in enumerate(signers) ], ) # {"documentId": ..., "recipients": [...]}, first one "ready" # Step 3: POST /api/kiosk/next TURN_RACE = {"RecipientNotInTurn", "NotSignersTurn"} async def mint_next(document_id: str, recipient_id: str) -> str: for attempt in range(6): try: link = await TurboSign.create_signing_url(document_id, recipient_id=recipient_id) return link["url"] except ConflictError as err: if err.code not in TURN_RACE or attempt == 5: raise await asyncio.sleep(1.5) ``` ```php use TurboDocx\TurboSign; use TurboDocx\Exceptions\ConflictException; use TurboDocx\Types\Requests\CreateEmbeddedSignatureRequest; use TurboDocx\Types\Requests\CreateSigningUrlRequest; use TurboDocx\Types\Requests\EmbeddedSignatureRecipient; use TurboDocx\Types\Requests\EmbeddedRecipientAuth; use TurboDocx\Types\Requests\EmbeddedRecipientFields; // Step 2: POST /api/kiosk/start function startKiosk(array $signers) { $recipients = []; foreach ($signers as $i => $s) { $recipients[] = new EmbeddedSignatureRecipient( name: $s['name'], email: $s['email'], signingOrder: $i + 1, auth: new EmbeddedRecipientAuth(emailOtp: true), fields: new EmbeddedRecipientFields(signature: '{signature' . ($i + 1) . '}'), ); } // ->documentId and ->recipients; the first recipient is "ready". return TurboSign::createEmbeddedSignature(new CreateEmbeddedSignatureRequest( recipients: $recipients, file: file_get_contents(__DIR__ . '/purchase-agreement.pdf'), fileName: 'purchase-agreement.pdf', documentName: 'Purchase Agreement', )); } // Step 3: POST /api/kiosk/next function mintNext(string $documentId, string $recipientId): string { for ($attempt = 0; ; $attempt++) { try { $link = TurboSign::createSigningUrl($documentId, new CreateSigningUrlRequest(recipientId: $recipientId)); return $link->url; } catch (ConflictException $e) { $race = in_array($e->errorCode, ['RecipientNotInTurn', 'NotSignersTurn'], true); if (!$race || $attempt >= 5) { throw $e; } usleep(1_500_000); } } } ``` ```go // Package setup as in "Your own iframe", plus imports: context, errors, fmt, time. // Signer is your own struct with Name and Email fields. // Step 2: POST /api/kiosk/start func StartKiosk(ctx context.Context, client *turbodocx.Client, pdf []byte, signers []Signer) (*turbodocx.CreateEmbeddedSignatureResponse, error) { recipients := make([]turbodocx.EmbeddedSignatureRecipient, len(signers)) for i, s := range signers { recipients[i] = turbodocx.EmbeddedSignatureRecipient{ Name: s.Name, Email: s.Email, SigningOrder: i + 1, Auth: &turbodocx.EmbeddedRecipientAuth{EmailOTP: true}, Fields: &turbodocx.EmbeddedRecipientFields{Signature: fmt.Sprintf("{signature%d}", i+1)}, } } // The first recipient is "ready"; later ones are "pending" with an empty EmbedURL. return client.TurboSign.CreateEmbeddedSignature(ctx, &turbodocx.CreateEmbeddedSignatureRequest{ File: pdf, FileName: "purchase-agreement.pdf", DocumentName: "Purchase Agreement", Recipients: recipients, }) } // Step 3: POST /api/kiosk/next func MintNext(ctx context.Context, client *turbodocx.Client, documentID, recipientID string) (string, error) { for attempt := 0; ; attempt++ { link, err := client.TurboSign.CreateSigningURL(ctx, documentID, &turbodocx.CreateSigningURLRequest{RecipientID: recipientID}) if err == nil { return link.URL, nil } var conflict *turbodocx.ConflictError race := errors.As(err, &conflict) && (conflict.Code == "RecipientNotInTurn" || conflict.Code == "NotSignersTurn") if !race || attempt >= 5 { return "", err } time.Sleep(1500 * time.Millisecond) } } ``` ```java // Imports: java.nio.file.*, java.util.*, com.turbodocx.TurboDocxException, com.turbodocx.models.*. // client as in "Your own iframe"; Signer is your own record with name() and email(). // Step 2: POST /api/kiosk/start public CreateEmbeddedSignatureResponse startKiosk(List signers) throws Exception { List recipients = new ArrayList(); for (int i = 0; i = 5) throw e; Thread.sleep(1500); } } } ``` ```ruby # Step 2: POST /api/kiosk/start def start_kiosk(signers) TurboDocxSdk::TurboSign.create_embedded_signature( file: StringIO.new(File.binread("purchase-agreement.pdf")), fileName: "purchase-agreement.pdf", documentName: "Purchase Agreement", recipients: signers.each_with_index.map do |s, i| { name: s[:name], email: s[:email], signingOrder: i + 1, auth: { emailOtp: true }, fields: { signature: "{signature#{i + 1}}" } } end ) # "documentId" and "recipients"; the first recipient is "ready" end # Step 3: POST /api/kiosk/next TURN_RACE = %w[RecipientNotInTurn NotSignersTurn].freeze def mint_next(document_id, recipient_id) attempts = 0 begin TurboDocxSdk::TurboSign.create_signing_url(document_id, recipient_id: recipient_id)["url"] rescue TurboDocxSdk::ConflictError => e raise unless TURN_RACE.include?(e.code) && (attempts += 1) :::warning Mint just in time, never ahead Don't try to pre-mint every signer's URL. TurboSign enforces the order: a later signer's URL can't be created until it is genuinely their turn. ::: ## Step 4: Frame each signer in turn On your page, frame the first signer's `embedUrl`. When `turbosign:completed` arrives, ask your server for the next signer's URL and frame that. This example uses the [web component](./web-component.md); the [React widget](./react-widget.md) works the same way with `onCompleted`. ```html const form = document.getElementById("signing"); const who = document.getElementById("who"); const start = await fetch("/api/kiosk/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ signers: [ { name: "Alex Rivera", email: "alex@example.com" }, { name: "Sam Chen", email: "sam@example.com" }, ], }), }).then((r) => r.json()); const queue = start.recipients; let index = 0; function show(url) { who.textContent = `${queue[index].name}, it's your turn to sign.`; form.setAttribute("embed-url", url); } show(queue[0].embedUrl); form.addEventListener("turbosign:completed", async () => { index += 1; if (index >= queue.length) { who.textContent = "Everyone has signed. Thank you!"; form.remove(); return; } who.textContent = `Preparing ${queue[index].name}'s turn...`; const { url } = await fetch("/api/kiosk/next", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId: start.documentId, recipientId: queue[index].recipientId }), }).then((r) => r.json()); show(url); }); ``` :::tip Hand the device over cleanly Show whose turn it is above the signing panel, as in the example. Each signer verifies with a code sent to **their own** email, so the person holding the device must be able to read that inbox. ::: ## What the signers see 1. Someone enters both signers in order and clicks **Start signing**. ![The kiosk form with two signers and the Start signing button highlighted](/img/embedded-signing/kiosk-01-start.png) 2. The first signer sees it is their turn, clicks **Send Code**, enters the code from **their own** email, accepts the consent, and signs. ![The first signer marked signing now, with the Send Code button highlighted](/img/embedded-signing/kiosk-02-first-turn.png) 3. Your page shows "Preparing" for a moment, then loads the second signer's turn. The first signer is marked done. ![The second signer marked signing now after the first is done, with the Send Code button highlighted](/img/embedded-signing/kiosk-03-second-turn.png) 4. The second signer verifies with their own code and signs. When the last signer finishes, TurboSign emails the completed document to everyone. ![The All signers are done confirmation highlighted](/img/embedded-signing/kiosk-04-done.png) ## What the completion event contains Each signer's `turbosign:completed` has `scope: "recipient"`: only that signer finished. Your page uses it to move to the next signer. For the **whole document**, don't rely on the last browser event. Check the document's status on your server, or wait for the `completed` [webhook](../../Webhooks.md), before you treat the agreement as fully signed. ## Common errors | Symptom | Cause | Fix | |---|---|---| | HTTP `409` `RecipientNotInTurn` or `NotSignersTurn` right after a signer finishes | TurboSign has not advanced the turn yet. | Retry for a few seconds, as in Step 3. | | HTTP `409` `RecipientNotInTurn` that never clears | An earlier signer has not actually signed. | Frame the earlier signer again. | | HTTP `409` `RecipientAlreadySigned` | That signer already signed. | Skip to the next one. | | The second signer's panel is blank | Same as any blank frame: your origin is not allowed. | Add your origin under **Allowed embedding domains**. | | HTTP `403` | User-role API key, or embedded signing is off. | Use an **Administrator** or **Contributor** key, and check Step 1. | ## What's next - **Next:** [Email and SMS passcode](../../identity-verification/one-time-passcode.md), to choose how signers verify. - [API reference](../reference.md#createembeddedsignature-one-call): the `ready`, `pending` and `completed` statuses in detail. --- # Embed Signing with the Web Component (No React) In this guide you drop the `turbosign-form` custom element into any page. It works in Vue, Angular, Svelte, server-rendered templates, or plain HTML, with no React and no build step required. The element renders the iframe, checks where each message comes from, and fires a `turbosign:completed` DOM event when the signer finishes. TurboSign verifies the signer with a six-digit code sent to their email. **What you'll build:** - A server route that creates the document and returns a signing URL. - A page with the `turbosign-form` element and an event listener. ## Prerequisites - The setup in [Before you start](../index.md#before-you-start): embedded signing on, your origin allowed, and an **Administrator** or **Contributor** API key. - A PDF with `{signature1}` and `{date1}` text anchors. ## Step 1: Check your setup and choose how signers verify ## Step 2: Load the element Pick one of these. The element registers itself as `turbosign-form` when the module loads. **With a bundler** (Vite, webpack, Angular CLI): ```bash npm install @turbodocx/embed ``` ```javascript ``` **Without a build step**, load the published ES module from a CDN: ```html ``` :::tip Pin the version Pin an exact version in the CDN URL, as above, so a new release can't change your page without you knowing. ::: ## Step 3: Create the document and signing URL on your server Add a route to your server, for example `POST /api/signing-session`. It calls `createEmbeddedSignature` and returns the signer's `embedUrl`. Your API key never reaches the browser. ## Step 4: Add the element and listen for completion ```html const form = document.getElementById("signing"); const res = await fetch("/api/signing-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Jane Doe", email: "jane@example.com" }), }); const { embedUrl } = await res.json(); form.setAttribute("embed-url", embedUrl); form.addEventListener("turbosign:completed", (event) => { const { documentId, event: kind } = event.detail; // kind is "signing_complete", or "already_signed" when the signer reopened a finished link. // Confirm on your server (status or `completed` webhook) before you mark the deal as signed. form.replaceWith(Object.assign(document.createElement("p"), { textContent: "All set. You're signed." })); }); ``` ### Attributes | Attribute | Required | Description | |---|---|---| | `embed-url` | Yes | The per-recipient URL from your server. | | `origin` | Yes, in practice | The exact TurboSign origin, `https://app.turbodocx.com`. You can also derive it from the URL you frame: `new URL(embedUrl).origin`. Without it, every message is ignored. | | `height` | No | A CSS length or a number of pixels. Defaults to `720px`. | | `title` | No | The iframe's accessible name. Defaults to `TurboSign signing`. | | `allow-any-origin` | No | Development only. Accepts messages from any origin. Never ship it. | The element re-emits `turbosign:completed` as a bubbling `CustomEvent`, so a listener on a parent element works too. It also defines `turbosign:declined` and `turbosign:error`, which the signing page does not send yet. :::warning Don't leave origin empty The element fails closed. If `origin` is missing, it ignores every message and `turbosign:completed` never fires. `allow-any-origin` turns that off for local debugging only. ::: ## What the signer sees The signing panel inside `turbosign-form` is the same one the other build guides show. The screenshots below come from the sample app's **Single signer** tab, because it has no web component tab. 1. Your page shows the TurboSign signing panel. The signer clicks **Send Code**, and TurboSign emails a six-digit code to the signer's address. ![The Verify your identity panel with the Send Code button highlighted](/img/embedded-signing/iframe-02-send-code.png) 2. The signer types the code and clicks **Verify And Continue**. ![The code entry panel with the Verify And Continue button highlighted](/img/embedded-signing/iframe-03-enter-code.png) 3. The signer ticks **I have read and agree to the TurboSign consent terms** and clicks **Continue**. ![The TurboSign Consent panel with the consent statement highlighted; the checkbox is just to its left, and Continue is below](/img/embedded-signing/iframe-04-consent.png) 4. The document opens. The signer clicks the **Signature** field, types or draws a signature, and clicks **Save**. ![The document with the Signature field highlighted](/img/embedded-signing/iframe-05-document.png) 5. The signer clicks **Submit Signature**. The element fires `turbosign:completed`. ![The signed document with the Submit Signature button highlighted](/img/embedded-signing/iframe-06-submit.png) ## What the completion event contains `event.detail` has the fields of the `turbosign:completed` message: `documentId`, `status` (`completed`), `event` (`signing_complete` or `already_signed`) and `scope` (`recipient`). See the [API reference](../reference.md#completion-event). ## Common errors | Symptom | Cause | Fix | |---|---|---| | The element is blank | Your origin is not under **Allowed embedding domains**. | Add the exact origin, including the port in development. | | Signing finishes but no completion message arrives (often in Firefox) | The signing page posts only to an origin it can identify, and your page or iframe sends no referrer. | Don't use `referrerpolicy="no-referrer"` on the iframe or a `no-referrer` page policy; keep the default `strict-origin-when-cross-origin`. | | `turbosign:completed` never fires | `origin` is missing or wrong, or the listener is attached to the wrong element. | Set `origin="https://app.turbodocx.com"` and listen on the element (or a parent). | | Nothing renders at all | The module never loaded, so the tag is an unknown element. | Check the network tab for the script, and that it is loaded with `type="module"`. | | HTTP `403` when your server creates the URL | The API key belongs to a **User**, or **Enable identity verification** is off. | Use an **Administrator** or **Contributor** key, and ask an admin to check Step 1. | | HTTP `403` `OtpOverrideNotAllowed` | Your organization verifies every request and locked the method to a different channel. | Ask an admin to let senders change the method, or [request an SMS passcode](../passcodes.md#request-an-sms-passcode-from-your-code). | ## What's next - **Next:** [Kiosk signing (two signers, one device)](./sequential-signers.md). - [External identity verification](../../identity-verification/external-identity-verification.md), to skip the passcode when your identity vendor already verified the signer. - [API reference](../reference.md): every field, event and error. --- # Embedded Signing Embedded signing takes a signer straight from your own UI to a TurboSign signing page, without signing-link emails. Your backend asks TurboSign for a signing URL when the signer is ready, and your app opens it in an iframe, a new tab, or a redirect. **At a glance:** - **Your signer never leaves your app.** The signing page appears inside your product, and no signing-link email is needed. - **Your API key stays on your server.** The browser only ever sees a per-signer URL. - **Each signer is verified your way.** Use an email or SMS passcode, or your own identity verification vendor. The sender override skips verification while you test. - **The signer's real email is the signer of record**, and the verification lands on the certificate of completion and in the audit trail. - **You get a completion event** in the browser, and the `completed` webhook on your server. ## How it works Every embedded signing integration makes the same four moves: 1. **Your app asks your server for a signing session.** Your server creates the document and asks TurboDocx for a signing URL for one signer. 2. **The signing URL comes back to your app**, which shows it, usually in an iframe. 3. **The signing page verifies the signer** and collects the signature. 4. **The signing page tells your app it finished** with a `turbosign:completed` message, and your server confirms the result. ## Choose how signers verify Every embedded signer needs a verification method, unless you use the sender override while testing. Your app shows the signing page itself and TurboSign emails no link, so a check is what ties the person at the screen to the signer of record. | Method | Who verifies the signer | Signing URL | |---|---|---| | [Email or SMS passcode](../identity-verification/one-time-passcode.md) | TurboSign sends a code before the document opens | Reusable for the life of the document | | [External identity verification](../identity-verification/external-identity-verification.md) | Your identity provider; your backend asserts the result | Single-use, expires in about five minutes | | [Sender override](../identity-verification/sender-override.md) | Nobody (testing only, marked not identity-verified) | Single-use, expires in about five minutes | The [Identity verification](../identity-verification/index.md) section compares them in full, including which ones also work for signers you email. ## Choose your path | You want to... | Signer verifies with | Front end | Guide | |---|---|---|---| | Write the least code in a React app | Email passcode | The React `TurboSignForm` component | [React widget](./build/react-widget.md) | | Keep full control of the iframe, in any framework | Email passcode | Your own `iframe` and message listener | [Your own iframe](./build/own-iframe.md) | | Use Vue, Angular, Svelte or plain HTML | Email passcode | The `turbosign-form` web component | [Web component](./build/web-component.md) | | Have two or more people sign in order on one device | An email passcode each | Any | [Kiosk signing (two signers, one device)](./build/sequential-signers.md) | | Verify signers by text message | SMS passcode | Any | [Passcodes in embedded signing](./passcodes.md#request-an-sms-passcode-from-your-code) | | Reuse a check your identity vendor already ran | Your identity provider | Any | [External identity verification](../identity-verification/external-identity-verification.md) | | Try the flow in development without verification | Nothing (marked not identity-verified) | Any | [Sender override for testing](../identity-verification/sender-override.md) | :::tip Not sure? Start with the React widget or the web component They check the message origin and surface the completion event for you. Choose your own iframe only when you need full control of the frame. ::: ## Before you start You need these in place before any guide works: 1. **An admin has turned on embedded signing.** In **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**, the **Enable identity verification** switch is on. Despite its name, it is the master switch for embedded signing and every verification mode. 2. **Your app's origin is allowed.** On the **Identity & embedding** tab, your origin (for example `https://app.yourcompany.com`) is under **Allowed embedding domains**. 3. **You have an API key with the right role:** an **Administrator** or **Contributor** key. 4. **Your PDF has text anchors** where fields go, for example `{signature1}` and `{date1}`. 5. **The TurboDocx SDK is installed on your server.** See the [TurboSign SDK](../../SDKs/turbosign.md) for your language. [Set up your organization](./set-up-your-organization.md) walks an admin through steps 1 and 2 with screenshots. :::warning The signing page will not appear until your origin is allowed Embedding is denied by default. Until an admin adds your app's origin under **Allowed embedding domains**, the iframe stays blank. That is the clickjacking protection working, not a bug. ::: :::caution User-role API keys are rejected Requesting a signing URL needs an **Administrator** or **Contributor** key. A **User**-role key can create the document, but the signing URL request fails with HTTP `403`. ::: :::caution localhost is for development only You can allow `http://localhost` origins to test on your machine. Remove every `http://` origin before you go live; production origins must be `https://`. ::: :::note Ruby The Ruby tabs in these guides show the Ruby SDK API. The `turbodocx-sdk` gem is not on RubyGems yet; until it is, install it from the [TurboDocx SDK repository](https://github.com/TurboDocx/SDK/tree/main/packages/ruby-sdk). ::: ## The flow, end to end 1. Your backend prepares the document with an embedded recipient and `sendEmail: false`, then stores the returned `documentId` on the customer record. 2. Your app shows a "Sign now" button. Clicking it calls your backend, not a stored link. 3. Your backend confirms the signed-in user matches the customer record, calls `createSigningUrl`, and returns the URL. 4. The signing page verifies the signer (a passcode, the asserted external verification, or nothing for override) and shows the document. 5. The signer signs. The page posts `turbosign:completed`, and TurboSign returns the signer to your `returnUrl` if you set one. 6. The `completed` webhook updates your record. Download the signed PDF; the certificate shows the identity-verification line. The [API reference](./reference.md) has every request and response field. ## Common errors {#errors} | HTTP | `code` | What to do | |---|---|---| | (blank iframe) | none | Add your exact origin under **Allowed embedding domains**. | | 403 | none (a plain `403 Forbidden`) | The API key belongs to a **User**. Use an **Administrator** or **Contributor** key. | | 403 | `EmbeddedSigningNotEnabled` | Ask an admin to turn on **Enable identity verification**. | | 403 | `OtpOverrideNotAllowed` | Your organization locked the passcode method. Omit the channel, or ask an admin to let senders change it. | | 409 | `RecipientNotInTurn`, `NotSignersTurn` | An earlier signer has not finished. Mint this signer's URL when it is their turn. | | 410 | `SigningUrlNotRedeemable` | A single-use URL was used or expired. Request a new one. | Every error code is in the [API reference](./reference.md#errors). ## Good practice - **Never store a signing URL.** Request one when the signer clicks. Passcode signers get a reusable link that lasts as long as the document; external identity verification and override signers get a single-use link that expires after about five minutes. - **Verify the signer in your own app first.** Confirm the logged-in user is the recipient before you request a URL. - **Use `externalId`** to reference a signer by your own record (an Airtable row, a CRM contact) instead of storing TurboDocx's recipient id. - **Set allowed embedding domains** before you iframe the signing page. - **Confirm completion on your server.** Treat the browser event as a hint, and rely on the document status or the `completed` webhook. ## Next [Set up your organization](./set-up-your-organization.md), then pick a guide from [Choose your path](#choose-your-path). --- # Passcodes in Embedded Signing An admin turns passcodes on in [Email and SMS passcode](../identity-verification/one-time-passcode.md). Your code then asks for a passcode on each embedded signer. - **Email passcode:** every [build guide](./build/react-widget.md) uses one: `auth: { emailOtp: true }` in `createEmbeddedSignature`. - **SMS passcode:** this page. - **Which channel applies when you set none,** and how each SDK leaves the channel out: see [The recipient](./reference.md#the-recipient) in the API reference. :::tip Passcodes only for signers in your own app If only the signers in your own app should verify, keep **Only when requested**. Turn on SMS and connect a provider ([Steps 4-5](../identity-verification/one-time-passcode.md#step-4-allow-sms-as-an-alternative-to-email)), then have your integration request SMS on each recipient it embeds. Other signature requests, including Pipelines, stay passcode-free. See [Verify only your embedded signers by SMS](#verify-only-your-embedded-signers-by-sms). ::: ## Request an SMS passcode from your code {#request-an-sms-passcode-from-your-code} Before you start, check these: 1. Your plan includes SMS passcodes (**Pro** or **Enterprise**). 2. An admin turned on **Allow SMS as an alternative to email** ([Step 4](../identity-verification/one-time-passcode.md#step-4-allow-sms-as-an-alternative-to-email)). 3. An admin connected an SMS provider, and its status reads **Connected** ([Step 5](../identity-verification/one-time-passcode.md#step-5-connect-your-sms-provider)). Your request does not choose a provider; TurboSign uses the one your organization connected. 4. You have the signer's mobile number in international format, for example `+15551234567`. Then give the recipient an SMS `auth` block instead of `emailOtp`. `createEmbeddedSignature` sets the recipient's phone from it. ```typescript // Continues the setup from "Your own iframe": imports and TurboSign.configure(...). const { documentId, recipients } = await TurboSign.createEmbeddedSignature({ file: await readFile("contract.pdf"), fileName: "contract.pdf", documentName: "Service Agreement", recipients: [ { name: "Jane Doe", email: "jane@example.com", auth: { sms: { phoneNumber: "+15551234567" } }, // text the passcode fields: { signature: "{signature1}", date: "{date1}" }, }, ], }); const embedUrl = recipients[0].embedUrl; ``` ```python # Inside an async function, after TurboSign.configure(...) as in "Your own iframe". pdf holds the PDF bytes. result = await TurboSign.create_embedded_signature( file=pdf, file_name="contract.pdf", document_name="Service Agreement", recipients=[ { "name": "Jane Doe", "email": "jane@example.com", "auth": {"sms": {"phone_number": "+15551234567"}}, # text the passcode "fields": {"signature": "{signature1}", "date": "{date1}"}, } ], ) embed_url = result["recipients"][0]["embedUrl"] ``` ```php // Continues the setup from "Your own iframe": TurboSign::configure(...) and the use lines. $result = TurboSign::createEmbeddedSignature(new CreateEmbeddedSignatureRequest( recipients: [ new EmbeddedSignatureRecipient( name: 'Jane Doe', email: 'jane@example.com', auth: new EmbeddedRecipientAuth(smsPhoneNumber: '+15551234567'), // text the passcode fields: new EmbeddedRecipientFields(signature: '{signature1}', date: '{date1}'), ), ], file: file_get_contents(__DIR__ . '/contract.pdf'), fileName: 'contract.pdf', documentName: 'Service Agreement', )); $embedUrl = $result->recipients[0]->embedUrl; ``` ```go // Inside a function that returns error, with client and ctx as in "Your own iframe". pdf holds the PDF bytes. result, err := client.TurboSign.CreateEmbeddedSignature(ctx, &turbodocx.CreateEmbeddedSignatureRequest{ File: pdf, FileName: "contract.pdf", DocumentName: "Service Agreement", Recipients: []turbodocx.EmbeddedSignatureRecipient{ { Name: "Jane Doe", Email: "jane@example.com", Auth: &turbodocx.EmbeddedRecipientAuth{ // text the passcode SMS: &turbodocx.EmbeddedRecipientSMS{PhoneNumber: "+15551234567"}, }, Fields: &turbodocx.EmbeddedRecipientFields{Signature: "{signature1}", Date: "{date1}"}, }, }, }) if err != nil { return err } fmt.Println(result.Recipients[0].EmbedURL) // frame this URL ``` ```java // Inside a method that throws IOException, with client as in "Your own iframe". CreateEmbeddedSignatureResponse result = client.turboSign().createEmbeddedSignature( new CreateEmbeddedSignatureRequest.Builder() .file(Files.readAllBytes(Paths.get("contract.pdf"))) .fileName("contract.pdf") .documentName("Service Agreement") .recipients(List.of( new EmbeddedSignatureRecipient.Builder() .name("Jane Doe") .email("jane@example.com") .auth(EmbeddedRecipientAuth.sms("+15551234567")) // text the passcode .fields(new EmbeddedRecipientFields.Builder() .signature("{signature1}") .date("{date1}") .build()) .build())) .build()); String embedUrl = result.getRecipients().get(0).getEmbedUrl(); ``` ```ruby # Continues the setup from "Your own iframe": require "turbodocx_sdk" and configure. result = TurboDocxSdk::TurboSign.create_embedded_signature( file: StringIO.new(File.binread("contract.pdf")), fileName: "contract.pdf", documentName: "Service Agreement", recipients: [ { name: "Jane Doe", email: "jane@example.com", auth: { sms: { phoneNumber: "+15551234567" } }, # text the passcode fields: { signature: "{signature1}", date: "{date1}" } } ] ) embed_url = result["recipients"].first["embedUrl"] ``` Frame `embedUrl` exactly as in the [build guides](./build/react-widget.md). The signer sees the same gate as for email, and the code arrives by text message. If you send with `sendSignature` instead, set `phone` on the recipient and `identityVerification: { "mode": "otp", "channel": "sms" }`. | Error | Cause | |---|---| | `OtpPhoneRequired` (400), or `PhoneRequiredForSmsOtp` from the JS, Python, Go, Java or Ruby SDK before the request is sent | The recipient asks for SMS but has no phone number. The PHP SDK doesn't check first, so PHP callers get `OtpPhoneRequired` from the API. With the `createEmbeddedSignature` SMS shorthand, the phone comes from `phoneNumber`, so this only happens when that is empty. | | `OtpPhoneInvalid` (400) | The number is well-formed but cannot exist. | | `OtpNotEntitled`, `SmsOtpLimitExceeded` (402) | The plan does not include SMS passcodes, or the SMS allowance is used up. | | `SmsOtpNotEnabled` (403) | **Allow SMS as an alternative to email** is off. | | `OtpOverrideNotAllowed` (403) | The organization verifies every request and locked the method to a different channel. Ask an admin to turn on **Let senders change the method per recipient**. | | `SmsProviderNotConfigured` (409) | No SMS provider is saved for the organization. | ## Verify only your embedded signers by SMS {#verify-only-your-embedded-signers-by-sms} A common setup: your team keeps sending ordinary signature requests (from the TurboDocx app and from Pipelines) with no passcode, while signers in your own app verify by text message. You do not need an SMS default for the whole organization to do this. 1. On the **One-time passcode** tab, turn on **Enable identity verification**. 2. Under **When to verify signers**, keep **Only when requested** (the default). Signature requests sent from the app and from Pipelines keep signing with no passcode; in the app, each signer's **Identity verification** stays on **No verification** unless a sender changes it. 3. Under **Text message (SMS)**, turn on **Allow SMS as an alternative to email**, then connect your SMS provider and check the status reads **Connected to Twilio** (or RingCentral). 4. In your integration, ask for SMS on each recipient you embed, as in the code above. With `sendSignature`, the recipient looks like this: ```json { "name": "Jane Doe", "email": "jane@example.com", "phone": "+15551234567", "identityVerification": { "mode": "otp", "channel": "sms" } } ``` :::tip Pick the channel in your app, not from defaultChannel Choose SMS in your own app's configuration. Do not copy it from `defaultChannel`: with **Only when requested** that value is `none`, which tells you nothing about the channel you want. ::: ## Check the organization default from your code Your integration can read the result with `GET /turbosign/embedded-signing-settings`: - `defaultChannel` is `none`, `email`, or `sms`. - `allowChannelOverride` tells it whether a different channel is accepted. See [The organization default](./reference.md#the-organization-default) for details. ## Next - **Next:** [API reference](./reference.md) - [Email and SMS passcode](../identity-verification/one-time-passcode.md): the admin settings, SMS providers and what the signer sees. --- # Embedded Signing API Reference Every method and event your integration uses for embedded signing, each laid out the same way: what it does, request, response, example, errors. For a walkthrough, start with a [build guide](./build/react-widget.md). | Method or event | Use it to | |---|---| | [`createEmbeddedSignature`](#createembeddedsignature-one-call) | Create the document and get a signing URL for the first signer, in one call. | | [`createSigningUrl`](#requesting-a-signing-url) | Get a signing URL for one signer of an existing document. | | [`getEmbeddedSigningSettings`](#organization-settings) | Read what your organization allows. | | [`turbosign:completed`](#completion-event) | Know in the browser that a signer finished. | | [Shared concepts](#shared-concepts) | The recipient block, the organization default, sending without emails, passcode limits. | | [All errors](#errors) | Every error code in one table. | ## createEmbeddedSignature {#createembeddedsignature-one-call} Creates the document, adds each signer, and returns a signing URL for whoever signs first. It is an SDK helper that wraps `sendSignature` and `createSigningUrl`, so there is no separate REST endpoint. It sets `sendEmail: false` for you. ### Request | Field | Required | Description | |---|---|---| | `recipients` | Yes | The signers, in order. Each takes `name`, `email`, optional `phone`, optional `signingOrder` (defaults to position plus one), optional `auth` and optional `fields`. | | `file`, `fileName`, `fileLink`, `templateId`, `deliverableId` | One source | The document to sign. | | `documentName`, `documentDescription` | No | Shown to the signer. | | `senderName`, `senderEmail`, `ccEmails` | No | Override the configured sender, add CC addresses. | | `fields` | No | Full field list. When set, it replaces every recipient's `fields` shorthand. | | `sendEmail` | No | Defaults to `false`. | | `returnUrl` | No | `https` URL the signer returns to after signing. | A recipient's `auth` asks for a passcode, and its `fields` places fields on text anchors such as `{signature1}`. Leave `auth` off for no passcode (the [organization default](#the-organization-default) then applies). The auth and fields shorthand in each SDK | Language | Email passcode | SMS passcode | Fields shorthand | |---|---|---|---| | JavaScript / TypeScript | `auth: { emailOtp: true }` | `auth: { sms: { phoneNumber: "+1..." } }` | `fields: { signature, date, initials, fullName }` | | Python | `"auth": {"email_otp": True}` | `"auth": {"sms": {"phone_number": "+1..."}}` | `"fields": {"signature", "date", "initials", "full_name"}` | | PHP | `new EmbeddedRecipientAuth(emailOtp: true)` | `new EmbeddedRecipientAuth(smsPhoneNumber: '+1...')` | `new EmbeddedRecipientFields(signature: ..., date: ...)` | | Go | `&EmbeddedRecipientAuth{EmailOTP: true}` | `&EmbeddedRecipientAuth{SMS: &EmbeddedRecipientSMS{PhoneNumber: "+1..."}}` | `&EmbeddedRecipientFields{Signature: ..., Date: ...}` | | Java | `EmbeddedRecipientAuth.emailOtp()` | `EmbeddedRecipientAuth.sms("+1...")` | `new EmbeddedRecipientFields.Builder().signature(...).date(...)` | | Ruby | `auth: { emailOtp: true }` | `auth: { sms: { phoneNumber: "+1..." } }` | `fields: { signature:, date:, initials:, fullName: }` | ### Response A `documentId` and one entry per recipient, in signing order: | Field | Description | |---|---| | `recipientId` | TurboDocx's id for the recipient. Keep it to mint a later URL. | | `embedUrl` | The URL to frame. Set only when `status` is `ready`; `null` otherwise (an empty string in Go). | | `status` | `ready` (their turn, frame it now), `pending` (an earlier signer has not signed yet), or `completed` (already signed). | | `identityVerificationMode` | For a `ready` signer, the mode TurboSign resolved for the URL. For `pending` or `completed`, the mode you requested through `auth` (`null` when you set none, even if the organization default applies). | ### Example ### Errors | Status | `code` | Meaning | |---|---|---| | 400 | `OtpPhoneRequired`, `OtpPhoneInvalid` | An SMS signer has no phone number, or one that cannot exist. The JS, Python, Go, Java and Ruby SDKs raise `PhoneRequiredForSmsOtp` before sending. | | 402 | `OtpNotEntitled`, `SmsOtpLimitExceeded` | The plan does not include this verification, or the SMS allowance is used up. | | 403 | none (a plain `403 Forbidden`) | The API key belongs to a **User**. | | 403 | `EmbeddedSigningNotEnabled`, `SmsOtpNotEnabled` | **Enable identity verification**, or SMS, is off. | | 403 | `OtpOverrideNotAllowed` | The organization locked the passcode method to a different channel. | | 409 | `SmsProviderNotConfigured` | No SMS provider is saved. | :::note What the one-call helper doesn't cover The `auth` shorthand only produces passcodes, and its recipients take no `externalId`. For `external_idv` or `override` recipients, or to look recipients up by `externalId`, use `sendSignature` with `sendEmail: false` and then `createSigningUrl`. See [External identity verification](../identity-verification/external-identity-verification.md) and [Sender override for testing](../identity-verification/sender-override.md). ::: ## createSigningUrl {#requesting-a-signing-url} Returns a signing URL for one signer of an existing document. Request it at the moment the signer clicks, and never store it. REST: `POST /turbosign/documents/{documentId}/signing-url` ### Request | Field | Required | Description | |---|---|---| | `recipientId` or `externalId` | Exactly one | Picks the recipient. | | `returnUrl` | No | `https` URL the signer returns to after signing. See [Return URL](#return-url). | | `identityAssertion` | For `external_idv` | Your provider's verification. See [External identity verification](../identity-verification/external-identity-verification.md). | Unknown keys are rejected. ### Response The REST response wraps the result in `data.results`. The SDKs return the inner object. ```json { "data": { "results": { "url": "https://app.turbodocx.com/e-signature/embed/{documentId}?sut=...", "expiresAt": "2026-09-16T15:07:00Z", "recipientId": "...", "externalId": "your_customer_123", "identityVerificationMode": "override", "pendingChecks": [] } } } ``` | Field | Description | |---|---| | `url` | The URL to open, redirect to, or frame. | | `expiresAt` | When the URL stops working (see the table below). | | `recipientId`, `externalId` | The recipient the URL is for. | | `identityVerificationMode` | `otp`, `external_idv`, `override`, or `null`. | | `pendingChecks` | The passcode step the signer must clear (`email_otp` or `sms_otp`). Empty for every other mode. | | Mode | `url` | `expiresAt` | `pendingChecks` | |---|---|---|---| | `otp` or no verification | The reusable signing link | The document's own expiry, or `null` when it doesn't expire | The passcode step the signer must clear (`email_otp` or `sms_otp`); empty with no verification | | `external_idv` or `override` | A **single-use** link. Opening it consumes it. | About five minutes after issue | Empty | #### Return URL `returnUrl` must be an `https` URL. When signing finishes, the signer is returned there. - TurboSign stores it on the recipient when you request the signing URL. It is never carried in the signing URL itself, so a signer cannot edit it into a redirect to another site. - The signing page reads it back when it loads and redirects there after the signer finishes. - It is set per request. Requesting a new signing URL without `returnUrl` clears the one stored earlier. ### Example ```typescript // Continues the setup from "Your own iframe": imports and TurboSign.configure(...). const { url, expiresAt, identityVerificationMode, pendingChecks } = await TurboSign.createSigningUrl(documentId, { externalId: "your_customer_123", // or recipientId, exactly one returnUrl: "https://app.yourcompany.com/signed", }); ``` ```python # Inside an async function, after TurboSign.configure(...) as in "Your own iframe". pdf holds the PDF bytes. link = await TurboSign.create_signing_url( document_id, external_id="your_customer_123", # or recipient_id, exactly one return_url="https://app.yourcompany.com/signed", ) url, pending_checks = link["url"], link["pendingChecks"] ``` ```php use TurboDocx\Types\Requests\CreateSigningUrlRequest; $link = TurboSign::createSigningUrl($documentId, new CreateSigningUrlRequest( externalId: 'your_customer_123', // or recipientId, exactly one returnUrl: 'https://app.yourcompany.com/signed' )); $url = $link->url; ``` ```go // Inside a function that returns error, with client and ctx as in "Your own iframe". pdf holds the PDF bytes. link, err := client.TurboSign.CreateSigningURL(ctx, documentID, &turbodocx.CreateSigningURLRequest{ ExternalID: "your_customer_123", // or RecipientID, exactly one ReturnURL: "https://app.yourcompany.com/signed", }) if err != nil { return err } fmt.Println(link.URL) // open, redirect to, or frame this URL ``` ```java // Inside a method that throws IOException, with client as in "Your own iframe". CreateSigningUrlResponse link = client.turboSign().createSigningUrl( documentId, new CreateSigningUrlRequest.Builder() .externalId("your_customer_123") // or recipientId, exactly one .returnUrl("https://app.yourcompany.com/signed") .build()); String url = link.getUrl(); ``` ```ruby # Continues the setup from "Your own iframe": require "turbodocx_sdk" and configure. link = TurboDocxSdk::TurboSign.create_signing_url( document_id, external_id: "your_customer_123", # or recipient_id:, exactly one return_url: "https://app.yourcompany.com/signed" ) url = link["url"] ``` ### Errors | Status | `code` | Meaning | |---|---|---| | 400 | `RecipientSelectorInvalid`, `InvalidReturnUrl` | Send exactly one selector; `returnUrl` must be `https`. | | 400 | `IdentityAssertion*`, `IdentityProviderMismatch`, `IdentityEmailMismatch` | The `external_idv` assertion is missing or fails a check. | | 403 | none (a plain `403 Forbidden`) | The API key belongs to a **User**. | | 403 | `EmbeddedSigningNotEnabled`, `ExternalIdvNotAllowed`, `IdentityOverrideNotAllowed` | The organization has not turned on what the recipient uses. | | 404 | `RecipientNotFound` | No recipient matches the selector. | | 409 | `RecipientNotInTurn`, `NotSignersTurn`, `RecipientAlreadySigned`, `DocumentNotSignable` | Not this signer's turn, already signed, or the document can't be signed. | | 410 | `SigningUrlNotRedeemable` | A single-use URL was opened already or expired. Request a new one. | :::caution Deprecated: GET signing-link `GET /turbosign/documents/{documentId}/recipients/{recipientId}/signing-link` is deprecated. It still works, and goes through the same checks and audit trail as `createSigningUrl`. Its responses carry a `Deprecation: true` header and a `Link` header pointing to the successor, `POST /turbosign/documents/{documentId}/signing-url`. Move to the `POST` endpoint, which handles every identity mode. ::: ## getEmbeddedSigningSettings {#organization-settings} Reads what your organization allows, so your integration can check before it sends or requests a URL. It is read-only: an admin changes these in [Set up your organization](./set-up-your-organization.md), where changes are recorded in the settings audit trail. REST: `GET /turbosign/embedded-signing-settings` ### Request No parameters. ### Response ```json { "data": { "results": { "enabled": true, "allowExternalIdv": true, "allowIdentityOverride": false, "defaultChannel": "email", "allowChannelOverride": false, "allowedFrameAncestors": ["https://app.yourcompany.com"] } } } ``` | Field | Description | |---|---| | `enabled` | **Enable identity verification** is on: embedded signing and every verification mode are available. | | `allowExternalIdv` | Your integration may assert a signer's identity with its own provider (`external_idv`). | | `allowIdentityOverride` | A sender may issue a link that skips verification (`override`). | | `defaultChannel` | The organization default passcode channel: `none` (only when requested), `email`, or `sms`. | | `allowChannelOverride` | Whether a request may give a recipient a channel other than `defaultChannel`. When `false`, a different channel is rejected with `OtpOverrideNotAllowed`, so omit the channel to take the default. Always `true` when `defaultChannel` is `none` or embedded signing is off. | | `allowedFrameAncestors` | The origins allowed to embed the signing page in an iframe. An empty list denies all framing. | ### Example ```typescript // Continues the setup from "Your own iframe": imports and TurboSign.configure(...). const settings = await TurboSign.getEmbeddedSigningSettings(); if (!settings.enabled) throw new Error("Ask an admin to turn on embedded signing."); ``` ```python # Inside an async function, after TurboSign.configure(...) as in "Your own iframe". pdf holds the PDF bytes. settings = await TurboSign.get_embedded_signing_settings() if not settings["enabled"]: raise RuntimeError("Ask an admin to turn on embedded signing.") ``` ```php // Continues the setup from "Your own iframe": TurboSign::configure(...) and the use lines. $settings = TurboSign::getEmbeddedSigningSettings(); if (!$settings->enabled) { throw new RuntimeException('Ask an admin to turn on embedded signing.'); } ``` ```go // Continues the client setup from the build guides; needs the "errors" import. settings, err := client.TurboSign.GetEmbeddedSigningSettings(ctx) if err != nil { return err } if !settings.Enabled { return errors.New("ask an admin to turn on embedded signing") } ``` ```java // Inside a method that throws IOException, with client as in "Your own iframe". EmbeddedSigningSettings settings = client.turboSign().getEmbeddedSigningSettings(); if (!settings.isEnabled()) { throw new IllegalStateException("Ask an admin to turn on embedded signing."); } ``` ```ruby # Continues the setup from "Your own iframe": require "turbodocx_sdk" and configure. settings = TurboDocxSdk::TurboSign.get_embedded_signing_settings raise "Ask an admin to turn on embedded signing." unless settings["enabled"] ``` ### Errors Only authentication errors (HTTP `401`) for an invalid API key. ## Completion event {#completion-event} When a signer finishes, the signing page posts this message to the page that frames it. The [build guides](./build/own-iframe.md) show how to listen for it. ### Payload ```json { "type": "turbosign:completed", "documentId": "4f1c...", "status": "completed", "event": "signing_complete", "scope": "recipient" } ``` | Field | Description | |---|---| | `type` | Always `turbosign:completed`. | | `documentId` | The signing document's id. | | `status` | `completed`. | | `event` | `signing_complete` when the signer just finished, or `already_signed` when they reopened a link they had already completed. Use it to skip one-time side effects. | | `scope` | `recipient`: **this** signer finished. Others on the document may still be pending. For the whole document, read the status on your server or watch the `completed` [webhook](../Webhooks.md). | ### Example ```javascript window.addEventListener("message", (event) => { if (event.origin !== "https://app.turbodocx.com") return; if (event.source !== iframe.contentWindow) return; if (event.data?.type === "turbosign:completed") { // Confirm on your server before you mark the agreement as signed. } }); ``` :::note The signing page posts only to an origin it can identify. A page or iframe with `referrerpolicy="no-referrer"` can stop the message in some browsers (often Firefox). The `@turbodocx/embed` package also defines `turbosign:declined` and `turbosign:error`, but the signing page does not send those events yet. ::: ## Shared concepts {#shared-concepts} ### The recipient Mark a recipient for embedded signing by giving it an `identityVerification` block. The signer's real `email` is always required and stays the signer of record. ```json { "name": "Jane Doe", "email": "jane@example.com", "signingOrder": 1, "phone": "+15551234567", "externalId": "your_customer_123", "identityVerification": { "mode": "otp", "channel": "email" } } ``` | Field | Description | |---|---| | `phone` | Required when the passcode is delivered by SMS, in international format (for example `+15551234567`). The number must be one that can exist: a well-formed but impossible number is rejected with `OtpPhoneInvalid`. | | `externalId` | Your own identifier for the signer (for example an Airtable record id). Unique within a document. Lets you request a signing URL by your key instead of storing TurboDocx's recipient id. | | `identityVerification` | One of the three blocks below. Omit it to take the [organization default](#the-organization-default). | | Mode | Block | |---|---| | One-time passcode | `{ "mode": "otp", "channel": "email" \| "sms" }`. Omit `channel` to take the organization default (email when that default is `none`). | | External identity verification | `{ "mode": "external_idv", "provider": "your-idv-vendor", "maxAgeMinutes": 1440 }` | | Override | `{ "mode": "override", "overrideIdentityVerification": true, "reason": "Sandbox testing" }` | Verification is not tied to embedding. The same per-recipient step-up applies whether the signer arrives through an embedded URL or an emailed link. Leaving the channel out in each SDK - **JS/TS, Python, Go:** leave `channel` out of the `otp` block. - **Ruby:** the recipient takes a plain hash; leave `"channel"` out of it. - **Java:** pass `null` to `IdentityVerification.otp(...)`. (`otpEmail()` and `otpSms()` always set a channel.) - **PHP:** `IdentityVerification::otp()` always sends a channel (it defaults to `email`), so leave `identityVerification` off the recipient instead. An `otp` block with no channel uses the organization default channel, or email when that default is `none`. ### The organization default The admin's choice under **When to verify signers** applies to every recipient that does not set its own channel. It applies to signatures created in the TurboDocx app **and** to documents you send through the API or SDK. | Setting | `defaultChannel` | What a recipient with no channel gets | |---|---|---| | **Only when requested** | `none` | No passcode, unless the request asks for one. This default is never locked, so a request can always turn verification on. | | **On every signature request** | `email` or `sms` | A passcode by that method. Unless the admin turns on **Let senders change the method per recipient**, the method is locked (`allowChannelOverride: false`). | When the method is locked: - a request that sets the **same** channel, or omits it, succeeds; - a request that sets a **different** channel fails with `OtpOverrideNotAllowed`. :::note Exemptions Automated sends (Pipelines, bulk signature sending, TurboQuote, and the Wrike integration) are exempt from the organization default. Recipients they create are verified only when the request sets verification explicitly. Recipients using `external_idv` or `override` skip the passcode, so the channel default does not apply to them. ::: To verify only your embedded signers by text message while everyone else signs without a passcode, see [Verify only your embedded signers by SMS](./passcodes.md#verify-only-your-embedded-signers-by-sms). ### Sending without signing-link emails When every signer signs inside your app, send the document with `sendEmail: false` on `prepare-for-signing`. TurboSign then sends no signing-link emails for that document: - not the initial request, - not the "your turn" email to the next signer, - and no scheduled reminders or expiry warnings. The reply says so: ```json { "message": "Document sent for signing. Signing-link emails were not sent (sendEmail: false); request a signing URL for each recipient." } ``` Explicit sender actions still send. **Resend Email** and a manual **Remind now** email the signer. Passcode emails and the completed-document copy are unaffected. `createEmbeddedSignature` sets `sendEmail: false` for you. ### Passcode attempts and lockout | Rule | Limit | |---|---| | Code length and lifetime | Six digits, expires after 10 minutes | | Requesting a new code | At most every 30 seconds, and five times per hour | | Wrong entries per code | Five, then the signer must request a new code | | Wrong entries in total | **20**, across resends, then the signer is locked | When a signer is locked, the signing page shows the lock, and both sending and verifying a code return HTTP `423` until the sender resends the signing request. Only the sender can lift the lock, with **Resend Email** in the app or `POST /turbosign/documents/{documentId}/resend-email`. That resend emails the signer, even on a document sent with `sendEmail: false`. The document's sender gets an email alert twice per signer: 1. at 5 wrong codes ("having trouble verifying"), so you can check the signer's email or phone number before the lock; 2. when the signer is locked out at 20. The alerts go to the sender's email address. A document with no sender email (for example an API-key send without `senderEmail`) gets no alert. ## All errors {#errors} Identity and passcode errors from sending a document and from `createSigningUrl` use one response body. `message` and `error` carry the same human-readable text, and `type` and `code` carry the same machine-readable value, so read whichever pair your client already uses: ```json { "message": "This organization does not allow changing the identity-verification method. Omit otpChannel or set it to 'email'.", "type": "OtpOverrideNotAllowed", "error": "This organization does not allow changing the identity-verification method. Omit otpChannel or set it to 'email'.", "code": "OtpOverrideNotAllowed" } ``` | Status | `code` | Meaning | |---|---|---| | 400 | `OtpPhoneRequired` | The recipient resolves to SMS but has no `phone`. | | 400 | `OtpPhoneInvalid` | The SMS number is well-formed but cannot exist. | | 400 | `IdentityConfigInvalid`, `IdvProviderRequired`, `OverrideNotAcknowledged`, `IdentityModeConflict` | The recipient's `identityVerification` block is invalid or incomplete. | | 400 | `DuplicateExternalId` | Two recipients on the document share an `externalId`. | | 400 | `RecipientSelectorInvalid` | `createSigningUrl` needs exactly one of `recipientId` or `externalId`. | | 400 | `InvalidReturnUrl` | `returnUrl` is not an `https` URL. | | 400 | `IdentityAssertionRequired`, `IdentityAssertionInvalid`, `IdentityProviderMismatch`, `IdentityEmailMismatch`, `IdentityAssertionStale`, `IdentityAssertionReused` | The `external_idv` assertion is missing or fails a check (see [External identity verification](../identity-verification/external-identity-verification.md#errors)). | | 402 | `OtpNotEntitled`, `SmsOtpLimitExceeded` | The plan does not include this verification, or the SMS allowance is used up. | | 403 | none (a plain `403 Forbidden`) | The API key belongs to a **User**. Requesting a signing URL needs an **Administrator** or **Contributor** key. | | 403 | `OtpOverrideNotAllowed` | The organization locked the passcode method and the request set a different channel. Omit the channel or match `defaultChannel`. | | 403 | `EmbeddedSigningNotEnabled`, `SmsOtpNotEnabled`, `ExternalIdvNotAllowed`, `IdentityOverrideNotAllowed` | The organization has not turned on the feature or mode the request uses. | | 404 | `RecipientNotFound` | No recipient on the document matches the selector. | | 409 | `SmsProviderNotConfigured` | The recipient resolves to SMS but the organization has no SMS provider saved. | | 409 | `RecipientRequiresSingleUseUrl` | The deprecated `signing-link` endpoint was called for an `external_idv` or `override` recipient. Use `createSigningUrl`. | | 409 | `DocumentNotSignable`, `RecipientAlreadySigned`, `RecipientNotInTurn`, `NotSignersTurn` | The document or recipient is not in a signable state. | | 410 | `SigningUrlNotRedeemable` | A single-use signing URL was already used or has expired. Request a new one. | | 423 | `locked` | The signer entered 20 wrong codes. See [Passcode attempts and lockout](#passcode-attempts-and-lockout). | A missing document returns `404` with the older `{ "message", "type": "DocumentNotFound" }` body. The signer-facing passcode endpoints that the signing page calls use their own `{ "error", "code" }` body with lowercase codes, for example `locked` with HTTP `423`. :::warning A blank iframe is not an API error When your origin is not under **Allowed embedding domains**, the API calls all succeed but the browser refuses to render the frame (a `frame-ancestors` error in the console). Add your origin in [Set up your organization](./set-up-your-organization.md#step-5-allow-the-origins-that-may-embed-the-signing-page). ::: --- # Set Up Your Organization for Embedded Signing Embedded signing lets your application take a signer straight to a TurboSign signing page instead of sending signing-link emails, and verify each signer with a one-time passcode. Before your integration can request signing URLs (see the [overview](./index.md)), an organization admin turns the feature on in your E-Signature settings. This guide walks through every setting. You need an **admin** account for your organization. ## Step 1: Open your E-Signature settings Click your name at the bottom of the left sidebar and choose **Settings**. In the settings menu, click **Features and integrations**. Under **Core Features**, find the **Signatures** card and click **Configure E-Signature**. The full path, used throughout these docs: **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**. ![The Features and integrations menu link and the Configure E-Signature button on the Signatures card highlighted](/img/how-to-enable-embedded-signing/01-configure-esignature.png) ## Step 2: Open the Identity Verification section In the E-Signature Settings dialog, click **Identity Verification** in the left-hand section list. This section has two tabs: **One-time passcode** and **Identity & embedding**. Changes in this section save as you make them, except the SMS provider form, which needs **Save SMS provider**. This section has no Save or Close button; close the dialog when you're done. ![The E-Signature Settings dialog with the Identity Verification section highlighted](/img/how-to-enable-embedded-signing/02-identity-verification-tab.png) ## Step 3: Turn on embedded signing (Enable identity verification) On the **One-time passcode** tab, turn on **Enable identity verification**. Despite its name, this switch turns on embedded signing for every verification mode: passcodes, external identity verification and the override. While it is off, every signing URL request fails with `EmbeddedSigningNotEnabled`. Then, under **When to verify signers**, keep **Only when requested** (a passcode only for recipients that ask for one, for example through the API) or choose **On every signature request** (every signer enters a passcode). ![The One-time passcode tab with the Enable identity verification toggle and the selected Only when requested option highlighted](/img/how-to-enable-embedded-signing/03-identity-verification-settings.png) Choosing the passcode channel (email or SMS), connecting an SMS provider, and setting up delivery-failure alerts are covered in a dedicated guide: [Email and SMS passcode](../identity-verification/one-time-passcode.md). :::note With **Only when requested**, verification is set per recipient and a recipient sent without it signs with no extra step. With **On every signature request**, the method you pick applies to signatures created in the app and to documents sent through the SDK or API. Automated sends such as Pipelines and bulk signature sending are exempt. ::: ## Step 4: Allow external identity verification or an override Switch to the **Identity & embedding** tab. Two optional switches change how a signer can be verified: - **Allow external identity verification** lets your identity verification vendor verify a signer. Your integration asserts the verification when it requests the signing link, instead of TurboSign sending a passcode. See [External Identity Verification (IdV) for Embedded Signing](../identity-verification/external-identity-verification.md) for the request shape and provider examples. - **Allow identity verification override** lets a sender send a link that **skips** verification. This is for development and testing; every signature completed this way is marked as **not identity-verified** on the certificate and in the audit trail. While it is on, the settings show a persistent banner. ![The Identity & embedding tab with the Allow external identity verification and Allow identity verification override switches highlighted](/img/how-to-enable-embedded-signing/04-identity-embedding-toggles.png) ## Step 5: Allow the origins that may embed the signing page Under **Allowed embedding domains**, list the origins that may put the signing page in an **iframe**. This is the clickjacking control: only origins on this list can frame the page. ![The Identity & embedding tab with the Allowed embedding domains input highlighted](/img/how-to-enable-embedded-signing/05-allowed-embedding-domains.png) :::caution Deny by default The list is **empty by default, which means no site can embed the signing page at all**: the iframe stays blank until you add your app's origin. Type a full `https://` origin (for example `https://app.yourcompany.com`) and press **Enter** to add it; repeat for each origin. An empty list is the safest setting; add an origin only when you actually embed. ::: ## Step 6: localhost is for local development only For local development you can add an `http://localhost` (or `http://127.0.0.1`) origin so you can test the iframe on your machine. When you do, the settings show a bright warning: ![The NOT FOR PRODUCTION USE warning highlighted after an http://localhost origin is added to Allowed embedding domains](/img/how-to-enable-embedded-signing/06-localhost-http-warning.png) :::caution Not for production `http://` origins can be spoofed and weaken clickjacking protection, so they are for **local development and testing only**. Remove every `http://` origin and use `https://` before the configuration is used in production. Production origins must be `https`. ::: ## What's next - **Next:** pick a build guide. The [React widget](./build/react-widget.md), [your own iframe](./build/own-iframe.md), the [web component](./build/web-component.md), or [kiosk signing (two signers, one device)](./build/sequential-signers.md). - [Email and SMS passcode](../identity-verification/one-time-passcode.md): choose email or SMS delivery, connect an SMS provider, set up delivery-failure alerts, and see what the signer sees. - [External identity verification](../identity-verification/external-identity-verification.md): assert a signer's identity from your own identity verification provider instead of a passcode. - [Sender override for testing](../identity-verification/sender-override.md): try the flow without verification in development. - [API reference](./reference.md): every request field, event and error. --- # External Identity Verification (IdV) for Embedded Signing External identity verification (`external_idv`) is for apps that already verify their users with an identity verification provider. Your app verifies the signer with that provider (for example a government ID scan plus a selfie). Your backend then tells TurboSign who verified the signer, when, and with which reference id. TurboSign checks that assertion, records it in the tamper-evident audit trail, and gives you a single-use signing URL. The signer goes straight to the document without a TurboSign passcode. This page covers when to use it, how to turn it on, the exact request shape, and worked examples for common identity verification providers. For the rest of embedded signing (sending without emails, return URLs, iframes), see the [embedded signing overview](../embedded-signing/index.md) and the [API reference](../embedded-signing/reference.md). ## When to use external IdV instead of a passcode TurboSign can verify an embedded signer in two ways. Pick the one that matches what your app already does. | | One-time passcode (`otp`) | External identity verification (`external_idv`) | |---|---|---| | Who verifies the signer | TurboSign, by sending a code by email or SMS | Your identity verification provider, before the signer reaches TurboSign | | What it proves | The signer controls that inbox or phone | Whatever your provider checked: an ID document, a selfie match, a database record, a trusted login | | Extra step for the signer | Enter a six-digit code on the signing page | None on the signing page | | Signing URL | Reusable signing link | Single-use link that expires in about five minutes | | Best for | Apps with no identity provider, or where inbox or phone control is enough | Apps that already run KYC, onboarding, or step-up verification with a provider | Use external IdV when: - your app already verifies users with a provider and you don't want to make them verify a second time; - you need stronger evidence than control of an inbox or phone, such as a document check with liveness; - you want the provider's reference id and evidence link on the signature's audit trail. Use a one-time passcode when you don't run your own identity verification. See [Email and SMS passcode](./one-time-passcode.md). :::warning Your responsibilities With external identity verification, your app and your identity verification provider verify the signer. You are responsible for the verification actually happening and for every value you assert being accurate. TurboSign records your assertion. It does not contact your provider or independently confirm who the signer is. See [Responsibility and legal considerations](#responsibility-and-legal-considerations). ::: ## Step 1: Turn on external identity verification An organization **admin** does this once. 1. Go to **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**. (Settings is in the menu under your name at the bottom of the left sidebar.) 2. On the **One-time passcode** tab, turn on **Enable identity verification**. Despite its name, this is the master switch for embedded signing and every verification mode: while it is off, every signing URL request fails with `EmbeddedSigningNotEnabled`. Under **When to verify signers**, keep **Only when requested** if you don't want passcodes on other signature requests. ![The One-time passcode tab with the Enable identity verification switch highlighted](/img/external-identity-verification/01-enable-identity-verification.png) 3. Click the **Identity & embedding** tab and turn on **Allow external identity verification**. The description under it reads "Let your identity verification vendor verify a signer. Your integration asserts the verification when it requests the signing link." ![The Identity & embedding tab with the Allow external identity verification switch highlighted](/img/external-identity-verification/02-allow-external-identity-verification.png) Changes in this section save as you make them; close the dialog when you're done. Your integration can confirm the setting with `TurboSign.getEmbeddedSigningSettings()`: `enabled` and `allowExternalIdv` should both be `true`. :::tip Embedding in an iframe? If you plan to show the signing page inside your app in an iframe, also add your app's origin under **Allowed embedding domains** on the same tab. See [Set up your organization](../embedded-signing/set-up-your-organization.md#step-5-allow-the-origins-that-may-embed-the-signing-page). ::: ## Step 2: Mark the recipient for external IdV When you send the document, give the recipient an `identityVerification` block with `mode: "external_idv"`. Send with `sendEmail: false` so TurboSign doesn't email a signing link your signer doesn't need. ```json { "name": "Jane Doe", "email": "jane@example.com", "signingOrder": 1, "externalId": "your_customer_123", "identityVerification": { "mode": "external_idv", "provider": "Persona", "maxAgeMinutes": 1440 } } ``` | Field | Required | Description | |---|---|---| | `mode` | Yes | `"external_idv"`. | | `provider` | Yes | A name for your identity verification provider, up to 200 characters. The assertion you send later must use **exactly the same string** (the match is case-sensitive). It is printed in the audit trail, so use a readable name such as `"Persona"`. | | `maxAgeMinutes` | No | How old the verification may be when you request the signing URL. Between `5` and `10080` (7 days). Defaults to `1440` (24 hours). | An `external_idv` block can't carry the override fields (`overrideIdentityVerification`, `reason`). If your organization hasn't turned on **Allow external identity verification**, the send fails with `ExternalIdvNotAllowed`. ## Step 3: Verify the signer with your provider Run your provider's verification flow in your app as you normally would. Then wait for the **server-side** result. [External IdV provider mappings](./external-idv-provider-mappings.md) shows how the result from Persona, Stripe Identity, Onfido, Jumio, Veriff, CLEAR, ID.me, Incode or your own manual review maps onto the assertion. :::caution Assert only a confirmed result Identity verification providers deliver the final decision asynchronously, usually by webhook. Build the assertion from that webhook, or from a server-side fetch of the verification by its id. Do not assert off the browser's "finished" event: the user finishing the flow is not the same as the provider approving them. ::: ## Step 4: Request the signing URL with an identity assertion When the signer clicks "Sign now" in your app, your backend calls `createSigningUrl` with an `identityAssertion`. The one-call `createEmbeddedSignature` helper only sets up passcodes, so external IdV uses two calls: `sendSignature` with an `external_idv` recipient and `sendEmail: false` (Step 2), then `createSigningUrl` with the assertion (this step). Both calls in each SDK language: ```typescript // TurboSign.configure(...) as in "Your own iframe". // Step 2: send the document, no signing-link email. const sent = await TurboSign.sendSignature({ file: await readFile("contract.pdf"), fileName: "contract.pdf", documentName: "Account Agreement", sendEmail: false, recipients: [ { name: "Jane Doe", email: "jane@example.com", signingOrder: 1, externalId: "your_customer_123", identityVerification: { mode: "external_idv", provider: "Persona" }, }, ], fields: [ { type: "signature", recipientEmail: "jane@example.com", template: { anchor: "{signature1}", placement: "replace", size: { width: 100, height: 30 } }, }, ], }); // Step 4: after your provider approves the signer, mint a single-use URL. const { url } = await TurboSign.createSigningUrl(sent.documentId, { externalId: "your_customer_123", identityAssertion: { provider: "Persona", verificationId: "inq_ABC123", verifiedAt: "2026-09-16T15:02:00Z", subjectEmail: "jane@example.com", method: "id_document_liveness", }, }); ``` ```python from turbodocx_sdk import TurboSign # Inside an async function, after TurboSign.configure(...) as in "Your own iframe". pdf holds the PDF bytes. # Step 2: send the document, no signing-link email. sent = await TurboSign.send_signature( file=pdf, file_name="contract.pdf", document_name="Account Agreement", send_email=False, recipients=[ { "name": "Jane Doe", "email": "jane@example.com", "signingOrder": 1, "externalId": "your_customer_123", "identityVerification": {"mode": "external_idv", "provider": "Persona"}, } ], fields=[ { "type": "signature", "recipientEmail": "jane@example.com", "template": {"anchor": "{signature1}", "placement": "replace", "size": {"width": 100, "height": 30}}, } ], ) # Step 4: after your provider approves the signer, mint a single-use URL. link = await TurboSign.create_signing_url( sent["documentId"], external_id="your_customer_123", identity_assertion={ # camelCase keys, as sent to the API "provider": "Persona", "verificationId": "inq_ABC123", "verifiedAt": "2026-09-16T15:02:00Z", "subjectEmail": "jane@example.com", "method": "id_document_liveness", }, ) url = link["url"] ``` ```php use TurboDocx\TurboSign; use TurboDocx\Types\Recipient; use TurboDocx\Types\Field; use TurboDocx\Types\SignatureFieldType; use TurboDocx\Types\TemplateConfig; use TurboDocx\Types\FieldPlacement; use TurboDocx\Types\IdentityVerification; use TurboDocx\Types\IdentityAssertion; use TurboDocx\Types\Requests\SendSignatureRequest; use TurboDocx\Types\Requests\CreateSigningUrlRequest; // Step 2: send the document, no signing-link email. $sent = TurboSign::sendSignature(new SendSignatureRequest( recipients: [ new Recipient( name: 'Jane Doe', email: 'jane@example.com', signingOrder: 1, externalId: 'your_customer_123', identityVerification: IdentityVerification::externalIdv('Persona'), ), ], fields: [ new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'jane@example.com', template: new TemplateConfig( anchor: '{signature1}', placement: FieldPlacement::REPLACE, size: ['width' => 100, 'height' => 30] ) ), ], file: file_get_contents(__DIR__ . '/contract.pdf'), documentName: 'Account Agreement', sendEmail: false )); // Step 4: after your provider approves the signer, mint a single-use URL. $link = TurboSign::createSigningUrl($sent->documentId, new CreateSigningUrlRequest( externalId: 'your_customer_123', identityAssertion: new IdentityAssertion( provider: 'Persona', verificationId: 'inq_ABC123', verifiedAt: '2026-09-16T15:02:00Z', subjectEmail: 'jane@example.com', method: 'id_document_liveness', ), )); $url = $link->url; ``` ```go // Inside a function that returns error, with client and ctx as in "Your own iframe". pdf holds the PDF bytes. // Step 2: send the document, no signing-link email. sendEmail := false sent, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ File: pdf, FileName: "contract.pdf", DocumentName: "Account Agreement", SendEmail: &sendEmail, Recipients: []turbodocx.Recipient{ { Name: "Jane Doe", Email: "jane@example.com", SigningOrder: 1, ExternalID: "your_customer_123", IdentityVerification: &turbodocx.IdentityVerification{Mode: "external_idv", Provider: "Persona"}, }, }, Fields: []turbodocx.Field{ { Type: "signature", RecipientEmail: "jane@example.com", Template: &turbodocx.TemplateAnchor{ Anchor: "{signature1}", Placement: "replace", Size: &turbodocx.Size{Width: 100, Height: 30}, }, }, }, }) if err != nil { return err } // Step 4: after your provider approves the signer, mint a single-use URL. link, err := client.TurboSign.CreateSigningURL(ctx, sent.DocumentID, &turbodocx.CreateSigningURLRequest{ ExternalID: "your_customer_123", IdentityAssertion: &turbodocx.IdentityAssertion{ Provider: "Persona", VerificationID: "inq_ABC123", VerifiedAt: "2026-09-16T15:02:00Z", SubjectEmail: "jane@example.com", Method: "id_document_liveness", }, }) if err != nil { return err } fmt.Println(link.URL) // open, redirect to, or frame this URL ``` ```java // Inside a method that throws IOException, with client as in "Your own iframe". // Step 2: send the document, no signing-link email. SendSignatureResponse sent = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .file(Files.readAllBytes(Paths.get("contract.pdf"))) .fileName("contract.pdf") .documentName("Account Agreement") .sendEmail(false) .recipients(List.of( new Recipient.Builder() .name("Jane Doe") .email("jane@example.com") .signingOrder(1) .externalId("your_customer_123") .identityVerification(IdentityVerification.externalIdv("Persona")) .build())) .fields(List.of( new Field.Builder() .type("signature") .recipientEmail("jane@example.com") .template(new Field.TemplateAnchor.Builder() .anchor("{signature1}") .placement("replace") .size(new Field.Size(100, 30)) .build()) .build())) .build()); // Step 4: after your provider approves the signer, mint a single-use URL. CreateSigningUrlResponse link = client.turboSign().createSigningUrl( sent.getDocumentId(), new CreateSigningUrlRequest.Builder() .externalId("your_customer_123") .identityAssertion(new IdentityAssertion.Builder() .provider("Persona") .verificationId("inq_ABC123") .verifiedAt("2026-09-16T15:02:00Z") .subjectEmail("jane@example.com") .method("id_document_liveness") .build()) .build()); String url = link.getUrl(); ``` ```ruby # Continues the setup from "Your own iframe": require "turbodocx_sdk" and configure. # Step 2: send the document, no signing-link email. sent = TurboDocxSdk::TurboSign.send_signature( "file" => StringIO.new(File.binread("contract.pdf")), "documentName" => "Account Agreement", "sendEmail" => false, "recipients" => [ { "name" => "Jane Doe", "email" => "jane@example.com", "signingOrder" => 1, "externalId" => "your_customer_123", "identityVerification" => { "mode" => "external_idv", "provider" => "Persona" } } ], "fields" => [ { "type" => "signature", "recipientEmail" => "jane@example.com", "template" => { "anchor" => "{signature1}", "placement" => "replace", "size" => { "width" => 100, "height" => 30 } } } ] ) # Step 4: after your provider approves the signer, mint a single-use URL. link = TurboDocxSdk::TurboSign.create_signing_url( sent["documentId"], external_id: "your_customer_123", identity_assertion: { "provider" => "Persona", "verificationId" => "inq_ABC123", "verifiedAt" => "2026-09-16T15:02:00Z", "subjectEmail" => "jane@example.com", "method" => "id_document_liveness" } ) url = link["url"] ``` Open `url` right away: it is single-use and expires in about five minutes. To show it inside your app, hand it to the [React widget](../embedded-signing/build/react-widget.md), frame it in [your own iframe](../embedded-signing/build/own-iframe.md), or use the [web component](../embedded-signing/build/web-component.md). The REST equivalent is `POST /turbosign/documents/{documentId}/signing-url` with the same body (`externalId` or `recipientId`, `identityAssertion`, optional `returnUrl`). Unknown keys are rejected. ### Required assertion fields | Field | Description | |---|---| | `provider` | Must equal the `provider` on the recipient's `identityVerification` block. Up to 200 characters. | | `verificationId` | Your provider's reference id for this verification, up to 256 characters. Recorded in the audit trail and used to stop one verification being used by two signers. | | `verifiedAt` | ISO 8601 timestamp of when the provider verified the signer. Must be within the recipient's `maxAgeMinutes`, and no more than two minutes in the future (to allow for clock skew). | | `subjectEmail` | The email address your provider verified. Must be a valid email and, by default, must equal the recipient's email (case and surrounding spaces are ignored). | ### Optional assertion fields Each optional field adds evidence to the audit trail. | Field | Description | |---|---| | `method` | How your provider verified the signer. One of: `id_document` (a government ID document check), `id_document_liveness` (an ID document plus a selfie or liveness match), `kba` (knowledge-based questions), `database` (checked against an authoritative data source), `sso` (a trusted single sign-on or federated identity), or `other`. | | `methodDetail` | A free-text description of the technique, up to 200 characters. **Required when `method` is `other`.** | | `assuranceLevel` | A free-text label for the assurance level your provider attests to, up to 40 characters. For example `ial2_aal2` (NIST SP 800-63), `eidas_substantial` or `eidas_high`. Only send a level your provider and your configuration actually meet. | | `verifiedName` | The signer's legal name as your provider confirmed it, up to 200 characters. | | `evidenceUrl` | A link to the verification record in your provider's dashboard, so an auditor can trace the assertion back to its source. Must be an `https` URL, up to 2048 characters. | | `overrideEmailMatching` | `true` skips the email match check. See [When the verified email differs](#when-the-verified-email-differs). Defaults to `false`. | ### What TurboSign checks Before it issues the URL, TurboSign confirms that: 1. embedded signing (**Enable identity verification**) and **Allow external identity verification** are still on for your organization. The second switch is checked again here, so turning it off stops new URLs for documents already sent; 2. the recipient is an `external_idv` recipient and an assertion was sent; 3. `provider` matches the recipient exactly; 4. `subjectEmail` matches the recipient's email, unless you set `overrideEmailMatching`; 5. `verifiedAt` is a valid date, no more than two minutes in the future (to allow for clock skew), and not older than `maxAgeMinutes`; 6. the optional fields are valid (`method` from the list, `methodDetail` present for `other`, `evidenceUrl` is `https`); 7. no **other** recipient on the same document already used this `verificationId`. ### The response ```json { "data": { "results": { "url": "https://app.turbodocx.com/e-signature/embed/{documentId}?sut=...", "expiresAt": "2026-09-16T15:07:00Z", "recipientId": "...", "externalId": "your_customer_123", "identityVerificationMode": "external_idv", "pendingChecks": [] } } } ``` The SDKs return the inner `results` object. - **The URL is single-use.** Opening it consumes it, and it expires about five minutes after you request it. Request it when the signer clicks, and never store it. - **Requesting a new URL revokes the previous one.** If the signer's link expired, request a new one with the same assertion. That works as long as `verifiedAt` is still within `maxAgeMinutes`, because the reuse check only looks at other recipients. - **`pendingChecks` is always empty** for `external_idv`. The signing page opens the document without a passcode. ### When the verified email differs Sometimes the signer verified with your provider under one email (say, a personal address) but signs at another (a work address). By default that request fails with `IdentityEmailMismatch`. If you have confirmed in your own app that the verified person is the signer of record, set `overrideEmailMatching: true`: ```typescript const { url } = await TurboSign.createSigningUrl(documentId, { recipientId, identityAssertion: { provider: "Persona", verificationId: "inq_ABC123", verifiedAt: "2026-09-16T15:02:00Z", subjectEmail: "jane.personal@example.com", overrideEmailMatching: true, }, }) ``` Only the email comparison is skipped. Every other check still runs, and `subjectEmail` is still required and recorded. :::caution You take responsibility for the match `overrideEmailMatching: true` turns off only the email check. You alone are responsible for confirming that the person your provider verified is the intended signer. Use it only when you have confirmed that link in your own systems, and keep a record of how you confirmed it. The override is recorded in TurboSign's audit record, but it is not shown on the rendered audit trail PDF. ::: ## What the signer sees These screenshots come from the sample app's **External IdV** tab (linked from the box at the top of this page). It **simulates** the identity verification provider and labels it as simulated; in your app this is your provider's real flow. 1. In your app, the signer starts the verification (here, **Verify identity to sign**). ![The External IdV form with the Verify identity to sign button highlighted](/img/embedded-signing/idv-01-start.png) 2. The signer completes your provider's flow, for example a photo ID and a selfie. ![The simulated identity check with Photo ID and selfie selected and the Continue button highlighted](/img/embedded-signing/idv-02-simulator.png) 3. Your provider reports the verified identity, and the signer continues to sign. ![The simulated Identity verified step with the Continue to sign button highlighted](/img/embedded-signing/idv-03-verified.png) 4. Your backend sends the assertion (provider, verification id, time, email, method) when it mints the signing URL. The signing page opens with **no passcode gate**: only the consent step, then the document. ![The simulated assertion sent to TurboSign highlighted, above a signing panel showing consent with no passcode step](/img/embedded-signing/idv-04-assertion.png) 5. The signer clicks the **Signature** field, signs, and clicks **Submit Signature**. ![The document inside the host app with the Signature field highlighted](/img/embedded-signing/idv-05-document.png) 6. Your page receives `turbosign:completed`. The certificate of completion and the audit trail show "Identity Verified via" your provider, with its reference id. ![The All set confirmation highlighted, noting the external identity verification on the certificate](/img/embedded-signing/idv-06-done.png) ## What lands in the audit trail Each signing URL request and redemption adds entries to the document's tamper-evident, hash-chained audit trail, which is included with the completed document: | Entry | When | What it records | |---|---|---| | **Signing Link Issued** | Your backend calls `createSigningUrl` | The identity mode and the link's expiry. | | **Identity Verified** via `provider` (ref `verificationId`) | Same call, after the assertion passes every check | `provider`, `verificationId`, `verifiedAt`, `subjectEmail`, plus any of `method`, `methodDetail`, `assuranceLevel`, `verifiedName` and `evidenceUrl` you sent. | | **Signing Link Opened** | The signer opens the single-use URL | The time the link was redeemed. | For example, with the Persona assertion above, the entry reads "Identity Verified via Persona (ref inq_ABC123)". Entries also carry the name of the API key or user that requested the link and the request's IP address, like other audit trail entries. ## Provider examples How a verification from Persona, Stripe Identity, Onfido, Jumio, Veriff, CLEAR, ID.me or your own in-house process maps onto a TurboSign assertion is on its own page: [External IdV provider mappings](./external-idv-provider-mappings.md). ## Errors These errors come back from sending the document or from `createSigningUrl`. They use the same body as the other identity errors (`message`, `type`, `error` and `code`); see [Errors](../embedded-signing/reference.md#errors) for the full list. | Status | `code` | When | |---|---|---| | 400 | `IdvProviderRequired` | The recipient's `external_idv` block has no `provider`. | | 400 | `IdentityConfigInvalid` | `provider` is longer than 200 characters, or `maxAgeMinutes` is outside 5 to 10080. | | 400 | `IdentityModeConflict` | The `external_idv` block carries override fields, or you sent an `identityAssertion` for a recipient that is not `external_idv`. | | 400 | `IdentityAssertionRequired` | The recipient is `external_idv` but no `identityAssertion` was sent. | | 400 | `IdentityProviderMismatch` | The assertion's `provider` doesn't exactly match the recipient's. | | 400 | `IdentityEmailMismatch` | `subjectEmail` doesn't match the recipient's email and `overrideEmailMatching` isn't `true`. | | 400 | `IdentityAssertionStale` | `verifiedAt` is older than the recipient's `maxAgeMinutes`. Verify the signer again. | | 400 | `IdentityAssertionInvalid` | `verifiedAt` is not a valid date or is more than two minutes in the future, `method` is not an allowed value, `methodDetail` is missing for `other`, `evidenceUrl` is not `https`, or `provider` is too long. | | 400 | `IdentityAssertionReused` | Another recipient on the same document already used this `verificationId`. | | 403 | `ExternalIdvNotAllowed` | **Allow external identity verification** is off for your organization. | | 403 | `EmbeddedSigningNotEnabled` | **Enable identity verification** is off for your organization. | | 409 | `RecipientRequiresSingleUseUrl` | The deprecated `signing-link` endpoint was called for an `external_idv` recipient. Use `createSigningUrl`. | | 410 | `SigningUrlNotRedeemable` | The single-use URL was already opened or has expired. Request a new one. | A request body that fails the endpoint's schema (for example an unknown key, a `subjectEmail` that isn't an email, or a field over its length limit) is rejected with a 400 validation error before these checks run. ## Responsibility and legal considerations **You are responsible for the verification.** You choose the provider, the method, the assurance level and the pass or fail rules. You are responsible for the verification actually happening and for every asserted value being accurate: `provider`, `verificationId`, `verifiedAt`, `subjectEmail`, and any `method`, `assuranceLevel`, `verifiedName` or `evidenceUrl` you send. **TurboSign records your assertion. It does not verify it.** TurboSign does not contact your provider, review identity documents, or independently confirm that the signer is who the assertion says. It only checks format and consistency, as listed in [What TurboSign checks](#what-turbosign-checks). Passing these checks does not mean a verification happened or was done correctly. The assertion is recorded in the audit trail and on the certificate as information you reported. **The audit trail is one part of the record.** It shows what your app asserted and what happened in the signing session, not the evidence your provider collected. Keep your verification records alongside it. **Keep your own evidence.** Retain verification results, reference ids and evidence for as long as your legal, regulatory and contractual obligations require, in a way you can match to the TurboSign document and `verificationId`. If you send `evidenceUrl`, TurboSign stores the link only. You keep the content behind it available and access-controlled. **Protect the signing URL.** The URL is single-use and issued to your application. Give it only to the person you verified, in their authenticated session, and don't log, store or email it unprotected. Anyone who opens it can act as that signer. **What counts as enough verification is your decision.** It depends on the document type, your industry and the laws that apply (for example ESIGN, UETA, eIDAS, or rules for your sector). Validity and enforceability depend on your use case and implementation. TurboSign does not decide whether your verification meets any legal, regulatory or contractual requirement. **Misuse is your responsibility.** This includes an assertion sent without a real verification, with inaccurate information, or with email matching overridden. Only assert after a verification that actually succeeded for the person who will sign. :::note Not legal advice This page is general product information, not legal advice. TurboSign is not a law firm. Consult your own counsel about the requirements that apply to your documents. ::: ## Frequently asked questions ### Can I use my own identity verification provider with e-signatures? Yes. With external identity verification, TurboSign accepts the result from any identity verification provider you already use, such as Persona, Onfido, Jumio, Veriff, Stripe Identity, CLEAR, ID.me or Incode, or from your own in-house process. Your backend sends the provider name, its reference id, the verification time and the verified email when it requests the signing URL. ### Does the signer have to verify twice? No. If your provider already verified the signer within the recipient's `maxAgeMinutes` (24 hours by default, up to 7 days), the signer opens the document directly. TurboSign doesn't send them a passcode. ### Is an external IdV assertion legally binding? Electronic signature laws such as the US ESIGN Act and UETA, and eIDAS in the EU, generally recognize electronic signatures. Identity evidence helps show who signed. An external IdV assertion adds your provider's verification details to a tamper-evident audit trail, which strengthens that evidence. It does not by itself make a signature a specific legal type (for example an eIDAS advanced or qualified electronic signature), and the weight of the evidence depends on the quality of the verification your provider performed. For requirements that apply to your documents and jurisdiction, consult your legal counsel. ### Does TurboSign verify the signer's ID itself? No. In this mode your provider performs the verification and you are responsible for it. TurboSign checks that the assertion is consistent (provider, email, age, no reuse across signers) and records it. See [Responsibility and legal considerations](#responsibility-and-legal-considerations). ### What if the signing link expires before the signer opens it? Request a new one. A new request revokes the old link and returns a fresh single-use URL. You can reuse the same assertion while `verifiedAt` is still within `maxAgeMinutes`. ### Can I use external IdV with documents sent by email? The assertion is passed to `createSigningUrl`, so external IdV is used with signing URLs your app requests and opens for the signer. For signers who arrive from a signing-link email, use a [one-time passcode](./one-time-passcode.md). ## What's next - **Next:** [External IdV provider mappings](./external-idv-provider-mappings.md) - [Sender override for testing](./sender-override.md), to try the flow in development without any verification. - [Set up your organization](../embedded-signing/set-up-your-organization.md): every setting in the Identity Verification section, including allowed embedding domains. - [Embedded signing API reference](../embedded-signing/reference.md): sending without emails, return URLs and the full error list. - [TurboSign Webhooks](../Webhooks.md): get notified when the document is completed. --- # External IdV Provider Mappings This page shows how to build the `identityAssertion` for [external identity verification](./external-identity-verification.md) from common identity verification providers. Use it at Step 3 of that guide, once your provider has approved the signer. The examples below show how a verification from some well-known identity verification providers maps onto a TurboSign assertion. They are examples only: TurboSign works with any provider (or your own in-house process), and listing a provider here does not imply a partnership or certification. Check your provider's current documentation for the exact field names in its webhook or API response. In every case: - `provider` is any readable name you choose, used identically on the recipient and in the assertion. - `verificationId` is your provider's id for the verification. - `verifiedAt` is when the provider reached its approved decision. - `subjectEmail` is the email you have on file for the verified user. - `evidenceUrl` is the `https` link to that record in your provider's dashboard, if you want auditors to be able to open it. - `method` should describe what your configuration actually checked. If your flow includes a selfie or liveness step, use `id_document_liveness`; if it checks the ID only, use `id_document`. ## Persona For example, if you verify signers with Persona, each verification is an **inquiry**, with an id that starts with `inq_`. Persona reports the outcome on the inquiry's status and sends webhook events such as `inquiry.approved`. | Assertion field | Value | |---|---| | `provider` | `"Persona"` | | `verificationId` | The inquiry id (`inq_...`) | | `verifiedAt` | The time the inquiry was approved | | `method` | `id_document_liveness` when your inquiry template includes a government ID and a selfie; `id_document` for ID only | | `evidenceUrl` | The inquiry's link in your Persona dashboard | ```typescript // In your handler for Persona's inquiry.approved webhook, after verifying the webhook signature, // store the inquiry id and approval time on the user. Later, when the user clicks "Sign now": const { url } = await TurboSign.createSigningUrl(user.documentId, { externalId: user.id, identityAssertion: { provider: "Persona", verificationId: user.personaInquiryId, // "inq_..." verifiedAt: user.personaApprovedAt, // ISO 8601 subjectEmail: user.email, method: "id_document_liveness", verifiedName: user.verifiedLegalName, evidenceUrl: user.personaInquiryDashboardUrl, // https link to the inquiry }, returnUrl: "https://app.yourcompany.com/signed", }) ``` ## Stripe Identity For example, if you verify signers with Stripe Identity, each verification is a **VerificationSession**, with an id that starts with `vs_`. Stripe sends the `identity.verification_session.verified` webhook event when every check in the session has passed. | Assertion field | Value | |---|---| | `provider` | `"Stripe Identity"` | | `verificationId` | The VerificationSession id (`vs_...`) | | `verifiedAt` | The time of the `identity.verification_session.verified` event | | `method` | `id_document_liveness` when the session requires a matching selfie; `id_document` otherwise | | `evidenceUrl` | The session's link in your Stripe Dashboard | ```python from turbodocx_sdk import TurboSign # The recipient was sent with: # "identityVerification": {"mode": "external_idv", "provider": "Stripe Identity"} link = await TurboSign.create_signing_url( document_id, external_id=user_id, identity_assertion={ "provider": "Stripe Identity", "verificationId": verification_session_id, # "vs_..." "verifiedAt": verified_at_iso, # from the verified event "subjectEmail": user_email, "method": "id_document_liveness", "evidenceUrl": stripe_dashboard_session_url, }, return_url="https://app.yourcompany.com/signed", ) signing_url = link["url"] # single-use, about five minutes ``` The Python SDK passes `identity_assertion` through as-is, so its keys stay camelCase. ## Onfido (Entrust) For example, if you verify signers with Onfido (now part of Entrust), the verification is a **workflow run** (or, in older integrations, a **check** made of reports). A workflow run reaches the status `approved`; a check's result is `clear` when every report passes. | Assertion field | Value | |---|---| | `provider` | `"Onfido"` | | `verificationId` | The workflow run id (or check id) | | `verifiedAt` | The time the workflow run was approved, or the check completed as `clear` | | `method` | `id_document_liveness` for a document report plus a facial similarity report; `id_document` for a document report only | ## Jumio For example, if you verify signers with Jumio, each verification is a **workflow execution** (a transaction) on a Jumio account. Jumio posts a callback when the workflow finishes, with a decision such as `PASSED`. | Assertion field | Value | |---|---| | `provider` | `"Jumio"` | | `verificationId` | The workflow execution id | | `verifiedAt` | The time the workflow execution finished with a passing decision | | `method` | `id_document_liveness` for an ID plus selfie workflow; `id_document` for an ID-only workflow | ## Veriff For example, if you verify signers with Veriff, each verification is a **session**. Veriff's decision webhook reports the session's decision, and a successful one has the status `approved`. | Assertion field | Value | |---|---| | `provider` | `"Veriff"` | | `verificationId` | The Veriff session id | | `verifiedAt` | The decision time from the decision webhook | | `method` | `id_document_liveness` for a document plus selfie flow; `id_document` for document only | ## CLEAR For example, if you verify signers with CLEAR (CLEAR Verified), your backend creates a **verification session** and learns the outcome through CLEAR's webhooks or by polling the session. | Assertion field | Value | |---|---| | `provider` | `"CLEAR"` | | `verificationId` | The CLEAR verification session id | | `verifiedAt` | The time the session completed successfully | | `method` | The value that matches how CLEAR verified the person in your integration. If none of the fixed values fits, use `other` with a `methodDetail` such as `"CLEAR Verified session"` | ## ID.me For example, if your signers sign in with ID.me (over OpenID Connect or SAML) as part of your app's login or step-up flow, the verification is that authenticated ID.me login. | Assertion field | Value | |---|---| | `provider` | `"ID.me"` | | `verificationId` | A unique id for that verification event, for example the id of the login or token exchange your app recorded | | `verifiedAt` | The time of that login | | `method` | `sso` | | `assuranceLevel` | `ial2_aal2` only if your ID.me integration actually requested and received an IAL2/AAL2 verification | ## Incode For example, if you verify signers with Incode (Incode Omni), each verification is an **onboarding session**, identified by its **interview id** (`interviewId`, also called the session id). When the user finishes, Incode sends the Onboarding Status webhook with the `ONBOARDING_FINISHED` event. Your backend then fetches the session's scores (`/omni/get/score`) and treats the session as passed only when the overall status (`overall.status`) is `OK`. `WARN` and `FAIL` are not a pass. | Assertion field | Value | |---|---| | `provider` | `"Incode"` | | `verificationId` | The session's `interviewId` | | `verifiedAt` | The time your backend confirmed `overall.status` is `OK` (Incode's score response doesn't document a decision timestamp, so record your own) | | `subjectEmail` | The email you have on file for the user. Incode's scores and ID data don't necessarily include an email; check your Incode configuration | | `verifiedName` | Optional: `name.fullName` from the session's OCR data (Fetch OCR Data) | | `method` | `id_document_liveness` when your Incode flow includes a selfie or face match; `id_document` for ID only | ```typescript // Incode docs: Fetch scores (developer.incode.com/integrate-by-platform/fetch-scores/), // Fetch OCR data (developer.incode.com/docs/fetch-extracted-ocr), Onboarding Status webhook (ONBOARDING_FINISHED). // After ONBOARDING_FINISHED, your backend fetched the scores and saw overall.status === "OK": const { url } = await TurboSign.createSigningUrl(user.documentId, { externalId: user.id, identityAssertion: { provider: "Incode", verificationId: user.incodeInterviewId, verifiedAt: user.incodeApprovedAt, // when you confirmed overall.status "OK" subjectEmail: user.email, method: "id_document_liveness", verifiedName: user.incodeFullName, // name.fullName from the OCR data, optional }, }) ``` ## In-house or manual review You don't need a third-party provider. Many teams verify customers themselves: an operations reviewer approves the customer in your own portal or back office, for example after checking an ID document, a branch visit or a KYC call. Then your app hands off to TurboSign. | Assertion field | Value | |---|---| | `provider` | Your own system's name, for example `"internal-review"`. Use the same string on the recipient (`identityVerification.provider`) | | `verificationId` | Your approval or case record id | | `verifiedAt` | When the reviewer approved the customer | | `subjectEmail` | The customer's email on file | | `method` | The value that matches what the reviewer checked: `id_document`, `id_document_liveness`, `kba`, `database`, `sso`, or `other`. For a manual review that fits none of them, use `other` with a `methodDetail` such as `"Manual ID review by operations"`; `methodDetail` is required when `method` is `other` | | `verifiedName` | Optional: the legal name the reviewer confirmed | | `evidenceUrl` | Optional: an `https` link to the approval record in your back office | ```typescript const { url } = await TurboSign.createSigningUrl(customer.documentId, { externalId: customer.id, identityAssertion: { provider: "internal-review", verificationId: approval.caseId, verifiedAt: approval.approvedAt, // ISO 8601 subjectEmail: customer.email, method: "other", methodDetail: "Manual ID review by operations", evidenceUrl: approval.recordUrl, // optional, https only }, }) ``` - **Approval age:** the approval must be newer than the recipient's `maxAgeMinutes` (5 to 10080 minutes, default 1440, so 24 hours) and no more than two minutes in the future. If a reviewer approved days ago, set a longer `maxAgeMinutes` on the recipient or re-confirm the customer. - **Keep the approval record.** The audit trail points to your `provider` and `verificationId`, so retain that record for as long as you keep the signed documents. - **Low-code:** from an automation tool, call `POST /turbosign/documents/{documentId}/signing-url` in an HTTP step on the server side, with an **Administrator** or **Contributor** API key. Never call it from the browser. ## Next - **Back to:** [External identity verification](./external-identity-verification.md#step-4-request-the-signing-url-with-an-identity-assertion), Step 4. - **Next:** [Sender override for testing](./sender-override.md) --- # Identity Verification Identity verification makes a signer prove who they are before the document opens. You set it per recipient, and an admin can set a default for the whole organization. **At a glance:** - **Three ways to verify:** a one-time passcode by email or SMS, external identity verification from your own provider, or the sender override (testing only, no verification). - **Passcodes work everywhere:** on signing links TurboSign emails, and on signing pages you embed in your own app. - **External identity verification and the override are for embedded signing:** they work only through a signing URL your app requests. - **Every verification is recorded** on the certificate of completion and in the audit trail. ## The three ways to verify a signer | | One-time passcode (`otp`) | External identity verification (`external_idv`) | Sender override (`override`) | |---|---|---|---| | **Who verifies the signer** | TurboSign, by email or SMS | Your identity verification provider, before the signer reaches TurboSign | Nobody | | **What the signer does** | Enters a six-digit code on the signing page | Nothing extra on the signing page | Nothing extra | | **Emailed signing links** | Yes | No | No | | **Embedded signing** | Yes | Yes | Yes | | **Signing URL** | Reusable for the life of the document | Single-use, expires in about five minutes | Single-use, expires in about five minutes | | **Use it for** | Most apps; control of an inbox or phone is enough | Apps that already run KYC or step-up checks with a provider | Development and testing only | | **Guide** | [Email and SMS passcode](./one-time-passcode.md) | [External identity verification](./external-identity-verification.md) | [Sender override for testing](./sender-override.md) | `external_idv` and `override` recipients sign only through a single-use URL that your app requests with `createSigningUrl`. TurboSign never sends them signing, reminder or resend emails, so use them with [embedded signing](../embedded-signing/index.md). ## Signers you email For ordinary signature requests (sent from the TurboDocx app, or through the API with signing-link emails on), a passcode is the only verification method. - A sender can turn it on per recipient, in the app or through the API. - An admin can require it on every signature request with **When to verify signers** > **On every signature request**. See [Email and SMS passcode](./one-time-passcode.md). ## Signers in your own app (embedded signing) When your app shows the signing page, TurboSign doesn't email the signer a link, so nothing proves the person at the screen controls the signer's inbox. Choose a verification method for every embedded signer: 1. **One-time passcode** if your app has no identity provider. TurboSign sends a code to the signer's email or phone. 2. **External identity verification** if your app already verifies users with a provider. The signer isn't asked for a passcode. 3. **Sender override** only while you build and test. Every signature is marked as not identity-verified. :::caution A recipient with no verification is unprotected If you leave verification off a recipient (and your organization verifies only when requested), TurboSign still issues a signing URL, and whoever opens it can sign. Use a passcode or external identity verification in production. ::: ## Before you start An admin turns on **Enable identity verification** in **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**. Despite its name, this one switch turns on passcodes, external identity verification, the override and embedded signing. While it is off, requests that use any of them fail with `EmbeddedSigningNotEnabled`. [Set up your organization](../embedded-signing/set-up-your-organization.md) shows every click with screenshots. ## Next - **Next:** [Email and SMS passcode](./one-time-passcode.md) - [External identity verification](./external-identity-verification.md) and its [provider mappings](./external-idv-provider-mappings.md) - [Sender override for testing](./sender-override.md) - [Embedded signing](../embedded-signing/index.md) --- ## Email and SMS Passcode (One-Time Passcode) # Email and SMS Passcode A one-time passcode (OTP) verifies a signer's identity before they can open your document. The signer receives a short code by email or text message and enters it on the signing page. It works on signing links TurboSign emails and on signing pages you embed in your own app. This guide is for admins. It covers requiring a passcode, choosing email or SMS, connecting an SMS provider, setting up delivery-failure alerts, and what the signer sees. :::tip Using passcodes with embedded signing? Requesting a passcode from your code, SMS in each SDK, and verifying only your embedded signers are covered in [Passcodes in embedded signing](../embedded-signing/passcodes.md). ::: ## Before you start - **Who:** you need an **admin** account for your organization. - **Where:** everything lives on the **One-time passcode** tab of the **Identity Verification** section in your E-Signature settings. - **Saving:** changes on this tab save as you make them. The one exception is the SMS provider form, which needs **Save SMS provider**. To reach the tab: 1. Go to **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**. (Settings is in the menu under your name at the bottom of the left sidebar.) 2. Stay on the **One-time passcode** tab. For screenshots of these first clicks, see [Set up your organization](../embedded-signing/set-up-your-organization.md), Steps 1-2. :::note What this controls These settings decide **when** signers are verified by default and **which** channels are available. The default applies to signatures created in the app and to documents sent through the API or SDK. Automated sends (Pipelines, bulk signature sending, TurboQuote, and the Wrike integration) are exempt. They verify a recipient only when the request asks for it. ::: ### At a glance | Step | What you do | Needed for | |---|---|---| | [1](#step-1-turn-on-enable-identity-verification) | Turn on **Enable identity verification** | Everyone | | [2](#step-2-choose-when-to-verify-signers) | Choose when to verify signers | Everyone | | [3](#step-3-use-email-the-simplest-path) | Use email | Email only (no setup) | | [4](#step-4-allow-sms-as-an-alternative-to-email) | Allow SMS | SMS only | | [5](#step-5-connect-your-sms-provider) | Connect your SMS provider | SMS only | | [6](#step-6-get-alerted-when-a-passcode-cannot-be-delivered) | Set up delivery-failure alerts | Optional (on by default) | | [7](#step-7-know-what-happens-when-a-signer-keeps-entering-the-wrong-code) | Understand wrong-code alerts and lockout | Reference | ## Step 1: Turn on Enable identity verification On the **One-time passcode** tab, turn on **Enable identity verification**. The rest of the passcode settings stay hidden until this is on, so turn it on first. ![The One-time passcode tab with the Enable identity verification toggle and the selected Only when requested option highlighted](/img/how-to-enable-embedded-signing/03-identity-verification-settings.png) Turning it on makes passcode verification available. Whether every signer gets a passcode depends on the choice in Step 2. :::note One switch for every verification mode **Enable identity verification** also turns on external identity verification, the sender override and embedded signing for your organization. ::: ## Step 2: Choose when to verify signers Under **When to verify signers**, choose one: | Option | What happens | |---|---| | **Only when requested** (the default) | No passcode by default. A sender can turn it on for a recipient, and a request made through the API or SDK (for example, embedded signing) can ask for it. This option is never locked, so a request can always turn verification on. | | **On every signature request** | Every signer enters a passcode before signing, including on documents sent through the API or SDK. | ![The When to verify signers options with Only when requested selected and highlighted](/img/how-to-configure-otp/otp-01-when-to-verify.png) ### If you choose On every signature request Two more settings appear. **Method** sets how the passcode reaches signers: - **Email** sends the passcode to the signer's email address. Email is available on every plan. - **SMS** texts the passcode to the signer's mobile number. SMS requires a connected provider and is available on Pro and Enterprise plans (see Steps 4-5). :::warning SMS needs a connected provider first **SMS cannot be selected until SMS is turned on and provider credentials are saved (Steps 4-5).** Check that the provider status reads **Connected to Twilio** (or RingCentral) before you rely on it. ::: **Let senders change the method per recipient** is off by default. - **Off** locks the method. Every request uses the method above, and a request that asks for a different method for a recipient is rejected. - **On** lets a sender pick another method, or no verification, for a recipient. This setting applies to the email channel too, so it is not tied to your SMS plan. ## Step 3: Use email (the simplest path) Email passcodes work on every plan and need no setup. If **Email** is your method, you are done: signers receive their passcode by email. Next, continue to [Step 6](#step-6-get-alerted-when-a-passcode-cannot-be-delivered) to set up delivery-failure alerts, or skip to [What the signer sees](#what-the-signer-sees). ## Step 4: Allow SMS as an alternative to email To let signers verify by text message, turn on **Allow SMS as an alternative to email** under **Text message (SMS)**. Senders can then choose SMS instead of email for a recipient. Each signer verifies by one method, not both, and SMS may incur usage charges. ![The Text message (SMS) section with the Allow SMS as an alternative to email toggle highlighted](/img/how-to-configure-otp/otp-02-allow-sms.png) :::note SMS is plan-gated SMS verification is available on **Pro and Enterprise plans**. If your plan does not include it, the toggle is disabled and an **Upgrade to unlock SMS verification** card appears. ::: ### Signer mobile numbers SMS verification needs a mobile number for each signer you verify this way. You enter it when you add the recipient to a signature request. - The number must include the country code. - The number must be one that can exist. A well-formed number that cannot exist is rejected when the request is created (`OtpPhoneInvalid`), not later when the signer asks for a code. ## Step 5: Connect your SMS provider TurboSign sends SMS passcodes through **your own** SMS account, so your provider bills passcodes at your rates. Connect the provider under **Text message (SMS)** once the toggle from Step 4 is on. 1. Choose your **Provider**: **Twilio** or **RingCentral**. 2. Enter the **From number** in international format with the country code, for example `+13055551234`. 3. Enter your provider credentials: - **Twilio:** Account SID and Auth Token. - **RingCentral:** Server URL, Client ID, Client Secret, and JWT. RingCentral needs some setup on its side first, so follow [Set up RingCentral](#set-up-ringcentral) below. 4. Click **Save SMS provider**. ![The SMS provider form with the provider, from number, credential fields, and Save SMS provider button highlighted](/img/how-to-configure-otp/otp-03-sms-provider-form.png) ### Confirm the connection When you click **Save SMS provider**, TurboSign checks the account straight away. The check is free and no text is sent. 1. Check the status at the top of the box. It should read **Connected to Twilio**. 2. If it reads **Twilio rejected these credentials**, read the alert below the fields for the provider's reason. 3. Use **Send a test message** to text a number you control and confirm delivery end to end. **Verify connection** re-runs the check at any time. ![The Send a test message area with the Test number field and Send test message button highlighted](/img/how-to-configure-otp/otp-04-sms-test-message.png) ### Provider status The status at the top of the box is one of these. With RingCentral, the status names RingCentral instead of Twilio. | Status (Twilio) | Status (RingCentral) | Meaning | |---|---|---| | **SMS provider not connected** | **SMS provider not connected** | No credentials are saved yet. | | **Twilio credentials saved** | **RingCentral credentials saved** | Credentials are saved but have not been checked in this session. | | **Connected to Twilio** | **Connected to RingCentral** | The provider accepted the credentials. This is the state you want. | | **Twilio rejected these credentials** | **RingCentral rejected these credentials** | Fix the credentials and save again. | :::caution Use a production provider account SMS passcodes use a custom message body, which **trial accounts** (for example a Twilio trial) block. Use a paid, production provider account. Sending to US numbers also requires **A2P 10DLC registration** on your provider account. TurboSign links to your provider's registration flow next to the credential fields. ::: :::note When SMS becomes selectable The **SMS** method (Step 2) stays disabled until SMS is turned on, your plan includes it, **and** provider credentials are saved. Saving does not prove the credentials work, so check that the status reads **Connected to Twilio** (or RingCentral). If you later remove the provider credentials while **SMS** is the method, the method switches to **Email**, so signers are still verified on every request through a channel that can deliver. ::: ### Set up RingCentral With RingCentral, you bring your own RingCentral app and a JWT credential. Do these steps in the [RingCentral Developer Console](https://developers.ringcentral.com/console) first, then enter the details in TurboSign. #### 1. Create a RingCentral app 1. In the Developer Console, create a **REST API** app. 2. In the app's **Auth** section, choose the **JWT auth flow**. 3. Add the **SMS** permission to the app. 4. Copy the app's **Client ID** and **Client Secret**. You enter both in TurboSign. #### 2. Create a JWT credential 1. Sign in to the Developer Console as the **RingCentral user who owns the number you will send from**. The JWT acts as this user, and texts are sent from this user's numbers. 2. Go to **Credentials** and create a **JWT credential**. We recommend restricting it to the Client ID of the app from the previous step. 3. Choose an expiration date, or none. RingCentral JWTs never expire unless you set a date. If you set one, TurboSign shows it in the form (see [Check when your JWT expires](#check-when-your-jwt-expires)). 4. Copy the JWT. You enter it in TurboSign. #### 3. Choose the From number The **From number** must be: - In international (E.164) format with the country code, for example `+13055551234`. - A number that belongs to the user from the previous step and has the **SmsSender** feature in RingCentral. - For US and Canada local numbers, registered to an approved **10DLC (TCR) brand and campaign** before it can send. This applies to developer (sandbox) accounts too, and texts sent from a sandbox account carry a test watermark. :::warning A number that only has the **A2PSmsSender** feature (RingCentral's high-volume SMS API) cannot send through TurboSign. ::: #### 4. Enter the details in TurboSign 1. In the SMS provider form, choose **RingCentral** as the **Provider**. 2. Enter the **From number** from the previous step. 3. Enter the **Server URL** for the environment where you created the app and the JWT. Any other address is rejected when you save. | Environment | Server URL | |---|---| | Production | `https://platform.ringcentral.com` | | Sandbox | `https://platform.devtest.ringcentral.com` | 4. Enter the **Client ID**, **Client Secret**, and **JWT**. 5. Click **Save SMS provider**. TurboSign checks the account straight away, without sending a text. The status at the top of the box should read **Connected to RingCentral**. 6. Use **Send a test message** to text a number you control and confirm delivery. #### Check when your JWT expires Each time you open the form, TurboSign shows when the saved JWT expires, under the **JWT** field. The JWT itself is never shown. You see one of these: | What you see | What it means | |---|---| | **JWT expires on** *date*, with a small calendar tile showing the date | Texts stop on that date until you save a new JWT. | | An orange warning, **JWT expires on** *date* **(in** *N* **days)** | The date is 30 days away or less. Create a new JWT in RingCentral and save it in TurboSign before then. | | A red **This JWT expired on** *date* | Text messages cannot be sent until you create a new JWT in RingCentral and save it in TurboSign. | | **No expiration set on this JWT.** | It keeps working until it is revoked in RingCentral. | | **Couldn't read an expiration date from this JWT.** | Check it in the RingCentral Developer Console. | When you save a JWT that has an expiration date, the confirmation also tells you, for example **SMS provider saved. Expiration detected on this JWT: JWT expires on** *date*. ![The RingCentral SMS provider form with the JWT expiration date and calendar tile under the JWT field highlighted](/img/how-to-configure-otp/otp-06-ringcentral-jwt-expiry.png) :::tip Replacing a JWT Create a new JWT in the Developer Console, paste it into the **JWT** field, and click **Save SMS provider**. Leave the other secret fields blank to keep their saved values. ::: #### Troubleshooting RingCentral **RingCentral rejects the credentials or reports "RingCentral auth failed".** Check that: - The **Server URL** matches where the app and JWT were created (sandbox or production). - The app uses the **JWT auth flow**. - The JWT is allowed for this app's Client ID. **The test message fails with a 403 error or MSG-242.** The From number has one of these problems: - It is missing the **SmsSender** feature. - It is not on an approved 10DLC campaign. - It does not belong to the user who created the JWT. To check a number's features, call `GET /restapi/v1.0/account/~/extension/~/phone-number` with the RingCentral API (this needs the **Read Accounts** permission). ## Step 6: Get alerted when a passcode cannot be delivered Delivery-failure alerts email an admin when a one-time passcode cannot be delivered to a signer, so someone can step in for the blocked signer. These failures are rare. **Alerts are on by default.** Turn off **Alert an admin when a passcode fails to send** if you do not want them. To choose who is notified, use **Send alerts to**: - **All organization admins:** every admin receives the alert. - **Specific addresses:** enter the exact addresses that should be alerted (for example `ops@example.com`), one chip per address. ![The Delivery failure alerts section with the Send alerts to selector highlighted](/img/how-to-configure-otp/otp-05-delivery-failure-alerts.png) ## Step 7: Know what happens when a signer keeps entering the wrong code Each passcode expires after 10 minutes and allows five wrong entries before the signer must request a new one. Wrong entries also add up across new codes: | Wrong codes in total | What happens | |---|---| | **5** | The document's sender gets a "having trouble verifying" email, so they can check the signer's email address or phone number early. | | **20** | The signer is locked out and the sender gets a "locked out" email. The signer cannot request or enter a code until the sender resends the signing request. | To clear a lockout, the sender uses **Resend Email** in the document's menu (see [Managing Your Signatures](../Managing-Your-Signatures.md)). Resending emails the signer a fresh link and clears the lock. :::note These alerts go to the sender of the document, not to the admins on the delivery-failure list. ::: ## What the signer sees When a recipient requires a passcode, the signer meets the passcode gate on the signing page before the document loads. 1. The signer clicks **Send Code**. 2. They receive the one-time code by email (or SMS). 3. They enter it and continue to the document. ![The signer's Verify your identity gate with the Send Code button highlighted](/img/how-to-configure-otp/signer-otp-gate.png) ## Troubleshooting | Problem | What to check | |---|---| | The signer didn't get the email code | Ask them to check spam, then click **Resend Code** after 30 seconds. If a code cannot be delivered, the admins on the [delivery-failure list](#step-6-get-alerted-when-a-passcode-cannot-be-delivered) get an alert. | | The signer didn't get the text message | Check the provider status reads **Connected** ([Provider status](#provider-status)) and that the signer's number includes the country code ([Signer mobile numbers](#signer-mobile-numbers)). | | **SMS** can't be selected | Turn on **Allow SMS as an alternative to email** and save your provider first ([Step 4](#step-4-allow-sms-as-an-alternative-to-email) and [Step 5](#step-5-connect-your-sms-provider)). SMS is on Pro and Enterprise plans. | | A signer is locked out | They entered 20 wrong codes. The sender clicks **Resend Email** on the document ([Step 7](#step-7-know-what-happens-when-a-signer-keeps-entering-the-wrong-code)). | | RingCentral messages stopped | The JWT may have expired. See [Check when your JWT expires](#check-when-your-jwt-expires) and [Troubleshooting RingCentral](#troubleshooting-ringcentral). | ## What's next - **Next:** [External identity verification](./external-identity-verification.md), if your app already verifies users with an identity verification vendor. - [Passcodes in embedded signing](../embedded-signing/passcodes.md): request email or SMS passcodes from your code. - [Identity verification overview](./index.md): compare the three ways to verify a signer. --- ## Sender Override for Development and Testing # Sender Override for Testing The override lets you try embedded signing end to end without setting up a passcode or an identity provider. The signer goes straight to the document with no verification step. :::warning Development and testing only Every signature completed this way is clearly marked as **not identity-verified** on the certificate of completion and in the audit trail. While the override is allowed, the settings show a persistent banner. Turn it off before you go live. If your organization needs it in production, an admin can leave it on, but the marking still applies to every override signature. ::: **What you'll build:** - An organization with the override switched on. - A test document whose recipient skips verification. - A single-use signing URL you can open or frame. ## Prerequisites - An **admin** account, to change the settings below. - **Enable identity verification** turned on (Step 1). This switch turns on embedded signing for every mode, including the override. - An **Administrator** or **Contributor** API key for your server. - If you will frame the page: your origin under **Allowed embedding domains** (see [Set up your organization](../embedded-signing/set-up-your-organization.md#step-5-allow-the-origins-that-may-embed-the-signing-page)). ## Step 1: Turn on embedded signing and allow the override (admin) 1. Go to **Settings** > **Features and integrations** > **Signatures** card > **Configure E-Signature** > **Identity Verification**. (Settings is in the menu under your name at the bottom of the left sidebar.) 2. On the **One-time passcode** tab, make sure **Enable identity verification** is on. Without it, every signing URL request fails with `EmbeddedSigningNotEnabled`, even for override recipients. 3. Click the **Identity & embedding** tab and turn on **Allow identity verification override**. The change saves right away, and a banner appears: "Identity verification override is enabled for this organization. Signers can be sent links that skip verification." ![The Allow identity verification override switch turned on, with the persistent override banner below it, highlighted](/img/embedded-signing/override-on-banner.png) Your integration can confirm it with the organization settings: `allowIdentityOverride` is `true`. ## Step 2: Send a test document with an override recipient The one-call `createEmbeddedSignature` helper only sets up passcodes, so the override uses `sendSignature` with `sendEmail: false`, then `createSigningUrl`. The `override` block needs `overrideIdentityVerification: true` and a `reason`, which is recorded. ```typescript // Continues the setup from "Your own iframe": imports and TurboSign.configure(...). const sent = await TurboSign.sendSignature({ file: await readFile("contract.pdf"), fileName: "contract.pdf", documentName: "Override test", sendEmail: false, recipients: [ { name: "Test Signer", email: "test.signer@example.com", signingOrder: 1, externalId: "test_123", identityVerification: { mode: "override", overrideIdentityVerification: true, reason: "Sandbox testing", }, }, ], fields: [ { type: "signature", recipientEmail: "test.signer@example.com", template: { anchor: "{signature1}", placement: "replace", size: { width: 100, height: 30 } }, }, ], }); const { url } = await TurboSign.createSigningUrl(sent.documentId, { externalId: "test_123" }); ``` ```python from turbodocx_sdk import TurboSign # Inside an async function, after TurboSign.configure(...) as in "Your own iframe". pdf holds the PDF bytes. sent = await TurboSign.send_signature( file=pdf, file_name="contract.pdf", document_name="Override test", send_email=False, recipients=[ { "name": "Test Signer", "email": "test.signer@example.com", "signingOrder": 1, "externalId": "test_123", "identityVerification": { "mode": "override", "overrideIdentityVerification": True, "reason": "Sandbox testing", }, } ], fields=[ { "type": "signature", "recipientEmail": "test.signer@example.com", "template": {"anchor": "{signature1}", "placement": "replace", "size": {"width": 100, "height": 30}}, } ], ) link = await TurboSign.create_signing_url(sent["documentId"], external_id="test_123") url = link["url"] ``` ```php // Continues the setup from "Your own iframe": TurboSign::configure(...) and the use lines. $sent = TurboSign::sendSignature(new SendSignatureRequest( recipients: [ new Recipient( name: 'Test Signer', email: 'test.signer@example.com', signingOrder: 1, externalId: 'test_123', identityVerification: IdentityVerification::override('Sandbox testing'), ), ], fields: [ new Field( type: SignatureFieldType::SIGNATURE, recipientEmail: 'test.signer@example.com', template: new TemplateConfig( anchor: '{signature1}', placement: FieldPlacement::REPLACE, size: ['width' => 100, 'height' => 30] ) ), ], file: file_get_contents(__DIR__ . '/contract.pdf'), documentName: 'Override test', sendEmail: false )); $link = TurboSign::createSigningUrl($sent->documentId, new CreateSigningUrlRequest(externalId: 'test_123')); $url = $link->url; ``` ```go // Inside a function that returns error, with client and ctx as in "Your own iframe". pdf holds the PDF bytes. sendEmail := false sent, err := client.TurboSign.SendSignature(ctx, &turbodocx.SendSignatureRequest{ File: pdf, FileName: "contract.pdf", DocumentName: "Override test", SendEmail: &sendEmail, Recipients: []turbodocx.Recipient{ { Name: "Test Signer", Email: "test.signer@example.com", SigningOrder: 1, ExternalID: "test_123", IdentityVerification: &turbodocx.IdentityVerification{ Mode: "override", OverrideIdentityVerification: true, Reason: "Sandbox testing", }, }, }, Fields: []turbodocx.Field{ { Type: "signature", RecipientEmail: "test.signer@example.com", Template: &turbodocx.TemplateAnchor{ Anchor: "{signature1}", Placement: "replace", Size: &turbodocx.Size{Width: 100, Height: 30}, }, }, }, }) if err != nil { return err } link, err := client.TurboSign.CreateSigningURL(ctx, sent.DocumentID, &turbodocx.CreateSigningURLRequest{ExternalID: "test_123"}) if err != nil { return err } fmt.Println(link.URL) // open, redirect to, or frame this URL ``` ```java // Inside a method that throws IOException, with client as in "Your own iframe". SendSignatureResponse sent = client.turboSign().sendSignature( new SendSignatureRequest.Builder() .file(Files.readAllBytes(Paths.get("contract.pdf"))) .fileName("contract.pdf") .documentName("Override test") .sendEmail(false) .recipients(List.of( new Recipient.Builder() .name("Test Signer") .email("test.signer@example.com") .signingOrder(1) .externalId("test_123") .identityVerification(IdentityVerification.override("Sandbox testing")) .build())) .fields(List.of( new Field.Builder() .type("signature") .recipientEmail("test.signer@example.com") .template(new Field.TemplateAnchor.Builder() .anchor("{signature1}") .placement("replace") .size(new Field.Size(100, 30)) .build()) .build())) .build()); String url = client.turboSign().createSigningUrl( sent.getDocumentId(), new CreateSigningUrlRequest.Builder().externalId("test_123").build()).getUrl(); ``` ```ruby # Continues the setup from "Your own iframe": require "turbodocx_sdk" and configure. sent = TurboDocxSdk::TurboSign.send_signature( "file" => StringIO.new(File.binread("contract.pdf")), "documentName" => "Override test", "sendEmail" => false, "recipients" => [ { "name" => "Test Signer", "email" => "test.signer@example.com", "signingOrder" => 1, "externalId" => "test_123", "identityVerification" => { "mode" => "override", "overrideIdentityVerification" => true, "reason" => "Sandbox testing" } } ], "fields" => [ { "type" => "signature", "recipientEmail" => "test.signer@example.com", "template" => { "anchor" => "{signature1}", "placement" => "replace", "size" => { "width" => 100, "height" => 30 } } } ] ) url = TurboDocxSdk::TurboSign.create_signing_url(sent["documentId"], external_id: "test_123")["url"] ``` ## Step 3: Open the URL The override URL is **single-use** and expires in about five minutes. Opening it consumes it, so request a fresh one each time. Open it in a new tab, or frame it exactly as in the build guides: the [React widget](../embedded-signing/build/react-widget.md), [your own iframe](../embedded-signing/build/own-iframe.md), or the [web component](../embedded-signing/build/web-component.md). The signing page skips verification: the signer accepts the TurboSign consent terms, then the document opens. ## What the signer sees 1. The signing page skips verification. The signer accepts the TurboSign consent terms, and the document opens. 2. The signer signs, and your page receives `turbosign:completed`. 3. The certificate of completion and the audit trail mark the signature as **not identity-verified**. ## Common errors | HTTP | `code` | Cause | Fix | |---|---|---|---| | 403 | `EmbeddedSigningNotEnabled` | **Enable identity verification** is off. | Turn it on (Step 1). | | 403 | `IdentityOverrideNotAllowed` | **Allow identity verification override** is off. | Turn it on (Step 1). | | 400 | `OverrideNotAcknowledged` | The block is missing `overrideIdentityVerification: true` or a `reason`. | Send both. | | 400 | `IdentityModeConflict` | The `identityVerification` block is invalid, for example it mixes fields from two modes. | Send only the override fields. | | 410 | `SigningUrlNotRedeemable` | The single-use URL was already opened or expired. | Request a new URL. | | 403 | none (a plain `403 Forbidden`) | The API key belongs to a **User**. | Use an **Administrator** or **Contributor** key. | ## What's next - **Next:** [API reference](../embedded-signing/reference.md), for every field, event and error. - Before going live, switch to a real check: [Email and SMS passcode](./one-time-passcode.md) or [External identity verification](./external-identity-verification.md). --- ## Welcome # Welcome to TurboDocx ![TurboDocx welcome banner for document automation platform](/img/welcome_to_dev-docs/Welcome.png) TurboDocx generates DOCX and PPTX documents from templates, collects legally binding e-signatures with TurboSign, and builds sales quotes and proposals with TurboQuote. Every product works from the TurboDocx app, a REST API, or an official SDK, so you can do this by hand or automate it from your own systems. This page is a map of the documentation. Pick the path that matches what you're trying to do. ## What you need before you start - A TurboDocx account and access to your organization's [Dashboard](./Dashboard.md). - For API or SDK work: an API key and organization ID, generated from your organization settings in the app. - For enterprise setup (SSO, SCIM, SharePoint): admin access to your TurboDocx organization and to your identity provider. ## Generate documents and presentations TurboDocx Templating turns an existing Word or PowerPoint file into a reusable template with variables, then fills it in to produce a finished document. - [How to Create a Template](./TurboDocx-Templating/How-to-Create-a-Template.md): turn an existing document into a reusable template. - [How to Create a Document Template](./TurboDocx-Templating/How-to-Create-a-Document-Template.md) and [How to Create a Presentation Template](./TurboDocx-Templating/How-to-Create-a-Presentation-Template.md): format-specific walkthroughs for Word and PowerPoint. - [How to Create a Deliverable](./TurboDocx-Templating/How-to-Create-a-Deliverable.md): generate a finished document from a template. - [Advanced Templating](./Advanced-Configuration/Advanced-Templating.md): loops, conditionals, and expressions for complex templates. ## Get documents signed TurboSign sends documents for e-signature, tracks who has signed, and keeps the completed audit trail. - [Setting up TurboSign](./TurboSign/Setting-up-TurboSign.md): send your first document for signature. - [Managing Your Signatures](./TurboSign/Managing-Your-Signatures.md): resend, remind, void, and download signed documents. - [API Signatures](./TurboSign/API-Signatures.md): send and track signature requests from your own code. ## Build and send quotes TurboQuote builds sales quotes and proposals from a product catalog, price books, and line items. - [Creating a New Quote](./TurboQuote/Creating-a-New-Quote.md): build your first quote. - [Adding a New Product](./TurboQuote/Adding-a-New-Product.md) and [Bulk Importing from a Spreadsheet](./TurboQuote/Bulk-Importing-from-a-Spreadsheet.md): populate your product catalog. ## Build on the API and SDKs Every TurboDocx product is also a REST API. Official SDKs cover JavaScript/TypeScript, Python, PHP, Go, Java, and Ruby, so most teams never call the raw API by hand. - [SDKs Overview](./SDKs/index.md): pick your language, install the client library, and find your API key. - [Deliverable API](./API/Deliverable-API.md): the document-generation endpoints behind the Deliverable SDKs. ## Connect your other tools - [Google Drive](./Integrations/Google-Drive.md) and [OneDrive and SharePoint](./Integrations/OneDrive-and-SharePoint.md): import templates from cloud storage and export deliverables back to it. - [Salesforce](./Integrations/SalesForce.md), [HubSpot](./Integrations/Hubspot.md), [Zoom](./Integrations/Zoom.md), and [Wrike](./Integrations/Wrike/index.md): trigger document generation and signature workflows from the tools your team already uses. - [TurboDocx Pipelines](./Pipelines/TurboDocx-Pipelines.md): automate document intake, field extraction, and signing end to end. ## Configure your organization - [Single Sign-On (SSO)](./Advanced-Configuration/Single-Sign-On.md): let your organization authenticate through your own identity provider. - [SCIM Provisioning](./Advanced-Configuration/SCIM-Provisioning.md): automate user account creation, updates, and deactivation from your identity provider. ## Troubleshooting If a document, template, or integration isn't behaving as expected, check that specific product's page first, most end with their own troubleshooting section, for example [Template Troubleshooting](./TurboDocx-Templating/Template-Troubleshooting.md). If you can't find an answer there, contact TurboDocx support with the organization name and a description of what you tried. ## Frequently asked questions **Do I need to write code to use TurboDocx?** No. Templates, deliverables, quotes, and signature requests can all be created from the TurboDocx app. The API and SDKs exist for teams that want to automate the same actions from their own systems. **Which document formats does TurboDocx generate?** DOCX and PPTX from templates, and PDF from TurboSign and document conversion. **Where do I find my API key and organization ID?** In your organization settings inside the TurboDocx app. See "How to Get Your Credentials" in the [SDKs Overview](./SDKs/index.md) for the exact steps. **Is TurboDocx multi-tenant?** Yes. Access is organized by TurboDocx organization, and enterprise features like SSO and SCIM are configured per organization.