9.1 KiB
date, title
| date | title |
|---|---|
| 2026-04-22 | Partial Signed Pdf Download |
Summary
Let team members fetch a PDF with all currently-inserted fields burned in while the envelope is still in PENDING status. Today the only available bytes for a pending envelope are the original (no fields) - the sealed PDF only materialises after the last recipient signs and the seal-document job runs.
Exposed in two places:
- v2 API:
GET /api/v2/envelope/item/{envelopeItemId}/download?version=pending(API-token auth) - UI: a
Partialbutton in the existingEnvelopeDownloadDialog, alongsideOriginal. Replaces theSignedslot when the envelope isPENDING. Backed by the existing session-authed file routeGET /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending.
Scope
- v2 API only (no v1).
internalVersion === 2envelopes only. Legacy v1 returns 400ENVELOPE_LEGACY.- Team-side / owner only. No recipient-token download path - recipients have the in-app overlay viewer for verification, and a downloadable half-signed PDF is a leak vector for partially-executed contracts. Enforced both at the server (the recipient-token file route does not accept
pending) and at the UI (the dialog hides the Partial button when a recipient token is set). - No PKI signature, no certificate page, no audit log appendix - the response is explicitly not a final executed document.
- No watermark or banner text. The filename suffix (
_pending.pdf), theCache-Control: no-store, privateheader, and the absence of a PKI signature are sufficient to signal draft status.
Behaviour
API response matrix (both /api/v2/envelope/item/{id}/download?version=pending and the UI-facing /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending):
| Envelope status | Response |
|---|---|
PENDING (v2) |
200, PDF with currently-inserted fields burned in |
PENDING (v1) |
400 ENVELOPE_LEGACY |
DRAFT |
400 ENVELOPE_DRAFT |
COMPLETED |
400 ENVELOPE_COMPLETED |
REJECTED |
400 ENVELOPE_REJECTED |
All v1-vs-v2 / status-mismatch errors are 4xx so callers can cleanly separate them from real server failures (5xx). Specifically v1 PENDING returns 400 not 501: 5xx is reserved for actual server problems, while "this envelope can't satisfy this request shape" is a client-addressable condition.
Filename: {title}_pending.pdf.
ETag is content-addressed over sha256(envelope.status + sorted((field.id, field.customText, field.signature?.id, field.signature?.created) for inserted===true fields)). Returns 304 on If-None-Match match.
No persistent caching. Generated on-demand per request when ETag misses.
Error response shape (envelope item v2 download route and the team-side file route): preserves the existing { error: <message> } field for backwards compatibility and adds code: <APP_ERROR_CODE> as a new field for callers that want to branch on it. The document download route (/document/{documentId}/download) is untouched.
UI
apps/remix/app/components/dialogs/envelope-download-dialog.tsx:
- The dialog shows
Originalplus one of:Signedwhen status isCOMPLETED(existing behaviour)Partialwhen status isPENDING, there is no recipient token, and the envelope is not legacy (!isLegacy)- nothing otherwise
- New optional prop
isLegacy?: boolean. Only consulted to gate thePartialbutton, so callers whose status can never bePENDING(DRAFT/COMPLETED/REJECTED hardcoded, orisComplete: truematchers) and callers that always set a recipient token can omit it. Three call sites pass it (isLegacy={envelope.internalVersion === 1}):documents-table-action-dropdown.tsx,envelope-editor.tsx,document-page-view-dropdown.tsx. The other eight callers were left alone.
Trade-off: a future team-side dialog usage where status could be PENDING but the dev forgets isLegacy will silently not render the Partial button. The status gate prevents an actively broken click; missing button is discoverable in testing. Required-prop alternative was rejected because eight of eleven call sites would carry a meaningless value.
Files
Server:
apps/remix/server/api/download/download.types.ts- added'pending'to theversionenum; split the validator intoparam(envelopeItemId) +query(version). The original wiring as a path-param validator was a pre-existing bug: requests like?version=originalwere silently returning the signed PDF sinceversionactually arrives as a query string. Fixed as a side effect.packages/trpc/server/envelope-router/download-envelope-item.types.ts- mirrored the enum change in the OpenAPI schema.apps/remix/server/api/download/download.ts- the envelope item v2 route now fetches envelope recipients alongside the envelope, branches onversionwhen calling the helper, and emits AppError responses as{ error, code }consistently across all status codes.apps/remix/server/api/files/files.types.ts- added'pending'to the team-side download schema only. The recipient-token download schema is untouched, so/api/files/token/.../download/pendingis rejected by the schema validator.apps/remix/server/api/files/files.ts- the team-side download handler fetches envelope recipients and dispatches thependingbranch through the samehandleEnvelopeItemFileRequesthelper. Wrapped in a try/catch that returns{ error, code }for AppErrors.apps/remix/server/api/files/files.helpers.ts-handleEnvelopeItemFileRequestis now a single entry point taking a discriminated-union options type. The static-file flow (signed/original) and the on-demand pending flow are private helpers in the same module.packages/lib/server-only/pdf/generate-partial-signed-pdf.ts(new) - small orchestrator that loads the original PDF, groups inserted fields by page, calls the existinginsertFieldInPDFV2overlay helper for each page, flattens, and saves.packages/lib/errors/app-error.ts- addedENVELOPE_DRAFT,ENVELOPE_COMPLETED,ENVELOPE_REJECTED,ENVELOPE_LEGACYcodes, all mapped to 400. The legacy-envelope case deliberately returns 4xx rather than 501 to keep "this resource can't satisfy this operation" distinct from real 5xx server failures in caller logs/metrics.
Client:
packages/lib/utils/envelope-download.ts-EnvelopeItemPdfUrlOptionsdownload variant now allows'pending'as a version. The recipient-token URL builder will produce a URL the server rejects, but the dialog gates on no-token at the call site.packages/lib/client-only/download-pdf.ts-DocumentVersionextended; filename suffix logic moved into a small switch (_signed.pdf,_pending.pdf,.pdf).apps/remix/app/components/dialogs/envelope-download-dialog.tsx- secondary download derivation with the newPartialbranch, optionalisLegacyprop.apps/remix/app/components/tables/documents-table-action-dropdown.tsx,apps/remix/app/components/general/envelope-editor/envelope-editor.tsx,apps/remix/app/components/general/document/document-page-view-dropdown.tsx- passisLegacy={envelope.internalVersion === 1}(orrow.internalVersion === 1) to the dialog.
Verification
-
E2E (
packages/app-tests/e2e/api/v2/partial-signed-pdf-download.spec.ts):- Pending envelope, recipient 1 signs, API token download with
?version=pendingreturns 200 + PDF; subsequent call withIf-None-Match: <etag>returns 304; after recipient 2 completes the envelope flips toCOMPLETEDand the same call returns 400ENVELOPE_COMPLETED;?version=signedthen succeeds. - Draft envelope returns 400
ENVELOPE_DRAFT. internalVersion === 1pending envelope returns 400ENVELOPE_LEGACY.
- Pending envelope, recipient 1 signs, API token download with
-
npx tsc --noEmit -p apps/remix/tsconfig.jsonandnpm run lint. -
Manual: open the Documents table or envelope editor on a PENDING envelope (v2), open the download dialog, confirm
Partialappears alongsideOriginaland produces a_pending.pdfwith current fields burned in. Same dialog on a COMPLETED envelope showsSigned. Same dialog on a v1 PENDING envelope shows neither (status gate would show Partial, but theisLegacyflag filters it out).
Out of Scope / Follow-ups
- Recipient-token download path (API and UI) - decided against. Revisit if there is concrete demand and a story for limiting the leak vector.
- v1 API parity / v1 partial rendering - not building. Implementing partial for v1 would require porting
legacy_insertFieldInPDF/insertFieldInPDFV1into a partial-only flow, which is code with no long-term home as v1 is being phased out. - Document download route (
/document/{documentId}/download) - untouched. Same error shape and validator wiring as before. Consider normalising to the same{ error, code }shape in a follow-up if any caller wants to branch oncodefrom that route. - Persistent caching layer / job-queue generation - revisit if p95 latency on large PDFs becomes an issue.
- Specific toast for
ENVELOPE_LEGACYin the dialog - currently the catch-all "Something went wrong" handles it. Worth a polish if v1 PENDING envelopes are common in your data and we see complaints. (Note: with theisLegacygate at the UI, the error is unreachable from the dialog itself; the API can still surface it for direct callers.)