@@ -162,10 +186,10 @@ export default function DocumensoSettingsPage() {
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
- backwards compatibility. v2 is recommended for new ports and unlocks the features
- below. Switching versions does not require any code changes —
- version-aware client methods pick the right endpoint per port. Switch, save, then run
- the test-connection button to confirm the chosen instance is actually on the matching
+ backwards compatibility. v2 is recommended for new ports and unlocks the features below.
+ Switching versions does not require any code changes — version-aware
+ client methods pick the right endpoint per port. Switch, save, then run the
+ test-connection button to confirm the chosen instance is actually on the matching
Documenso version.
@@ -175,7 +199,10 @@ export default function DocumensoSettingsPage() {
-
-
+
Bulk field placement. One API call per envelope vs. v1's
per-field POST loop. Faster contract generation, fewer transient retries on
@@ -183,23 +210,32 @@ export default function DocumensoSettingsPage() {
-
-
+
- Percent-based field coordinates. No page-dimension lookup
- needed — coordinates are portable across page sizes. v1 requires us to assume A4
- for auto-placed fields.
+ Percent-based field coordinates. No page-dimension lookup needed
+ — coordinates are portable across page sizes. v1 requires us to assume A4 for
+ auto-placed fields.
-
-
+
Richer field metadata. TEXT labels & required flags, NUMBER
- min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults — all
- ignored by v1, surfaced by v2 in the signing UI.
+ min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults — all ignored
+ by v1, surfaced by v2 in the signing UI.
-
-
+
v2-flavoured webhook events.
RECIPIENT_VIEWED,{' '}
RECIPIENT_SIGNED, DOCUMENT_RECIPIENT_COMPLETED,{' '}
@@ -208,11 +244,54 @@ export default function DocumensoSettingsPage() {
-
-
+
- Envelope/embed endpoints.
GET and{' '}
- DELETE go through /api/v2/envelope/... when v2 is
- selected. Future embedded-signing iframe work will plug in here.
+ Envelope CRUD endpoints. GET, DELETE,
+ POST /envelope/create (multipart), POST /envelope/distribute,{' '}
+ POST /envelope/redistribute, GET /envelope/{'{id}'}/download{' '}
+ — all routed through /api/v2/envelope/... when v2 is selected. The
+ template-generate path is intentionally still v1 (relies on Documenso 2.x's
+ backward-compat window — see the deferred-roadmap below).
+
+
+ -
+
+
+ One-call send. v2's
/envelope/distribute returns
+ per-recipient signingUrl in the same response — v1 requires a
+ separate GET to fetch them. Faster send flow on the rep side.
+
+
+ -
+
+
+ Sequential signing enforcement. Pick SEQUENTIAL in the "v2
+ signing behaviour" card below and Documenso 2.x refuses to email recipient
+ N+1 until recipient N has signed. Eliminates the "approver signed before the
+ developer did" race on EOIs.
+
+
+ -
+
+
+ Post-signing redirect URL. Set in the "v2 signing
+ behaviour" card; Documenso redirects the signer to that URL after they
+ complete signing. Use to land clients on the marketing site's success page
+ or back in the portal instead of Documenso's default thank-you page. (v1
+ honours this too — listed here because the admin setting was added with the v2
+ work.)
@@ -220,35 +299,35 @@ export default function DocumensoSettingsPage() {
- v2 capabilities on the roadmap (not yet wired)
+ v2 capabilities deferred (would need new code paths)
-
- Sequential signing (
signingOrder: SEQUENTIAL) — would
- force client → developer → approver order on EOIs instead of all-at-once.
+
+ Single-shot /template/use
+ {' '}
+ with v2 prefillFields by ID — current EOI flow uses{' '}
+ /api/v1/templates/{'{id}'}/generate-document with{' '}
+ formValues keyed by name. v2 instances accept both during their
+ backward-compat window; full migration requires per-template field-ID capture in
+ admin settings.
-
- Post-signing redirect URL (
redirectUrl) — would land
- signed clients back on the portal rather than Documenso's page.
+
+ Update envelope metadata after creation (/envelope/update)
+ {' '}
+ — change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
+ re-generating.
-
- Single-shot
/template/use (v2 prefillFields by ID
- replacing v1 formValues by name) — currently the EOI flow still uses the v1
- template path even when API version is v2. Needs per-template field-ID mapping in
- the template config before we can switch.
-
- -
- Update envelope metadata (
/envelope/update) — change
- title / subject / redirectUrl after creation without re-generating.
-
- -
- Recipient roles beyond SIGNER (APPROVER / CC / VIEWER) — would let
- sales managers receive copies without a signature slot.
+ Non-SIGNER recipient roles (CC / VIEWER) — APPROVER role is
+ already used by the EOI template; CC + VIEWER not yet exposed in the recipient
+ builder. Useful for sales managers who want a copy without a signature slot.
- These items have no admin setting yet because they need code changes first. They
- live here so you know what's in the pipeline.
+ Sequential signing and post-signing redirect URL are now wired —
+ see the new "v2 signing behaviour" card below to configure them.
@@ -261,6 +340,12 @@ export default function DocumensoSettingsPage() {
extra={
}
/>
+
+
): Record {
const safeRecipients = applyRecipientRedirect(recipients);
if (env.EMAIL_REDIRECT_TO) {
@@ -167,11 +182,100 @@ export async function createDocument(
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
);
}
+ const { apiVersion } = await resolveCreds(portId);
+
+ if (apiVersion === 'v2') {
+ // v2: multipart /envelope/create with payload + files. Convert the
+ // base64 PDF to a Buffer and ship it under `files`. Returns
+ // `{ id: envelopeId }` only — caller distributes separately via
+ // sendDocument(envelopeId).
+ const { baseUrl, apiKey } = await resolveCreds(portId);
+ const pdfBuffer = Buffer.from(pdfBase64, 'base64');
+ const form = new FormData();
+ const payload = {
+ type: 'DOCUMENT',
+ title,
+ recipients: safeRecipients.map((r, i) => ({
+ email: r.email,
+ name: r.name,
+ role: r.role,
+ signingOrder: r.signingOrder || i + 1,
+ })),
+ ...(meta
+ ? {
+ meta: {
+ ...(meta.subject ? { subject: meta.subject } : {}),
+ ...(meta.message ? { message: meta.message } : {}),
+ ...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
+ ...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}),
+ },
+ }
+ : {}),
+ };
+ form.append('payload', JSON.stringify(payload));
+ form.append(
+ 'files',
+ new Blob([pdfBuffer], { type: 'application/pdf' }),
+ `${title.replace(/[^a-z0-9-_]+/gi, '-')}.pdf`,
+ );
+
+ let res: Response;
+ try {
+ res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/create`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: form,
+ });
+ } catch (err) {
+ if (err instanceof FetchTimeoutError) {
+ throw new CodedError('DOCUMENSO_TIMEOUT', {
+ internalMessage: `/api/v2/envelope/create timed out after ${err.timeoutMs}ms`,
+ });
+ }
+ throw err;
+ }
+ if (!res.ok) {
+ const errText = await res.text();
+ logger.error(
+ { status: res.status, err: errText, portId },
+ 'Documenso v2 envelope/create error',
+ );
+ if (res.status === 401 || res.status === 403) {
+ throw new CodedError('DOCUMENSO_AUTH_FAILURE', {
+ internalMessage: `v2 envelope/create → ${res.status}`,
+ });
+ }
+ throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
+ internalMessage: `v2 envelope/create → ${res.status}: ${errText}`,
+ });
+ }
+ const created = (await res.json()) as Record;
+ // v2 returns just `{ id }`. Re-fetch the full envelope so the
+ // caller gets recipients (without signing URLs — those come after
+ // distribute). Keeps shape identical to v1's createDocument response.
+ const envelopeId = String(created.id ?? created.documentId ?? '');
+ return getDocument(envelopeId, portId);
+ }
+
+ // v1: existing path. Meta keys are accepted at the top level.
return documensoFetch(
'/api/v1/documents',
{
method: 'POST',
- body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
+ body: JSON.stringify({
+ title,
+ document: pdfBase64,
+ recipients: safeRecipients,
+ ...(meta?.subject || meta?.message || meta?.redirectUrl
+ ? {
+ meta: {
+ ...(meta.subject ? { subject: meta.subject } : {}),
+ ...(meta.message ? { message: meta.message } : {}),
+ ...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}),
+ },
+ }
+ : {}),
+ }),
},
portId,
).then(normalizeDocument);
@@ -219,6 +323,34 @@ export async function sendDocument(docId: string, portId?: string): Promise;
+ // Distribute response shape: { success, id, recipients: [...] }.
+ // The recipients carry name/email/token/role/signingOrder/signingUrl.
+ // Normalize by re-wrapping into the document shape that downstream
+ // callers already consume.
+ return normalizeDocument({
+ id: distributed.id,
+ // v2 doesn't return `status` on the distribute response — the call
+ // itself moves the envelope from DRAFT to PENDING, so PENDING is
+ // the correct authoritative state.
+ status: 'PENDING',
+ recipients: distributed.recipients,
+ });
+ }
+
return documensoFetch(
`/api/v1/documents/${docId}/send`,
{
@@ -254,6 +386,25 @@ export async function sendReminder(
);
return;
}
+ const { apiVersion } = await resolveCreds(portId);
+
+ if (apiVersion === 'v2') {
+ // v2 sends reminders via redistribute. Documenso 2.x doesn't expose a
+ // recipient-targeted reminder endpoint directly; instead /envelope/redistribute
+ // resends to all pending recipients on the envelope. Single-recipient
+ // targeting requires admin-side filtering. For now we redistribute the
+ // entire envelope, which is functionally equivalent for the typical
+ // case (most reminders go to the one outstanding signer).
+ await documensoFetch(
+ '/api/v2/envelope/redistribute',
+ {
+ method: 'POST',
+ body: JSON.stringify({ envelopeId: docId, recipientIds: [signerId] }),
+ },
+ portId,
+ );
+ return;
+ }
await documensoFetch(
`/api/v1/documents/${docId}/recipients/${signerId}/remind`,
{
@@ -264,8 +415,13 @@ export async function sendReminder(
}
export async function downloadSignedPdf(docId: string, portId?: string): Promise {
- const { baseUrl, apiKey } = await resolveCreds(portId);
- const path = `/api/v1/documents/${docId}/download`;
+ const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
+ // v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
+ // envelope namespace). v1: existing /documents/{id}/download.
+ const path =
+ apiVersion === 'v2'
+ ? `/api/v2/envelope/${docId}/download`
+ : `/api/v1/documents/${docId}/download`;
let res: Response;
try {
res = await fetchWithTimeout(`${baseUrl}${path}`, {
diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts
index ef20c438..22b66f31 100644
--- a/src/lib/services/documenso-payload.ts
+++ b/src/lib/services/documenso-payload.ts
@@ -12,6 +12,13 @@ export interface DocumensoTemplatePayload {
subject: string;
redirectUrl: string;
distributionMethod: 'NONE' | 'EMAIL';
+ /**
+ * PARALLEL = all signers can sign in any order (default, current behaviour).
+ * SEQUENTIAL = signers must complete in the order their `signingOrder`
+ * number dictates (client → developer → approver for EOI). v2 enforces
+ * this server-side; v1 ignores the key and behaves as PARALLEL regardless.
+ */
+ signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
};
formValues: {
Name: string;
@@ -54,6 +61,11 @@ export interface DocumensoPayloadOptions {
approverEmail?: string;
/** Redirect URL after signing. Defaults to the app URL. */
redirectUrl?: string;
+ /**
+ * PARALLEL (default) or SEQUENTIAL — v2-only enforcement (v1 ignores).
+ * Set via per-port `documenso_signing_order` system_settings key.
+ */
+ signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
}
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
@@ -129,6 +141,7 @@ export function buildDocumensoPayload(
subject: 'Your LOI is ready to be signed',
redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL,
distributionMethod: 'NONE',
+ ...(options.signingOrder ? { signingOrder: options.signingOrder } : {}),
},
formValues: {
Name: context.client.fullName,
diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts
index 864d1557..8b75fb90 100644
--- a/src/lib/services/document-templates.ts
+++ b/src/lib/services/document-templates.ts
@@ -902,7 +902,11 @@ async function generateAndSignViaDocumensoTemplate(
developerEmail: signers.developer.email,
approverName: signers.approver.name,
approverEmail: signers.approver.email,
- redirectUrl: env.APP_URL,
+ // Prefer per-port post-signing redirect (typically marketing-site
+ // /sign/success on v2). Falls back to APP_URL on v1 / when unset.
+ redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
+ // v2-only signing-order enforcement. v1 instances ignore this key.
+ ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
});
const documensoDoc = await documensoGenerateFromTemplate(
diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts
index e771e309..947a133e 100644
--- a/src/lib/services/documents.service.ts
+++ b/src/lib/services/documents.service.ts
@@ -34,6 +34,7 @@ import {
voidDocument as documensoVoid,
} from '@/lib/services/documenso-client';
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
+import { getPortDocumensoConfig } from '@/lib/services/port-config';
import {
listTree,
collectDescendantIds,
@@ -699,24 +700,39 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
const pdfBuffer = Buffer.concat(chunks);
const pdfBase64 = pdfBuffer.toString('base64');
- // Create document in Documenso + send
- const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
- { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
- {
- name: eoiSigners.developer.name,
- email: eoiSigners.developer.email,
- role: 'SIGNER',
- signingOrder: 2,
- },
- {
- name: eoiSigners.approver.name,
- email: eoiSigners.approver.email,
- role: 'SIGNER',
- signingOrder: 3,
- },
- ]);
+ // Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL).
+ // Both are optional — passing undefined yields v1's legacy behavior.
+ const docCfg = await getPortDocumensoConfig(portId);
- await documensoSend(documensoDoc.id);
+ // Create document in Documenso + send. portId is required for the v2
+ // envelope/create code path (which routes by per-port apiVersion);
+ // meta.signingOrder is honoured only on v2 instances.
+ const documensoDoc = await documensoCreate(
+ doc.title,
+ pdfBase64,
+ [
+ { name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
+ {
+ name: eoiSigners.developer.name,
+ email: eoiSigners.developer.email,
+ role: 'SIGNER',
+ signingOrder: 2,
+ },
+ {
+ name: eoiSigners.approver.name,
+ email: eoiSigners.approver.email,
+ role: 'SIGNER',
+ signingOrder: 3,
+ },
+ ],
+ portId,
+ {
+ ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
+ ...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
+ },
+ );
+
+ await documensoSend(documensoDoc.id, portId);
// Update signer records with signing URLs from Documenso response
for (const docSigner of documensoDoc.recipients) {
diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts
index 675632d1..6e03ef4b 100644
--- a/src/lib/services/port-config.ts
+++ b/src/lib/services/port-config.ts
@@ -77,6 +77,16 @@ export const SETTING_KEYS = {
// uses templates rather than per-deal uploads. Optional.
documensoContractTemplateId: 'documenso_contract_template_id',
documensoReservationTemplateId: 'documenso_reservation_template_id',
+ // v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement on
+ // multi-recipient envelopes. When SEQUENTIAL is set + apiVersion=v2,
+ // Documenso refuses to email recipient N+1 until recipient N has signed.
+ // Ignored entirely on v1 instances.
+ documensoSigningOrder: 'documenso_signing_order',
+ // v2-only override of the post-signing redirect URL set on documentMeta.
+ // Falls back to the embedded signing host (or APP_URL) when unset. Use
+ // this to land signed clients on /portal/eoi-complete (or wherever
+ // makes sense for the workflow).
+ documensoRedirectUrl: 'documenso_redirect_url',
// Branding
brandingLogoUrl: 'branding_logo_url',
@@ -222,6 +232,19 @@ export interface PortDocumensoConfig {
* user's email for in-CRM signing-status updates. */
developerUserId: string | null;
approverUserId: string | null;
+ /**
+ * v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement.
+ * `null` keeps the upstream default (PARALLEL); a non-null value gets
+ * passed verbatim. v1 instances ignore this — see admin Documenso page.
+ */
+ signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
+ /**
+ * v2-only: post-signing redirect URL set on documentMeta. When null,
+ * the upstream Documenso default applies (Documenso's own thank-you
+ * page). Typically set to `{embeddedSigningHost}/sign/success` so
+ * signers land back on the branded marketing site.
+ */
+ redirectUrl: string | null;
}
function toIntOrNull(raw: unknown): number | null {
@@ -255,6 +278,8 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting(SETTING_KEYS.documensoApiKeyOverride, portId),
@@ -276,6 +301,8 @@ export async function getPortDocumensoConfig(portId: string): Promise(SETTING_KEYS.documensoApproverLabel, portId),
readSetting(SETTING_KEYS.documensoDeveloperUserId, portId),
readSetting(SETTING_KEYS.documensoApproverUserId, portId),
+ readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
+ readSetting(SETTING_KEYS.documensoRedirectUrl, portId),
]);
return {
@@ -299,6 +326,8 @@ export async function getPortDocumensoConfig(portId: string): Promise