Own the download: export, upload to S3, hand back a link
Hide or take over the SDK's own Download, export the edited document through
viewer.createExporter(), upload it to your bucket through a presigned URL, then give the person a link from your backend.Most requestedtoolbar.downloadcallbacks.onDownloadviewer.createExporter()exporter.uploadTo()exporter.getBlob()exportCompletedviewer.markSaved()
The viewer produces the finished PDF; where it goes is your decision. Two ways to start are shown, because they suit different products: keep the toolbar button and take over what it does with
callbacks.onDownload, or hide it with toolbar.download: false and put a Submit action of your own in the toolbar.What you get
- The SDK's Download dialog and flow never run; your function does.
- The exported bytes carry what the viewer holds: the form values and signatures the user entered, and Edit Text's pending edits, written in first.
- A whole-document export clears
viewer.hasUnsavedChanges; whether the upload succeeded is yours to track.
1. Hide the button, or take it over
toolbar.download: false hides the built-in Download. callbacks.onDownload keeps the button and runs your function instead of the SDK's dialog and flow (after forms.validateRequired, when that is on). The S3 example in Framework integrations hides Print and Download and adds a toolbar action; uploadTo() sends Content-Type: application/pdf unless headers overrides it.viewer.tsTypeScript
const viewer = new PDFViewer(container, doc, { toolbar: { print: false, download: false, actions: [ { id: 'submit', label: 'Submit Form', icon: 'done', variant: 'primary', onClick: async () => { const presignedUrl = await fetchPresignedUrl(); // your API const exporter = viewer.createExporter(); const res = await exporter.uploadTo(presignedUrl, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, }); if (!res.ok) { showError(res.status); // uploadTo does not reject on an HTTP error return; } showSuccessNotification(); }, }, ], }, }); // Or keep the toolbar's Download and take over what it does: const viewer2 = new PDFViewer(container, doc, { callbacks: { onDownload: () => void submit() }, });
A rejected promise from an action's
onClick is logged and reported through the error event. Actions are set when the viewer is built and cannot be disabled or relabeled later.2. Export the bytes yourself when the upload needs more than a PUT
getBlob() returns the same export as a Blob, for an upload with progress of your own or a multipart form. Every export method takes flatten, invalidateSignatures, pages, onProgress(done, total) and signal; a flatten left out comes from the viewer's export option. A flattening export of a signed document is refused with INVALID_STATE unless invalidateSignatures: true is given.save.tsTypeScript
const controller = new AbortController(); const blob = await viewer.createExporter().getBlob({ flatten: true, onProgress: (done, total) => progress.set(done / total), signal: controller.signal, // aborting rejects with ABORTED }); const res = await fetch(presignedUrl, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, body: blob, }); if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
3. Know when the export finished, and when it is saved
exportCompleted fires on the viewer for the toolbar's Download and for every exporter from viewer.createExporter(), with sizeBytes and partial (true for a page range). An export of the whole document clears hasUnsavedChanges by itself; nothing fires when it goes back to false, so exportCompleted is that moment. Only when you save another way (doc.save(), an upload of bytes you already had) do you call viewer.markSaved() yourself.save.tsTypeScript
viewer.on('exportCompleted', ({ sizeBytes, partial }) => { if (!partial) uploadStatus.set(`Exported ${sizeBytes} bytes`); }); // Saving through doc.save() instead: the viewer cannot see it, so tell it. const bytes = await doc.save(); await fetch('/api/documents/42', { method: 'PUT', body: bytes.slice() }); viewer.markSaved();
A signature drawn into a form field on screen is not in
doc.save(): it lives in the viewer until viewer.createExporter() writes it. When a viewer is on screen and you want everything the user sees, use the exporter.Gotchas
uploadTo()resolves to thefetchResponseand does not reject on an HTTP error status: checkresponse.ok. It rejects withNETWORK_ERRORwhen the request could not be sent at all (the address, the network, or a CORS refusal: the response must allow your origin) andABORTEDwhensignalaborted it.forms.validateRequiredholds back the toolbar's Download while a required field is empty, with or without youronDownload;viewer.createExporter()is not held back. Gate it yourself withdoc.forms.getValues()and the fields.- A change made while an export ran is not in the bytes and stays unsaved, and an undo back to the saved state still counts.
- Each export method rejects with
FEATURE_NOT_LICENSEDwhen the license lacksexport,INVALID_ARGUMENTwhenpagesis empty or names a page the document does not have,EXPORT_FAILEDwhen the document cannot be written, andDOCUMENT_CLOSEDafterdoc.close(). - The presigned URL's origin is a request of your own: it needs to be in your page's
connect-src.