# Documenso integration audit Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle. --- ## Per-port configuration All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`. | Setting key | Type | Purpose | | ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- | | `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. | | `documenso_api_key_override` | string | API key. Stored plaintext. | | `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. | | `documenso_eoi_template_id` | int | Template ID for EOI generation. | | `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). | | `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). | | `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). | | `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). | | `documenso_developer_email` | string | Developer signer email. | | `documenso_approver_name` | string | Approver display name. | | `documenso_approver_email` | string | Approver email. | | `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. | | `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. | | `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. | | `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign//`. | | `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. | | `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. | --- ## Document type matrix | Type | Generation flow | Signers | Field placement | | --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- | | **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template | | **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback | | **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement | ## Documenso field types Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes. | Field type | Use case | Needs `fieldMeta`? | What goes in meta | | ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- | | `SIGNATURE` | Drawn signature — almost every signing flow | No | — | | `FREE_SIGNATURE` | Type-or-draw signature variant | No | — | | `INITIALS` | Per-page initials block | No | — | | `DATE` | Auto-fills the date when the recipient signs | No | — | | `EMAIL` | Auto-fills the recipient's email | No | — | | `NAME` | Auto-fills the recipient's name | No | — | | `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` | | `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` | | `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` | | `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` | | `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` | Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel. `fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances. --- ## Documenso v1 vs v2 endpoint mapping The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`. | Operation | v1 (1.13–1.32) | v2.x | | ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- | | Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` | | Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) | | Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` | | Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) | | Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` | | Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` | | Download finalized PDF | `GET /api/v1/documents/{id}/download` → `{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) | | Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` | | Healthcheck | `GET /api/v1/health` | (v1 path used) | **Field key rename in v2 responses**: `id` → `documentId` and recipient `id` → `recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes. --- ## Signing-flow lifecycle ``` [rep clicks Generate] (CRM) │ ▼ buildEoiContext(interestId, portId) service │ ▼ generateAndSign(templateId, ctx, signers) creates Documenso doc │ ▼ POST /documents/{id}/send {sendEmail:false} Documenso starts the chain; it does NOT email signers │ ▼ extract signing URLs from response service │ ▼ transformSigningUrl(url, host, role) wrap as {host}/sign// │ ▼ if eoi_send_mode === 'auto': sendSigningInvitation(client) our branded HTML email goes out else: UI shows the URL + Send button rep dispatches manually ``` When the client signs: ``` Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso │ ▼ verify x-documenso-secret (per-port lookup) │ ▼ update document_signers row: status='signed', signedAt=... │ ▼ if next signer in chain has not been notified: sendSigningInvitation(developer) cascading "your turn" email ``` When the document reaches fully-signed: ``` Documenso fires DOCUMENT_COMPLETED webhook │ ▼ download signed PDF from Documenso │ ▼ store in storage backend → creates files row │ ▼ update document: status='completed', completedAt=... │ ▼ sendSigningCompleted([client, developer, approver], pdfFileId) all parties get the signed PDF │ ▼ update interest: pipelineStage='eoi_signed' (or contract_signed, etc) ``` --- ## Embedded signing on the marketing website The CRM emits signing URLs in the form `{embeddedSigningHost}/sign//`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s ``, and POSTs back to the CRM webhook on completion. For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin. ### nginx CORS block to apply on `signatures.portnimara.dev` Add to the relevant `server { ... }` block: ```nginx location / { # CORS for embedded signing — allow the marketing-website origin # to load the Documenso signing iframe. add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; # Preflight if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always; add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain charset=UTF-8'; add_header 'Content-Length' 0; return 204; } # ... your existing proxy_pass block to Documenso } ``` To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex: ```nginx set $cors_origin ""; if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") { set $cors_origin $http_origin; } add_header 'Access-Control-Allow-Origin' $cors_origin always; ``` --- ## What's deferred vs landed in this build **Landed:** - Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso` - Branded invitation, completion, and reminder email templates - `transformSigningUrl()` for `{host}/sign//` URL wrapping - Documenso v1 + v2 dual-version client (existing) - Webhook handler with timing-safe per-port secret resolution (existing) - Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder - Stage-conditional tab visibility for EOI / Contract / Reservation **Deferred (separate sessions):** - Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog. - Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs). - Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be `computeDefaultSignatureLayout()` (footer-anchored fields). - Webhook handler enhancements to track per-signer `sent_at`/`opened_at`/`signed_at` and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status. - Auto-store signed PDFs in storage backend and trigger `sendSigningCompleted()` on `DOCUMENT_COMPLETED`. Old system has this; needs porting. **Manual ops work for you:** - Apply the nginx CORS block above on your prod Documenso instance. - Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics). - Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.