feat(documenso): full v2 endpoint coverage + sequential signing + redirectUrl
Wire up the remaining version-aware paths so a port pointed at Documenso 2.x
takes the v2 endpoint on every CRUD operation, with two new v2-only settings
exposed in admin UI.
documenso-client.ts:
- createDocument: v2 multipart /envelope/create + getDocument follow-up to
return the full doc shape (v1 path unchanged)
- sendDocument: v2 /envelope/distribute (returns per-recipient signingUrl in
the same response — eliminates the v1 separate-GET round-trip)
- sendReminder: v2 /envelope/redistribute with recipientIds filter
- downloadSignedPdf: v2 /envelope/{id}/download
- CreateDocumentMeta type: { subject, message, redirectUrl, signingOrder }
threaded through v1 + v2 paths (v1 ignores signingOrder)
port-config.ts:
- New settings: documenso_signing_order (PARALLEL/SEQUENTIAL, v2-only),
documenso_redirect_url (both versions honour)
- PortDocumensoConfig gains signingOrder + redirectUrl
documenso-payload.ts:
- DocumensoTemplatePayload.meta gains signingOrder
- buildDocumensoPayload reads from options.signingOrder, omits when null
document-templates.ts (EOI template flow):
- Pass docCfg.signingOrder + docCfg.redirectUrl into buildDocumensoPayload
documents.service.ts (sendForSigning uploaded-doc flow):
- Pass portId to documensoCreate + documensoSend (was missing)
- Thread signingOrder + redirectUrl via the new meta param
Admin Documenso settings page:
- v2 benefits card updated: now lists envelope CRUD, one-call send,
sequential enforcement, post-sign redirect as wired (was roadmap)
- Roadmap callout pruned to the three remaining deferred items:
template/use migration, /envelope/update, non-SIGNER recipient roles
- New "v2 signing behaviour" SettingsFormCard with the two new settings
Template flow stays on /api/v1/templates/{id}/generate-document by design —
Documenso 2.x accepts v1 endpoints via backward compat; full migration to
v2 /template/use requires per-template field-ID capture (admin schema work,
deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -154,11 +154,26 @@ function applyPayloadRedirect(payload: Record<string, unknown>): Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional metadata applied to the document on creation. v1 accepts
|
||||
* `redirectUrl` and `subject`/`message` on its `/documents` endpoint.
|
||||
* v2's `/envelope/create` accepts the same plus `signingOrder` for
|
||||
* PARALLEL-vs-SEQUENTIAL signing enforcement.
|
||||
*/
|
||||
export interface CreateDocumentMeta {
|
||||
subject?: string;
|
||||
message?: string;
|
||||
redirectUrl?: string;
|
||||
/** v2 only. v1 ignores. */
|
||||
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
title: string,
|
||||
pdfBase64: string,
|
||||
recipients: DocumensoRecipient[],
|
||||
portId?: string,
|
||||
meta?: CreateDocumentMeta,
|
||||
): Promise<DocumensoDocument> {
|
||||
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<string, unknown>;
|
||||
// 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<Docu
|
||||
// Documenso's perspective.
|
||||
return getDocument(docId, portId);
|
||||
}
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
|
||||
// Returns the envelope with per-recipient signingUrl fields populated —
|
||||
// this is one of the genuine v2 wins (saves a separate GET round-trip).
|
||||
const distributed = (await documensoFetch(
|
||||
'/api/v2/envelope/distribute',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ envelopeId: docId }),
|
||||
},
|
||||
portId,
|
||||
)) as Record<string, unknown>;
|
||||
// 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<Buffer> {
|
||||
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}`, {
|
||||
|
||||
Reference in New Issue
Block a user