The first time you spin up Puppeteer and call page.pdf(), it feels solved. You get a PDF. It renders. Ship it.
That decision comes back to haunt you when you're generating 500 contracts a day, your designer updated the brand template and nobody propagated the change, compliance wants audit-ready flat files, and legal is asking why the e-signatures aren't attached to the archived copies. What started as a utility function is now load-bearing infrastructure, and the duct tape is showing.
Headless-browser rendering gives you a PDF with no template management, no compliance-grade output, and no downstream handoff. Those things require a different design. This guide walks through that design, a three-stage pipeline covering data binding from a Word template, synchronous document generation, post-processing for compliance and delivery, and an eSign handoff, all through REST APIs.
Why "Just Use Puppeteer" Breaks at Scale
Puppeteer works. At low volume, with a single developer maintaining the rendering code, it's perfectly reasonable. The cracks appear when you add real-world pressure.
The first problem is template drift. When your HTML template lives in a repository owned by engineers, every brand update requires a PR, a review, and a deploy. Product managers can't touch it. Designers can't touch it. The template ends up frozen in a past state of the brand because the cost of updating it is higher than the cost of tolerating the inconsistency.
The second problem is rendering inconsistency. Headless Chrome's output changes between Chrome versions. Fonts render differently depending on what's installed on the host OS. Your CI/CD environment produces slightly different PDFs than your local machine. For documents that are legally significant (contracts, invoices, disclosures), "slightly different" is not acceptable.
The third problem is the compliance delta. page.pdf() produces a valid PDF, but it gives you no flattened file for archival compliance, no linearized file for web serving, and no audit trail. Those capabilities don't exist in Puppeteer. You bolt them on separately, and each bolt is a seam where something can break.
A production document pipeline needs reproducible data binding (the same input produces consistent, deterministic output), compliance-ready output formats (flat, archivable, signable files), and downstream handoff (delivering documents into signing workflows, storage systems, or portals). A headless browser covers none of these, so each one becomes a separate integration you own and maintain.
Pipeline Architecture: What You're Actually Building
The pipeline has three stages. Understanding them before writing any code prevents the most common integration mistakes.
Stage 1 is the data source. Your CRM, ERP, database, or internal service provides a JSON payload containing the values that populate the document. Business logic lives here, including field mapping, conditional logic for what sections to include, and data validation before any API call goes out.
Stage 2 is document generation. A Word template defines the document structure, and the generation API merges the JSON payload into it, producing a PDF or DOCX. You POST, you get back a document.
Stage 3 is post-processing and delivery. The generated PDF moves into a processing pipeline for compliance operations (flatten for archiving, linearize for web delivery, compress for storage), then hands off to an eSign workflow or an archive system.
The synchronous versus asynchronous split is the architectural decision with the biggest impact on integration complexity. The Document Generation API runs synchronously, so you POST a request and receive the rendered document in the response body, with no job IDs and no polling. That fits naturally into request-response backend workflows, such as generating a contract when a user clicks "Send Agreement" in your CRM. PDF Services operations (flatten, linearize, compress) run asynchronously, so you submit a job, get a task ID, and poll for completion. The DocGen response is immediate, but the post-processing chain adds latency you need to account for in your design.
Authentication is consistent across all three stages. Every request includes client_id and client_secret as request headers, retrieved from the developer dashboard after creating your account. The base host for both DocGen and PDF Services in these examples is https://na1.fusion.foxit.com; your dashboard shows the host assigned to your account. Store the credentials as environment variables, not in source code. The full request and response schema for every endpoint used below lives in Foxit's API reference, so keep it open as you build.
Prerequisites
This is a hands-on tutorial with runnable code in Python, Node.js, and cURL. Before the first API call, set up the following:
- A Foxit developer account for
client_id/client_secretcredentials. The free Developer plan includes 500 shared credits per year across PDF Services and Document Generation. -
Python 3.8+ with pip and a virtual environment, or Node.js 18+ with npm. The examples use Python's
requestsand Node'saxiosplusform-data. - cURL for quick endpoint checks from the terminal.
- A code editor such as VS Code with the Python extension (PyCharm, WebStorm, or Sublime Text work too).
Scaffold the workspace in one shot:
mkdir pdf-pipeline && cd pdf-pipeline
python3 -m venv .venv && source .venv/bin/activate
pip install requests
# Node alternative:
# npm init -y && npm install axios form-data
export FOXIT_CLIENT_ID="your_client_id"
export FOXIT_CLIENT_SECRET="your_client_secret"
In this block you create an isolated project directory, activate a Python virtual environment so dependencies don't leak into your system install, add the HTTP library the examples use, and export your Foxit credentials as environment variables that every script below reads from the environment rather than from hardcoded strings.
Setup and Your First API Call
Start by creating an account at account.foxit.com/site/sign-up. The free Developer plan takes about two minutes to configure and gives you immediate API access to validate a complete pipeline integration before committing to a paid tier.
Next you need a Word template. Rather than building one from scratch, download a ready-made sample, invoice_simple.docx, which already contains token placeholders. Tokens use double curly braces, and this template defines {{ companyName }}, {{ invoiceNumber }}, {{ invoiceDate \@ MM/dd/yyyy }}, and {{ totalDue \# "$#,##0.00" }}. The \@ and \# suffixes are formatting switches that tell the engine how to render dates and currency.
The first API call takes that .docx, encodes it as base64, attaches a JSON data payload, and POSTs it to the Document Generation endpoint. Encode the file first:
import base64
with open("invoice_simple.docx", "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
In this code, you open the template in binary mode, base64-encode its raw bytes, and decode the result to a UTF-8 string so it can travel inside a JSON request body. In Node.js the equivalent is fs.readFileSync("invoice_simple.docx").toString("base64"). The API doesn't care which language produced the string, only that it's valid base64 that decodes to a well-formed .docx. Send a corrupted or truncated file and you'll get an error back with no usable output.
Now POST the encoded template with a matching data payload:
curl -X POST "https://na1.fusion.foxit.com/document-generation/api/GenerateDocumentBase64" \
-H "client_id: $FOXIT_CLIENT_ID" \
-H "client_secret: $FOXIT_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"base64FileString": "<BASE64_ENCODED_DOCX>",
"outputFormat": "pdf",
"documentValues": {
"companyName": "Acme Corp",
"invoiceNumber": "INV-1042",
"invoiceDate": "2026-01-15",
"totalDue": "5700"
}
}'
In this request, base64FileString carries the encoded template, outputFormat selects pdf (use docx to get a populated Word file back instead), and documentValues is the object whose keys map to the {{token}} names in the template. Credentials go in the headers, never in the body. Every key in documentValues corresponds to a token; tokens you omit render as an empty string, and keys that don't match any token are ignored. Values can be strings or numbers, so "totalDue": "5700" and "totalDue": 5700 both work, though sending strings keeps formatting predictable.
The response returns the generated document as a base64 string in the base64FileString field:
{
"message": "PDF Document Generated Successfully",
"fileExtension": "pdf",
"base64FileString": "<BASE64_ENCODED_PDF>"
}
Decode the base64FileString value to get the raw PDF bytes, then write them to disk, push to S3, or pass them straight into the next pipeline stage. There's no separate download step and no job ID to track, since the document is in the response body.
One constraint to plan around is the 4 MB cap on the .docx payload after base64 encoding. The usual culprits when you hit that ceiling are high-resolution embedded images, embedded fonts, and OLE objects. Compress images through Word's Picture Format tools to around 150 DPI, reference fonts instead of embedding them, and drop OLE objects entirely. For templates that genuinely exceed the limit after optimization, split them into component templates and merge the outputs downstream.
Designing Templates for Dynamic, High-Volume Documents
A few token patterns cover the vast majority of document generation use cases. Master these and you can handle contracts, invoices, compliance reports, onboarding kits, and offer letters from a single template architecture. To see all of them together, download invoice_table.docx, which exercises every pattern below.
Simple field substitution is the baseline. Place {{fieldName}} anywhere in the Word document (inside a paragraph, in a table cell, in a header) and the API replaces it with the corresponding value from the JSON payload.
Formatting switches control how the engine renders dates and numbers. Append \@ MM/dd/yyyy to a date token or \# "$#,##0.00" to a numeric token, and the engine formats the output for you. In invoice_table.docx, {{ invoiceDate \@ MM/dd/yyyy }} turns 2026-01-15 into 01/15/2026, and {{ totalDue \# "$#,##0.00" }} turns 5700 into $5,700.00.
Table row iteration handles line items, variable-length lists, and repeating structures. Wrap a table row with {{TableStart:arrayName}} and {{TableEnd:arrayName}} placed in the same row, reference each object's fields with bare {{fieldName}} tokens inside that row, and pass an array of objects in the payload. The engine expands the table to one row per array element. invoice_table.docx uses this structure:
| # | {{description}} | {{qty}} | {{unitPrice \# "$#,##0.00"}} | {{lineTotal \# "$#,##0.00"}} |
| {{ROW_NUMBER}} | {{TableStart:lineItems}} ... | | | ... {{TableEnd:lineItems}} |
The {{ROW_NUMBER}} token auto-numbers each generated row, and {{=SUM(ABOVE) \# "$#,##0.00"}} in a cell below the table sums the column above it. Driving that template with this payload:
{
"outputFormat": "pdf",
"documentValues": {
"companyName": "Acme Corp",
"invoiceNumber": "INV-1042",
"invoiceDate": "2026-01-15",
"lineItems": [
{
"description": "Consulting",
"qty": "3",
"unitPrice": "1500",
"lineTotal": "4500"
},
{
"description": "Licensing",
"qty": "1",
"unitPrice": "1200",
"lineTotal": "1200"
}
],
"totalDue": "5700"
}
}
produces a two-row table (1 Consulting 3 $1,500.00 $4,500.00 and 2 Licensing 1 $1,200.00 $1,200.00), a computed subtotal of $5,700.00, and a Total Due of $5,700.00, all formatted by the template. A 47-item order produces 47 rows with no template changes. The lineItems array is the only part of the payload that varies between a one-item order and a fifty-item order.
The actual PDF returned by the DocGen API for the payload above. Row numbers, currency and date formatting, the expanded line-item loop, and the SUM(ABOVE) subtotal all resolved server-side from the Word template.
For variable content that should appear only sometimes, drive it from the data layer. An omitted or empty token renders as blank, and an empty array produces zero table rows, so you control which content surfaces by controlling what you put in documentValues rather than by embedding branching logic in the template.
The operational implication matters for engineering teams at scale. Template ownership can live entirely outside the application codebase, whether that's a Word file in version control, a shared drive with access controls, or a document library. Non-engineers can modify the template without triggering a deploy cycle. The application code owns JSON payload construction and the template file owns document structure, which is what keeps the system maintainable as document types multiply.
For versioning, the practical approach is environment-specific template files, such as contract-v2.docx in production and contract-v3.docx in staging for validation before promotion. When you encode the template as base64 at request time, the version is implicit in which file you read. In-flight pipeline runs hold a reference to whichever file was live when they started, so there's no race condition with a template swap mid-generation.
Post-Generation Processing: Flatten, Linearize, Compress, and Sign
Most pipelines accumulate technical debt here. Teams generate the PDF and consider the job done, then bolt on post-processing as an afterthought when compliance raises questions or storage costs spike. Build the processing stage into the architecture from the start.
The bridge between DocGen and PDF Services works like this. DocGen returns a base64 PDF string. Decode it back to bytes and upload it once to PDF Services via POST /pdf-services/api/documents/upload, a multipart form upload that returns a documentId. From that point, PDF Services jobs chain by passing one job's resultDocumentId as the next job's input documentId, with no further download and re-upload cycles between operations.
// Node.js: bridge DocGen output into PDF Services and chain jobs
const axios = require("axios");
const FormData = require("form-data");
const BASE = "https://na1.fusion.foxit.com/pdf-services/api";
const auth = {
client_id: process.env.FOXIT_CLIENT_ID,
client_secret: process.env.FOXIT_CLIENT_SECRET,
};
// Step 1: decode the base64 PDF from DocGen and upload it once as multipart
async function uploadDocument(base64Pdf) {
const form = new FormData();
form.append("file", Buffer.from(base64Pdf, "base64"), {
filename: "document.pdf",
contentType: "application/pdf",
});
const res = await axios.post(`${BASE}/documents/upload`, form, {
headers: { ...auth, ...form.getHeaders() },
});
return res.data.documentId;
}
// Step 2: kick off a flatten job that returns a taskId for async polling
async function flattenDocument(documentId) {
const res = await axios.post(
`${BASE}/documents/modify/pdf-flatten`,
{ documentId },
{ headers: { ...auth, "Content-Type": "application/json" } },
);
return res.data.taskId;
}
// Step 3: start a compress job on a result document, no re-upload needed
async function compressDocument(documentId, compressionLevel) {
const res = await axios.post(
`${BASE}/documents/modify/pdf-compress`,
{ documentId, compressionLevel },
{ headers: { ...auth, "Content-Type": "application/json" } },
);
return res.data.taskId;
}
// Step 4: poll GET /tasks/:taskId until COMPLETED or FAILED
async function waitForCompletion(taskId) {
while (true) {
const res = await axios.get(`${BASE}/tasks/${taskId}`, { headers: auth });
const { status, resultDocumentId } = res.data;
if (status === "COMPLETED") return resultDocumentId; // pass to next job
if (status === "FAILED") throw new Error(`Task ${taskId} failed`);
await new Promise((r) => setTimeout(r, 2000)); // 2s polling interval
}
}
// Full chain: upload -> flatten -> compress -> return final ID for eSign
async function processDocument(base64Pdf) {
const docId = await uploadDocument(base64Pdf);
const flatTaskId = await flattenDocument(docId);
const flatDocId = await waitForCompletion(flatTaskId);
const compressTaskId = await compressDocument(flatDocId, "MEDIUM");
return await waitForCompletion(compressTaskId);
}
In this code, you take the base64 PDF that DocGen returned, decode it into a binary Buffer, and push it to the upload endpoint as a multipart file (uploading the base64 string as JSON is rejected with a 415, since the endpoint expects form data). The upload returns a documentId. You start a flatten job against that ID, poll the task endpoint every two seconds until it reports COMPLETED, then feed the returned resultDocumentId straight into a compress job without re-uploading anything. The final resultDocumentId is what you hand to the eSign stage.
Three operations cover the core post-processing needs:
Flatten (
POST /documents/modify/pdf-flatten) merges form fields and annotations into static page content, baking every value in as rendered text or image with no interactive elements remaining. This is required for compliance archiving under standards like PDF/A, and for producing tamper-evident audit files. If your documents land in a regulatory audit, a flattened PDF is the format auditors expect.Linearize (
POST /documents/optimize/pdf-linearize), also called "Fast Web View," restructures the internal byte order of the PDF so the first page renders before the rest of the file downloads. For portals serving multi-page contracts or reports, this cuts perceived load time. A 40-page contract in Fast Web View starts rendering in the browser immediately rather than waiting for the full download. It's the operation most teams skip and then regret when users complain about document load times.Compress (
POST /documents/modify/pdf-compress) reduces file size without degrading visible quality and takes acompressionLevelofLOW,MEDIUM, orHIGH. For high-volume pipelines sending documents via email or storing thousands of files, the delta between an uncompressed 800 KB file and a MEDIUM-compressed 200 KB file compounds quickly at scale.
For the eSign handoff, download the final document from PDF Services with GET /pdf-services/api/documents/{documentId}/download, then submit it to the Foxit eSign API to create an envelope, assign signers, and trigger the signing workflow. Because eSign runs on a separate host (https://na1.foxitesign.foxit.com), you move the bytes across rather than passing a PDF Services document ID directly. After authenticating for an eSign access token, create a folder with the PDF attached as a base64 string and "inputType": "base64", assign signers, and send. Configure a webhook endpoint in your application and the eSign API posts events (viewed, signed, completed, declined) as they happen.
Error Handling and Monitoring in Production
Document pipelines fail in three distinct ways, and handling each correctly prevents silent data loss.
Data validation failures happen before any API call goes out. Malformed JSON, missing required token values, or structural mistakes produce generation errors that are deterministic and reproducible. Validate your data payload against a schema before sending it to the DocGen endpoint. If totalDue is null when the document needs a value, catch that in your application layer rather than discovering a blank field in the rendered output.
Template rendering errors occur when the Word template contains broken token syntax or structural inconsistencies the generation engine can't resolve, such as loop tokens split across different table rows. These typically surface during integration testing, not in production, which means they're preventable. Use the Foxit Analyze Document API (POST /document-generation/api/AnalyzeDocumentBase64) to scan a template programmatically before promoting it. It returns singleTagsString, a comma-separated list of the scalar tokens the engine detected, and doubleTagsString, the table or loop region names. Diff those against your expected token set to catch mismatches before they reach users.
Downstream chain failures are the most dangerous because they happen after a successful generation step. The PDF was created correctly, but the flatten job failed, or the eSign envelope creation returned a 400. Without explicit handling, the document disappears into limbo.
// Polling with timeout and dead-letter branching, use for every chained job
async function pollWithTimeout(taskId, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await axios.get(`${BASE}/tasks/${taskId}`, { headers: auth });
const { status, resultDocumentId, error } = res.data;
if (status === "COMPLETED") return resultDocumentId;
if (status === "FAILED") {
// Log taskId, full error payload, and upstream context before re-queuing
console.error(`Task ${taskId} FAILED:`, JSON.stringify(error));
throw new Error(`Processing failed: ${error?.message}`);
}
await new Promise((r) => setTimeout(r, 2000));
}
// Timeout: treat as failure, don't silently drop the document
throw new Error(`Task ${taskId} timed out after ${timeoutMs}ms`);
}
In this code, you cap the polling loop with a deadline so a stuck job can't block the pipeline forever. On COMPLETED you return the result document ID for the next stage, on FAILED you log the task ID and the full error payload before throwing, and on timeout you throw rather than returning silently, which guarantees a failed document raises a visible error instead of vanishing.
Log the task ID returned from every API call. When something fails, that ID is your audit trail into the Foxit API logs. Set timeout thresholds per stage, since a flatten job running for 60 seconds on a 10 KB file is stuck, not slow. Implement dead-letter logic for documents that fail post-generation processing, whether that means re-queuing with exponential backoff or alerting an operator. A failed post-processing step that silently drops a document is worse than a visible error.
Your Next Step: Run This End-to-End Today
The fastest way to validate the architecture is to get one call working before building the full pipeline.
Sign up at account.foxit.com/site/sign-up, where the free Developer plan takes about two minutes to configure and gives you immediate API access. Download invoice_simple.docx, base64-encode it, and call the DocGen endpoint with a JSON payload that fills companyName, invoiceNumber, invoiceDate, and totalDue. Decode the base64 response and open the PDF. That's the core loop validated in under 15 minutes, and Foxit's Postman collection in the developer portal makes it even faster to prototype before you write integration code.
From there, layer in the pipeline stages one at a time. Upload that PDF to PDF Services, run a flatten job, poll for completion, and verify the output. Then add the compress step. Then wire in the eSign handoff. Building incrementally means you know exactly where problems originate.
Foxit's developer portal has code samples in Node.js, Python, and cURL ready to copy. The free Developer plan gives you 500 shared credits per year to cover your entire exploration and POC phase.
What's the most brittle part of your current document generation setup, whether that's template management, rendering consistency, compliance archiving, or the eSign handoff? Drop your approach in the comments. The real-world variations in how teams solve this are usually where the genuinely interesting engineering decisions live.

Top comments (0)