<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lucien Chemaly</title>
    <description>The latest articles on DEV Community by Lucien Chemaly (@luciench).</description>
    <link>https://dev.to/luciench</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3497693%2F8f1c42e1-ea56-4bec-a159-e3709e8e8155.png</url>
      <title>DEV Community: Lucien Chemaly</title>
      <link>https://dev.to/luciench</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luciench"/>
    <language>en</language>
    <item>
      <title>Agentic Document Workflows: How to Wire PDF APIs into AI Agents That Actually Work in Production</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Tue, 28 Jul 2026 08:08:22 +0000</pubDate>
      <link>https://dev.to/luciench/agentic-document-workflows-how-to-wire-pdf-apis-into-ai-agents-that-actually-work-in-production-2gdn</link>
      <guid>https://dev.to/luciench/agentic-document-workflows-how-to-wire-pdf-apis-into-ai-agents-that-actually-work-in-production-2gdn</guid>
      <description>&lt;p&gt;The demo works. You send a PDF to &lt;a href="https://platform.openai.com/docs/models/gpt-4o" rel="noopener noreferrer"&gt;GPT-4o&lt;/a&gt;, ask it to extract line items, and it returns something that looks like JSON. Then you run it on 500 invoices in staging and watch it hallucinate table rows, drop page-two data entirely, and return different field names on identical documents. By the time you get to 10,000 PDFs a day in production, the failure rate makes the whole approach unusable.&lt;/p&gt;

&lt;p&gt;That's the prototype trap. Sending raw PDFs to a vision model gets you coherent prose, but it loses table structure, page coordinates, and element types. The output is nondeterministic, which means your downstream agent can't reliably route on field values, validate totals, or trigger conditional workflows. You end up with a pipeline that works in a notebook and breaks in a queue.&lt;/p&gt;

&lt;p&gt;The delta between a demo and a deployable agentic document workflow is almost entirely in how you handle the document layer. The &lt;a href="https://www.marketsandmarkets.com/Market-Reports/intelligent-document-processing-market-195513136.html" rel="noopener noreferrer"&gt;Document AI market is projected to grow from USD 14.66 billion in 2025 to USD 27.62 billion by 2030 at a 13.5% CAGR&lt;/a&gt;, and most of that investment is being made precisely because raw LLM extraction doesn't hold up under real operating conditions.&lt;/p&gt;

&lt;p&gt;A dedicated, deterministic PDF API layer handles structural extraction and document operations, while the LLM handles reasoning and routing. This article shows you how to build that, specifically how to wire &lt;a href="https://developer-api.foxit.com" rel="noopener noreferrer"&gt;Foxit's PDF Services API&lt;/a&gt; into an agent pipeline that's auditable, composable, and designed to run at volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Production Gap: Why LLMs Reading PDFs Break at Scale
&lt;/h2&gt;

&lt;p&gt;LLM-based PDF extraction fails in production because vision models reconstruct document structure from visual patterns rather than reading it deterministically. At scale, this produces hallucinated table rows, token cost explosion, schema drift between runs, and no replayable audit trail. A better prompt won't fix any of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hallucinated table rows:&lt;/strong&gt; When a vision model interprets a multi-column PDF table as image input, it reconstructs structure from visual patterns. On clean PDFs, this is often accurate. On anything with merged cells, rotated text, or embedded watermarks, the model invents rows or merges adjacent data. At 10,000 invoices per day, a 2% hallucination rate is 200 wrong records, and you won't know which ones.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token cost explosion:&lt;/strong&gt; A 10-page PDF converted to images for vision input can consume 2,000–4,000 tokens per page depending on resolution. Processing 10,000 documents daily at that rate pushes monthly costs into the tens of thousands of dollars for the extraction step alone, before you've done any reasoning.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No structured output contract:&lt;/strong&gt; The LLM returns whatever JSON shape seems plausible given the prompt. Field names drift between runs. Tables become arrays of strings on one call and arrays of objects on the next. Downstream validators fail silently because the schema wasn't enforced upstream.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No audit trail:&lt;/strong&gt; If a contract clause gets misclassified and triggers the wrong workflow, you have no way to replay what the model saw and why it decided what it did. Under &lt;a href="https://www.hhs.gov/hipaa/for-professionals/security/index.html" rel="noopener noreferrer"&gt;HIPAA&lt;/a&gt; or &lt;a href="https://www.aicpa-cima.com/resources/landing/system-and-organization-controls-soc-suite-of-services" rel="noopener noreferrer"&gt;SOC 2&lt;/a&gt;, "the model just got it wrong" isn't an acceptable incident response.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean architectural boundary fixes all four. The PDF API handles structured extraction and document operations deterministically, and the LLM operates on the typed output rather than the raw document. That separation is what makes an agentic document workflow actually workable.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What an Agentic Document Workflow Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;An agentic document workflow (ADW) differs from RAG and traditional IDP by maintaining state across multi-step operations, coordinating extraction, retrieval, structured output, and downstream actions in a single orchestrated loop. The agent decides, acts, then decides again based on what the action produced.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.llamaindex.ai/blog/introducing-agentic-document-workflows" rel="noopener noreferrer"&gt;LlamaIndex coined the term "Agentic Document Workflows" (ADW)&lt;/a&gt; to describe a pattern that goes beyond both RAG and traditional intelligent document processing. RAG retrieves text chunks and generates answers in one pass. Traditional IDP classifies and extracts, then routes to a rules-based engine. The agentic loop layers statefulness on top of both, which is what enables conditional routing, validation, and document generation inside the same pipeline.&lt;/p&gt;

&lt;p&gt;The document lifecycle an agent must own in this model spans five stages, from ingest (OCR + structural parse) through extract (typed elements), reason (LLM decision layer), and act (generate, redact, sign, or archive), to emit (append an event trace for auditability). Each stage has a clear owner.&lt;/p&gt;

&lt;p&gt;The mistake most teams make is mixing the LLM layer and the PDF API layer. The LLM should never see raw PDF bytes. It should receive typed JSON from the PDF API (table arrays, text blocks with bounding box coordinates, form field values) and make decisions on that structured data. The PDF API owns extraction. The LLM owns understanding.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptk29v2jAQxr_KKa82La3WvZnEi00pCTQUStQwaVqCKss-wJpjR7YDQ8B33-VPN6otr3Lnu5-f5y45BdwIDEYQbJQ58B2zHlZxqYGeqMjiCcSGNxVqv4abmy9wX3yrlWFiBNkyX4E30NbkaPeSo4MoS8tSi6ElFWDRN1ajWPfI-w4yLpJf3jLu_1JybxtOpUzBcCaNJpRn7ue_mHGHiYvMKDWCaUIIqgPnmW_cUBN3Nckp75JfL302abPn9Okle15On5M8P0N8fTJeLrJ5skriM0yK2Bx0b_ZHmsFB-h1d8UfnLF8-kcK7T4AKW7vgjzW6EBzfYcXg7vbj7edBzKQTMy3m8wVE27Z2zo5oqd0ic0aHwBVzTm6OIeyZkoJ5DMGaxuNAmHaEh1PUTWZw89BpnqJGSw1nSEkyp3DYQ6sQPkC8HH8Hj1WtqOh1ZYSuG7--5uRyq88wK7B9GRCc9FGTo4zUW9gYJdCGsEUPrBGSPFsm1RtMZPlO7knNY_Gfb2NDIjySYST2sfbk3FS1Rfe6t7QzOi-iukYtWrnJvhuY2V59Wi9ShCA1WaCBt1MVyKWT7STfOJv1tD54vA7mXbAo3vUX3RitjsNVuTcW36-DEIIKbcWkaH-QUxl4WiyWFJSBwA1rlC-DS3D5DU17DSY" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptk29v2jAQxr_KKa82La3WvZnEi00pCTQUStQwaVqCKss-wJpjR7YDQ8B33-VPN6otr3Lnu5-f5y45BdwIDEYQbJQ58B2zHlZxqYGeqMjiCcSGNxVqv4abmy9wX3yrlWFiBNkyX4E30NbkaPeSo4MoS8tSi6ElFWDRN1ajWPfI-w4yLpJf3jLu_1JybxtOpUzBcCaNJpRn7ue_mHGHiYvMKDWCaUIIqgPnmW_cUBN3Nckp75JfL302abPn9Okle15On5M8P0N8fTJeLrJ5skriM0yK2Bx0b_ZHmsFB-h1d8UfnLF8-kcK7T4AKW7vgjzW6EBzfYcXg7vbj7edBzKQTMy3m8wVE27Z2zo5oqd0ic0aHwBVzTm6OIeyZkoJ5DMGaxuNAmHaEh1PUTWZw89BpnqJGSw1nSEkyp3DYQ6sQPkC8HH8Hj1WtqOh1ZYSuG7--5uRyq88wK7B9GRCc9FGTo4zUW9gYJdCGsEUPrBGSPFsm1RtMZPlO7knNY_Gfb2NDIjySYST2sfbk3FS1Rfe6t7QzOi-iukYtWrnJvhuY2V59Wi9ShCA1WaCBt1MVyKWT7STfOJv1tD54vA7mXbAo3vUX3RitjsNVuTcW36-DEIIKbcWkaH-QUxl4WiyWFJSBwA1rlC-DS3D5DU17DSY" alt="Diagram" width="879" height="1623"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Core API Operations Agents Need
&lt;/h2&gt;

&lt;p&gt;A production-ready PDF API layer for agentic workflows requires four capabilities, namely deterministic structural extraction that returns typed JSON, template-driven document generation, compliance-grade security operations (flatten, encrypt, redact), and programmatic eSign triggering with a native audit trail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured extraction&lt;/strong&gt; is the most important. The API should combine OCR, layout recognition, and AI parsing to return typed, element-classified JSON rather than raw text. &lt;a href="https://app.developer-api.foxit.com/reference/tag/pdf-structural-extraction-trial" rel="noopener noreferrer"&gt;Foxit's PDF Structural Extraction API&lt;/a&gt; extracts twelve different element types (text blocks, tables, form fields, images, and more) and returns them as structured JSON, making the output directly consumable by a downstream LLM or validation step without post-processing. The endpoint is currently in Trial status at schema v1.0.7, so pin your parsers to the &lt;code&gt;version.schema&lt;/code&gt; field in the response. The schema can change between versions, and a breaking change will fail silently if you're not checking it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Document generation:&lt;/strong&gt; Agents that process documents also need to produce them. Foxit's &lt;a href="https://developer-api.foxit.com/document-generation/" rel="noopener noreferrer"&gt;DocGen API&lt;/a&gt; takes a base64-encoded DOCX template and a JSON data payload, merges them, and returns a PDF or DOCX. Before generation, the &lt;a href="https://app.developer-api.foxit.com/reference/tag/document-generation" rel="noopener noreferrer"&gt;&lt;code&gt;Analyze Document (Base64)&lt;/code&gt; endpoint&lt;/a&gt; inspects the template's merge fields, which means you can validate that your data payload covers all required placeholders before sending the generation request, rather than discovering missing fields in the output.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security operations:&lt;/strong&gt; Compliance archiving almost always requires flattening (merging annotations and form fields into a static layer), encryption, and sometimes redaction. These need to be callable as discrete API operations. The PDF Services API covers these as separate endpoints.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;eSign triggering:&lt;/strong&gt; If your workflow ends in a signature (contract approval, remittance confirmation, policy acknowledgment), the signing step needs to be a programmatic API call. The eSign API initiates signing workflows and maintains a native audit trail including signer identity, timestamps, and an activity history endpoint you can query. This is the one leg of the pipeline where Foxit provides audit data natively. The rest of the trace you build yourself (more on that in Section 5).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Wiring a PDF API into Your Agent: The REST Workflow Pattern
&lt;/h2&gt;

&lt;p&gt;Every Foxit PDF Services API call follows a four-step async pattern (upload, submit, poll, download) that applies uniformly to extraction, OCR, compression, and flattening. Internalize this loop once and you can implement any operation without relearning the integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;p&gt;To run the code in this section and the pipeline in Section 7, you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python 3.8+&lt;/a&gt; with &lt;a href="https://pip.pypa.io/en/stable/" rel="noopener noreferrer"&gt;pip&lt;/a&gt; and a &lt;a href="https://docs.python.org/3/library/venv.html" rel="noopener noreferrer"&gt;virtual environment&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/a&gt; library&lt;/li&gt;
&lt;li&gt;A code editor such as &lt;a href="https://code.visualstudio.com/" rel="noopener noreferrer"&gt;VS Code&lt;/a&gt; with the &lt;a href="https://marketplace.visualstudio.com/items?itemName=ms-python.python" rel="noopener noreferrer"&gt;Python extension&lt;/a&gt; (PyCharm or any editor works too)&lt;/li&gt;
&lt;li&gt;A free Foxit developer account (&lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;sign up here&lt;/a&gt;, no credit card required) for your &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set up the workspace in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;adw-pipeline &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;adw-pipeline
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_id"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_secret"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The four-step loop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;POST to upload the source document as &lt;code&gt;multipart/form-data&lt;/code&gt; to receive a &lt;code&gt;documentId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;POST to the operation endpoint (e.g., structural extraction) with the &lt;code&gt;documentId&lt;/code&gt; to receive a &lt;code&gt;taskId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GET to poll task status using the &lt;code&gt;taskId&lt;/code&gt; until status is &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GET to download the result by its &lt;code&gt;resultDocumentId&lt;/code&gt; (a ZIP holding the structural JSON for extraction, a processed PDF for everything else)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full extraction loop against the &lt;code&gt;https://na1.fusion.foxit.com&lt;/code&gt; base URL using &lt;code&gt;requests&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://na1.fusion.foxit.com/pdf-services/api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Upload a PDF and return its documentId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/documents/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Submit a structural extraction task and return the taskId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/documents/pdf-structural-extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# 202 Accepted
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_until_done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Poll task status until COMPLETED, then return the resultDocumentId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/tasks/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extraction failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; did not complete within &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;download_structure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_document_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Download the result ZIP and return the parsed structural JSON.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/documents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result_document_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;zf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StructureInfo.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;start_extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result_doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_until_done&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;download_structure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, you upload the source PDF to receive a &lt;code&gt;documentId&lt;/code&gt;, submit the structural extraction task against &lt;code&gt;documents/pdf-structural-extract&lt;/code&gt; (the API answers &lt;code&gt;202 Accepted&lt;/code&gt; with a &lt;code&gt;taskId&lt;/code&gt;), poll the task endpoint every 3 seconds until the status flips to &lt;code&gt;COMPLETED&lt;/code&gt;, then download the result document. The result arrives as a ZIP containing &lt;code&gt;StructureInfo.json&lt;/code&gt; (the typed structural output) plus a rendered PNG of each page, so &lt;code&gt;download_structure&lt;/code&gt; unpacks the archive in memory and returns the parsed JSON. A &lt;code&gt;FAILED&lt;/code&gt; status surfaces as a &lt;code&gt;RuntimeError&lt;/code&gt; instead of being silently retried.&lt;/p&gt;

&lt;p&gt;For DocGen, the same upload-submit-poll-download shape applies conceptually, though the Base64 endpoint used in Section 7 is synchronous and returns the rendered document in the response body.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scaling the async model:&lt;/strong&gt; The PDF Services API doesn't offer webhook callbacks. For high-volume workloads at 10,000+ PDFs per day, a poll loop that blocks your agent threads will become a bottleneck. Use a job queue (&lt;a href="https://aws.amazon.com/sqs/" rel="noopener noreferrer"&gt;SQS&lt;/a&gt;, &lt;a href="https://docs.celeryq.dev/en/stable/" rel="noopener noreferrer"&gt;Celery&lt;/a&gt;, &lt;a href="https://redis.io/docs/latest/develop/data-types/streams/" rel="noopener noreferrer"&gt;Redis Streams&lt;/a&gt;) where each worker owns the poll loop for its assigned task, then calls the agent's reasoning step only after the extraction result is available. The eSign API is the Foxit product with native webhook support for event-driven callbacks. PDF Services is async polling only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Event-Sourced AI: Building an Auditable Agent Trace
&lt;/h2&gt;

&lt;p&gt;Event sourcing solves the agentic pipeline audit problem by capturing every AI decision as an immutable, append-only log entry keyed to a stable document ID. Every processing step (extraction, validation, generation, signing) becomes a replayable event with typed inputs, the policy version that governed the decision, and the output.&lt;/p&gt;

&lt;p&gt;Consider a failure mode that won't surface until you're in an audit. An agent misclassified a contract clause as non-binding, triggered the wrong approval workflow, and now you need to demonstrate exactly what inputs the agent saw and what policy it applied. Without that capture, you're reconstructing from logs and guessing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://martinfowler.com/eaaDev/EventSourcing.html" rel="noopener noreferrer"&gt;Event sourcing&lt;/a&gt; captures every state change in a system as an immutable event appended to a log. For an agent pipeline, that means every AI decision step appends a record containing its inputs, the policy or prompt version used, and the output. The log is append-only and keyed by a stable &lt;code&gt;document_id&lt;/code&gt;. You can replay any document's processing history, re-run a specific step with a changed policy, and produce a per-document audit trail on demand.&lt;/p&gt;

&lt;p&gt;Foxit's task-based API architecture gives you clean event boundaries to log. Each operation returns a typed payload, a &lt;code&gt;taskId&lt;/code&gt;, and a status, and those are the natural checkpoints where you append events. The eSign API contributes a native audit trail for the signing leg (signer identity, timestamps, and a queryable activity history endpoint), which is the one part of the pipeline you don't have to instrument yourself.&lt;/p&gt;

&lt;p&gt;The rest of the trace is yours to build. A minimal event schema needs five fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;document_id&lt;/code&gt;: the stable key every event is grouped under&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;event_type&lt;/code&gt;: e.g., &lt;code&gt;extraction_completed&lt;/code&gt;, &lt;code&gt;validation_passed&lt;/code&gt;, &lt;code&gt;document_generated&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timestamp&lt;/code&gt;: when the event was appended&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;payload&lt;/code&gt;: the API response or LLM output for the step&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;policy_version&lt;/code&gt;: the prompt hash or policy ID that governed the decision&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write this to a &lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;Postgres&lt;/a&gt; table with &lt;code&gt;id SERIAL, document_id UUID, event_type TEXT, payload JSONB, policy_version TEXT, created_at TIMESTAMPTZ DEFAULT NOW()&lt;/code&gt;. That's enough to replay any document through the pipeline and produce a per-event audit log.&lt;/p&gt;

&lt;p&gt;A naive LLM pipeline leaves intermediate states opaque and non-replayable. A deterministic PDF API layer combined with an append-only event log links every decision to its source data so you can reconstruct it on demand. That's what makes &lt;a href="https://www.hhs.gov/hipaa/for-professionals/security/index.html" rel="noopener noreferrer"&gt;HIPAA&lt;/a&gt;, SOC 2, or &lt;a href="https://gdpr-info.eu/" rel="noopener noreferrer"&gt;GDPR&lt;/a&gt; audits tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. MCP as the Agent-Native Integration Layer
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol (MCP) exposes PDF API tools directly to LLM agent hosts (Claude Desktop, VS Code, Cursor) without custom wrapper code. Foxit's open-source MCP Server ships 35+ callable tools and authenticates via three environment variables, making it the fastest path from agent host to production PDF operations.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) is an emerging open standard for exposing tool APIs to LLM-based agents. The agent host calls tools by name, the MCP server handles the underlying API calls, and typed results come back ready for the agent's context window, without custom wrapper code.&lt;/p&gt;

&lt;p&gt;Foxit publishes an &lt;a href="https://github.com/foxitsoftware/foxit-pdf-api-mcp-server" rel="noopener noreferrer"&gt;open-source MCP Server&lt;/a&gt; that exposes the PDF Services API as 35+ callable tools. It ships in Python (&lt;a href="https://github.com/jlowin/fastmcp" rel="noopener noreferrer"&gt;FastMCP&lt;/a&gt; framework, Python 3.11+, &lt;a href="https://github.com/astral-sh/uv" rel="noopener noreferrer"&gt;uv&lt;/a&gt;) and TypeScript (&lt;a href="https://pnpm.io/" rel="noopener noreferrer"&gt;pnpm&lt;/a&gt;) variants, and authenticates via three environment variables (&lt;code&gt;FOXIT_CLOUD_API_CLIENT_ID&lt;/code&gt;, &lt;code&gt;FOXIT_CLOUD_API_CLIENT_SECRET&lt;/code&gt;, and &lt;code&gt;FOXIT_CLOUD_API_HOST&lt;/code&gt;). Any MCP-compatible host (&lt;a href="https://claude.ai/download" rel="noopener noreferrer"&gt;Claude Desktop&lt;/a&gt;, &lt;a href="https://code.visualstudio.com/docs/copilot/setup" rel="noopener noreferrer"&gt;VS Code with GitHub Copilot&lt;/a&gt;, &lt;a href="https://www.cursor.com/" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt;) can connect to it. If you're already using one of those hosts, you can call PDF tools without writing any wrapper code at all.&lt;/p&gt;

&lt;p&gt;There's a genuine tradeoff, though. MCP eliminates custom integration work by adding a protocol hop and some latency overhead. For latency-sensitive, high-volume pipelines at the scale described in Section 4, direct REST calls with your own job queue are the right call. Use MCP when you want to iterate fast or when your agent host is already MCP-native and the overhead doesn't matter.&lt;/p&gt;

&lt;p&gt;For full installation steps, the complete tool catalog, and host configuration for Claude Desktop and Cursor, see the dedicated guide, &lt;em&gt;Foxit MCP Server: Give AI Agents Direct Access to 30+ PDF Tools via Model Context Protocol&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Build This Today: A Minimal Agentic Invoice Processing Pipeline
&lt;/h2&gt;

&lt;p&gt;A production-ready agentic invoice processing pipeline takes six steps, starting from a free developer account and ending in an optional eSign approval, with every step appended to an audit log. It ingests a PDF invoice, extracts line items as structured JSON, validates totals with an LLM reasoning step, and generates a remittance PDF confirmation using the DocGen API. You can implement this against your own documents this week.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;Create a free Foxit developer account&lt;/a&gt;. The free Developer plan gives you 500 credits per year with no credit card required. Your Client ID and Client Secret are available immediately from the dashboard.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; POST your invoice PDF to the upload endpoint and capture the &lt;code&gt;documentId&lt;/code&gt;. If you don't have an invoice handy, use this &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_full_test.pdf" rel="noopener noreferrer"&gt;sample invoice PDF&lt;/a&gt;, the same document the output below was extracted from.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuq88ir357rmri08me9uq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuq88ir357rmri08me9uq.png" alt="Sample invoice PDF with a bill-to block, invoice metadata, and a five-column line item table with subtotal, tax, and total due rows" width="612" height="792"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The sample invoice the pipeline ingests. The line item table is what structural extraction returns as a typed &lt;code&gt;table&lt;/code&gt; element in Step 3.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step 3:&lt;/strong&gt; POST to the PDF Structural Extraction endpoint with the &lt;code&gt;documentId&lt;/code&gt;. Poll until &lt;code&gt;COMPLETED&lt;/code&gt;, then download the result ZIP and read &lt;code&gt;StructureInfo.json&lt;/code&gt; (the &lt;code&gt;extract_pdf&lt;/code&gt; function from Section 4 does all of this). The JSON types every element it detects, so the invoice's line item table arrives as a structured &lt;code&gt;table&lt;/code&gt; element with typed cells, not as a block of text you then need to parse. Here is the output for the sample invoice, trimmed to one cell per concept:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"analyzeResult"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"software"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FoxitPDFAnalyzer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"idp-analysis"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"pageNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;612&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;792&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"point"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"elements"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INVOICE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"style"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fontFamilyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Arial"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fontSize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;24.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"boundingBox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;189&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;71&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;189&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title1"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invoice Number: INV-2025-0042"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"boundingBox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;187&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;254&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;187&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;254&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.87&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph3"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"rowCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"columnCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"cells"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"paragraph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Description"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph7"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"rowIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"columnIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"rowSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"columnSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"boundingBox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;171&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;286&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;258&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;286&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;258&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;171&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"paragraph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$1,560.00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paragraph15"&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"rowIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"columnIndex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"rowSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"columnSpan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"boundingBox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;430&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;517&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;517&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;328&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;430&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;328&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"regions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"boundingBox"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;519&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;519&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"table1"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin your parser to &lt;code&gt;analyzeResult.version.schema&lt;/code&gt;. The schema is at v1.0.7 and the endpoint is in Trial. Version increments are your signal to check for breaking changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Pass the table JSON to your LLM agent. The prompt becomes a structured data validation task ("do the line item totals equal the invoice total?") rather than an OCR interpretation task. The LLM operates on typed data, not image pixels.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; POST the validated JSON plus a DOCX remittance template to the DocGen &lt;code&gt;Generate Document (Base64)&lt;/code&gt; endpoint. A ready-made &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/remittance_confirmation.docx" rel="noopener noreferrer"&gt;remittance template&lt;/a&gt;, verified end-to-end against the live API, matches the sample invoice's data.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjbf3l5byz58xrdkjugv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcjbf3l5byz58xrdkjugv.png" alt="Word remittance template showing scalar merge tokens for payer and payment fields plus a TableStart/TableEnd line_items loop with ROW_NUMBER and currency picture strings" width="800" height="1027"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The remittance template's merge tokens. Note that &lt;code&gt;{{TableStart:line_items}}&lt;/code&gt; and &lt;code&gt;{{TableEnd:line_items}}&lt;/code&gt; sit in the same table row, which is required for the loop to render.&lt;/em&gt; Run the &lt;code&gt;Analyze Document (Base64)&lt;/code&gt; endpoint first to confirm your template's merge fields match your data payload, and keep the template under the endpoint's 4 MB post-base64 cap (compress embedded images in Word's Picture Format pane if you hit it). Receive the output PDF.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe10lif77jygv3qwquhif.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe10lif77jygv3qwquhif.png" alt="Rendered remittance advice PDF with payer, remittance ID, invoice number, payment details, and a two-row line item table populated by the DocGen API" width="612" height="792"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The remittance PDF generated from the template and the validated invoice data, rendered by the live DocGen API. Your Step 5 output should match this.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step 6 (optional):&lt;/strong&gt; POST to the eSign API to route the remittance for approval. The API creates a signing folder, notifies signers, and maintains the activity history audit trail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every step in this pipeline produces a discrete event with a typed payload. Append each one to your audit log. You end up with a complete, replayable record of what was extracted, what the agent decided, and what was generated, for every document that passes through the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is an agentic document workflow, and how does it differ from RAG?&lt;/strong&gt;&lt;br&gt;
An agentic document workflow (ADW) maintains state across multi-step operations and coordinates document processing, retrieval, structured output, and downstream actions in a single orchestrated loop. RAG retrieves text and generates a response in one pass. An ADW agent decides, acts, and then decides again based on what each action produced, enabling conditional routing, validation, and document generation within the same pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does sending raw PDFs to a vision model fail at scale?&lt;/strong&gt;&lt;br&gt;
Vision models reconstruct document structure from visual patterns rather than reading it deterministically. At 10,000 documents per day, even a 2% hallucination rate produces 200 wrong records, and you won't know which ones. Field name drift between runs breaks downstream validators, and there's no replayable audit trail when a misclassification triggers the wrong workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does a dedicated PDF API layer fix the production delta?&lt;/strong&gt;&lt;br&gt;
A dedicated PDF API like Foxit's PDF Services API extracts structure deterministically, returning typed JSON with twelve element types, bounding boxes, and form field values, rather than reconstructing it from pixels. The LLM then operates on that typed output rather than raw bytes, making the extraction step auditable, schema-consistent, and cost-predictable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the async polling pattern in the Foxit PDF Services API?&lt;/strong&gt;&lt;br&gt;
Every operation follows the same four-step loop. POST to upload the document (returns a &lt;code&gt;documentId&lt;/code&gt;), POST to the operation endpoint (returns a &lt;code&gt;taskId&lt;/code&gt;), GET to poll status until &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;, then GET to download the result by its &lt;code&gt;resultDocumentId&lt;/code&gt;. This pattern applies uniformly across extraction, OCR, compression, and flattening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When should I use the Foxit MCP Server instead of direct REST calls?&lt;/strong&gt;&lt;br&gt;
Use the MCP Server when you're already in an MCP-native host (Claude Desktop, Cursor, VS Code with Copilot) and want to call PDF tools without writing wrapper code. Use direct REST calls with a job queue (SQS, Celery, Redis Streams) for latency-sensitive, high-volume pipelines where the protocol hop overhead matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I build an auditable trace for an AI document pipeline?&lt;/strong&gt;&lt;br&gt;
Implement event sourcing, where every AI decision step appends an immutable record to an append-only log containing &lt;code&gt;document_id&lt;/code&gt;, &lt;code&gt;event_type&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;payload&lt;/code&gt;, and &lt;code&gt;policy_version&lt;/code&gt;. Foxit's task-based API provides clean event boundaries, and each &lt;code&gt;taskId&lt;/code&gt; response is a natural checkpoint. The eSign API contributes a native audit trail for the signing leg. The rest you instrument yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the Foxit PDF Structural Extraction API schema change between versions?&lt;/strong&gt;&lt;br&gt;
Yes. The endpoint is currently in Trial status at schema v1.0.7, and the schema can change between version increments. Always pin your parsers to the &lt;code&gt;analyzeResult.version.schema&lt;/code&gt; field in the response and check the &lt;a href="https://app.developer-api.foxit.com/reference/tag/pdf-structural-extraction-trial" rel="noopener noreferrer"&gt;Trial API reference&lt;/a&gt; when you see a version bump, or a breaking change will fail silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The delta between a working demo and a production agentic document workflow comes down to one architectural decision. Does your PDF layer produce deterministic, typed output? A vision model reconstructing structure from pixels doesn't scale. A dedicated extraction layer does.&lt;/p&gt;

&lt;p&gt;Foxit's PDF Services API, DocGen API, and eSign API cover the full document lifecycle (extraction, generation, security operations, and signing) through a consistent REST pattern your agents can call reliably at volume. Add an append-only event log on top of those clean API boundaries and you have the auditability story that compliance teams and incident response actually require.&lt;/p&gt;

&lt;p&gt;To test the extraction-to-generation pipeline against your own documents, &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;create a free Foxit developer account&lt;/a&gt; (500 credits, no credit card required). The full API reference is at &lt;a href="https://developer-api.foxit.com" rel="noopener noreferrer"&gt;developer-api.foxit.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What document type are you trying to automate in your current pipeline, and which step is giving you the most friction? Drop it in the comments.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>ai</category>
      <category>programming</category>
      <category>agents</category>
    </item>
    <item>
      <title>Building Event-Driven Manufacturing Microservices with Redpanda Connect</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:06:51 +0000</pubDate>
      <link>https://dev.to/luciench/building-event-driven-manufacturing-microservices-with-redpanda-connect-4e9</link>
      <guid>https://dev.to/luciench/building-event-driven-manufacturing-microservices-with-redpanda-connect-4e9</guid>
      <description>&lt;p&gt;Modern manufacturing environments generate an immense stream of events from programmable logic controllers (PLCs), supervisory control and data acquisition (SCADA) systems, edge sensors, and quality inspection devices. On the shop floor, &lt;a href="https://mqtt.org/" rel="noopener noreferrer"&gt;Message Queuing Telemetry Transport (MQTT)&lt;/a&gt; has become the dominant protocol for moving this telemetry between devices and systems. Its publish-subscribe model lets sensors push data to central brokers without needing to know who consumes it, and its lightweight design keeps memory and CPU usage low enough to run on the most constrained embedded hardware.&lt;/p&gt;

&lt;p&gt;Collecting events, however, only solves part of the problem. The data still needs to be interpreted as it arrives. I wanted to see if you could catch a quality breach or a bearing about to fail the moment the reading came in, using nothing but off-the-shelf MQTT and Redpanda Connect and no custom middleware. Batch-oriented systems and periodic ERP polling are fine for a lot of manufacturing use cases, but not for that.&lt;/p&gt;

&lt;p&gt;In this tutorial, I'll show you how to build a complete, runnable pipeline that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Simulates sensor telemetry and publishes it via MQTT.
&lt;/li&gt;
&lt;li&gt;Ingests those events into &lt;a href="https://www.redpanda.com/" rel="noopener noreferrer"&gt;Redpanda&lt;/a&gt; through &lt;a href="https://www.redpanda.com/connect" rel="noopener noreferrer"&gt;Redpanda Connect&lt;/a&gt;, using its built-in MQTT input.
&lt;/li&gt;
&lt;li&gt;Routes enriched events to domain-specific Redpanda topics.
&lt;/li&gt;
&lt;li&gt;Powers three Python microservices (quality SPC, predictive maintenance, and a live dashboard) that consume events and produce alerts.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before you begin, make sure you have the following installed and running:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;th&gt;Check Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.docker.com/products/docker-desktop/" rel="noopener noreferrer"&gt;Docker Desktop&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Includes Docker Engine and Docker Compose. Make sure it is running before you start&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;3.10 or later&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python3 --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pip.pypa.io/en/stable/installation/" rel="noopener noreferrer"&gt;pip&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Comes with Python, used to install project dependencies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip3 --version&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;macOS note:&lt;/strong&gt; Port 5000 is often used by AirPlay Receiver. The dashboard service defaults to port 5050 to avoid this conflict. If you want to use a different port, set &lt;code&gt;HTTP_PORT&lt;/code&gt; when starting the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding the Role of Microservices in Modern Manufacturing
&lt;/h2&gt;

&lt;p&gt;Traditional monolithic manufacturing execution systems (MES) combine quality control, machine management, job scheduling, and reporting into a single application. When high-frequency telemetry must be processed in milliseconds, these monoliths hit their limits. If you integrate a new sensor on a specific line, you have to fully redeploy the entire application. A bug in the new integration can cascade into unrelated functions.&lt;/p&gt;

&lt;p&gt;Microservices address this by decoupling the monolith into independent, specialized services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;quality service&lt;/strong&gt; for measurements and &lt;a href="https://en.wikipedia.org/wiki/Statistical_process_control" rel="noopener noreferrer"&gt;SPC&lt;/a&gt; tolerances.
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;maintenance service&lt;/strong&gt; for predictive analytics on vibration and temperature data.
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;dashboard service&lt;/strong&gt; for real-time KPIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is developed, deployed, and scaled independently. The quality team can ship a new surface-inspection algorithm while the maintenance team simultaneously improves a bearing-wear model, with no shared deployment windows and no cross-team regression risk.&lt;/p&gt;

&lt;p&gt;These advantages hinge on &lt;strong&gt;real-time event processing&lt;/strong&gt;. When a quality measurement falls outside SPC limits, the system must alert operators and potentially halt the line within seconds, before hundreds of defective parts are produced. Delays of even a few minutes, typical of batch processing or periodic database queries, can result in costly scrap and machine damage.&lt;/p&gt;

&lt;p&gt;Redpanda enables real-time event processing, designed for high-throughput, low-latency workloads. MQTT data flows through Redpanda Connect into topics where microservices consume it independently, creating a standardized ingestion path that eliminates point-to-point integrations. New services simply subscribe to the relevant topics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fla8njj5y4sv61fn4r45n.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fla8njj5y4sv61fn4r45n.jpg" alt=" " width="800" height="249"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MQTT for Shop-Floor Ingestion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.techtarget.com/searchnetworking/definition/edge-device" rel="noopener noreferrer"&gt;Edge devices&lt;/a&gt; and &lt;a href="https://www.unitronicsplc.com/what-is-plc-programmable-logic-controller/" rel="noopener noreferrer"&gt;PLCs&lt;/a&gt; often have limited computing capacity and memory. MQTT requires minimal resources and runs efficiently on even the most constrained hardware. This is important when thousands of sensors transmit data simultaneously across a factory floor.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mqtt.org/mqtt-specification/" rel="noopener noreferrer"&gt;MQTT's publish/subscribe model&lt;/a&gt; fits event-driven architecture naturally. Sensors publish readings to hierarchical topics such as &lt;code&gt;factory/line1/machine-01/temperature&lt;/code&gt; or &lt;code&gt;factory/line1/machine-02/vibration&lt;/code&gt; without knowing which services consume the data. Adding a new analytics service doesn't require a sensor reconfiguration because the service simply subscribes to the relevant topic tree. &lt;/p&gt;

&lt;p&gt;MQTT's quality-of-service levels guarantee delivery even after temporary outages, which is essential in environments where metal structures and electromagnetic interference frequently disrupt connectivity.&lt;/p&gt;

&lt;p&gt;Redpanda Connect provides a built-in &lt;a href="https://docs.redpanda.com/redpanda-connect/components/inputs/mqtt/" rel="noopener noreferrer"&gt;MQTT input&lt;/a&gt; that pulls telemetry directly into Redpanda topics. Sensor data flows into the central event backbone without additional middleware or custom code, and no intermediate databases or REST APIs slow down the data path. Some deployments use MQTT-native brokers built on Redpanda (e.g., &lt;a href="https://waterstream.io/" rel="noopener noreferrer"&gt;Waterstream&lt;/a&gt;) to consolidate broker and streaming layers for multi-factory setups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Event Modeling and Stream Processing Patterns
&lt;/h2&gt;

&lt;p&gt;Before writing any code, it helps to think about how topics, payloads, and delivery guarantees shape the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designing Topics
&lt;/h3&gt;

&lt;p&gt;Topic structure directly influences performance and maintainability. There are two common strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Physical hierarchy&lt;/strong&gt; (&lt;code&gt;factory/line1/machine-01/temperature&lt;/code&gt;) is used when services focus on specific equipment or locations. A line-specific dashboard subscribes to &lt;code&gt;factory/line1/#&lt;/code&gt; to receive only relevant data.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functional domains&lt;/strong&gt; (&lt;code&gt;quality.measurements&lt;/code&gt;, &lt;code&gt;maintenance.telemetry&lt;/code&gt;) are used when services focus on business functions. A quality service analyzing defects across all lines subscribes to a single topic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used physical hierarchy for MQTT ingestion here since it matches how the simulator already publishes data, then let Redpanda Connect's routing logic fan events out into functional Redpanda topics for the services that consume them.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.json.org/" rel="noopener noreferrer"&gt;JSON&lt;/a&gt; format is human-readable and easy to debug. For production environments generating millions of events daily, &lt;a href="https://avro.apache.org/" rel="noopener noreferrer"&gt;Avro&lt;/a&gt; with &lt;a href="https://docs.redpanda.com/current/manage/schema-reg/" rel="noopener noreferrer"&gt;Redpanda's Schema Registry&lt;/a&gt; offers schema evolution and compact serialization. Before a producer can publish data with a changed schema, the registry validates backward compatibility, preventing deployments that break downstream consumers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition keys:&lt;/strong&gt; Using &lt;code&gt;machine_id&lt;/code&gt; as the message key ensures all events from a given machine land in the same partition, preserving ordering for time-dependent analyses like trend calculations and SPC windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stream Processing Patterns
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Windowed aggregation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Continuous KPI calculation&lt;/td&gt;
&lt;td&gt;A five-minute rolling window computes machine utilization for OEE dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complex event processing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anomaly detection from correlated streams&lt;/td&gt;
&lt;td&gt;Temperature rising above baseline &lt;em&gt;while&lt;/em&gt; vibration increases triggers a predictive maintenance alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enrichment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adding business context to raw telemetry&lt;/td&gt;
&lt;td&gt;A compacted topic containing machine specifications provides tolerance limits without database lookups&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Beyond processing patterns, teams must also decide how events are delivered and acknowledged across the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Delivery Semantics Trade-offs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;At-least-once:&lt;/strong&gt; No events are lost; duplicates are possible. Choose this for monitoring and alerting where a duplicate notification is acceptable, but missing a warning is not.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exactly-once:&lt;/strong&gt; Each event is processed exactly once, at the cost of higher latency and resource consumption. Use this for inventory counts, production totals, or financial calculations where duplicates skew results.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event replay:&lt;/strong&gt; Redpanda retains events with configurable retention (commonly 7–30 days). Engineers can reprocess historical events for root-cause analysis of intermittent failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The end-to-end pipeline looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mrbf3cl9p2bxpw02vb4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mrbf3cl9p2bxpw02vb4.png" alt=" " width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;

&lt;p&gt;To understand the project structure, clone the repository and look at the layout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash git clone https://github.com/See4Devs/event-driven-redpanda.git cd event-driven-redpanda&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repository contains three infrastructure configuration files at the root and four service directories, each with its own Python entry point and dependencies:&lt;/p&gt;

&lt;p&gt;| &lt;code&gt;event-driven-redpanda/ |-- docker-compose.yml          # Orchestrates Redpanda, Mosquitto, Connect, Console |-- mosquitto.conf              # Mosquitto broker config (anonymous access for dev) |-- connect.yaml                # Redpanda Connect MQTT-to-Redpanda pipeline | |-- sensor_simulator/ |   |-- simulator.py            # Publishes fake MQTT telemetry for 3 machines |   +-- requirements.txt        # paho-mqtt | |-- quality_service/ |   |-- service.py              # SPC rolling-window breach detection -&amp;gt; alerts |   +-- requirements.txt        # confluent-kafka | |-- maintenance_service/ |   |-- service.py              # Vibration threshold anomaly detection -&amp;gt; alerts |   +-- requirements.txt        # confluent-kafka | +-- dashboard_service/     |-- service.py              # Last-known-state HTTP API (Flask)     +-- requirements.txt        # confluent-kafka, flask&lt;/code&gt; |&lt;br&gt;
| :---- |&lt;/p&gt;

&lt;p&gt;Here is what each component does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/strong&gt;: Defines the infrastructure: a single-node Redpanda broker, the Redpanda Console web UI, an &lt;a href="https://mosquitto.org/" rel="noopener noreferrer"&gt;Eclipse Mosquitto&lt;/a&gt; MQTT broker, a Redpanda Connect pipeline container, and a one-shot topic-setup container that creates the six Redpanda topics on startup.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mosquitto.conf&lt;/code&gt;&lt;/strong&gt;: Minimal Mosquitto configuration that listens on port 1883 with anonymous access (suitable for local development).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connect.yaml&lt;/code&gt;&lt;/strong&gt;: Declarative Redpanda Connect pipeline. Subscribes to MQTT topic &lt;code&gt;factory/line1/#&lt;/code&gt;, enriches payloads with ingestion metadata, and routes events to domain-specific Redpanda topics based on &lt;code&gt;sensor_type&lt;/code&gt; using Bloblang expressions.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sensor_simulator/&lt;/code&gt;&lt;/strong&gt;: Python script that publishes fake telemetry for three machines (&lt;code&gt;machine-01&lt;/code&gt;, &lt;code&gt;machine-02&lt;/code&gt;, &lt;code&gt;machine-03&lt;/code&gt;), each with three sensor types (temperature, vibration, pressure), to MQTT topics following the pattern &lt;code&gt;factory/line1/&amp;lt;machine_id&amp;gt;/&amp;lt;sensor_type&amp;gt;&lt;/code&gt;. A five percent  anomaly injection probability simulates real-world spikes.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;quality_service/&lt;/code&gt;&lt;/strong&gt;: Consumes temperature and pressure events from &lt;code&gt;quality.measurements&lt;/code&gt;, maintains a per-machine, per-sensor rolling window (default 30 readings), and publishes alerts to &lt;code&gt;quality.alerts&lt;/code&gt; when a reading breaches the mean +/- 3 sigma threshold (simplified Western Electric Rule 1).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maintenance_service/&lt;/code&gt;&lt;/strong&gt;: Consumes vibration events from &lt;code&gt;maintenance.telemetry&lt;/code&gt; and applies threshold-based anomaly detection: WARNING when the rolling average exceeds 3.0 mm/s, CRITICAL when any single reading exceeds 5.0 mm/s. Publishes alerts to &lt;code&gt;maintenance.alerts&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dashboard_service/&lt;/code&gt;&lt;/strong&gt;: Consumes from all measurement and alert topics to build a last-known-state view of every machine in memory (the "digital twin" pattern), then serves it over a &lt;a href="https://flask.palletsprojects.com/" rel="noopener noreferrer"&gt;Flask&lt;/a&gt; HTTP API on port 5050.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Building the Pipeline: Step by Step
&lt;/h2&gt;

&lt;p&gt;With the project cloned and the file layout in place, you can now walk through each component from infrastructure to application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install Python Dependencies
&lt;/h3&gt;

&lt;p&gt;From the project root, install the dependencies for all four services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash pip3 install -r sensor_simulator/requirements.txt \              -r quality_service/requirements.txt \              -r maintenance_service/requirements.txt \              -r dashboard_service/requirements.txt&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This installs &lt;a href="https://pypi.org/project/paho-mqtt/" rel="noopener noreferrer"&gt;&lt;code&gt;paho-mqtt&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://docs.confluent.io/kafka-clients/python/current/overview.html" rel="noopener noreferrer"&gt;&lt;code&gt;confluent-kafka&lt;/code&gt;&lt;/a&gt;, and &lt;a href="https://flask.palletsprojects.com/" rel="noopener noreferrer"&gt;&lt;code&gt;flask&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Start the Infrastructure
&lt;/h3&gt;

&lt;p&gt;Start all containers in the background:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash docker compose up -d&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This brings up five containers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Container&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Exposed Port&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redpanda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kafka-compatible streaming broker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;19092&lt;/code&gt; (Kafka API), &lt;code&gt;18082&lt;/code&gt; (HTTP Proxy), &lt;code&gt;18081&lt;/code&gt; (Schema Registry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redpanda-console&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web UI for inspecting topics and messages&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mosquitto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MQTT broker for sensor ingestion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1883&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redpanda-connect&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MQTT-to-Redpanda pipeline&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;topic-setup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One-shot container that creates Redpanda topics, then exits&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wait a few seconds for the healthchecks to pass, then verify the topics exist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash docker exec redpanda rpk topic list&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You should see all six topics:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;NAME                   PARTITIONS  REPLICAS dashboard.state        3           1 maintenance.alerts     1           1 maintenance.telemetry  3           1 quality.alerts         1           1 quality.measurements   3           1 sensor.raw             3           1&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;sensor.raw&lt;/code&gt; topic will remain empty during this tutorial. The simulator only produces temperature, vibration, and pressure readings, all of which are explicitly routed to &lt;code&gt;quality.measurements&lt;/code&gt; or &lt;code&gt;maintenance.telemetry&lt;/code&gt;. The &lt;code&gt;sensor.raw&lt;/code&gt; topic acts as a catch-all for any unrecognized sensor types, ensuring unknown events are captured rather than dropped.&lt;/p&gt;

&lt;p&gt;You can also open Redpanda Console in your browser to visually inspect topics and messages as they flow through the system. See the &lt;a href="https://docs.redpanda.com/current/reference/console/" rel="noopener noreferrer"&gt;Redpanda Console documentation&lt;/a&gt; for a full feature overview.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understand the Redpanda Connect Pipeline
&lt;/h3&gt;

&lt;p&gt;Before starting the simulator, take a moment to review the &lt;code&gt;connect.yaml&lt;/code&gt; file that is already running inside the &lt;code&gt;redpanda-connect&lt;/code&gt; container. Here it is in full:&lt;/p&gt;

&lt;p&gt;| &lt;code&gt;yaml input:   mqtt:     urls:       - tcp://mosquitto:1883     topics:       - "factory/line1/#"     client_id: "redpanda-connect-ingest"     qos: 1 pipeline:   processors:     # Step 1: Parse the raw JSON payload     - mapping: |         root = this     # Step 2: Add ingestion metadata     - mapping: |         root = this         root.ingested_at = now()         root.pipeline = "redpanda-connect"     # Step 3: Route to topic based on sensor_type     - mapping: |         let st = this.sensor_type.lowercase()         root = this         meta target_topic = match $st {           "temperature" =&amp;gt; "quality.measurements",           "pressure"    =&amp;gt; "quality.measurements",           "vibration"   =&amp;gt; "maintenance.telemetry",           _             =&amp;gt; "sensor.raw",         } output:   kafka:     addresses:       - redpanda:9092     topic: ${! meta("target_topic") }     key: ${! json("machine_id") }     partitioner: fnv1a_hash     max_in_flight: 64&lt;/code&gt; |&lt;br&gt;
| :---- |&lt;/p&gt;

&lt;p&gt;Here are the key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;input.mqtt.topics&lt;/code&gt; uses &lt;code&gt;factory/line1/#&lt;/code&gt;. The &lt;code&gt;#&lt;/code&gt; is MQTT's multi-level wildcard, subscribing to all sensors on line 1 regardless of machine or sensor type.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Pipeline.processors&lt;/code&gt; uses &lt;a href="https://docs.redpanda.com/redpanda-connect/guides/bloblang/about/" rel="noopener noreferrer"&gt;Bloblang&lt;/a&gt; mapping expressions. The &lt;code&gt;match&lt;/code&gt; expression routes temperature/pressure events to &lt;code&gt;quality.measurements&lt;/code&gt;, vibration events to &lt;code&gt;maintenance.telemetry&lt;/code&gt;, and everything else to &lt;code&gt;sensor.raw&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meta target_topic&lt;/code&gt; sets a metadata field that the &lt;code&gt;output.kafka.topic&lt;/code&gt; field interpolates with &lt;code&gt;${! meta("target_topic") }&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.redpanda.com/redpanda-connect/components/outputs/kafka/" rel="noopener noreferrer"&gt;&lt;code&gt;output.kafka&lt;/code&gt;&lt;/a&gt; points at &lt;code&gt;redpanda:9092&lt;/code&gt; because Redpanda is &lt;a href="https://docs.redpanda.com/current/develop/kafka-clients/" rel="noopener noreferrer"&gt;Kafka API&lt;/a&gt;-compatible. Standard Kafka clients and Redpanda Connect's Kafka output work directly against Redpanda without modification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The following diagram shows how MQTT topics flow through the Redpanda Connect pipeline and get routed to their respective Redpanda topics:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcs4n953ffh5v74f5onei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcs4n953ffh5v74f5onei.png" alt=" " width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Simulate Sensor Telemetry
&lt;/h3&gt;

&lt;p&gt;The sensor simulator publishes fake readings for three machines, each with three sensor types (temperature, vibration, pressure). Run it from the project root:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash python3 sensor_simulator/simulator.py&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your output will look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;Sensor simulator connected to localhost:1883 Publishing every 2.0s for machines: ['machine-01', 'machine-02', 'machine-03']   -&amp;gt; factory/line1/machine-01/temperature: 72.34 celsius   -&amp;gt; factory/line1/machine-01/vibration: 1.45 mm_s   -&amp;gt; factory/line1/machine-01/pressure: 5.12 bar   -&amp;gt; factory/line1/machine-02/temperature: 71.89 celsius&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each message is a JSON payload with this schema:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;json {   "event_id": "a1b2c3d4-...",   "machine_id": "machine-01",   "sensor_type": "temperature",   "value": 72.34,   "unit": "celsius",   "timestamp": "2025-01-15T10:30:00.000Z",   "line": "line1",   "factory": "factory-north" }&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Open Redpanda Console and navigate to the &lt;strong&gt;Topics&lt;/strong&gt; tab. You should see messages flowing into &lt;code&gt;quality.measurements&lt;/code&gt; and &lt;code&gt;maintenance.telemetry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Leave the simulator running in its own terminal window. Open new terminal windows for the following services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run the Quality SPC Service
&lt;/h3&gt;

&lt;p&gt;Open a new terminal. This microservice consumes temperature and pressure events from &lt;code&gt;quality.measurements&lt;/code&gt;, maintains a rolling window per machine per sensor, and publishes an alert to &lt;code&gt;quality.alerts&lt;/code&gt; when a reading breaches the mean +/- 3 sigma threshold (simplified &lt;a href="https://en.wikipedia.org/wiki/Western_Electric_rules" rel="noopener noreferrer"&gt;Western Electric Rule 1&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash python3 quality_service/service.py&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Output:&lt;/p&gt;

&lt;p&gt;| &lt;code&gt;Quality SPC service started  |  broker=localhost:19092   consuming: quality.measurements  -&amp;gt;  alerting: quality.alerts   window=30  sigma=3.0   &amp;lt;- temperature   | machine-01 | value=72.34   &amp;lt;- pressure      | machine-02 | value=5.12   ALERT  machine-03/temperature  value=92.15  z=3.41&lt;/code&gt; |&lt;br&gt;
| :---- |&lt;/p&gt;

&lt;p&gt;When the simulator injects an anomalous spike (five percent probability), the SPC check fires and produces an alert event:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;json {   "alert_id": "...",   "alert_type": "SPC_BREACH",   "machine_id": "machine-03",   "sensor_type": "temperature",   "value": 92.15,   "mean": 72.01,   "stdev": 1.48,   "z_score": 3.41,   "sigma_threshold": 3.0,   "window_size": 30,   "timestamp": "2025-01-15T10:31:05.000Z" }&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Run the Predictive Maintenance Service
&lt;/h3&gt;

&lt;p&gt;Open another terminal. This service consumes vibration events from &lt;code&gt;maintenance.telemetry&lt;/code&gt; and applies threshold-based anomaly detection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WARNING:&lt;/strong&gt; rolling average exceeds 3.0 mm/s.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRITICAL:&lt;/strong&gt; any single reading exceeds 5.0 mm/s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I picked 3.0/5.0 mm/s somewhat arbitrarily for this demo, but in a real deployment you'd tune these against your specific machine baselines. In a production system, this calls an ML inference endpoint. The threshold approach keeps the tutorial self-contained.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash python3 maintenance_service/service.py&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Output:&lt;/p&gt;

&lt;p&gt;| &lt;code&gt;Maintenance service started  |  broker=localhost:19092   consuming: maintenance.telemetry  -&amp;gt;  alerting: maintenance.alerts   warn=3.0 mm/s  critical=5.0 mm/s   &amp;lt;- vibration | machine-01 | value=1.23 mm/s   &amp;lt;- vibration | machine-02 | value=1.10 mm/s   CRITICAL  machine-03  value=5.24  avg=1.45&lt;/code&gt; |&lt;br&gt;
| :---- |&lt;/p&gt;

&lt;h3&gt;
  
  
  Run the Dashboard Service
&lt;/h3&gt;

&lt;p&gt;Open one more terminal. The dashboard service materializes the last-known state of every machine by consuming all measurement and alert topics, then exposes an HTTP API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash python3 dashboard_service/service.py&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once it's running, query it:&lt;/p&gt;

&lt;p&gt;| &lt;code&gt;bash # All machines and their latest sensor readings curl -s http://localhost:5050/machines | python3 -m json.tool # Detail for a specific machine curl -s http://localhost:5050/machines/machine-01 | python3 -m json.tool # Recent alerts across all machines curl -s http://localhost:5050/alerts | python3 -m json.tool&lt;/code&gt; |&lt;br&gt;
| :---- |&lt;/p&gt;

&lt;p&gt;Here's an example response from &lt;code&gt;/machines&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;json {   "machine-01": {     "temperature": { "value": 72.34, "unit": "celsius", "timestamp": "...", "topic": "quality.measurements" },     "vibration":   { "value": 1.23,  "unit": "mm_s",    "timestamp": "...", "topic": "maintenance.telemetry" },     "pressure":    { "value": 5.12,  "unit": "bar",     "timestamp": "...", "topic": "quality.measurements" }   },   "machine-02": { "..." },   "machine-03": { "..." } }&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the "&lt;a href="https://en.wikipedia.org/wiki/Digital_twin" rel="noopener noreferrer"&gt;digital twin&lt;/a&gt;" pattern: a continuously updated materialized view in memory, rebuilt entirely from the event stream without relying on an external database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Change Data Capture Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;Event-driven microservices excel at processing real-time MQTT streams, but many factories also run established enterprise systems that were not designed for event-driven architecture. ERP, MES, and warehouse management systems (WMS) store their data in relational databases.&lt;/p&gt;

&lt;p&gt;Change data capture (CDC) helps bridge this gap. When a work order is released or inventory is adjusted, these changes occur as database transactions. &lt;a href="https://docs.redpanda.com/redpanda-connect/components/catalog/" rel="noopener noreferrer"&gt;Redpanda Connect supports CDC connectors for PostgreSQL, MySQL, and MongoDB&lt;/a&gt; that capture these deltas and publish them as events automatically.&lt;/p&gt;

&lt;p&gt;A minimal CDC configuration for PostgreSQL with Redpanda Connect looks like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;yaml input:   postgres_cdc:     dsn: "postgres://user:pass@erp-db:5432/manufacturing?sslmode=disable"     schema: "public"     tables:       - work_orders       - inventory     snapshot: true output:   kafka:     addresses:       - redpanda:9092     topic: 'erp.${! meta("table") }'     key: ${! json("id") }&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; CDC should be used sparingly and exclusively for system-of-record deltas from transactional systems. Sensor streams, machine telemetry, and production events must remain event-native. CDC is not suitable for high-frequency time-series data because it is optimized for relational change capture, not continuous streams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup
&lt;/h2&gt;

&lt;p&gt;When you are done experimenting, stop the Python services with &lt;code&gt;Ctrl+C&lt;/code&gt; in each terminal, then tear down the Docker infrastructure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;bash docker compose down -v&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; flag removes the named volume (&lt;code&gt;redpanda-data&lt;/code&gt;), freeing disk space. Omit it if you want to preserve topic data between runs.&lt;/p&gt;

&lt;p&gt;All the code used in this tutorial is available on &lt;a href="https://github.com/See4Devs/event-driven-redpanda" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this tutorial, you built a sensor-first, event-driven manufacturing pipeline end to end. MQTT telemetry flows from simulated machines through an &lt;a href="https://mosquitto.org/" rel="noopener noreferrer"&gt;Eclipse Mosquitto&lt;/a&gt; broker, where Redpanda Connect ingests, normalizes, and routes events into domain-specific Redpanda topics. Three independent Python microservices (quality SPC, predictive maintenance, and a live dashboard) consume these events and produce alerts or materialized views. The entire stack runs locally with Docker Compose.&lt;/p&gt;

&lt;p&gt;I liked this setup because it took very little custom code to get there. The routing logic lives in a single Bloblang mapping in connect.yaml, and each microservice only has to worry about its own topic. Where legacy systems like ERP or MES aren't yet event-native, CDC connectors bridge the gap without forcing sensor data through the same relational path.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Build an Automated PDF Generation Pipeline: From Word Template to Signed Document</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 10 Jul 2026 06:51:33 +0000</pubDate>
      <link>https://dev.to/luciench/build-an-automated-pdf-generation-pipeline-from-word-template-to-signed-document-2dkk</link>
      <guid>https://dev.to/luciench/build-an-automated-pdf-generation-pipeline-from-word-template-to-signed-document-2dkk</guid>
      <description>&lt;p&gt;The first time you spin up &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer&lt;/a&gt; and call &lt;code&gt;page.pdf()&lt;/code&gt;, it feels solved. You get a PDF. It renders. Ship it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Just Use Puppeteer" Breaks at Scale
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The third problem is the compliance delta. &lt;code&gt;page.pdf()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline Architecture: What You're Actually Building
&lt;/h2&gt;

&lt;p&gt;The pipeline has three stages. Understanding them before writing any code prevents the most common integration mistakes.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeAEBwAE__nsiY29kZSI6ImZsb3djaGFydCBMUlxuICAgIEFbQ1JNIC8gRVJQIC8gREJdIC0tPnxKU09OIHBheWxvYWR8IEJbRG9jR2VuIEFQSTxici8-V29yZCB0ZW1wbGF0ZSArIGRhdGEgbWVyZ2VdXG4gICAgQiAtLT58YmFzZTY0IFBERnwgQ1tVcGxvYWQgdG8gUERGIFNlcnZpY2VzXVxuICAgIEMgLS0-fGRvY3VtZW50SWR8IER7UG9zdC1Qcm9jZXNzaW5nfVxuICAgIEQgLS0-fEZsYXR0ZW58IEVbQ29tcGxpYW5jZSBBcmNoaXZlXVxuICAgIEQgLS0-fExpbmVhcml6ZXwgRltXZWIgUG9ydGFsXVxuICAgIEQgLS0-fENvbXByZXNzfCBHW1N0b3JhZ2UgLyBFbWFpbF1cbiAgICBFIC0tPiBIW2VTaWduIEFQSV1cbiAgICBGIC0tPiBIXG4gICAgRyAtLT4gSFxuICAgIEggLS0-fFdlYmhvb2t8IElbQ29tcGxldGVkIFNpZ25lZCBEb2NdIiwibWVybWFpZCI6IntcInRoZW1lXCI6XCJkZWZhdWx0XCJ9In2Zn4yx" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeAEBwAE__nsiY29kZSI6ImZsb3djaGFydCBMUlxuICAgIEFbQ1JNIC8gRVJQIC8gREJdIC0tPnxKU09OIHBheWxvYWR8IEJbRG9jR2VuIEFQSTxici8-V29yZCB0ZW1wbGF0ZSArIGRhdGEgbWVyZ2VdXG4gICAgQiAtLT58YmFzZTY0IFBERnwgQ1tVcGxvYWQgdG8gUERGIFNlcnZpY2VzXVxuICAgIEMgLS0-fGRvY3VtZW50SWR8IER7UG9zdC1Qcm9jZXNzaW5nfVxuICAgIEQgLS0-fEZsYXR0ZW58IEVbQ29tcGxpYW5jZSBBcmNoaXZlXVxuICAgIEQgLS0-fExpbmVhcml6ZXwgRltXZWIgUG9ydGFsXVxuICAgIEQgLS0-fENvbXByZXNzfCBHW1N0b3JhZ2UgLyBFbWFpbF1cbiAgICBFIC0tPiBIW2VTaWduIEFQSV1cbiAgICBGIC0tPiBIXG4gICAgRyAtLT4gSFxuICAgIEggLS0-fFdlYmhvb2t8IElbQ29tcGxldGVkIFNpZ25lZCBEb2NdIiwibWVybWFpZCI6IntcInRoZW1lXCI6XCJkZWZhdWx0XCJ9In2Zn4yx" alt="Diagram" width="1904" height="249"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Authentication is consistent across all three stages. Every request includes &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; 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 &lt;code&gt;https://na1.fusion.foxit.com&lt;/code&gt;; 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 &lt;a href="https://docs.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit's API reference&lt;/a&gt;, so keep it open as you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;This is a hands-on tutorial with runnable code in Python, Node.js, and cURL. Before the first API call, set up the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;Foxit developer account&lt;/a&gt; for &lt;code&gt;client_id&lt;/code&gt; / &lt;code&gt;client_secret&lt;/code&gt; credentials. The free Developer plan includes 500 shared credits per year across PDF Services and Document Generation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python 3.8+&lt;/a&gt; with &lt;a href="https://pip.pypa.io/en/stable/installation/" rel="noopener noreferrer"&gt;pip&lt;/a&gt; and a &lt;a href="https://docs.python.org/3/library/venv.html" rel="noopener noreferrer"&gt;virtual environment&lt;/a&gt;, or &lt;a href="https://nodejs.org/en/download" rel="noopener noreferrer"&gt;Node.js 18+&lt;/a&gt; with &lt;a href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm" rel="noopener noreferrer"&gt;npm&lt;/a&gt;. The examples use Python's &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/a&gt; and Node's &lt;a href="https://axios-http.com/" rel="noopener noreferrer"&gt;&lt;code&gt;axios&lt;/code&gt;&lt;/a&gt; plus &lt;a href="https://github.com/form-data/form-data" rel="noopener noreferrer"&gt;&lt;code&gt;form-data&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://curl.se/" rel="noopener noreferrer"&gt;cURL&lt;/a&gt; for quick endpoint checks from the terminal.&lt;/li&gt;
&lt;li&gt;A code editor such as &lt;a href="https://code.visualstudio.com/" rel="noopener noreferrer"&gt;VS Code&lt;/a&gt; with the &lt;a href="https://marketplace.visualstudio.com/items?itemName=ms-python.python" rel="noopener noreferrer"&gt;Python&lt;/a&gt; extension (PyCharm, WebStorm, or Sublime Text work too).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scaffold the workspace in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;pdf-pipeline &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;pdf-pipeline
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;span class="c"&gt;# Node alternative:&lt;/span&gt;
&lt;span class="c"&gt;# npm init -y &amp;amp;&amp;amp; npm install axios form-data&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_id"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_secret"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and Your First API Call
&lt;/h2&gt;

&lt;p&gt;Start by creating an account at &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;account.foxit.com/site/sign-up&lt;/a&gt;. 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.&lt;/p&gt;

&lt;p&gt;Next you need a Word template. Rather than building one from scratch, download a ready-made sample, &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_simple.docx" rel="noopener noreferrer"&gt;&lt;code&gt;invoice_simple.docx&lt;/code&gt;&lt;/a&gt;, which already contains token placeholders. Tokens use double curly braces, and this template defines &lt;code&gt;{{ companyName }}&lt;/code&gt;, &lt;code&gt;{{ invoiceNumber }}&lt;/code&gt;, &lt;code&gt;{{ invoiceDate \@ MM/dd/yyyy }}&lt;/code&gt;, and &lt;code&gt;{{ totalDue \# "$#,##0.00" }}&lt;/code&gt;. The &lt;code&gt;\@&lt;/code&gt; and &lt;code&gt;\#&lt;/code&gt; suffixes are formatting switches that tell the engine how to render dates and currency.&lt;/p&gt;

&lt;p&gt;The first API call takes that &lt;code&gt;.docx&lt;/code&gt;, encodes it as base64, attaches a JSON data payload, and POSTs it to the Document Generation endpoint. Encode the file first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_simple.docx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;fs.readFileSync("invoice_simple.docx").toString("base64")&lt;/code&gt;. The API doesn't care which language produced the string, only that it's valid base64 that decodes to a well-formed &lt;code&gt;.docx&lt;/code&gt;. Send a corrupted or truncated file and you'll get an error back with no usable output.&lt;/p&gt;

&lt;p&gt;Now POST the encoded template with a matching data payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://na1.fusion.foxit.com/document-generation/api/GenerateDocumentBase64"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"client_id: &lt;/span&gt;&lt;span class="nv"&gt;$FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"client_secret: &lt;/span&gt;&lt;span class="nv"&gt;$FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
   "base64FileString": "&amp;lt;BASE64_ENCODED_DOCX&amp;gt;",
   "outputFormat": "pdf",
   "documentValues": {
     "companyName": "Acme Corp",
     "invoiceNumber": "INV-1042",
     "invoiceDate": "2026-01-15",
     "totalDue": "5700"
   }
 }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this request, &lt;code&gt;base64FileString&lt;/code&gt; carries the encoded template, &lt;code&gt;outputFormat&lt;/code&gt; selects &lt;code&gt;pdf&lt;/code&gt; (use &lt;code&gt;docx&lt;/code&gt; to get a populated Word file back instead), and &lt;code&gt;documentValues&lt;/code&gt; is the object whose keys map to the &lt;code&gt;{{token}}&lt;/code&gt; names in the template. Credentials go in the headers, never in the body. Every key in &lt;code&gt;documentValues&lt;/code&gt; 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 &lt;code&gt;"totalDue": "5700"&lt;/code&gt; and &lt;code&gt;"totalDue": 5700&lt;/code&gt; both work, though sending strings keeps formatting predictable.&lt;/p&gt;

&lt;p&gt;The response returns the generated document as a base64 string in the &lt;code&gt;base64FileString&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PDF Document Generated Successfully"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileExtension"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base64FileString"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;BASE64_ENCODED_PDF&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decode the &lt;code&gt;base64FileString&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One constraint to plan around is the 4 MB cap on the &lt;code&gt;.docx&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing Templates for Dynamic, High-Volume Documents
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_table.docx" rel="noopener noreferrer"&gt;&lt;code&gt;invoice_table.docx&lt;/code&gt;&lt;/a&gt;, which exercises every pattern below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simple field substitution&lt;/strong&gt; is the baseline. Place &lt;code&gt;{{fieldName}}&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Formatting switches&lt;/strong&gt; control how the engine renders dates and numbers. Append &lt;code&gt;\@ MM/dd/yyyy&lt;/code&gt; to a date token or &lt;code&gt;\# "$#,##0.00"&lt;/code&gt; to a numeric token, and the engine formats the output for you. In &lt;code&gt;invoice_table.docx&lt;/code&gt;, &lt;code&gt;{{ invoiceDate \@ MM/dd/yyyy }}&lt;/code&gt; turns &lt;code&gt;2026-01-15&lt;/code&gt; into &lt;code&gt;01/15/2026&lt;/code&gt;, and &lt;code&gt;{{ totalDue \# "$#,##0.00" }}&lt;/code&gt; turns &lt;code&gt;5700&lt;/code&gt; into &lt;code&gt;$5,700.00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Table row iteration&lt;/strong&gt; handles line items, variable-length lists, and repeating structures. Wrap a table row with &lt;code&gt;{{TableStart:arrayName}}&lt;/code&gt; and &lt;code&gt;{{TableEnd:arrayName}}&lt;/code&gt; placed in the same row, reference each object's fields with bare &lt;code&gt;{{fieldName}}&lt;/code&gt; tokens inside that row, and pass an array of objects in the payload. The engine expands the table to one row per array element. &lt;code&gt;invoice_table.docx&lt;/code&gt; uses this structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| #              | {{description}}                  | {{qty}} | {{unitPrice &lt;span class="se"&gt;\#&lt;/span&gt; "$#,##0.00"}} | {{lineTotal &lt;span class="se"&gt;\#&lt;/span&gt; "$#,##0.00"}} |
| {{ROW_NUMBER}} | {{TableStart:lineItems}} ...     |         |                              | ... {{TableEnd:lineItems}}   |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{{ROW_NUMBER}}&lt;/code&gt; token auto-numbers each generated row, and &lt;code&gt;{{=SUM(ABOVE) \# "$#,##0.00"}}&lt;/code&gt; in a cell below the table sums the column above it. Driving that template with this payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"documentValues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"companyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"invoiceNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"invoiceDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lineItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Consulting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"unitPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1500"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"lineTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4500"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Licensing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"unitPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"lineTotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1200"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"totalDue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5700"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;produces a two-row table (&lt;code&gt;1 Consulting 3 $1,500.00 $4,500.00&lt;/code&gt; and &lt;code&gt;2 Licensing 1 $1,200.00 $1,200.00&lt;/code&gt;), a computed subtotal of &lt;code&gt;$5,700.00&lt;/code&gt;, and a &lt;code&gt;Total Due&lt;/code&gt; of &lt;code&gt;$5,700.00&lt;/code&gt;, all formatted by the template. A 47-item order produces 47 rows with no template changes. The &lt;code&gt;lineItems&lt;/code&gt; array is the only part of the payload that varies between a one-item order and a fifty-item order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxarrllca9jxlb5h03pr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgxarrllca9jxlb5h03pr.png" alt="Rendered PDF invoice generated from invoice_table.docx, showing the company name, formatted invoice date, an auto-numbered two-row line-item table with currency-formatted amounts, a computed subtotal, and the total due" width="612" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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 &lt;code&gt;SUM(ABOVE)&lt;/code&gt; subtotal all resolved server-side from the Word template.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;documentValues&lt;/code&gt; rather than by embedding branching logic in the template.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For versioning, the practical approach is environment-specific template files, such as &lt;code&gt;contract-v2.docx&lt;/code&gt; in production and &lt;code&gt;contract-v3.docx&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-Generation Processing: Flatten, Linearize, Compress, and Sign
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;POST /pdf-services/api/documents/upload&lt;/code&gt;, a multipart form upload that returns a &lt;code&gt;documentId&lt;/code&gt;. From that point, PDF Services jobs chain by passing one job's &lt;code&gt;resultDocumentId&lt;/code&gt; as the next job's input &lt;code&gt;documentId&lt;/code&gt;, with no further download and re-upload cycles between operations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node.js: bridge DocGen output into PDF Services and chain jobs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;axios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://na1.fusion.foxit.com/pdf-services/api&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Step 1: decode the base64 PDF from DocGen and upload it once as multipart&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64Pdf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64Pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;document.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/documents/upload`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHeaders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: kick off a flatten job that returns a taskId for async polling&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flattenDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/documents/modify/pdf-flatten`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: start a compress job on a result document, no re-upload needed&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;compressionLevel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/documents/modify/pdf-compress`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;compressionLevel&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 4: poll GET /tasks/:taskId until COMPLETED or FAILED&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/tasks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resultDocumentId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resultDocumentId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// pass to next job&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Task &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 2s polling interval&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Full chain: upload -&amp;gt; flatten -&amp;gt; compress -&amp;gt; return final ID for eSign&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64Pdf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base64Pdf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flatTaskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;flattenDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flatDocId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flatTaskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compressTaskId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;compressDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flatDocId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEDIUM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForCompletion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressTaskId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, you take the base64 PDF that DocGen returned, decode it into a binary &lt;code&gt;Buffer&lt;/code&gt;, 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 &lt;code&gt;documentId&lt;/code&gt;. You start a flatten job against that ID, poll the task endpoint every two seconds until it reports &lt;code&gt;COMPLETED&lt;/code&gt;, then feed the returned &lt;code&gt;resultDocumentId&lt;/code&gt; straight into a compress job without re-uploading anything. The final &lt;code&gt;resultDocumentId&lt;/code&gt; is what you hand to the eSign stage.&lt;/p&gt;

&lt;p&gt;Three operations cover the core post-processing needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Flatten&lt;/strong&gt; (&lt;code&gt;POST /documents/modify/pdf-flatten&lt;/code&gt;) 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linearize&lt;/strong&gt; (&lt;code&gt;POST /documents/optimize/pdf-linearize&lt;/code&gt;), 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compress&lt;/strong&gt; (&lt;code&gt;POST /documents/modify/pdf-compress&lt;/code&gt;) reduces file size without degrading visible quality and takes a &lt;code&gt;compressionLevel&lt;/code&gt; of &lt;code&gt;LOW&lt;/code&gt;, &lt;code&gt;MEDIUM&lt;/code&gt;, or &lt;code&gt;HIGH&lt;/code&gt;. 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.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the eSign handoff, download the final document from PDF Services with &lt;code&gt;GET /pdf-services/api/documents/{documentId}/download&lt;/code&gt;, then submit it to the &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit eSign API&lt;/a&gt; to create an envelope, assign signers, and trigger the signing workflow. Because eSign runs on a separate host (&lt;code&gt;https://na1.foxitesign.foxit.com&lt;/code&gt;), 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 &lt;code&gt;"inputType": "base64"&lt;/code&gt;, assign signers, and send. Configure a webhook endpoint in your application and the eSign API posts events (viewed, signed, completed, declined) as they happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error Handling and Monitoring in Production
&lt;/h2&gt;

&lt;p&gt;Document pipelines fail in three distinct ways, and handling each correctly prevents silent data loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data validation failures&lt;/strong&gt; 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 &lt;code&gt;totalDue&lt;/code&gt; is null when the document needs a value, catch that in your application layer rather than discovering a blank field in the rendered output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template rendering errors&lt;/strong&gt; 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 (&lt;code&gt;POST /document-generation/api/AnalyzeDocumentBase64&lt;/code&gt;) to scan a template programmatically before promoting it. It returns &lt;code&gt;singleTagsString&lt;/code&gt;, a comma-separated list of the scalar tokens the engine detected, and &lt;code&gt;doubleTagsString&lt;/code&gt;, the table or loop region names. Diff those against your expected token set to catch mismatches before they reach users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downstream chain failures&lt;/strong&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Polling with timeout and dead-letter branching, use for every chained job&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pollWithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/tasks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resultDocumentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resultDocumentId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Log taskId, full error payload, and upstream context before re-queuing&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Task &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; FAILED:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Processing failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Timeout: treat as failure, don't silently drop the document&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Task &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; timed out after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, you cap the polling loop with a deadline so a stuck job can't block the pipeline forever. On &lt;code&gt;COMPLETED&lt;/code&gt; you return the result document ID for the next stage, on &lt;code&gt;FAILED&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Next Step: Run This End-to-End Today
&lt;/h2&gt;

&lt;p&gt;The fastest way to validate the architecture is to get one call working before building the full pipeline.&lt;/p&gt;

&lt;p&gt;Sign up at &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;account.foxit.com/site/sign-up&lt;/a&gt;, where the free Developer plan takes about two minutes to configure and gives you immediate API access. Download &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_simple.docx" rel="noopener noreferrer"&gt;&lt;code&gt;invoice_simple.docx&lt;/code&gt;&lt;/a&gt;, base64-encode it, and call the DocGen endpoint with a JSON payload that fills &lt;code&gt;companyName&lt;/code&gt;, &lt;code&gt;invoiceNumber&lt;/code&gt;, &lt;code&gt;invoiceDate&lt;/code&gt;, and &lt;code&gt;totalDue&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Foxit's developer portal has code samples in Node.js, Python, and cURL ready to copy. The &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;free Developer plan&lt;/a&gt; gives you 500 shared credits per year to cover your entire exploration and POC phase.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Document Generation for Developers: Security, Compliance, and Build-vs-Buy Decisions for the Template-Plus-Data Pipeline</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 29 May 2026 10:15:19 +0000</pubDate>
      <link>https://dev.to/luciench/document-generation-for-developers-security-compliance-and-build-vs-buy-decisions-for-the-7f4</link>
      <guid>https://dev.to/luciench/document-generation-for-developers-security-compliance-and-build-vs-buy-decisions-for-the-7f4</guid>
      <description>&lt;p&gt;When a deal closes in your CRM and a contract still needs a human to open Word, paste in the account name, and adjust the pricing table, you have a document generation problem. The mechanism for solving it is well understood: a rendering engine resolves a template against a structured data payload and emits a finished file. What most guides skip is the implementation layer that determines whether the system survives an audit, scales past the first team that uses it, and stops requiring developer time after launch. That means credential handling, data residency, compliance certifications, and the build-vs-buy threshold for the rendering layer itself.&lt;/p&gt;

&lt;p&gt;This article covers the architecture and the hard decisions. The "what is document generation" recap is intentionally short so the security, compliance, and decision-framework content has room to breathe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Document Generation: The Core Mental Model
&lt;/h2&gt;

&lt;p&gt;A document generation system does one thing: it takes a template (structural layout, fixed text, placeholders) and a data payload (field values for those placeholders), merges them in a rendering engine, and produces an output file.&lt;/p&gt;

&lt;p&gt;Three adjacent concepts get conflated with document generation frequently enough to distinguish here. Document editing puts a human in the loop to modify content interactively. Document management handles storage, versioning, and retrieval of existing files. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement" rel="noopener noreferrer"&gt;PDF form-fill&lt;/a&gt; annotates existing form fields with values without regenerating the document structure. Generation produces a net-new file on each call.&lt;/p&gt;

&lt;p&gt;Output format is a real decision. PDF is the correct choice when the output is delivery-final, immutable, and audit-ready, such as a signed contract, a compliance report, or a customer invoice. DOCX is the right choice when the generated file feeds a downstream collaborative editing workflow, such as a first draft that legal needs to mark up before execution.&lt;/p&gt;

&lt;p&gt;The rest of this article covers programmatic, API-driven generation at scale. If you're evaluating whether to build or buy the rendering layer, and whether your architecture can pass a &lt;a href="https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-suite-of-services" rel="noopener noreferrer"&gt;SOC 2&lt;/a&gt; or &lt;a href="https://www.hhs.gov/hipaa/index.html" rel="noopener noreferrer"&gt;HIPAA&lt;/a&gt; review, this is the relevant frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Components Every Document Generation System Requires
&lt;/h2&gt;

&lt;p&gt;Every doc gen system at production scale requires three things: a template, a data payload, and a rendering engine. Getting the contract between them right at design time saves hours of debugging later.&lt;/p&gt;

&lt;p&gt;Templates are &lt;code&gt;.docx&lt;/code&gt; files authored in &lt;a href="https://www.microsoft.com/en-us/microsoft-365/word" rel="noopener noreferrer"&gt;Microsoft Word&lt;/a&gt;, with &lt;code&gt;{{field_name}}&lt;/code&gt; double-brace tags placed anywhere Word accepts text: headings, table cells, footers, text boxes, even page headers. The template is a contract with the data payload. Every tag is a required key. If &lt;code&gt;{{invoiceNumber}}&lt;/code&gt; appears in the template and the payload omits &lt;code&gt;invoiceNumber&lt;/code&gt;, the rendered output contains a blank where the invoice number should be. The API does not raise an error for missing keys, and absent keys render as empty strings. That behavior matters when you're designing your payload validation layer.&lt;/p&gt;

&lt;p&gt;The data payload is a &lt;a href="https://www.json.org/json-en.html" rel="noopener noreferrer"&gt;JSON&lt;/a&gt; object whose keys map to tag names in the template. A minimal invoice payload looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"companyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoiceNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoiceDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalDue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String values drop in as-is. Numeric values render according to any format directives embedded in the tag itself, such as &lt;code&gt;{{ totalDue \# "\$#,##0.00" }}&lt;/code&gt; for currency formatting. Null values and missing keys both render as empty strings.&lt;/p&gt;

&lt;p&gt;The rendering engine is the service that resolves tags against the payload and emits the file. At render time, the engine performs tag substitution for scalar values, dynamic row expansion for array-backed table data, and output format encoding (&lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Base64" rel="noopener noreferrer"&gt;Base64&lt;/a&gt; for transport in JSON responses, or a binary write to disk). Its behavior on edge cases, including how it handles null values, missing keys, and malformed tags, is what you need to understand before connecting it to a production data source.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Foxit Document Generation API Request Model Works
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit Document Generation API&lt;/a&gt; uses a three-field POST body. &lt;code&gt;base64FileString&lt;/code&gt; carries the Base64-encoded &lt;code&gt;.docx&lt;/code&gt; template. &lt;code&gt;documentValues&lt;/code&gt; carries the JSON merge data. &lt;code&gt;outputFormat&lt;/code&gt; is the lowercase string &lt;code&gt;"pdf"&lt;/code&gt; or &lt;code&gt;"docx"&lt;/code&gt;. The endpoint is case-sensitive on &lt;code&gt;outputFormat&lt;/code&gt; and returns HTTP 500 for any value outside those two strings.&lt;/p&gt;

&lt;p&gt;The canonical endpoint for developer-tier accounts is &lt;code&gt;https://na1.fusion.foxit.com/document-generation/api/GenerateDocumentBase64&lt;/code&gt;. Authentication is header-based: include &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; alongside &lt;code&gt;Content-Type: application/json&lt;/code&gt;. There is no OAuth flow, no token exchange, and no session management.&lt;/p&gt;

&lt;p&gt;Base64 encoding is used because the API transports binary template content inside a JSON request body, where raw binary bytes would break JSON parsing. The encoding overhead adds roughly 33% to the payload size, which is why the endpoint enforces a 4 MB limit on the encoded template (approximately a 3 MB raw &lt;code&gt;.docx&lt;/code&gt; file). Payloads that exceed this cap return HTTP 413 or a generic 500 with an opaque message. To slim an oversized template, compress images via Word's Picture Format menu, remove embedded fonts and OLE objects, and split templates that contain too many high-resolution graphics.&lt;/p&gt;

&lt;p&gt;The synchronous execution model is a meaningful architectural choice. The rendered file arrives in the same HTTP response, eliminating the polling loop that complicates async pipelines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant App as Application
    participant API as GenerateDocumentBase64 Endpoint
    App-&amp;gt;&amp;gt;API: POST with base64FileString, documentValues, outputFormat
    API-&amp;gt;&amp;gt;API: Resolve tags against documentValues
    API-&amp;gt;&amp;gt;API: Encode rendered file as base64
    API--&amp;gt;&amp;gt;App: 200 OK with base64FileString in response body
    App-&amp;gt;&amp;gt;App: Decode base64, write output.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeAEBtQFK_nsiY29kZSI6InNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEFwcCBhcyBBcHBsaWNhdGlvblxuICAgIHBhcnRpY2lwYW50IEFQSSBhcyBHZW5lcmF0ZURvY3VtZW50QmFzZTY0IEVuZHBvaW50XG4gICAgQXBwLT4-QVBJOiBQT1NUIHdpdGggYmFzZTY0RmlsZVN0cmluZywgZG9jdW1lbnRWYWx1ZXMsIG91dHB1dEZvcm1hdFxuICAgIEFQSS0-PkFQSTogUmVzb2x2ZSB0YWdzIGFnYWluc3QgZG9jdW1lbnRWYWx1ZXNcbiAgICBBUEktPj5BUEk6IEVuY29kZSByZW5kZXJlZCBmaWxlIGFzIGJhc2U2NFxuICAgIEFQSS0tPj5BcHA6IDIwMCBPSyB3aXRoIGJhc2U2NEZpbGVTdHJpbmcgaW4gcmVzcG9uc2UgYm9keVxuICAgIEFwcC0-PkFwcDogRGVjb2RlIGJhc2U2NCwgd3JpdGUgb3V0cHV0LnBkZiIsIm1lcm1haWQiOiJ7XCJ0aGVtZVwiOlwiZGVmYXVsdFwifSJ98pmSnw%3D%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeAEBtQFK_nsiY29kZSI6InNlcXVlbmNlRGlhZ3JhbVxuICAgIHBhcnRpY2lwYW50IEFwcCBhcyBBcHBsaWNhdGlvblxuICAgIHBhcnRpY2lwYW50IEFQSSBhcyBHZW5lcmF0ZURvY3VtZW50QmFzZTY0IEVuZHBvaW50XG4gICAgQXBwLT4-QVBJOiBQT1NUIHdpdGggYmFzZTY0RmlsZVN0cmluZywgZG9jdW1lbnRWYWx1ZXMsIG91dHB1dEZvcm1hdFxuICAgIEFQSS0-PkFQSTogUmVzb2x2ZSB0YWdzIGFnYWluc3QgZG9jdW1lbnRWYWx1ZXNcbiAgICBBUEktPj5BUEk6IEVuY29kZSByZW5kZXJlZCBmaWxlIGFzIGJhc2U2NFxuICAgIEFQSS0tPj5BcHA6IDIwMCBPSyB3aXRoIGJhc2U2NEZpbGVTdHJpbmcgaW4gcmVzcG9uc2UgYm9keVxuICAgIEFwcC0-PkFwcDogRGVjb2RlIGJhc2U2NCwgd3JpdGUgb3V0cHV0LnBkZiIsIm1lcm1haWQiOiJ7XCJ0aGVtZVwiOlwiZGVmYXVsdFwifSJ98pmSnw%3D%3D" alt="Diagram" width="794" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Async polling patterns make sense when document volume is high enough to push render time past practical request timeouts, for example in overnight batch jobs processing tens of thousands of records. For on-demand generation triggered by a single user action or a webhook, synchronous delivery is simpler to implement and simpler to debug.&lt;/p&gt;

&lt;p&gt;Dynamic tables require a specific token placement. To render an array of line items into a Word table, place &lt;code&gt;{{TableStart:lineItems}}&lt;/code&gt; and &lt;code&gt;{{TableEnd:lineItems}}&lt;/code&gt; in the same table row, with the field tags for each column also in that row. The engine repeats the row for each element in the &lt;code&gt;lineItems&lt;/code&gt; array. You can also include &lt;code&gt;{{ROW_NUMBER}}&lt;/code&gt; in the row to get an auto-incrementing index. Placing the start and end tokens in different table rows produces a broken render with no error message, which is one of the more opaque failure modes in the system.&lt;/p&gt;

&lt;p&gt;The Analyze Document API is a utility endpoint worth knowing about when evaluating any doc gen service. Post a &lt;code&gt;.docx&lt;/code&gt; file to it and it returns all embedded tag names in the template. This lets you programmatically validate that a template matches a payload schema before calling the generation endpoint, and auto-build the &lt;code&gt;documentValues&lt;/code&gt; structure from a template file when the template changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration Patterns for Production Document Generation Workflows
&lt;/h2&gt;

&lt;p&gt;The most common trigger pattern in CRM-connected workflows goes like this: a deal moves to Closed Won in Salesforce, a webhook fires to your application, your application queries &lt;a href="https://developer.salesforce.com/" rel="noopener noreferrer"&gt;Salesforce&lt;/a&gt; for the deal fields, your application posts to the doc gen API, the rendered PDF contract comes back synchronously, and your application attaches the file to the CRM record or sends it to the signatory. Because the API is a standard REST endpoint, integrating with &lt;a href="https://developers.hubspot.com/" rel="noopener noreferrer"&gt;HubSpot&lt;/a&gt;, Salesforce, or SAP requires no proprietary connector, just an authenticated HTTP POST.&lt;/p&gt;

&lt;p&gt;For event-driven batch jobs, iterate over a dataset of N records and fire one POST per record. The key considerations are rate limiting and retry logic. If the downstream data source or the doc gen service has a request ceiling, a simple backoff-and-retry pattern handles transient failures without losing records. Log the request metadata (template version, record ID, timestamp) and the response status. Omit payload contents from logs when the payload contains regulated data.&lt;/p&gt;

&lt;p&gt;When a required tag has no corresponding key in &lt;code&gt;documentValues&lt;/code&gt;, the API renders a blank and moves on. The defensive pattern is to run payload validation against the tag list returned by the Analyze Document API before each call. For workflows where partial data is acceptable, build a fallback that fills missing keys with empty strings explicitly rather than relying on the API's implicit behavior. That delta matters during an audit when you need to explain why a generated compliance report had blank fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and Compliance for Document Generation Pipelines
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; travel as HTTP request headers, which means they're visible in any intermediary that can inspect headers in transit. Store them in environment variables or a secrets manager (&lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt;, &lt;a href="https://www.vaultproject.io/" rel="noopener noreferrer"&gt;HashiCorp Vault&lt;/a&gt;, or a CI/CD-native secrets store). They should never appear in source code, version control, or log output. Your application code should read from &lt;code&gt;os.environ["CLIENT_ID"]&lt;/code&gt; rather than hardcoding the string value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security" rel="noopener noreferrer"&gt;TLS&lt;/a&gt; at the API boundary encrypts the payload in transit, but your application is responsible for what happens to the document after the response arrives. If you're writing the rendered PDF to disk, a message queue, or cloud storage, that persistence layer needs its own encryption at rest. An unencrypted file sitting in an &lt;a href="https://aws.amazon.com/s3/" rel="noopener noreferrer"&gt;Amazon S3&lt;/a&gt; bucket with overly permissive ACLs falls outside what the API provider's TLS covers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-suite-of-services" rel="noopener noreferrer"&gt;SOC 2 Type II&lt;/a&gt;, &lt;a href="https://gdpr-info.eu/" rel="noopener noreferrer"&gt;GDPR&lt;/a&gt;, and &lt;a href="https://www.hhs.gov/hipaa/index.html" rel="noopener noreferrer"&gt;HIPAA&lt;/a&gt; each have specific implications for doc gen pipelines that go beyond a logo on a vendor's compliance page. SOC 2 Type II requires an auditable trail of access controls and data handling over time, so you'll need to log which user or service account triggered each generation event and what template was used. GDPR treats personally identifiable information in generated documents as regulated data, which means data subject rights (access, deletion, correction) extend to any stored generated files in addition to the source database records. HIPAA adds the requirement that protected health information (PHI) in generated documents be handled under a signed &lt;a href="https://www.hhs.gov/hipaa/for-professionals/covered-entities/sample-business-associate-agreement-provisions/index.html" rel="noopener noreferrer"&gt;Business Associate Agreement (BAA)&lt;/a&gt; with every service that processes or stores that data. Foxit's API platform carries SOC 2, GDPR, and HIPAA compliance posture, so the BAA question is addressable at the vendor level, but the application layer between your data source and the API remains your responsibility.&lt;/p&gt;

&lt;p&gt;Template files are themselves a security surface. Avoid embedding default field values that contain PII directly in the template file, because &lt;code&gt;.docx&lt;/code&gt; files land in version control, get emailed for review, and end up in shared drives. The template should contain the structural layout and formatting only, with PII traveling exclusively in the &lt;code&gt;documentValues&lt;/code&gt; payload at generation time, ephemeral in the request and response.&lt;/p&gt;

&lt;p&gt;Most managed doc gen providers don't persist the rendered document on their infrastructure after returning it in the response body, but verify this contractually before connecting regulated data. Log the request hash and response status. Document contents belong only in the storage system you control and have audited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs. Buy: Picking the Right Document Generation Rendering Approach
&lt;/h2&gt;

&lt;p&gt;The DIY path uses open-source libraries. &lt;a href="https://docxtpl.readthedocs.io/" rel="noopener noreferrer"&gt;&lt;code&gt;python-docxtpl&lt;/code&gt;&lt;/a&gt; renders Word templates in Python using &lt;a href="https://jinja.palletsprojects.com/" rel="noopener noreferrer"&gt;Jinja2&lt;/a&gt; syntax, covering scalar substitution and table loops at no licensing cost. &lt;a href="https://github.com/parallax/jsPDF" rel="noopener noreferrer"&gt;&lt;code&gt;jsPDF&lt;/code&gt;&lt;/a&gt; lets you construct PDFs in Node.js from scratch via code. Both give you complete control over rendering logic, and neither comes with a licensing fee. The cost is that your team owns maintenance, scaling infrastructure, and all compliance certification work. When your open-source rendering library has a memory leak under high concurrency, or when your auditor asks for your SOC 2 report, you answer those questions directly.&lt;/p&gt;

&lt;p&gt;A managed REST API trades infrastructure ownership for faster integration, predictable credit-based pricing, and inherited compliance certifications. The break-even point shifts depending on three variables: team size, document volume, and whether the use case requires certified compliance. At low volume (fewer than 500 documents per month) with no regulated data, the open-source path is often the right call. At higher volume, or when a compliance review is on the roadmap, the time cost of building and certifying your own rendering infrastructure typically exceeds the subscription cost of a managed service by a meaningful margin.&lt;/p&gt;

&lt;p&gt;To work out which path makes sense for your situation, answer these four questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many documents does the system need to generate per month, and is that number growing?&lt;/li&gt;
&lt;li&gt;Does the content include regulated data (PII, PHI, financial records) that triggers a compliance framework?&lt;/li&gt;
&lt;li&gt;Does your team have in-house PDF rendering expertise, or would you be learning while building?&lt;/li&gt;
&lt;li&gt;How often does the template change, and who owns that change process?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If document generation is a core differentiating feature of your product, investing in a custom rendering layer may pay off over time. If it's a workflow utility (such as generating invoices, contracts, or onboarding letters from existing system data), a managed API ships faster and costs less in total at moderate volume. The key signal is whether the rendering logic itself creates competitive value or whether it's plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You'll need &lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python 3.8+&lt;/a&gt; and &lt;a href="https://pip.pypa.io/en/stable/" rel="noopener noreferrer"&gt;&lt;code&gt;pip&lt;/code&gt;&lt;/a&gt; for the quickstart, with &lt;a href="https://docs.python.org/3/library/venv.html" rel="noopener noreferrer"&gt;&lt;code&gt;venv&lt;/code&gt;&lt;/a&gt; recommended for isolation. Install the &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/a&gt; library for HTTP calls. &lt;a href="https://code.visualstudio.com/" rel="noopener noreferrer"&gt;VS Code&lt;/a&gt; with the &lt;a href="https://marketplace.visualstudio.com/items?itemName=ms-python.python" rel="noopener noreferrer"&gt;Python extension&lt;/a&gt; works well as an editor, though PyCharm or Sublime Text work equally well. You'll also need a &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;free Foxit developer account&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Set up the workspace and load your credentials into the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;foxit-docgen-quickstart &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;foxit-docgen-quickstart
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://na1.fusion.foxit.com"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_id"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_client_secret"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quickstart: Generate Your First Document via the API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1.&lt;/strong&gt; Activate a free developer plan at &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;account.foxit.com/site/sign-up&lt;/a&gt;. You get 500 annual credits and no credit card is required. Retrieve &lt;code&gt;CLIENT_ID&lt;/code&gt; and &lt;code&gt;CLIENT_SECRET&lt;/code&gt; from the API Keys section of the developer dashboard, then load them into your environment using the block above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2.&lt;/strong&gt; Download the sample invoice template from the &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_simple.docx" rel="noopener noreferrer"&gt;foxit-demo-templates repository&lt;/a&gt;. The file contains &lt;code&gt;{{ companyName }}&lt;/code&gt;, &lt;code&gt;{{ invoiceNumber }}&lt;/code&gt;, &lt;code&gt;{{ invoiceDate \@ MM/dd/yyyy }}&lt;/code&gt;, and &lt;code&gt;{{ totalDue \# "\$#,##0.00" }}&lt;/code&gt; tokens, already validated against the live API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3.&lt;/strong&gt; The script reads credentials from &lt;code&gt;os.environ&lt;/code&gt;, &lt;a href="https://docs.python.org/3/library/base64.html" rel="noopener noreferrer"&gt;Base64-encodes&lt;/a&gt; &lt;code&gt;invoice_simple.docx&lt;/code&gt;, POSTs the three-field JSON body to &lt;code&gt;https://na1.fusion.foxit.com/document-generation/api/GenerateDocumentBase64&lt;/code&gt;, decodes the &lt;code&gt;base64FileString&lt;/code&gt; field in the response, and writes the bytes to &lt;code&gt;output.pdf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;client_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;client_secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_simple.docx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;template_b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64FileString&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;template_b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentValues&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;companyName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme Corp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoiceNumber&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INV-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoiceDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-15&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalDue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4200&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputFormat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/document-generation/api/GenerateDocumentBase64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;output_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base64FileString&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rendered document written to output.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;output.pdf&lt;/code&gt; is a rendered, branded invoice generated from structured data in a single synchronous HTTP call.&lt;/p&gt;

&lt;p&gt;What integration pattern is your team using to trigger document generation: synchronous on-demand, event-driven, or batch?&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes and Troubleshooting
&lt;/h2&gt;

&lt;p&gt;Word's autocorrect will silently replace straight quotes inside tags with smart (curly) quotes in some locales, which breaks tag parsing entirely. The risk shows up most often in format directives such as &lt;code&gt;{{ totalDue \# "$#,##0.00" }}&lt;/code&gt;, where the &lt;code&gt;"..."&lt;/code&gt; around the picture string is what gets converted. If your tags aren't resolving, paste them from a plain-text editor rather than typing them directly in Word, or disable autocorrect for the template file.&lt;/p&gt;

&lt;p&gt;Tag names are case-sensitive throughout the system. &lt;code&gt;{{ companyName }}&lt;/code&gt; and &lt;code&gt;{{ CompanyName }}&lt;/code&gt; are different tags. Cross-check every placeholder in the template against the JSON payload keys before your first test call.&lt;/p&gt;

&lt;p&gt;Missing payload fields render silently as empty strings. Validate the full payload against the tag list from the Analyze Document API before each call in production, especially when templates change and new tags get added without a corresponding update to the payload-building code.&lt;/p&gt;

&lt;p&gt;Loop tokens must sit in the same Word table row. Placing &lt;code&gt;{{TableStart:lineItems}}&lt;/code&gt; in row 1 and &lt;code&gt;{{TableEnd:lineItems}}&lt;/code&gt; in row 2 produces a broken render with no error. The entire row that contains both tokens becomes the repeating unit.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.python.org/3/library/base64.html#base64.b64encode" rel="noopener noreferrer"&gt;&lt;code&gt;base64.b64encode()&lt;/code&gt;&lt;/a&gt; function in Python returns a &lt;code&gt;bytes&lt;/code&gt; object. Forgetting the &lt;code&gt;.decode("utf-8")&lt;/code&gt; call means you'll pass a bytes object into &lt;code&gt;json.dumps()&lt;/code&gt;, which raises a &lt;code&gt;TypeError: Object of type bytes is not JSON serializable&lt;/code&gt;. The error message points at the serializer rather than the encoding step, which makes the root cause easy to miss.&lt;/p&gt;

&lt;p&gt;When a template hits the 4 MB encoded size cap, the API returns HTTP 413 or a 500 with a vague message. Slimming the template is the fix. Retrying with the same payload produces the same error. Compress images via Word's Picture Format compress tool, remove embedded fonts, and drop any OLE objects you don't need in the generated output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Document generation looks like a templating problem until you put it in front of an auditor. The rendering pipeline itself is the easy part, a template plus a structured payload merged by an engine that returns a finished file. What separates a working prototype from a system that holds up in production is the layer around the engine, where credential handling, data residency, SOC 2 and HIPAA coverage, and the build-vs-buy threshold are decided.&lt;/p&gt;

&lt;p&gt;The build-vs-buy decision compounds over time. An in-house pipeline on top of python-docx or WeasyPrint is defensible when the output format is fixed, the templates are stable, and the team has long-term capacity to own the rendering layer. Once any of those assumptions slips, the compliance surface, the template maintenance burden, and the cross-format requirements pull engineering attention away from the product itself. Shifting the rendering layer to a managed API with SOC 2 Type II, GDPR, and HIPAA coverage already in place removes a class of work that does not differentiate your product, in exchange for a vendor dependency that is easier to manage than a homegrown engine.&lt;/p&gt;

&lt;p&gt;The Python quickstart above is the smallest possible version of the production pattern, with credentials read from the environment, a synchronous request to &lt;code&gt;GenerateDocumentBase64&lt;/code&gt;, and a base64-decoded PDF written to disk. From there the path forward is well-trodden, by adding template validation through the Analyze Document API, layering retries and observability around the call, and expanding the template library as new document types come online. The architecture and the hard decisions are the same whether you generate ten documents a day or ten thousand. Getting them right early is what keeps the system out of the audit findings later.&lt;/p&gt;

&lt;p&gt;To test the three-field request model against a live endpoint today, activate a free Foxit developer account (no credit card and no sales call required) and run the Postman collection. &lt;a href="https://account.foxit.com/site/sign-up" rel="noopener noreferrer"&gt;Sign up at account.foxit.com/site/sign-up&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  LINKEDIN POSTS
&lt;/h2&gt;

&lt;p&gt;LinkedIn Post 1&lt;/p&gt;

&lt;p&gt;Most teams treat document generation as a template problem. The real problem is the implementation layer that sits around the template.&lt;/p&gt;

&lt;p&gt;The rendering pipeline itself is well understood: a template plus a structured data payload produces a finished file. PDF for audit-ready delivery, DOCX for collaborative editing workflows. That part takes an afternoon to prototype.&lt;/p&gt;

&lt;p&gt;What takes months to get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credential handling that doesn't leak API keys into logs or environment variables&lt;/li&gt;
&lt;li&gt;Data residency controls for documents that carry PII or contract terms&lt;/li&gt;
&lt;li&gt;SOC 2 and HIPAA compliance at the rendering layer, not just at the application layer&lt;/li&gt;
&lt;li&gt;A build-vs-buy threshold that accounts for long-term maintenance, not just first-week velocity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The teams that skip this layer ship a working proof of concept, then spend the next two quarters patching it before an audit.&lt;/p&gt;

&lt;p&gt;I wrote a detailed guide covering the architecture and the hard decisions, including a Python quickstart against the Foxit Document Generation API. Link in the comments.&lt;/p&gt;

&lt;p&gt;LinkedIn Post 2&lt;/p&gt;

&lt;p&gt;The build-vs-buy decision for document generation has one real variable: who maintains the rendering engine when requirements change.&lt;/p&gt;

&lt;p&gt;Building your own pipeline on top of a library like python-docx or WeasyPrint is the right call when your output format is fixed, your templates are stable, and you have engineering capacity to own it long-term. Most teams don't have all three.&lt;/p&gt;

&lt;p&gt;The hidden costs of building in-house:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HIPAA-compliant rendering requires data processing agreements and infrastructure controls your team has to configure and certify&lt;/li&gt;
&lt;li&gt;SOC 2 coverage for the rendering layer means your internal build is in scope for your next audit&lt;/li&gt;
&lt;li&gt;Template maintenance compounds as output types multiply across contracts, invoices, and compliance reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using a managed API like Foxit's shifts the compliance surface area and the maintenance burden off your team. The trade-off is vendor dependency, which is worth the cost once you're past the first two or three document types.&lt;/p&gt;

&lt;p&gt;Full guide with the architecture breakdown and a working Python quickstart in the comments.&lt;/p&gt;

&lt;p&gt;LinkedIn Post 3&lt;/p&gt;

&lt;p&gt;The most common bug in a production document generation pipeline is silent.&lt;/p&gt;

&lt;p&gt;A required field is missing from the payload. The API returns 200. The rendered PDF ships with a blank where the customer name should be. Nobody catches it until the customer does, or worse, until an auditor does.&lt;/p&gt;

&lt;p&gt;This happens because the template-to-data contract is implicit. A Word template declares placeholders with &lt;code&gt;{{ field_name }}&lt;/code&gt; tags, but a &lt;code&gt;.docx&lt;/code&gt; file is not a schema. The rendering engine merges what it can match and treats missing keys as empty strings by design. That is the right default for a generic engine and the wrong default for a regulated workflow where a blank field is a real problem.&lt;/p&gt;

&lt;p&gt;The pattern that closes the gap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the Analyze Document API (or your provider's equivalent) to enumerate every tag in the template before each call&lt;/li&gt;
&lt;li&gt;Validate the payload against that tag list at build time, not at render time&lt;/li&gt;
&lt;li&gt;Treat a missing key as an explicit error in your application layer, not a downstream PDF problem&lt;/li&gt;
&lt;li&gt;Log the template version and the payload schema together so audit reconstruction has both halves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most managed doc gen APIs expose a template introspection endpoint for exactly this reason. Foxit ships one. Use it.&lt;/p&gt;

&lt;p&gt;The full guide covers this pattern plus the rest of the security, compliance, and build-vs-buy decisions. Link in the comments.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
      <category>security</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Should You Vibe Code Your SaaS Starter or Just Buy One?</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 29 May 2026 06:39:09 +0000</pubDate>
      <link>https://dev.to/luciench/should-you-vibe-code-your-saas-starter-or-just-buy-one-3ceo</link>
      <guid>https://dev.to/luciench/should-you-vibe-code-your-saas-starter-or-just-buy-one-3ceo</guid>
      <description>&lt;p&gt;It's 2026 and AI coding tools have made everyone feel like a 10x engineer. Cursor writes your components. Claude Code refactors your whole codebase between sips of coffee. v0 spits out landing pages from a prompt. Bolt and Lovable promise you a "full SaaS in one shot."&lt;/p&gt;

&lt;p&gt;So the thought creeps in: &lt;em&gt;why would I pay $69 for a Next.js SaaS starter kit when I can vibe code one myself in a weekend?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article is the answer to that question, with actual numbers. I'm going to walk through what it really takes to build a production-ready SaaS starter in 2026 with AI tools, how much it costs in subscriptions and time, and what you get for $69–$119 if you just buy one. Then you can decide for yourself.&lt;/p&gt;

&lt;p&gt;Spoiler: the math is not even close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "production-ready" actually means
&lt;/h2&gt;

&lt;p&gt;Before the math, let's pin down what we're building. A SaaS starter isn't a landing page. It's the entire foundation a paid product sits on. Bolt and Lovable can scaffold a frontend in a prompt; they cannot ship you the following list.&lt;/p&gt;

&lt;p&gt;Here's what a real Next.js SaaS starter includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication.&lt;/strong&gt; Email/password, password reset, email verification, session management. Probably social OAuth (Google, GitHub, etc.). Probably 2FA. Definitely RBAC (owner, admin, member roles).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments.&lt;/strong&gt; Stripe and/or LemonSqueezy. Subscriptions and one-time purchases. Customer portal. Webhook handling with signature verification, idempotency, and retry logic. Two-way product sync. Guest checkout. Tax. Refunds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database.&lt;/strong&gt; Schema, migrations, seed data, RLS or equivalent permission layer, multi-tenancy if you need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin panel.&lt;/strong&gt; User management, billing oversight, support tools, feature flags, maintenance mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content layer.&lt;/strong&gt; A CMS or page builder so a non-developer can edit marketing pages without a deploy. (Most kits ship an MDX folder and pretend this is a CMS. It isn't.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email.&lt;/strong&gt; Transactional (welcome, password reset, receipt), templated, with a real provider (Resend, Postmark, SES).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; Error tracking (Sentry), analytics, logging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; Rate limiting, CSRF, secure headers, input validation everywhere, secret management.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing.&lt;/strong&gt; Unit tests, E2E tests, CI pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DX.&lt;/strong&gt; TypeScript strict mode, ESLint, Prettier, Husky, env validation, type-safe API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy.&lt;/strong&gt; Vercel/Render/Fly/Cloudflare config, env vars, preview deployments, production runbook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the floor. Skip any of it and you're shipping a hobby project, not a SaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vibe coding budget: what AI actually costs in 2026
&lt;/h2&gt;

&lt;p&gt;Let's price the toolchain. You're not vibe coding with one tool — you're stitching several together.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cursor Pro&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;Day-to-day code editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Max (5x or 20x)&lt;/td&gt;
&lt;td&gt;$100–$200&lt;/td&gt;
&lt;td&gt;The model that actually does the heavy lifting on long contexts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT Plus&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;Second opinion, image gen, faster turnaround on small stuff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0 by Vercel (Premium)&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;UI scaffolding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot Pro&lt;/td&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;If you want completion in your IDE too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Realistic total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$170–$270/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can scrape by on $20–$40/mo with just Cursor and Claude Pro, but if you're seriously trying to build a production codebase with AI doing most of the writing, you'll burn through Pro-tier limits in a week. Anyone who's actually shipped a real project with AI in 2026 knows the $100+/month tier is where the work happens.&lt;/p&gt;

&lt;p&gt;If the build takes you three months (we'll get to why that's optimistic in a second), you're looking at &lt;strong&gt;$500–$800 just in subscriptions&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The time budget: how long this actually takes
&lt;/h2&gt;

&lt;p&gt;Here's the part most "vibe code your SaaS" tweets quietly skip. AI is fast at scaffolding. AI is medium-speed at debugging. AI is slow at the parts of a SaaS that matter most.&lt;/p&gt;

&lt;p&gt;Let me break it down by component, with realistic ranges for a developer using current AI tools (2026), assuming 8-hour workdays:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Hours (with AI)&lt;/th&gt;
&lt;th&gt;Hours (without AI)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth with RBAC, 2FA, OAuth&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;td&gt;60–100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe + webhook reliability&lt;/td&gt;
&lt;td&gt;30–60&lt;/td&gt;
&lt;td&gt;80–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LemonSqueezy as second provider&lt;/td&gt;
&lt;td&gt;15–25&lt;/td&gt;
&lt;td&gt;40–60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database schema, migrations, RLS&lt;/td&gt;
&lt;td&gt;15–25&lt;/td&gt;
&lt;td&gt;40–60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin panel&lt;/td&gt;
&lt;td&gt;30–50&lt;/td&gt;
&lt;td&gt;80–120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CMS / page builder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60–150&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200–400&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email + templates + provider wiring&lt;/td&gt;
&lt;td&gt;10–20&lt;/td&gt;
&lt;td&gt;30–50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics dashboard&lt;/td&gt;
&lt;td&gt;15–40&lt;/td&gt;
&lt;td&gt;40–80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability (Sentry, logging)&lt;/td&gt;
&lt;td&gt;5–10&lt;/td&gt;
&lt;td&gt;15–25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing (unit + E2E)&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;td&gt;60–100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security pass (rate limiting, headers, validation)&lt;/td&gt;
&lt;td&gt;10–20&lt;/td&gt;
&lt;td&gt;30–50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy, CI, env config&lt;/td&gt;
&lt;td&gt;10–20&lt;/td&gt;
&lt;td&gt;30–50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Landing pages, marketing, docs&lt;/td&gt;
&lt;td&gt;20–40&lt;/td&gt;
&lt;td&gt;50–80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Polish, edge cases, bug hunting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40–80&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80–160&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300–620 hours&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;835–1,455 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AI saves you roughly half the work. That's real. But half of "a lot" is still a lot.&lt;/p&gt;

&lt;p&gt;At a conservative $50/hour for a senior developer (and if you're cheaper than that, you're using time you could spend on the actual product), that's &lt;strong&gt;$15,000–$31,000 of your own time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At $100/hour: &lt;strong&gt;$30,000–$62,000&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI quietly costs you money
&lt;/h2&gt;

&lt;p&gt;The hours estimate above assumes everything works. It usually doesn't. Here's where AI confidently produces code that almost works and then bites you in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe webhooks.&lt;/strong&gt; AI will write a webhook handler that looks great. Then a customer chargebacks, the event arrives out of order, your idempotency key is wrong, you double-issue a refund, Stripe disputes, you spend a Saturday on the phone with support. Stripe webhooks are where junior developers — and apparently most LLMs — discover the meaning of "distributed systems."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session management.&lt;/strong&gt; AI will give you a working JWT auth flow on the happy path. Then a user clears cookies on one device, the refresh token expires on another, and they hit an infinite redirect loop. You will debug this for two days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-level security.&lt;/strong&gt; AI is genuinely bad at RLS policies. Either it generates policies that don't actually enforce anything (so any logged-in user can read every other user's data), or it generates policies so restrictive that no one can read anything. There is no middle ground in the default output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The CMS.&lt;/strong&gt; This is the killer. Building a real CMS — drag-and-drop sections, inline editing, media library, draft/publish, image optimization, slug routing, SEO fields — is the kind of multi-week subsystem that AI scaffolds in a day and then can't finish in three months. It's a UX problem, not a code generation problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email deliverability.&lt;/strong&gt; AI writes the SMTP code. It doesn't tell you about SPF, DKIM, DMARC, warmup periods, or why your password reset emails go to spam for two weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge cases you don't know exist.&lt;/strong&gt; What happens if a webhook fires before a user's account row commits? What if a Stripe customer object exists but the local user row doesn't? What if two browser tabs both try to refresh a token at the same time? AI doesn't ask these questions. You only learn the questions after you ship and break.&lt;/p&gt;

&lt;p&gt;Each of these gotchas costs you a day to a week. There are dozens of them. They're the difference between "I built a SaaS in a weekend" Twitter content and an actual production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What buying gets you for $69
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkptrh83vqbtgjah2cg83.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkptrh83vqbtgjah2cg83.png" alt=" " width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For comparison, here's what a current-generation Next.js SaaS starter ships with on day one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (latest), TypeScript strict mode&lt;/li&gt;
&lt;li&gt;Auth with RBAC, password reset, email verification&lt;/li&gt;
&lt;li&gt;Stripe and LemonSqueezy (toggle via env var)&lt;/li&gt;
&lt;li&gt;Webhook handlers with signature verification, idempotency, retry&lt;/li&gt;
&lt;li&gt;Customer portal with subscription and billing management&lt;/li&gt;
&lt;li&gt;Admin panel with user, billing, and content management&lt;/li&gt;
&lt;li&gt;A real CMS with 20+ section templates and a media library (if you pick the right kit — see the previous section on this)&lt;/li&gt;
&lt;li&gt;AI chat widget with intent detection and lead capture&lt;/li&gt;
&lt;li&gt;Real-time analytics with UTM attribution&lt;/li&gt;
&lt;li&gt;Email integration (Resend, SES, etc.)&lt;/li&gt;
&lt;li&gt;52+ unit tests, Playwright E2E tests&lt;/li&gt;
&lt;li&gt;15 database migrations&lt;/li&gt;
&lt;li&gt;15+ pre-built themes&lt;/li&gt;
&lt;li&gt;Coming Soon and Maintenance modes&lt;/li&gt;
&lt;li&gt;A CLAUDE.md so your AI tools actually understand the codebase&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Price: &lt;strong&gt;$69–$119, one time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The time-to-value is also different. With a ready-made kit, you clone the repo, fill in your env vars, and have a working SaaS in an afternoon. With a vibe-coded build, you have an afternoon of work that takes you to maybe 5% of the feature list above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest math
&lt;/h2&gt;

&lt;p&gt;Let me put it side by side.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Vibe code with AI&lt;/th&gt;
&lt;th&gt;Buy a starter kit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cash outlay&lt;/td&gt;
&lt;td&gt;$500–$800 (subscriptions over 3 months)&lt;/td&gt;
&lt;td&gt;$69–$119 (one-time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your time&lt;/td&gt;
&lt;td&gt;300–620 hours&lt;/td&gt;
&lt;td&gt;5–20 hours setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of your time @ $50/hr&lt;/td&gt;
&lt;td&gt;$15,000–$31,000&lt;/td&gt;
&lt;td&gt;$250–$1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to working SaaS&lt;/td&gt;
&lt;td&gt;3–6 months&lt;/td&gt;
&lt;td&gt;1 afternoon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk of production bugs&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low (battle-tested code)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;You, forever&lt;/td&gt;
&lt;td&gt;Kit updates from maintainer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning value&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your time is worth literally nothing — you're a student, you're between jobs, you're treating this as your free education — then vibe coding makes sense.&lt;/p&gt;

&lt;p&gt;For anyone else, the comparison isn't $69 vs $500 in AI subscriptions. It's $69 vs three to six months of your life.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should vibe code it anyway
&lt;/h2&gt;

&lt;p&gt;I'm going to push back on my own argument now, because there are real cases where building it yourself is the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're learning.&lt;/strong&gt; If the goal is to understand how a SaaS works end-to-end, building it yourself with AI as your tutor is genuinely valuable. You'll come out the other side a better developer. The "wasted" time is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your stack is exotic.&lt;/strong&gt; If you want to build on Bun + Hono + LibSQL + Better Auth + Polar + Cloudflare Workers, no starter kit serves you. You're going to build a lot of this yourself anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have specific architectural requirements.&lt;/strong&gt; Multi-tenant with strict data isolation? On-prem deployment? Air-gapped enterprise? Most starter kits assume single-tenant SaaS on a cloud provider. Roll your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You're a senior engineer who actually QAs AI output.&lt;/strong&gt; If you can spot when AI generates a subtly broken Stripe webhook and fix it in 10 minutes, the time penalty is much smaller. AI as a productivity multiplier for someone who already knows the answers is genuinely transformative. AI as a substitute for senior judgment is a trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You enjoy it.&lt;/strong&gt; This is a real reason. If you'd rather spend a Saturday building auth than reading a kit's documentation, do it. Just don't pretend it's the economically rational choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should just buy
&lt;/h2&gt;

&lt;p&gt;For everyone else — solo founders, indie hackers, teams who want to be selling product not building plumbing — the answer is obvious. Buy a starter kit.&lt;/p&gt;

&lt;p&gt;The starter kit market in 2026 is more competitive than it's ever been. $69 buys you a codebase that two years ago would have been a $50,000 contract job. The differentiator now is which kit fits your needs, not whether to build it yourself.&lt;/p&gt;

&lt;p&gt;If you want the most feature-complete kit on the market right now — auth, payments (Stripe + LemonSqueezy), admin panel, a real drag-and-drop CMS with 20+ section templates, AI chat widget, real-time analytics, the whole package — &lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;RapidLaunch&lt;/a&gt; ships all of it for $69–$119, one-time. That's less than a single month of Claude Max plus Cursor. And you'd still have to write the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual question
&lt;/h2&gt;

&lt;p&gt;The vibe coding question is really a question about what you want to be doing.&lt;/p&gt;

&lt;p&gt;If you want to be building a SaaS &lt;em&gt;product&lt;/em&gt; — the thing that solves a customer problem, the thing you can sell — buying a starter is the only sane move. Your time is better spent on what makes your product different, not on rebuilding auth for the thousandth time.&lt;/p&gt;

&lt;p&gt;If you want to be building a &lt;em&gt;codebase&lt;/em&gt; for the experience of building it, vibe code away. AI makes this more fun than it's ever been.&lt;/p&gt;

&lt;p&gt;Just be honest with yourself about which one you're doing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;Skip the build, ship the product →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>vibecoding</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Best Next.js SaaS Starter Kits with a Built-in CMS (2026)</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Mon, 18 May 2026 18:35:21 +0000</pubDate>
      <link>https://dev.to/luciench/the-best-nextjs-saas-starter-kits-with-a-built-in-cms-2026-445h</link>
      <guid>https://dev.to/luciench/the-best-nextjs-saas-starter-kits-with-a-built-in-cms-2026-445h</guid>
      <description>&lt;p&gt;It's month two of your SaaS. Your co-founder pings you on Slack: &lt;em&gt;"Can we change the hero headline? I think it's confusing."&lt;/em&gt; You open the repo, edit a string, push, wait for Vercel, redeploy. Five minutes for a typo.&lt;/p&gt;

&lt;p&gt;Three weeks in, you've done this fifteen times.&lt;/p&gt;

&lt;p&gt;This is the dirty secret of Next.js SaaS boilerplates. Almost all of them ship "a CMS" that turns out to be a folder of MDX files. Great for a developer who loves Git. Catastrophic for anyone else on the team — a non-technical co-founder, a marketing hire, a freelance copywriter — who needs to update content without going through a pull request.&lt;/p&gt;

&lt;p&gt;Once you actually need a content workflow, you have three options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Integrate a headless CMS as a separate service&lt;/strong&gt; (Contentful, Sanity, Storyblok). Adds infrastructure, a recurring bill, another vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build one yourself.&lt;/strong&gt; Weeks of work. Custom admin UI, role-based permissions, image uploads, draft/publish states, preview, version history. You will get this wrong the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a starter kit where the CMS is already built in.&lt;/strong&gt; Cheapest, fastest, and the only option a sane solo founder should consider in 2026.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article is about option three. I looked at the Next.js SaaS starter kit market and asked one question: &lt;em&gt;who can actually run the marketing site without touching code?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most kits don't make the cut. The ones that do break into three tiers, and the gap between tier 1 and tier 3 is enormous.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as a "real" CMS in a starter kit
&lt;/h2&gt;

&lt;p&gt;Before the ranking, here are the criteria. There are three tiers of CMS capability in this market, and the difference between them is what a non-developer can actually do on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — Visual page builder / drag-and-drop CMS.&lt;/strong&gt; A non-developer can build, edit, and publish marketing pages from an admin UI. Sections are pre-built components, content is editable inline, no Git involved. This is what most people picture when they say "CMS."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — Integrated headless CMS.&lt;/strong&gt; A real CMS UI (typically Sanity Studio or similar) is wired into the kit. A non-developer can edit structured content (blog posts, testimonials, FAQs), but page layout is still code. You're not paying for the CMS as a separate SaaS — it ships with the kit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — MDX blog.&lt;/strong&gt; Markdown files in a &lt;code&gt;/content&lt;/code&gt; folder. Useful for technical blogs, useless for anyone who doesn't use Git. Some kits dress this up with extra content types (changelog, roadmap), but the workflow stays dev-only.&lt;/p&gt;

&lt;p&gt;Kits that ship none of the above don't make the list. I'll cover those briefly at the end for completeness.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real-world test: changing the hero headline
&lt;/h2&gt;

&lt;p&gt;Before the ranking, here's the test I keep coming back to. Imagine your co-founder, who doesn't code, wants to A/B test two hero headlines. What happens in each tier?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1 (visual builder):&lt;/strong&gt; Log into the admin panel, click the hero section, type the new headline, save. Live in 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2 (headless CMS):&lt;/strong&gt; Log into the CMS dashboard, find the "Homepage" document, edit the &lt;code&gt;heroTitle&lt;/code&gt; field, publish. Live in a minute or two — but only if the developer set up that field as editable content, which is a one-time piece of work that may or may not be done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3 (MDX blog):&lt;/strong&gt; Slack the developer. Wait. The developer edits a &lt;code&gt;.tsx&lt;/code&gt; file, commits, pushes, waits for the deploy. The headline is hardcoded, so they may have to refactor it before they can change it. Live in 20 minutes to 2 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the difference between a CMS and a Git workflow with extra steps. Keep this scenario in mind as you read.&lt;/p&gt;

&lt;p&gt;Here are the six kits with a real content layer, ranked by depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RapidLaunch — Tier 1 (Visual Page Builder)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1ayrzjtzmy9qob83rv5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh1ayrzjtzmy9qob83rv5.png" alt=" " width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $69 (Starter) / $119 (Pro/Unlimited) — one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;rapidlaun.ch&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; Drag-and-drop visual page builder&lt;/p&gt;

&lt;p&gt;RapidLaunch is the only kit on this list with a full visual page builder out of the box. You get 25+ templates, 35+ customizable components, 20+ section templates, a visual navigation editor, a footer editor, and a media library. There's also an AI template generator that scaffolds a complete site layout from a business description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a non-developer can do on day one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build a landing page from pre-made sections (hero, pricing, FAQ, testimonials, feature grid, etc.) by dragging them into place&lt;/li&gt;
&lt;li&gt;Edit copy, swap images, change CTAs without touching code&lt;/li&gt;
&lt;li&gt;Edit the main navigation and footer through dedicated editors&lt;/li&gt;
&lt;li&gt;Upload and manage images through the media library&lt;/li&gt;
&lt;li&gt;Toggle Coming Soon mode or Maintenance mode with one click&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the kind of workflow most teams don't get until they pay for Webflow ($30+/month) or stand up a headless CMS as a separate service. RapidLaunch ships it as part of the kit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack&lt;/strong&gt; (carried over from the wider comparison): Next.js 16 with App Router, TypeScript in strict mode, Supabase for database/auth/storage, Stripe and LemonSqueezy (choose via env var), Tailwind CSS + DaisyUI with 15+ theme presets, TipTap rich text editor, TanStack Query, Vitest + Playwright. Authentication includes Owner + Admin roles (RBAC).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Supabase is the only database option. Auth is email/password only out of the box — Supabase supports social OAuth, but you'd add it yourself. The project is newer than ShipFast or Makerkit, so the community is smaller (though growing).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; The only kit on this list where a non-developer can run the marketing site from day one. If editing pages without a deploy is a requirement, this is the pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Indie Starter — Tier 2 (Sanity CMS Integration)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkvaw3g6mjru3eza1jpv2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkvaw3g6mjru3eza1jpv2.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $69 (Tier 1) / $199 (Tier 2) / $299 (Tier 3) — one-time, unlimited projects&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://indie-starter.dev" rel="noopener noreferrer"&gt;indie-starter.dev&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; Integrated Sanity Studio&lt;/p&gt;

&lt;p&gt;Indie Starter wires Sanity CMS into the kit so you get a real headless CMS UI without setting it up yourself. Most kits with a "blog" mean MDX files. Indie Starter means a hosted CMS dashboard where you can model content types, edit posts in a rich editor, schedule publishing, and manage media — the standard Sanity workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a non-developer can do on day one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edit blog posts, articles, and any custom content types in Sanity Studio&lt;/li&gt;
&lt;li&gt;Upload and manage images&lt;/li&gt;
&lt;li&gt;Preview content before publishing&lt;/li&gt;
&lt;li&gt;Schedule posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What they can't do: edit landing-page layout. Marketing pages are still code. The CMS handles structured content, not visual page composition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack:&lt;/strong&gt; Next.js (latest), TypeScript, Supabase (PostgreSQL), magic links + Google/GitHub OAuth, Stripe with webhooks, Tailwind CSS + Shadcn/ui, Resend, Umami + Google Analytics, Zod. SEO infrastructure (sitemaps, robots.txt, Schema.org) is built in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Sanity is a separate service. The integration is free, but Sanity has its own free-tier limits (3 users, 10K documents, 5GB assets) — if you outgrow them, that's a separate bill. Some features are tier-locked, so check what's in the $69 tier before committing. Supabase is the only database option. Limited auth compared to bigger kits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; The second-best CMS story in this market, and at $69 the cheapest path to a real headless CMS workflow. Best for content-heavy products (blogs, knowledge bases, documentation) where structured content matters more than landing-page flexibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Nextbase — Tier 3+ (MDX Blog + Content Features)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdqeh4vcsu9et001lyr6i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdqeh4vcsu9et001lyr6i.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $99 (Essential) / $299 (Pro) / $399 (Ultimate) — one-time, lifetime access&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://usenextbase.com" rel="noopener noreferrer"&gt;usenextbase.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; MDX blog + roadmap, changelog, user feedback (Pro tier and up)&lt;/p&gt;

&lt;p&gt;Nextbase ships an MDX blog like everyone else in tier 3, but the Pro tier ($299) adds content features that most starter kits don't: a user feedback collection system, a public roadmap, and a changelog. None of these are a CMS in the strict sense, but they're content features you'd otherwise build yourself or pay separate tools for (Canny, Featurebase, etc.).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a non-developer can do on day one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read user feedback and roadmap submissions in the admin panel&lt;/li&gt;
&lt;li&gt;Publish changelog entries (depending on implementation)&lt;/li&gt;
&lt;li&gt;Not much beyond that — blog content still lives in MDX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack:&lt;/strong&gt; Next.js 15+, TypeScript, Supabase, Stripe, Tailwind CSS + Shadcn, Jest + Playwright, Sentry, PostHog + Google Analytics, OpenAI GPT-4. The monitoring stack (Sentry + PostHog) is a real differentiator — you start with observability instead of bolting it on later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; The blog is still MDX. Features are tier-locked, so the content extras only show up at $299 Pro and above. Supabase only. Smaller community than ShipFast or Makerkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Worth considering if you want product-content features (feedback, roadmap, changelog) bundled in. Not a CMS replacement, but the closest thing to one in the tier 3 group.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. ShipFast — Tier 3 (MDX Blog)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1enumgezq0j0u6fx0ku8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1enumgezq0j0u6fx0ku8.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $199 (Starter) / $249 (All-in) / $299 (Bundle) — one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;shipfa.st&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; MDX blog&lt;/p&gt;

&lt;p&gt;ShipFast is the most popular Next.js boilerplate by community size, but the CMS story is minimal: an MDX blog. That's it. No admin UI for content, no headless CMS integration, no page builder. ShipFast is built around the philosophy of shipping fast — content management is something you add later if you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack:&lt;/strong&gt; Next.js 15, TypeScript or JavaScript, MongoDB or Supabase, Google OAuth + magic links, Stripe + LemonSqueezy, Tailwind CSS, Mailgun or Resend. The 8,000+ Discord and shipping culture are the real product — the codebase itself is lighter than most competitors at the same price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Beyond the lack of CMS, there's no admin dashboard beyond basic user management, no analytics, no AI features. If your SaaS grows past MVP, you'll build a lot on top of what ShipFast ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for founders who want to ship an MVP in a week and don't care about a CMS workflow until later. If you do care about CMS, look elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. LaunchFast — Tier 3 (MDX Blog)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fktqe2ygidyj40dhgldwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fktqe2ygidyj40dhgldwb.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $99 (single framework) / $149 (Astro + Next.js + SvelteKit bundle) — one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://www.launchfa.st" rel="noopener noreferrer"&gt;launchfa.st&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; MDX blog&lt;/p&gt;

&lt;p&gt;LaunchFast is the "bring your own stack" kit — it supports 5+ database providers, multiple email services, several storage backends, and deploys to half a dozen targets. The trade-off is that the kit itself is less opinionated, which extends to content: you get an MDX blog and the assumption that you'll wire up whatever CMS you want on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack:&lt;/strong&gt; Next.js (also Astro and SvelteKit), TypeScript, MongoDB/Firebase/PostgreSQL/Redis/SQLite, flexible auth, Stripe + LemonSqueezy, Resend/Postmark/SendGrid/Mailgun/AutoSend, S3/R2/Firebase/Supabase Storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Maximum flexibility, but more decisions and more wiring. If you want a CMS, you're integrating one yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Good if you already know you want a specific CMS (Payload, Strapi, Sanity, Directus) and want a kit that won't fight you when you add it. Not good if you want a CMS to be solved for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Shipped.club — Tier 3 (MDX Blog + Special Pages)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxv3z47x158nlgjfk6hz4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxv3z47x158nlgjfk6hz4.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $149 — one-time, lifetime access&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://shipped.club" rel="noopener noreferrer"&gt;shipped.club&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;CMS type:&lt;/strong&gt; MDX blog + waitlist / pre-order / affiliate pages&lt;/p&gt;

&lt;p&gt;Shipped.club ships an MDX blog plus pre-built pages most kits don't include: a waitlist page, a pre-order page, and an affiliate program page. These aren't CMS-managed (they're code), but they're content surfaces that ship working out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rest of the stack:&lt;/strong&gt; Next.js 14, TypeScript, Supabase, NextAuth + Supabase Auth, LemonSqueezy (no Stripe), ChakraUI + Tailwind, MailChimp + Loops. PPP pricing is a nice touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Stuck on Next.js 14. LemonSqueezy is the only payment option. Supabase only. Less documentation than the bigger kits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Worth considering if the bundled pages (waitlist, pre-order, affiliate) save you time and an MDX blog is enough on the content side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kits without any CMS at all
&lt;/h2&gt;

&lt;p&gt;For honesty: four kits in the broader Next.js SaaS market ship with no CMS layer of any kind. Worth mentioning so this list doesn't look cherry-picked.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supastarter&lt;/strong&gt; ($349+): excellent on payments (5 providers), team management, and multi-framework support (Next.js / Nuxt / SvelteKit), but blog and docs only — no CMS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Makerkit&lt;/strong&gt; ($299+): strong on multi-tenant B2B with row-level security and the most complete auth on the market, but no CMS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nextless.js&lt;/strong&gt; ($699+): AWS-native with serverless and React Native support, but no CMS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel SaaS Starter&lt;/strong&gt; (free): minimal by design — auth, Stripe, Drizzle. You'd add a CMS yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are good kits for what they do. They just don't compete on content management.&lt;/p&gt;

&lt;h2&gt;
  
  
  CMS-Focused Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kit&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;CMS Type&lt;/th&gt;
&lt;th&gt;Non-Dev Editing&lt;/th&gt;
&lt;th&gt;Blog&lt;/th&gt;
&lt;th&gt;Visual Builder&lt;/th&gt;
&lt;th&gt;Media Library&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;RapidLaunch&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$69&lt;/td&gt;
&lt;td&gt;Drag-and-drop builder&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via CMS&lt;/td&gt;
&lt;td&gt;Yes (20+ sections)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://indie-starter.dev" rel="noopener noreferrer"&gt;Indie Starter&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$69&lt;/td&gt;
&lt;td&gt;Sanity CMS&lt;/td&gt;
&lt;td&gt;Yes (Sanity Studio)&lt;/td&gt;
&lt;td&gt;Sanity&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Sanity)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://usenextbase.com" rel="noopener noreferrer"&gt;Nextbase&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$99 / $299 Pro&lt;/td&gt;
&lt;td&gt;MDX + feedback/roadmap&lt;/td&gt;
&lt;td&gt;Partial (admin only)&lt;/td&gt;
&lt;td&gt;MDX (Pro)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;ShipFast&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$199&lt;/td&gt;
&lt;td&gt;MDX&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.launchfa.st" rel="noopener noreferrer"&gt;LaunchFast&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$99&lt;/td&gt;
&lt;td&gt;MDX&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://shipped.club" rel="noopener noreferrer"&gt;Shipped.club&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;td&gt;MDX + special pages&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose RapidLaunch if&lt;/strong&gt; you need a non-developer to own the marketing site, or you don't want to redeploy every time a headline changes. The drag-and-drop builder is the only one of its kind in this market, and at $69 one-time it undercuts two months of a Webflow CMS subscription.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Indie Starter if&lt;/strong&gt; you want a real headless CMS workflow (Sanity Studio) at the lowest possible entry price ($69). Best for content-heavy products where structured content matters more than visual page composition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Nextbase Pro if&lt;/strong&gt; you want product-content features (user feedback, roadmap, changelog) bundled with your starter, and you're okay with MDX for the blog itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose ShipFast, LaunchFast, or Shipped.club if&lt;/strong&gt; an MDX blog is genuinely enough for now and you'll handle content management yourself later. These are good kits — just don't pick them expecting a CMS workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The CMS gap in the Next.js SaaS starter market is real. Most kits hand you authentication, payments, and a landing page, then leave content management as an exercise for the reader. For solo developers building their first product, that's often fine. For anyone with a non-technical co-founder, a marketing function, or a growth-stage product where copy changes weekly, it's a problem you'll hit by month two.&lt;/p&gt;

&lt;p&gt;If you want a CMS workflow without paying extra for one or building it yourself, the practical choice today is &lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;RapidLaunch&lt;/a&gt; for a full visual page builder, or &lt;a href="https://indie-starter.dev" rel="noopener noreferrer"&gt;Indie Starter&lt;/a&gt; for an integrated Sanity workflow at the lowest price. Everything else in this market punts on the content layer.&lt;/p&gt;

&lt;p&gt;If I were starting a new SaaS today and knew I'd be editing the marketing site regularly, I'd pick RapidLaunch. The combination of a drag-and-drop builder, an AI template generator, and the rest of the kit (auth, payments, admin dashboard, analytics) at $69–$119 one-time is a better deal than buying Webflow plus any other starter on this list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;Try RapidLaunch&lt;/a&gt; and run your marketing site without a deploy pipeline.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cms</category>
      <category>content</category>
      <category>website</category>
    </item>
    <item>
      <title>HTML to PDF API: Convert Web Content to PDF Programmatically with Foxit</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Thu, 16 Apr 2026 17:04:49 +0000</pubDate>
      <link>https://dev.to/luciench/html-to-pdf-api-convert-web-content-to-pdf-programmatically-with-foxit-1df0</link>
      <guid>https://dev.to/luciench/html-to-pdf-api-convert-web-content-to-pdf-programmatically-with-foxit-1df0</guid>
      <description>&lt;p&gt;Your &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer&lt;/a&gt; setup works fine at low volume. You launch a Chrome process, load the page, call &lt;code&gt;page.pdf()&lt;/code&gt;, and write the bytes to disk. Then your invoice generation hits 500 documents per night, your report export feature goes live across three time zones simultaneously, and the wheels come off. Chrome processes time out waiting for JavaScript hydration. Memory climbs until your container OOMs. The font that renders correctly on your MacBook looks wrong on the Linux build server. You spend a Friday afternoon tuning &lt;a href="https://pptr.dev/api/puppeteer.puppeteerlifecycleevent" rel="noopener noreferrer"&gt;&lt;code&gt;networkidle2&lt;/code&gt;&lt;/a&gt; timeouts per template instead of shipping features.&lt;/p&gt;

&lt;p&gt;That failure mode comes from treating a rendering engine as a conversion service. &lt;a href="https://developer.chrome.com/docs/chromium/new-headless" rel="noopener noreferrer"&gt;Headless Chrome&lt;/a&gt; is a browser, and running it at production document volume means operating a browser fleet: process pooling, memory isolation, crash recovery, rendering consistency across OS environments. All of that infrastructure overhead comes directly out of engineering time.&lt;/p&gt;

&lt;p&gt;A managed REST API sidesteps that entirely. You POST your HTML (or a URL), the service renders the PDF, and you download the result. The rendering infrastructure becomes the API provider's problem. This guide covers how to build that conversion pipeline end-to-end using &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit PDF Services API&lt;/a&gt;, from authentication through batch processing and production error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Production Problem with Headless Browser PDF Conversion
&lt;/h2&gt;

&lt;p&gt;A standard &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;Puppeteer&lt;/a&gt; setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At five documents a day, this is fine. At five hundred concurrent, each &lt;code&gt;puppeteer.launch()&lt;/code&gt; spins up a full Chromium process, roughly 100-200MB RSS on Linux. In a container with 2GB of memory and 20 concurrent requests, you're at the memory ceiling before accounting for the Node.js process or any other application memory.&lt;/p&gt;

&lt;p&gt;The standard fix is a Chrome process pool (libraries like &lt;a href="https://github.com/thomasdondorf/puppeteer-cluster" rel="noopener noreferrer"&gt;&lt;code&gt;puppeteer-cluster&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://github.com/coopernurse/node-pool" rel="noopener noreferrer"&gt;&lt;code&gt;generic-pool&lt;/code&gt;&lt;/a&gt;). Now you're managing pool size tuning, handling pool exhaustion under burst traffic, and writing cleanup logic for crashed Chrome instances. You've added significant operational complexity to what started as a one-liner.&lt;/p&gt;

&lt;p&gt;Font rendering is its own category of pain. Chrome on macOS uses CoreText. Chrome on Linux uses &lt;a href="https://freetype.org/" rel="noopener noreferrer"&gt;FreeType&lt;/a&gt; with &lt;a href="https://www.freedesktop.org/wiki/Software/fontconfig/" rel="noopener noreferrer"&gt;fontconfig&lt;/a&gt;. The same CSS &lt;code&gt;font-family: 'Inter'&lt;/code&gt; declaration produces visibly different output depending on whether Inter is installed as a system font or loaded via a &lt;code&gt;@font-face&lt;/code&gt; declaration, and whether the fallback stack resolves differently across environments. Teams that ship invoice PDFs to customers discover this in production.&lt;/p&gt;

&lt;p&gt;JavaScript execution adds another dimension. If your page renders a data table via a React component that fetches data on mount, &lt;code&gt;networkidle2&lt;/code&gt; is an unreliable wait condition. Network activity can go idle before the DOM finishes updating. You end up tuning &lt;a href="https://pptr.dev/api/puppeteer.page.waitforselector" rel="noopener noreferrer"&gt;&lt;code&gt;waitForSelector&lt;/code&gt;&lt;/a&gt; or adding arbitrary timeouts per template, and those timeouts become technical debt that breaks when the page changes.&lt;/p&gt;

&lt;p&gt;A managed REST API with consistent rendering environments and no infrastructure to maintain solves these problems at the architectural level.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Cloud HTML-to-PDF APIs Handle Rendering
&lt;/h2&gt;

&lt;p&gt;Cloud conversion APIs typically accept input in two modes: URL mode and file upload mode.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;URL mode&lt;/strong&gt;, you pass a public URL. The API fetches the page, renders it, and returns a PDF. This works when your page is publicly accessible and all assets (fonts, images, stylesheets) load from the same domain or CDN. The tradeoff is that the API's rendering environment must reach your server, which creates a dependency on network reachability and your server's response time. If you're generating PDFs from an internal dashboard behind a VPN, URL mode doesn't work without additional networking.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;file upload mode&lt;/strong&gt;, you construct the complete HTML file (with inlined CSS and assets where needed) and upload it to the API. The service processes the file and returns a PDF. This eliminates the external asset dependency and makes your conversion more deterministic. The same HTML file always produces the same PDF, regardless of what's deployed on your web server at the time.&lt;/p&gt;

&lt;p&gt;Beyond input mode, rendering fidelity depends on several factors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media" rel="noopener noreferrer"&gt;&lt;code&gt;@media print&lt;/code&gt;&lt;/a&gt; rules&lt;/strong&gt; control what renders into the PDF. Navigation bars, sidebars, and hover states should be hidden via print stylesheets so they don't appear in the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Font loading strategy&lt;/strong&gt; determines rendering consistency. Relying on system fonts produces different output across environments. Embedding fonts via &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face" rel="noopener noreferrer"&gt;&lt;code&gt;@font-face&lt;/code&gt;&lt;/a&gt; with a CDN URL or base64-inlined data guarantees consistent rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page layout properties&lt;/strong&gt; (paper size, margins, orientation) can be controlled through CSS &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@page" rel="noopener noreferrer"&gt;&lt;code&gt;@page&lt;/code&gt;&lt;/a&gt; rules embedded in the HTML itself. This keeps layout configuration in the document rather than in API parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript execution&lt;/strong&gt; matters for pages that render content dynamically. Some APIs wait for the page to stabilize before capturing; others capture immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These factors are the same ones you'd manage with &lt;a href="https://pptr.dev/api/puppeteer.pdfoptions" rel="noopener noreferrer"&gt;Puppeteer's &lt;code&gt;page.pdf()&lt;/code&gt; options&lt;/a&gt;, but with a cloud API you handle them through your HTML and CSS rather than through in-process code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Foxit PDF Services API: Authentication and First Conversion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit PDF Services API&lt;/a&gt; is a &lt;a href="https://developer-api.foxit.com/developer-blogs/use-cases-workflow-examples/automated-document-pipelines/introducing-pdf-apis-from-foxit/" rel="noopener noreferrer"&gt;cloud-hosted REST API&lt;/a&gt; built on Foxit's proprietary PDF engine, backed by over 20 years of PDF technology development. Create an account at &lt;a href="https://app.developer-api.foxit.com/pricing" rel="noopener noreferrer"&gt;the Foxit Developer Portal&lt;/a&gt; (the Developer plan is free, includes 500 credits/year, and requires no credit card). Generate your API credentials (&lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;) from the Developer Dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the Async Workflow
&lt;/h3&gt;

&lt;p&gt;Foxit PDF Services uses an &lt;strong&gt;asynchronous task-based workflow&lt;/strong&gt;. Every operation follows the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Submit the job (upload a file, or POST a URL)&lt;/li&gt;
&lt;li&gt;Receive a &lt;code&gt;taskId&lt;/code&gt; in the response&lt;/li&gt;
&lt;li&gt;Poll the task status until it completes or fails&lt;/li&gt;
&lt;li&gt;Download the result using the &lt;code&gt;resultDocumentId&lt;/code&gt; from the completed task&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design handles long-running operations cleanly. A complex HTML page might take several seconds to render, and the async pattern means your client never blocks on a single HTTP request waiting for rendering to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  URL-to-PDF Conversion
&lt;/h3&gt;

&lt;p&gt;For pages that are publicly accessible, URL-to-PDF is the simplest path. You POST the URL directly and the API fetches, renders, and converts it. The complete workflow in Python uses the &lt;a href="https://docs.python-requests.org/" rel="noopener noreferrer"&gt;&lt;code&gt;requests&lt;/code&gt;&lt;/a&gt; library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sleep&lt;/span&gt;

&lt;span class="n"&gt;HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_API_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# e.g., https://na1.fusion.foxit.com
&lt;/span&gt;&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_url_to_pdf_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Submit a URL for PDF conversion. Returns a taskId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/create/pdf-from-url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Poll until the task completes or fails. Returns the task status object.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/tasks/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Task &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;download_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Download the resulting PDF by its document ID.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Full workflow: URL to PDF
&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_url_to_pdf_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/invoice/1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;download_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_1042.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PDF generated successfully.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three reusable functions map to the async workflow: &lt;code&gt;create_url_to_pdf_task()&lt;/code&gt; submits a public URL and returns a &lt;code&gt;taskId&lt;/code&gt;, &lt;code&gt;poll_task()&lt;/code&gt; checks task status in a loop until it reaches &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;, and &lt;code&gt;download_document()&lt;/code&gt; streams the resulting PDF to disk. The final three lines wire them together into the complete conversion pipeline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before running:&lt;/strong&gt; Set your &lt;code&gt;FOXIT_API_HOST&lt;/code&gt;, &lt;code&gt;FOXIT_CLIENT_ID&lt;/code&gt;, and &lt;code&gt;FOXIT_CLIENT_SECRET&lt;/code&gt; environment variables with the values from your &lt;a href="https://app.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit Developer Dashboard&lt;/a&gt;. Never commit credentials to source control; use environment variables or a secrets manager.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  HTML File-to-PDF Conversion
&lt;/h3&gt;

&lt;p&gt;When your content isn't publicly accessible (internal dashboards, dynamically generated reports), you can upload an HTML file directly. This follows the same 4-step async pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Upload a file to Foxit. Returns a documentId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_html_to_pdf_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create an HTML-to-PDF conversion task. Returns a taskId.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;AUTH_HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/create/pdf-from-html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Full workflow: HTML file to PDF
&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_html_to_pdf_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;download_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTML converted to PDF successfully.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You first upload a local &lt;code&gt;.html&lt;/code&gt; file via &lt;code&gt;upload_document()&lt;/code&gt;, which returns a &lt;code&gt;documentId&lt;/code&gt; referencing the uploaded file on Foxit's servers. Then &lt;code&gt;create_html_to_pdf_task()&lt;/code&gt; submits that &lt;code&gt;documentId&lt;/code&gt; for conversion. The rest of the workflow is identical: poll for completion, then download the result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Replace &lt;code&gt;"report.html"&lt;/code&gt; with the path to your own HTML file. This code reuses the &lt;code&gt;poll_task()&lt;/code&gt; and &lt;code&gt;download_document()&lt;/code&gt; functions from the URL-to-PDF example above, so make sure both are defined in the same script.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;URL-to-PDF skips the upload step because you POST the URL directly. HTML file conversion requires uploading the &lt;code&gt;.html&lt;/code&gt; file first via the &lt;code&gt;/documents/upload&lt;/code&gt; endpoint. Both use the same poll-and-download pattern after task creation.&lt;/p&gt;

&lt;p&gt;Refer to the &lt;a href="https://docs.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit API documentation&lt;/a&gt; and the &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Postman workspace&lt;/a&gt; for the complete parameter reference, including any additional rendering options supported by these endpoints. The &lt;a href="https://github.com/foxitsoftware/developerapidemos" rel="noopener noreferrer"&gt;GitHub demo repository&lt;/a&gt; contains working examples in Python, Node.js, and PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlling CSS and JavaScript Rendering in HTML-to-PDF Conversion
&lt;/h2&gt;

&lt;p&gt;Regardless of which API you use for HTML-to-PDF conversion, output quality depends on how well you prepare the HTML. The rendering parameters live in your document, not in API request fields.&lt;/p&gt;

&lt;p&gt;The most common rendering problem between "looks right in a browser" and "looks wrong in a PDF" is the CSS media type. By default, browsers render with &lt;code&gt;screen&lt;/code&gt; styles, which means your navigation bar, sidebar, and hover states all appear in the output. For PDF output, you want your &lt;code&gt;@media print&lt;/code&gt; rules to take over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="n"&gt;print&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.sidebar&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="nc"&gt;.no-print&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Inter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Arial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.invoice-table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;page-break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.page-header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;page-break-before&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20mm&lt;/span&gt; &lt;span class="m"&gt;15mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stylesheet hides non-essential UI elements (navigation, sidebars) when printing, sets a clean body font, and uses &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-inside" rel="noopener noreferrer"&gt;&lt;code&gt;page-break-inside: avoid&lt;/code&gt;&lt;/a&gt; to prevent the renderer from splitting a table row across pages. The nested &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@page" rel="noopener noreferrer"&gt;&lt;code&gt;@page&lt;/code&gt;&lt;/a&gt; rule sets the paper size and margins at the CSS level, so layout configuration stays in the document rather than in API parameters.&lt;/p&gt;

&lt;p&gt;For font rendering consistency, don't rely on system fonts. Include a &lt;code&gt;@font-face&lt;/code&gt; declaration in your HTML that loads from a CDN, or inline the font as base64:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Inter"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url("https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ.woff2")&lt;/span&gt;
      &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"woff2"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This embeds the &lt;a href="https://fonts.google.com/specimen/Inter" rel="noopener noreferrer"&gt;Inter&lt;/a&gt; font directly in the HTML using a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face" rel="noopener noreferrer"&gt;&lt;code&gt;@font-face&lt;/code&gt;&lt;/a&gt; declaration pointing to Google Fonts. Inter renders in the PDF regardless of what fonts are installed in the API's container environment. The tradeoff is latency: the rendering engine fetches the font file during conversion. If you're running high-volume batch jobs, consider inlining the font as a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data" rel="noopener noreferrer"&gt;base64 data URI&lt;/a&gt; to eliminate that network round trip.&lt;/p&gt;

&lt;p&gt;For JavaScript-heavy pages, make sure the content has fully rendered before the API captures it. If you're using the URL-to-PDF endpoint, the API fetches and renders the live page, so your page's JavaScript will execute. For the HTML file upload path, keep your HTML self-contained with all data already rendered in the markup, rather than relying on client-side JavaScript to populate it after load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch HTML-to-PDF Conversion at Scale
&lt;/h2&gt;

&lt;p&gt;Sequential conversion is the naive starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;html_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_html_to_pdf_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;download_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each invoice is processed one at a time, uploading, converting, polling, and downloading before moving to the next. Each iteration blocks on the poll loop before starting the next conversion. At a few seconds per document (upload, render, poll, download), 500 invoices could take over 30 minutes.&lt;/p&gt;

&lt;p&gt;Concurrent dispatch with a semaphore to cap parallelism fixes that. Check your plan's rate limits before setting the semaphore ceiling in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_API_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FOXIT_CLIENT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;MAX_CONCURRENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;  &lt;span class="c1"&gt;# Adjust based on your plan's rate limits
&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;html_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="c1"&gt;# Step 1: Upload the HTML file
&lt;/span&gt;            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
                    &lt;span class="n"&gt;upload_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upload_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="c1"&gt;# Step 2: Create the conversion task
&lt;/span&gt;            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/create/pdf-from-html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
                &lt;span class="n"&gt;task_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="c1"&gt;# Step 3: Poll for completion
&lt;/span&gt;            &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/tasks/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COMPLETED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;result_doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                        &lt;span class="k"&gt;break&lt;/span&gt;
                    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Task failed for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="c1"&gt;# Step 4: Download the result
&lt;/span&gt;            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result_doc_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/download&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;pdf_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_dir&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error converting &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;batch_convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;output_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_CONCURRENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;connector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TCPConnector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MAX_CONCURRENT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nf"&gt;convert_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;inv&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;succeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;succeeded&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv_1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;templates/invoice_1042.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inv_1043&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;templates/invoice_1043.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# ... up to thousands of entries
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;batch_convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Converted &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;succeeded&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; PDFs. Failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://docs.python.org/3/library/asyncio.html" rel="noopener noreferrer"&gt;&lt;code&gt;asyncio&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://docs.aiohttp.org/en/stable/" rel="noopener noreferrer"&gt;&lt;code&gt;aiohttp&lt;/code&gt;&lt;/a&gt; let you process multiple conversions concurrently. The &lt;code&gt;convert_one()&lt;/code&gt; function runs the full 4-step workflow (upload, create task, poll, download) for a single invoice, while &lt;code&gt;batch_convert()&lt;/code&gt; dispatches all invoices in parallel, capped by a semaphore. Results are collected via &lt;code&gt;asyncio.gather()&lt;/code&gt; and split into succeeded and failed lists.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before running:&lt;/strong&gt; Set &lt;code&gt;FOXIT_API_HOST&lt;/code&gt;, &lt;code&gt;FOXIT_CLIENT_ID&lt;/code&gt;, and &lt;code&gt;FOXIT_CLIENT_SECRET&lt;/code&gt; as environment variables with your credentials from the &lt;a href="https://app.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Developer Dashboard&lt;/a&gt;. Adjust &lt;code&gt;MAX_CONCURRENT&lt;/code&gt; based on your &lt;a href="https://app.developer-api.foxit.com/pricing" rel="noopener noreferrer"&gt;plan's rate limits&lt;/a&gt;, and update the &lt;code&gt;invoices&lt;/code&gt; list with your actual file paths.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With &lt;code&gt;MAX_CONCURRENT = 10&lt;/code&gt; and several seconds per conversion (including polling), the batch processes 10 documents at a time. The semaphore prevents you from flooding the API with simultaneous requests and hitting the rate limit ceiling. &lt;code&gt;asyncio&lt;/code&gt; is part of Python's standard library, so no additional dependencies beyond &lt;code&gt;aiohttp&lt;/code&gt; are needed.&lt;/p&gt;

&lt;p&gt;Credit consumption matters at scale. The &lt;a href="https://app.developer-api.foxit.com/pricing" rel="noopener noreferrer"&gt;Developer plan&lt;/a&gt; includes 500 credits/year. The Startup plan ($1,750/year) provides 3,500 credits. Each conversion typically costs 1 credit. For higher volumes, the Business plan ($4,500/year) includes 150,000 credits. Check your remaining credit balance via the &lt;a href="https://app.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Developer Dashboard&lt;/a&gt; before launching a large batch job.&lt;/p&gt;

&lt;p&gt;For volumes beyond what a single process can handle efficiently, a queue-based architecture decouples submission from processing. Services like &lt;a href="https://aws.amazon.com/sqs/" rel="noopener noreferrer"&gt;Amazon SQS&lt;/a&gt; or &lt;a href="https://redis.io/docs/latest/develop/data-types/streams/" rel="noopener noreferrer"&gt;Redis Streams&lt;/a&gt; handle the message brokering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App Server -&amp;gt; Message Queue (SQS / Redis Streams) -&amp;gt; Worker Pool (N workers)
  Worker: upload HTML -&amp;gt; create task -&amp;gt; poll -&amp;gt; download PDF -&amp;gt; store in S3/GCS
  Worker: update job status in Postgres / Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worker picks a job from the queue, runs the 4-step conversion workflow, writes the resulting PDF to S3 or GCS, and updates the job status in a database. This pattern handles burst volume naturally: jobs queue up during spikes, workers drain at the rate the API allows, and your app server is never blocked waiting for conversions to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Deployment Patterns for HTML-to-PDF Pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Error Handling and Retry Logic
&lt;/h3&gt;

&lt;p&gt;Map HTTP status codes to decisions before writing any retry logic, because not all errors warrant a retry.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;400 Bad Request&lt;/code&gt; means your request body is malformed. Retrying the same payload returns another 400, so fix the payload. A &lt;code&gt;429 Too Many Requests&lt;/code&gt; and a &lt;code&gt;503 Service Unavailable&lt;/code&gt; are transient: back off and retry. A &lt;code&gt;FAILED&lt;/code&gt; task status means the conversion itself failed (possibly due to invalid HTML or unreachable URLs). Check the task response for diagnostic details.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;requests.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RequestException&lt;/span&gt;

&lt;span class="n"&gt;PERMANENT_ERRORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;TRANSIENT_ERRORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;post_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;base_delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;POST with exponential backoff and jitter for transient errors.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PERMANENT_ERRORS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Permanent error &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TRANSIENT_ERRORS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max retries exceeded. Last status: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transient error &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Retrying in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;RequestException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unexpected: exhausted retries without returning or raising&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Usage with the URL-to-PDF endpoint
&lt;/span&gt;&lt;span class="n"&gt;auth_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;post_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/create/pdf-from-url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/invoice/1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auth_headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every POST request goes through a retry loop with &lt;a href="https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/" rel="noopener noreferrer"&gt;exponential backoff&lt;/a&gt;. The function distinguishes between permanent errors (like &lt;code&gt;400&lt;/code&gt; or &lt;code&gt;401&lt;/code&gt;, which you shouldn't retry) and transient errors (like &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;503&lt;/code&gt;, which resolve on their own). Each retry doubles the wait time and adds random jitter to avoid synchronized retry waves.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before running:&lt;/strong&gt; Replace &lt;code&gt;CLIENT_ID&lt;/code&gt;, &lt;code&gt;CLIENT_SECRET&lt;/code&gt;, and &lt;code&gt;HOST&lt;/code&gt; with your Foxit credentials and API host, or load them from environment variables as shown in the earlier examples.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The jitter (&lt;code&gt;random.uniform(0, 0.5)&lt;/code&gt;) prevents a thundering herd where every worker wakes up and retries simultaneously after a 429 burst. Plain exponential backoff produces synchronized retry waves when all workers hit the rate limit at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Output Optimization: Compression and Linearization
&lt;/h3&gt;

&lt;p&gt;After conversion, you can chain additional PDF operations using the same async pattern. Upload the resulting PDF, call the compression or linearization endpoint, poll, and download the optimized version.&lt;/p&gt;

&lt;p&gt;For PDFs served directly in a browser, &lt;a href="https://kb.foxit.com/hc/en-us/articles/23450952035348-What-is-linearized-PDF" rel="noopener noreferrer"&gt;linearization&lt;/a&gt; enables Fast Web View, which lets the browser display page one while the rest of the file downloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compress_and_linearize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_pdf_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Compress a PDF, then linearize it for fast web viewing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;json_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Upload the PDF
&lt;/span&gt;    &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upload_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_pdf_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Compress
&lt;/span&gt;    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/modify/pdf-compress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compressionLevel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json_headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;compressed_doc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Linearize the compressed result (no need to re-upload; use the resultDocumentId)
&lt;/span&gt;    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOST&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pdf-services/api/documents/optimize/pdf-linearize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;compressed_doc_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json_headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# Download the final optimized PDF
&lt;/span&gt;    &lt;span class="nf"&gt;download_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resultDocumentId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You chain two PDF operations back-to-back here. First, you upload the PDF and compress it at &lt;code&gt;MEDIUM&lt;/code&gt; level (valid options are &lt;code&gt;LOW&lt;/code&gt;, &lt;code&gt;MEDIUM&lt;/code&gt;, and &lt;code&gt;HIGH&lt;/code&gt;). Once compression completes, you pass the &lt;code&gt;resultDocumentId&lt;/code&gt; directly into the linearization step, which avoids a second upload. The final download gives you a PDF that's both smaller and optimized for progressive loading in browsers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This function reuses &lt;code&gt;upload_document()&lt;/code&gt;, &lt;code&gt;poll_task()&lt;/code&gt;, and &lt;code&gt;download_document()&lt;/code&gt; from the earlier examples. Make sure those functions are defined in the same script with your credentials configured. The &lt;a href="https://dev.to/foxitdevelopers/how-to-chain-pdf-actions-with-foxit-p99"&gt;Foxit developer blog post on chaining PDF actions&lt;/a&gt; covers this pattern in detail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Monitoring and Secret Management
&lt;/h3&gt;

&lt;p&gt;Monitor three things per conversion job: how long each call takes (to spot API degradation early), credits consumed per job type (to forecast when you'll hit your plan ceiling), and failure rate broken down by error code (to catch template regressions before customers do). Set an alert when your remaining credits fall below 20% of the plan allocation. The &lt;a href="https://app.developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit Developer Dashboard&lt;/a&gt; surfaces real-time usage data worth checking before kicking off large batch runs.&lt;/p&gt;

&lt;p&gt;Store API credentials in environment variables or a secrets manager (&lt;a href="https://aws.amazon.com/secrets-manager/" rel="noopener noreferrer"&gt;AWS Secrets Manager&lt;/a&gt;, &lt;a href="https://www.vaultproject.io/" rel="noopener noreferrer"&gt;HashiCorp Vault&lt;/a&gt;, &lt;a href="https://cloud.google.com/secret-manager" rel="noopener noreferrer"&gt;GCP Secret Manager&lt;/a&gt;). When team members leave or you suspect a credential leak, rotate them from the Developer Dashboard. New credentials can be generated and old ones revoked without downtime, as long as you update your environment before revoking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start Converting HTML to PDF
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://app.developer-api.foxit.com/pricing" rel="noopener noreferrer"&gt;Foxit Developer plan&lt;/a&gt; is free, requires no credit card, and gives you 500 credits to start with. Grab your &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; from the Developer Dashboard, then either clone the &lt;a href="https://github.com/foxitsoftware/developerapidemos" rel="noopener noreferrer"&gt;demo repository&lt;/a&gt; for ready-made examples in Python, Node.js, and PHP, or drop the URL-to-PDF snippet from this guide into a script and run it against any public page.&lt;/p&gt;

&lt;p&gt;Once your first conversion comes back, check credit usage in the Dashboard to project costs at your production volume. If you need more throughput, the Startup plan ($1,750/year for 3,500 credits) is self-serve with no sales call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.developer-api.foxit.com/pricing" rel="noopener noreferrer"&gt;Get started for free on the Foxit Developer Portal&lt;/a&gt;&lt;/p&gt;

</description>
      <category>foxit</category>
      <category>pdf</category>
      <category>html</category>
    </item>
    <item>
      <title>The 10 Best Next.js Starter Kits for SaaS in 2026</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Wed, 15 Apr 2026 06:45:10 +0000</pubDate>
      <link>https://dev.to/luciench/the-10-best-nextjs-starter-kits-for-saas-in-2026-p7d</link>
      <guid>https://dev.to/luciench/the-10-best-nextjs-starter-kits-for-saas-in-2026-p7d</guid>
      <description>&lt;p&gt;You've decided to build a SaaS product with Next.js. The smart move is starting from a production-ready starter kit instead of spending three months wiring up auth, payments, and a dashboard from scratch. The not-so-smart move is picking the wrong one and realizing it six weeks in.&lt;/p&gt;

&lt;p&gt;There are dozens of Next.js boilerplates competing for your money right now. Some are genuinely excellent. Some are glorified &lt;code&gt;create-next-app&lt;/code&gt; templates with a Stripe webhook bolted on. This comparison cuts through the noise with real technical details: actual tech stacks, actual pricing, actual feature coverage, and honest trade-offs.&lt;/p&gt;

&lt;p&gt;I've built products on several of these and evaluated the rest against real-world SaaS requirements: authentication with RBAC, payment flexibility, database architecture, CMS capabilities, and how much code you'll actually need to write after checkout.&lt;/p&gt;

&lt;p&gt;Here are the 10 best Next.js starter kits for SaaS in 2026, ranked.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RapidLaunch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyijzp1b6xvh9y6hkf65c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyijzp1b6xvh9y6hkf65c.png" alt=" " width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $69 (Starter) / $119 (Pro/Unlimited) - one-time.&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;rapidlaun.ch&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16 with App Router&lt;/li&gt;
&lt;li&gt;TypeScript (strict mode)&lt;/li&gt;
&lt;li&gt;Supabase (PostgreSQL + Auth + Storage)&lt;/li&gt;
&lt;li&gt;Stripe + LemonSqueezy (choose via env variable)&lt;/li&gt;
&lt;li&gt;Tailwind CSS + DaisyUI&lt;/li&gt;
&lt;li&gt;TanStack Query&lt;/li&gt;
&lt;li&gt;TipTap rich text editor&lt;/li&gt;
&lt;li&gt;Zod validation&lt;/li&gt;
&lt;li&gt;Vitest + Playwright testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;RapidLaunch is the most feature-complete starter kit on this list. Where most boilerplates give you auth, payments, and a landing page, RapidLaunch ships a full drag-and-drop CMS with 20+ section templates, an AI-powered chat widget with lead capture, real-time analytics with a 3D interactive visitor globe, and an AI template generator that builds complete websites from a business description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The CMS alone justifies the price. You get 25+ templates, 35+ customizable components, a visual navigation editor, footer editor, and a media library. Your non-technical co-founder can update the marketing site without touching code. That's a feature most teams don't get until they integrate a headless CMS as a separate service.&lt;/p&gt;

&lt;p&gt;The AI chat widget runs on configurable OpenAI models (GPT-4, O3 Mini, and five others) with real-time streaming, conversation memory, and an intent detection system that automatically triggers a contact form when it detects purchase intent. That replaces tools like Intercom or Drift that run $50-$150/month.&lt;/p&gt;

&lt;p&gt;The analytics dashboard tracks visitors in real-time with UTM attribution, browser fingerprinting, individual visitor session histories, and a WebGL globe showing geographic distribution. That's the kind of feature you'd pay $200+/month for from a standalone analytics tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The full feature set:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication with RBAC (Owner + Admin roles)&lt;/li&gt;
&lt;li&gt;Stripe and LemonSqueezy billing (subscriptions, one-time, guest checkout)&lt;/li&gt;
&lt;li&gt;Automated webhook handling and two-way product sync&lt;/li&gt;
&lt;li&gt;Customer portal with account, billing, and subscription management&lt;/li&gt;
&lt;li&gt;Admin panel (CMS, users, billing, analytics, chat management)&lt;/li&gt;
&lt;li&gt;Maintenance mode and Coming Soon mode (one-click toggle)&lt;/li&gt;
&lt;li&gt;Cookie consent system&lt;/li&gt;
&lt;li&gt;SEO optimization&lt;/li&gt;
&lt;li&gt;15+ DaisyUI theme presets&lt;/li&gt;
&lt;li&gt;52+ unit tests, 15 database migrations&lt;/li&gt;
&lt;li&gt;CLAUDE.md file for AI-assisted development&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Supabase is the only database option. If you need raw PostgreSQL with a different hosting provider or want MongoDB, you'll need to adapt the data layer. Auth is email/password only out of the box (no social OAuth yet, though Supabase supports adding it). The project is newer than established competitors like ShipFast or Makerkit, so the community is still growing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; If you want the most complete out-of-the-box experience (CMS + AI + analytics + auth + payments) at a price that undercuts most competitors, RapidLaunch is the strongest option available right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ShipFast
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1uutgvplkcrf1tl5t54s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1uutgvplkcrf1tl5t54s.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $199 (Starter) / $249 (All-in) / $299 (Bundle with CodeFast) - one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;shipfa.st&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 15&lt;/li&gt;
&lt;li&gt;TypeScript or JavaScript&lt;/li&gt;
&lt;li&gt;MongoDB or Supabase&lt;/li&gt;
&lt;li&gt;Google OAuth + Magic Links&lt;/li&gt;
&lt;li&gt;Stripe + LemonSqueezy&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;Mailgun or Resend for email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ShipFast is the most popular Next.js boilerplate by sheer community size. Built by Marc Lou, it has a 5,000+ member Discord with revenue leaderboards and a culture of shipping fast. The average user launches in 7 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you get:&lt;/strong&gt; Auth, payments, email with DNS setup, SEO, a blog, pre-built UI components, and $1,210 in partner discounts. It's laser-focused on getting an MVP out the door with minimal friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; No CMS, no admin dashboard beyond basic user management, no analytics, no AI features. The database choice is MongoDB or Supabase only. If your SaaS grows beyond MVP stage, you'll be building significant infrastructure on top of what ShipFast provides. The community and shipping culture are genuinely valuable, but the actual codebase is thinner than competitors at the same price point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for solo founders who prioritize speed over feature completeness and value the community aspect. If you want to ship an MVP in a week and figure out the rest later, ShipFast delivers on that promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Supastarter
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1b01sf02utz7cg8jupmp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1b01sf02utz7cg8jupmp.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $349 (Solo) / $799 (Startup, 5 seats) / $1,499 (Agency, 10 seats, white-label) - one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://supastarter.dev" rel="noopener noreferrer"&gt;supastarter.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js, Nuxt, or SvelteKit (same features across frameworks)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Prisma or Drizzle ORM&lt;/li&gt;
&lt;li&gt;better-auth (2FA, passkeys, RBAC)&lt;/li&gt;
&lt;li&gt;Stripe, LemonSqueezy, Polar, Creem, Dodo Payments (5 providers)&lt;/li&gt;
&lt;li&gt;Hono.js with oRPC&lt;/li&gt;
&lt;li&gt;Tailwind CSS + Radix UI&lt;/li&gt;
&lt;li&gt;TanStack Query&lt;/li&gt;
&lt;li&gt;trigger.dev / QStash for background jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supastarter is the enterprise option. Five payment providers, framework-agnostic (the same feature set works on Next.js, Nuxt, or SvelteKit), i18n baked in, team/organization management, and a super admin dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; The payment flexibility is unmatched. Five providers means you can support customers in markets where Stripe isn't available without rebuilding your billing layer. The Agency tier at $1,499 includes white-label licensing, which is the right call if you're building SaaS products for clients. It's also optimized for AI coding agents (Cursor and Claude Code ready).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; No built-in CMS (blog and docs only), no AI features, no analytics dashboard. The starting price at $349 is higher than most alternatives, and the Agency tier at $1,499 is the most expensive kit on this list. The breadth of options (three frameworks, two ORMs, five payment providers) means more decisions upfront. If you're a solo developer building one product, Supastarter may be more kit than you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for teams and agencies that need multi-framework support, maximum payment flexibility, and white-label capabilities. Overkill for a solo founder building one product.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Makerkit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopgn0q549lw1kkwypudj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fopgn0q549lw1kkwypudj.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $299 (Pro, individual) / $599 (Teams, 5 collaborators) - one-time, lifetime access&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://makerkit.dev" rel="noopener noreferrer"&gt;makerkit.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16, React 19&lt;/li&gt;
&lt;li&gt;TypeScript 5&lt;/li&gt;
&lt;li&gt;Supabase (with RLS), Drizzle, or Prisma (choose your stack)&lt;/li&gt;
&lt;li&gt;OAuth + MFA (Google, GitHub, Facebook, X, Discord, TOTP)&lt;/li&gt;
&lt;li&gt;Stripe Checkout + Customer Portal&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4 + Shadcn UI&lt;/li&gt;
&lt;li&gt;Playwright for E2E testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Makerkit has been around since 2022, making it one of the longest-running Next.js SaaS kits. It's focused on multi-tenant B2B SaaS with row-level security, RBAC, and team management built in from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; The database flexibility is strong. You choose between Supabase with row-level security, Drizzle ORM with Better Auth, or Prisma 7 with Better Auth. Each stack is fully implemented, not just a config toggle. The Figma UI kit and React.Email templates are nice touches that save design time. Auth is the most complete of any kit here: email/password, magic links, social OAuth (five providers), and TOTP MFA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; No CMS, no AI features, no built-in analytics. The $299 starting price is on the higher side for an individual license. The multiple stack options, while flexible, mean the documentation and codebase are more complex to navigate than a more opinionated kit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for B2B SaaS products that need multi-tenancy, strong auth, and row-level security from day one. The longest track record of any kit on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Nextbase
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9tgnen8mna7yr8dj1mwr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9tgnen8mna7yr8dj1mwr.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $99 (Essential) / $299 (Pro) / $399 (Ultimate) - one-time, lifetime access&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://usenextbase.com" rel="noopener noreferrer"&gt;usenextbase.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 15+&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Supabase&lt;/li&gt;
&lt;li&gt;Stripe&lt;/li&gt;
&lt;li&gt;Tailwind CSS + Shadcn&lt;/li&gt;
&lt;li&gt;Jest + Playwright&lt;/li&gt;
&lt;li&gt;Sentry error tracking&lt;/li&gt;
&lt;li&gt;PostHog + Google Analytics&lt;/li&gt;
&lt;li&gt;OpenAI GPT-4&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nextbase takes a tiered approach. The $99 Essential tier covers auth, payments, multi-tenancy, admin panel, and MDX docs. The $299 Pro tier adds a blog, user feedback collection, roadmap functionality, changelog, and user impersonation. The $399 Ultimate tier unlocks everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; AI integration with GPT-4 is built in, and the monitoring stack (Sentry + PostHog) means you start with observability from day one instead of bolting it on later. The $99 entry price for a solid set of core features is competitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Supabase only. Features are locked by tier, so you need to check exactly what you're getting at each price point. No CMS beyond the MDX blog. The community is smaller than ShipFast or Makerkit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Strong mid-range option with a genuinely useful tiered pricing model. The $99 Essential tier is one of the best value propositions for a production-ready starter.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. LaunchFast
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmq321w0g5ljv6c6sy6kc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmq321w0g5ljv6c6sy6kc.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $99 (single framework) / $149 (Astro + Next.js + SvelteKit bundle) - one-time&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://www.launchfa.st" rel="noopener noreferrer"&gt;launchfa.st&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (also Astro and SvelteKit versions)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;5+ database options (MongoDB, Firebase, PostgreSQL via Neon/Supabase/Xata, Redis, SQLite)&lt;/li&gt;
&lt;li&gt;Flexible auth (email, magic links, OAuth)&lt;/li&gt;
&lt;li&gt;Stripe + LemonSqueezy&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;Resend, Postmark, SendGrid, Mailgun, AutoSend&lt;/li&gt;
&lt;li&gt;S3, Cloudflare R2, Firebase, Supabase Storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LaunchFast is the "bring your own stack" option. It supports more database providers, email services, file storage backends, and deployment targets than any other kit on this list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; At $99 you get maximum flexibility. Swap databases, auth providers, payment processors, and deployment targets without rewriting your app. The $149 bundle gives you the same feature set across three frameworks. Deployment options include Vercel, Cloudflare Workers, Render, Fly.io, Netlify, and AWS Amplify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Less opinionated means more decisions and more wiring. No CMS, no AI features, no analytics dashboard. The documentation covers breadth over depth for each provider combination. If you want something that works out of the box with minimal configuration, this isn't it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for developers who have strong preferences about their stack and want a starter kit that adapts to them, not the other way around. The $99 price point is excellent for the flexibility offered.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Shipped.club
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkfs0i3k0l06pqv0qe3ex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkfs0i3k0l06pqv0qe3ex.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $149 - one-time, lifetime access&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://shipped.club" rel="noopener noreferrer"&gt;shipped.club&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Supabase&lt;/li&gt;
&lt;li&gt;NextAuth + Supabase Auth&lt;/li&gt;
&lt;li&gt;LemonSqueezy&lt;/li&gt;
&lt;li&gt;ChakraUI + Tailwind CSS&lt;/li&gt;
&lt;li&gt;MailChimp + Loops&lt;/li&gt;
&lt;li&gt;MDX blog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shipped.club is the budget option that doesn't feel like one. At $149 you get a complete starter with auth, payments, a landing page, waitlist page, pre-order page, and an affiliate program built in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; The affiliate program is a feature no other kit on this list includes out of the box. Dual UI framework support (Chakra UI and Tailwind) gives you styling flexibility. PPP (Purchasing Power Parity) pricing makes it accessible globally. There's also a Chrome Extension version if that's your product format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Stuck on Next.js 14 (not the latest). LemonSqueezy is the only payment option (no Stripe). Supabase only. Less documentation and a smaller community than the top-tier competitors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best bang for your buck if you're on a tight budget and LemonSqueezy fits your payment needs. The affiliate program inclusion is a smart differentiator.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Indie Starter
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9nxpqkehj5jmy9ssz5wx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9nxpqkehj5jmy9ssz5wx.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $69 (Tier 1) / $199 (Tier 2) / $299 (Tier 3) - one-time, unlimited projects&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://indie-starter.dev" rel="noopener noreferrer"&gt;indie-starter.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (latest)&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Supabase (PostgreSQL)&lt;/li&gt;
&lt;li&gt;Magic links + social OAuth (Google, GitHub)&lt;/li&gt;
&lt;li&gt;Stripe with webhooks&lt;/li&gt;
&lt;li&gt;Tailwind CSS + Shadcn/ui&lt;/li&gt;
&lt;li&gt;Sanity CMS&lt;/li&gt;
&lt;li&gt;Resend for email&lt;/li&gt;
&lt;li&gt;Umami + Google Analytics&lt;/li&gt;
&lt;li&gt;Zod validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Indie Starter stands out for its tiered pricing model starting at just $69. The Sanity CMS integration is a differentiator; most kits ship a basic MDX blog, but Indie Starter gives you a proper headless CMS for content management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; The $69 entry tier makes it the cheapest paid option on this list. SEO infrastructure (sitemaps, robots.txt, Schema.org markup) is built in, not an afterthought. Sanity CMS integration means you get a real content management workflow, not just markdown files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Feature availability varies by tier. Supabase is the only database option. The community is smaller. Limited auth options compared to Makerkit or Supastarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for indie hackers who want a solid foundation at minimal cost. The $69 tier is genuinely usable, not a stripped-down demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Nextless.js
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxi4pqq0qw540j64hnfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxi4pqq0qw540j64hnfs.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; $699 (Single, 1 project) / $2,099 (Unlimited) - one-time + optional $199-$599/year renewal&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://nextlessjs.com" rel="noopener noreferrer"&gt;nextlessjs.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14&lt;/li&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;AWS Lambda (serverless)&lt;/li&gt;
&lt;li&gt;PostgreSQL, MySQL, MongoDB, or DynamoDB&lt;/li&gt;
&lt;li&gt;AWS Cognito (email, social login, MFA)&lt;/li&gt;
&lt;li&gt;Stripe&lt;/li&gt;
&lt;li&gt;AWS SES, SendGrid, Postmark, Mailgun, Mandrill&lt;/li&gt;
&lt;li&gt;AWS CDK (Infrastructure as Code)&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;li&gt;Jest (unit, integration, E2E)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nextless.js is the AWS-native option. If your company runs on AWS and needs serverless architecture with Infrastructure as Code, this is the only kit that delivers that out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; Full AWS CDK templates for infrastructure provisioning. Four database options in the Unlimited tier. Silo multi-tenancy model for proper data isolation. React Native boilerplate included for mobile support. This is the only kit on this list with a mobile-ready path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; The most expensive option by far ($699-$2,099). Annual renewal for updates adds ongoing cost. AWS vendor lock-in is real. The serverless architecture has a learning curve if you're not already AWS-comfortable. Stuck on Next.js 14. No CMS, no AI features, no analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; The right choice only if you're building on AWS and need serverless architecture with IaC. For everyone else, the price and complexity aren't justified.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Next.js SaaS Starter (by Vercel)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fazr4h9a3lrap5160s5mt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fazr4h9a3lrap5160s5mt.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price:&lt;/strong&gt; Free (open source)&lt;br&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;a href="https://github.com/nextjs/saas-starter" rel="noopener noreferrer"&gt;github.com/nextjs/saas-starter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tech Stack:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js (latest)&lt;/li&gt;
&lt;li&gt;TypeScript (93% of codebase)&lt;/li&gt;
&lt;li&gt;PostgreSQL + Drizzle ORM&lt;/li&gt;
&lt;li&gt;Email/password auth with JWTs in cookies&lt;/li&gt;
&lt;li&gt;Stripe&lt;/li&gt;
&lt;li&gt;Shadcn/ui&lt;/li&gt;
&lt;li&gt;Zod validation&lt;/li&gt;
&lt;li&gt;pnpm&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The official Next.js SaaS starter from Vercel. It's intentionally minimal: auth, Stripe checkout, subscription management via Customer Portal, Server Actions with Zod validation, an activity logging system, and protected routes with middleware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What sets it apart:&lt;/strong&gt; It's free, it's from Vercel, and it uses the latest Next.js patterns. The codebase is clean and readable, making it an excellent learning resource. No bloat, no unnecessary abstractions. If you understand what you're building and just need a solid starting point, this does the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; Intentionally minimal means you'll add everything yourself: email service, landing pages, admin dashboard, CMS, AI integration, analytics, advanced auth (social OAuth, MFA), file storage. This is a starting point, not a finished product. If you factor in the hours to build what paid kits include, the "free" label is misleading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best for experienced developers who want maximum control and are comfortable building features from scratch. Also the best learning resource for understanding Next.js SaaS architecture patterns.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;RapidLaunch&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://shipfa.st" rel="noopener noreferrer"&gt;ShipFast&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://supastarter.dev" rel="noopener noreferrer"&gt;Supastarter&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://makerkit.dev" rel="noopener noreferrer"&gt;Makerkit&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://usenextbase.com" rel="noopener noreferrer"&gt;Nextbase&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://www.launchfa.st" rel="noopener noreferrer"&gt;LaunchFast&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://shipped.club" rel="noopener noreferrer"&gt;Shipped.club&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://indie-starter.dev" rel="noopener noreferrer"&gt;Indie Starter&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://nextlessjs.com" rel="noopener noreferrer"&gt;Nextless.js&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://github.com/nextjs/saas-starter" rel="noopener noreferrer"&gt;Vercel Starter&lt;/a&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Starting Price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$69&lt;/td&gt;
&lt;td&gt;$199&lt;/td&gt;
&lt;td&gt;$349&lt;/td&gt;
&lt;td&gt;$299&lt;/td&gt;
&lt;td&gt;$99&lt;/td&gt;
&lt;td&gt;$99&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;td&gt;$69&lt;/td&gt;
&lt;td&gt;$699&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time&lt;/td&gt;
&lt;td&gt;One-time + renewal&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js Version&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Latest&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;15+&lt;/td&gt;
&lt;td&gt;Latest&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Latest&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Latest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Strict&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Strict&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;MongoDB/Supabase&lt;/td&gt;
&lt;td&gt;Prisma/Drizzle&lt;/td&gt;
&lt;td&gt;Supabase/Drizzle/Prisma&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;5+ options&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;4 options&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth: Email/Password&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (magic links)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth: Social OAuth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (extensible)&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;5+ providers&lt;/td&gt;
&lt;td&gt;5 providers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;20+ providers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Google, GitHub&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth: MFA/2FA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (passkeys)&lt;/td&gt;
&lt;td&gt;TOTP MFA&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth: RBAC&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (RLS)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LemonSqueezy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (only)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Other Payments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Polar, Creem, Dodo&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CMS/Page Builder&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Drag-and-drop (20+ sections)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Sanity CMS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Blog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Via CMS&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;MDX (Pro tier)&lt;/td&gt;
&lt;td&gt;MDX&lt;/td&gt;
&lt;td&gt;MDX&lt;/td&gt;
&lt;td&gt;Sanity&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Admin Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (full)&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI Chat Widget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GPT-4 (7 models)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;GPT-4&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI Site Generator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built-in Analytics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (3D globe, UTM, fingerprinting)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;PostHog + GA&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Umami + GA&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Customer Portal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (Stripe)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email Integration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Mailgun/Resend&lt;/td&gt;
&lt;td&gt;Multiple&lt;/td&gt;
&lt;td&gt;React.Email&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;5 providers&lt;/td&gt;
&lt;td&gt;MailChimp/Loops&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;5 providers&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;i18n&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-tenancy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (RLS)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (silo)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Testing Suite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vitest + Playwright&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Playwright&lt;/td&gt;
&lt;td&gt;Jest + Playwright&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Jest&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Maintenance Mode&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Affiliate Program&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mobile Support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;React Native&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Figma UI Kit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Unlimited Projects&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pro tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Solo tier+&lt;/td&gt;
&lt;td&gt;Pro tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Unlimited tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Community Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Growing&lt;/td&gt;
&lt;td&gt;8,000+&lt;/td&gt;
&lt;td&gt;1,300+&lt;/td&gt;
&lt;td&gt;Established&lt;/td&gt;
&lt;td&gt;Growing&lt;/td&gt;
&lt;td&gt;110+&lt;/td&gt;
&lt;td&gt;330+&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;15.7K stars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The Next.js starter kit market has matured significantly. Every option on this list will save you time compared to starting from scratch. The difference comes down to how much time.&lt;/p&gt;

&lt;p&gt;Kits like the Vercel SaaS Starter and LaunchFast give you a foundation and expect you to build. Kits like ShipFast and Shipped.club get you to MVP fast with the essentials. And kits like RapidLaunch, Supastarter, and Makerkit aim to give you a near-complete product from day one.&lt;/p&gt;

&lt;p&gt;If I'm starting a new SaaS project today, my pick is &lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;RapidLaunch&lt;/a&gt;. The combination of a full CMS with drag-and-drop builder, AI-powered chat with lead capture, enterprise-grade analytics, and dual payment provider support (Stripe + LemonSqueezy) at $69-$119 one-time is the best value proposition in this space. You'd spend more than that in a single month on the standalone tools it replaces.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rapidlaun.ch" rel="noopener noreferrer"&gt;Get started with RapidLaunch&lt;/a&gt; and launch your SaaS in days, not months.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
    </item>
    <item>
      <title>Backstage SaaS &amp; Open Source Alternatives</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Thu, 29 Jan 2026 07:26:39 +0000</pubDate>
      <link>https://dev.to/luciench/backstage-saas-open-source-alternatives-2m08</link>
      <guid>https://dev.to/luciench/backstage-saas-open-source-alternatives-2m08</guid>
      <description>&lt;p&gt;Spotify's &lt;a href="https://roadie.io/backstage-spotify/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; created the Internal Developer Portal category. It showed that a central hub for services, documentation, and tooling could actually improve how developers work. But here's what a lot of engineering leaders figure out the hard way: Backstage isn't right for every team.&lt;/p&gt;

&lt;p&gt;If you're reading this, you've probably realized that adopting Backstage means committing serious engineering resources. You need a dedicated team, TypeScript expertise, and months of setup time. For a lot of teams, the operational overhead just isn't worth it.&lt;/p&gt;

&lt;p&gt;But the problem Backstage solves doesn't go away. As your organization grows past about 150 people, you hit what's called the &lt;a href="https://en.wikipedia.org/wiki/Dunbar%27s_number" rel="noopener noreferrer"&gt;Dunbar Number Effect&lt;/a&gt;. Anthropologist Robin Dunbar found that humans can only maintain stable social networks of around 150 people. Go beyond that, and tribal knowledge disappears. Nobody knows who owns what. The informal Slack channels that worked when you were 30 people turn into complete chaos.&lt;/p&gt;

&lt;p&gt;You need a system to fix this chaos, a "system of record" for your engineering organization. But you don't need the complexity of self-hosted Backstage to get there.&lt;/p&gt;

&lt;p&gt;This guide covers the best Backstage alternatives in 2026. I'll break down three paths you can take, Build, Buy, or Hybrid, and help you figure out which one makes sense without drowning your platform team in maintenance work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Paths to an IDP: Build, Buy, or Hybrid
&lt;/h2&gt;

&lt;p&gt;Before we look at specific tools, you need to understand the three approaches you can take:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build (Self-Hosted Backstage):&lt;/strong&gt; You take the open-source Backstage project and dedicate engineers to build, customize, and maintain your own portal. Ultimate flexibility, but significant headcount and operational costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy (Proprietary IDPs):&lt;/strong&gt; You purchase a SaaS solution from a vendor like Cortex or Port. Quick to set up and feature-rich, but you're locked into their proprietary data model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid (Managed Backstage):&lt;/strong&gt; You use a service like Roadie that handles the hosting and maintenance of Backstage for you. You get the open-source ecosystem without the operational burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backstage Alternatives: At a Glance
&lt;/h2&gt;

&lt;p&gt;Here's a quick comparison of your options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Core Technology&lt;/th&gt;
&lt;th&gt;Hosting Model&lt;/th&gt;
&lt;th&gt;Key Strength&lt;/th&gt;
&lt;th&gt;Ideal For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Roadie&lt;/td&gt;
&lt;td&gt;Backstage&lt;/td&gt;
&lt;td&gt;SaaS (Managed)&lt;/td&gt;
&lt;td&gt;Backstage ecosystem without the overhead&lt;/td&gt;
&lt;td&gt;Teams who want Backstage's power but need a managed solution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cortex&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;SaaS&lt;/td&gt;
&lt;td&gt;Engineering metrics and scorecards&lt;/td&gt;
&lt;td&gt;Organizations focused on measuring service quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;SaaS&lt;/td&gt;
&lt;td&gt;Developer-friendly API and flexibility&lt;/td&gt;
&lt;td&gt;Teams building custom workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atlassian Compass&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;SaaS&lt;/td&gt;
&lt;td&gt;Deep Atlassian integration&lt;/td&gt;
&lt;td&gt;Companies invested in the Atlassian stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpsLevel&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;SaaS&lt;/td&gt;
&lt;td&gt;Service maturity and reliability checks&lt;/td&gt;
&lt;td&gt;SRE teams enforcing production standards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-Hosted Backstage&lt;/td&gt;
&lt;td&gt;Backstage (OSS)&lt;/td&gt;
&lt;td&gt;Self-Hosted&lt;/td&gt;
&lt;td&gt;Ultimate customization&lt;/td&gt;
&lt;td&gt;Large orgs with dedicated platform teams (5+ engineers)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Hybrid Approach: Managed Backstage
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Roadie
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjk55dpfc1pbat14xasv1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjk55dpfc1pbat14xasv1.png" alt=" " width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://roadie.io/" rel="noopener noreferrer"&gt;Roadie&lt;/a&gt; isn't an alternative to Backstage, it's a different way to adopt it. The core idea is that you shouldn't have to choose between the power of an open-source community and the convenience of a SaaS product.&lt;/p&gt;

&lt;p&gt;I've talked to a lot of teams who committed to self-hosting Backstage, only to realize it requires 3-12 engineers and 6-12 months to get production-ready. That's a significant investment. Roadie solves this by providing a secure, scalable, and fully managed Backstage experience out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams who've decided on Backstage but want to accelerate their timeline and reduce operational burden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Get a production-ready Backstage instance running in minutes. Roadie handles upgrades, security, and maintenance.&lt;/li&gt;
&lt;li&gt;Enterprise features like Role-Based Access Control, enterprise-grade search, and scorecards come built-in.&lt;/li&gt;
&lt;li&gt;Install any open-source Backstage plugin without rebuilding your instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Roadie uses Backstage as its foundation, so you get the same data model and core experience. If you want a completely different, highly opinionated UI, a proprietary vendor might fit better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Buy" Approach: Proprietary IDPs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cortex
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fciix318u8wtur2nrnm1n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fciix318u8wtur2nrnm1n.png" alt=" " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cortex.io/" rel="noopener noreferrer"&gt;Cortex&lt;/a&gt; has established itself as a leader in the IDP space. They focus heavily on service quality, reliability, and engineering metrics. Their Scorecards feature is particularly strong for defining standards and tracking adoption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Organizations focused on establishing and measuring engineering standards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt; A central inventory for microservices and APIs, scorecards to track service health, and a scaffolder for creating new services from templates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Cortex is proprietary. You're locked into their data model, and migrating away later could be painful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Port
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fapz8ep2vaec20w81hvip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fapz8ep2vaec20w81hvip.png" alt=" " width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.getport.io/" rel="noopener noreferrer"&gt;Port&lt;/a&gt; is built around flexibility. Their developer-friendly API lets you ingest any data and build custom workflows. They position themselves as a platform for building a developer portal, not a rigid out-of-the-box solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Platform teams with strong dev skills who want to build highly custom experiences.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt; A flexible "blueprint" model to define any asset, a self-service hub for custom actions, and scorecards for tracking quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; Port's flexibility is powerful but comes with a steeper learning curve. Expect more initial setup compared to more opinionated platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Atlassian Compass
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fejre6rjpe9w2rvhsi065.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fejre6rjpe9w2rvhsi065.png" alt=" " width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.atlassian.com/software/compass" rel="noopener noreferrer"&gt;Atlassian Compass&lt;/a&gt; is Atlassian's entry into developer experience. Its main advantage is seamless integration with Jira, Confluence, and Bitbucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Companies already standardized on Atlassian tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt; A component catalog for tracking ownership, health scorecards, and deep native integration with other Atlassian products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; If you're not an Atlassian-centric organization, Compass may feel less compelling compared to other options.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpsLevel
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fde29ux2aoxvjhf4ou5h2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fde29ux2aoxvjhf4ou5h2.png" alt=" " width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.opslevel.com/" rel="noopener noreferrer"&gt;OpsLevel&lt;/a&gt; is a mature player focused on service ownership and reliability. SRE and platform teams like them because they help answer, "Is our software ready for production?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; SRE-driven organizations enforcing service maturity standards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt; A complete service catalog, an extensive library of automated maturity checks, and integrations with on-call tools like PagerDuty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; OpsLevel's focus is more on reliability and standards than on developer self-service, which is stronger in other platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Build" Approach: Self-Hosted Backstage
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F444snlufnj8ic1yayn7w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F444snlufnj8ic1yayn7w.png" alt=" " width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Choosing to self-host Backstage is a significant commitment. You should treat it like building an internal product, not just deploying a tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Large organizations with a well-funded platform team (5+ engineers) that has a clear mandate to build and maintain a customized developer portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features:&lt;/strong&gt; Complete control over the code and data model. You can customize it to your exact specifications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Considerations:&lt;/strong&gt; This path has a &lt;a href="https://roadie.io/blog/backstage-how-much-does-it-really-cost/" rel="noopener noreferrer"&gt;high cost of ownership&lt;/a&gt;. You need to account for the fully-loaded salaries of a dedicated engineering team, 6-12 months of initial build time, and ongoing operational burden for maintenance and upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Choose the Right Path
&lt;/h2&gt;

&lt;p&gt;Your choice depends on your organization's priorities, resources, and philosophy. Here are the questions you should ask:&lt;/p&gt;

&lt;h3&gt;
  
  
  How important is the open-source ecosystem to us?
&lt;/h3&gt;

&lt;p&gt;If you want to avoid vendor lock-in and tap into community innovation, choose between self-hosting Backstage or using a managed service like Roadie. If you prefer an all-in-one vendor experience, a proprietary option like Cortex or Port makes more sense.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the size and skill set of our platform team?
&lt;/h3&gt;

&lt;p&gt;If you have 5-10 engineers with TypeScript experience and a mandate to build a custom portal, self-hosted Backstage is viable. If your platform team is smaller or focused on other priorities, Roadie or a proprietary vendor is more efficient.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's our most critical problem right now?
&lt;/h3&gt;

&lt;p&gt;If you need scorecards and don't mind vendor lock-in, tools like Cortex or OpsLevel offer polished solutions.&lt;/p&gt;

&lt;p&gt;If you want to build custom workflows from scratch and are comfortable in a closed-source ecosystem, Port gives you a flexible API.&lt;/p&gt;

&lt;p&gt;If your organization lives entirely in the Atlassian suite, Compass is a natural extension.&lt;/p&gt;

&lt;p&gt;If you want enterprise features combined with the freedom of the open-source ecosystem, Roadie gives you both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;An Internal Developer Portal is a long-term investment in your developer experience. The choice between Build, Buy, and Hybrid depends entirely on your team's size, skills, and priorities.&lt;/p&gt;

&lt;p&gt;I've seen teams succeed with all three approaches. The key is being honest about what you can realistically maintain and what problems you're actually trying to solve.&lt;/p&gt;

&lt;p&gt;I'm curious what path you're evaluating. Are you leaning toward self-hosting Backstage? Considering a proprietary vendor? Looking at the hybrid approach with something like Roadie? What's the biggest factor driving your decision, team size, budget, or something else? Drop a comment and share what's working (or not working) in your evaluation process.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opensource</category>
      <category>saas</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The "Glue Work" Trap: Why Your Best Engineer Looks Like Your Worst Performer</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Wed, 28 Jan 2026 07:46:49 +0000</pubDate>
      <link>https://dev.to/luciench/the-glue-work-trap-why-your-best-engineer-looks-like-your-worst-performer-58jf</link>
      <guid>https://dev.to/luciench/the-glue-work-trap-why-your-best-engineer-looks-like-your-worst-performer-58jf</guid>
      <description>&lt;p&gt;Your top engineer closed half the tickets the new grad did last quarter. The performance dashboard says they're underperforming. Your gut says the opposite.&lt;/p&gt;

&lt;p&gt;I ran into this problem after moving from data engineering into management. One of my engineers spent three hours debugging why the nightly ETL job was timing out, which was blocking the entire team from running integration tests. They pair-programmed with our new hire to help them understand the data architecture. They wrote detailed documentation for the ETL migration because no one else would. They jumped into a Microsoft Teams thread at 9 PM to unblock a production deployment that couldn't wait until morning.&lt;/p&gt;

&lt;p&gt;Then I pulled up the performance dashboard. They had closed half the tickets that our new grad closed that quarter.&lt;/p&gt;

&lt;p&gt;The metrics said they were underperforming. My experience working with them said the opposite. They were the glue holding our team together.&lt;/p&gt;

&lt;p&gt;This isn't a data engineering problem. It's an engineering management problem. Your best engineers spend their time on essential work that keeps the team functional, but none of it shows up in the systems that measure performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Your Metrics Are Lying to You
&lt;/h2&gt;

&lt;p&gt;Jira and similar project management tools measure intent, not reality. They track what you planned to do, not what actually happened.&lt;/p&gt;

&lt;p&gt;The problem compounds as engineers get more senior. Junior engineers can focus on tickets. Senior engineers spend most of their time on work that never gets a ticket: reviewing 40% of the team's PRs, helping others debug gnarly issues, fixing the CI pipeline that's been broken for weeks, sitting in architecture reviews to share context.&lt;/p&gt;

&lt;p&gt;Creating a Jira ticket for "helped someone debug their environment setup" feels absurd. It took 20 minutes. But do that five times a week, and you've spent nearly two hours on invisible work. Multiply that across code reviews, production firefighting, mentoring conversations, and architectural guidance, and engineers can easily spend 50-70% of their time on work that has no corresponding ticket.&lt;/p&gt;

&lt;p&gt;The result: visible output shrinks as actual value increases. The ticket dashboard shows your best engineer as underperforming while they're actually the team's MVP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://noidea.dog/glue" rel="noopener noreferrer"&gt;Tanya Reilly calls this "being glue"&lt;/a&gt;, the technical leadership work that holds teams together but doesn't map cleanly to traditional promotion criteria.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Brag Documents Don't Work
&lt;/h2&gt;

&lt;p&gt;The standard advice is to have engineers maintain brag documents. &lt;a href="https://jvns.ca/blog/brag-documents/" rel="noopener noreferrer"&gt;Julia Evans popularized this approach&lt;/a&gt;: engineers keep a running log of everything they do, then compile it for performance reviews.&lt;/p&gt;

&lt;p&gt;This rarely works in practice.&lt;/p&gt;

&lt;p&gt;It requires constant discipline. Engineers need to remember to update it after every contribution. Most forget until the week before their review, then try to reconstruct six months of work from memory.&lt;/p&gt;

&lt;p&gt;It also feels self-aggrandizing. Engineers are trained to let their work speak for itself. Writing "I am great because I did X" feels uncomfortable, even when X is genuinely valuable.&lt;/p&gt;

&lt;p&gt;And here's the real problem: without data backing it up, a brag document is just the engineer's word against the metrics. When you're under pressure to justify ratings, you'll default to the numbers.&lt;/p&gt;

&lt;p&gt;Brag documents work for some people. But they put the burden on the wrong person. As a manager, you should have systems that capture your team's actual work, not force them to manually log it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Data Actually Shows
&lt;/h2&gt;

&lt;p&gt;Your engineers' work leaves digital traces everywhere. Every PR they review generates data in GitHub. Every commit they make has metadata. Every Teams conversation is timestamped. Every calendar event is logged.&lt;/p&gt;

&lt;p&gt;This digital exhaust contains proof of the glue work. The challenge is assembling it into a coherent picture.&lt;/p&gt;

&lt;p&gt;When an engineer reviews 127 PRs while their peers review 30, that's measurable. It explains why their ticket count is lower. They're multiplying everyone else's productivity.&lt;/p&gt;

&lt;p&gt;Those 50-line PRs fixing type definitions, updating the Dockerfile, or adding error handling don't map to epics. But they're real work that keeps the system running.&lt;/p&gt;

&lt;p&gt;When an engineer spends a week unblocking another team's API integration, that work might not even show up in your team's metrics. But it has real business impact.&lt;/p&gt;

&lt;p&gt;The data exists. You just need to surface it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Tools Like Span Solve This Problem
&lt;/h2&gt;

&lt;p&gt;I came across &lt;a href="https://span.app" rel="noopener noreferrer"&gt;Span&lt;/a&gt; and I wish I'd known about them when I was dealing with the invisible work issue on my team.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl6jkl96xyk5bfjanly9s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl6jkl96xyk5bfjanly9s.png" alt=" " width="544" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The approach is different from traditional productivity tools. Instead of trying to make engineers track their work better, Span analyzes the actual work in your codebase and other systems. It looks at your code, PRs, Jira tickets, incident management tools, and calendars to understand what's really happening.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfmo302ki64n1qbnksld.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnfmo302ki64n1qbnksld.png" alt=" " width="701" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The platform uses AI to automatically categorize work into different types. That random Tuesday afternoon an engineer spends fixing the ETL timeout? Span classifies it as infrastructure work without them needing to create a ticket. The three hours spent reviewing PRs? That shows up in the data.&lt;/p&gt;

&lt;p&gt;One feature that particularly addresses the glue work problem is called Investment Mix. According to one of their customers, Chad Bayer (VP of Technology at The Helper Bees), "Span's Investment Mix is the best on the market. It now powers our executive updates and board discussions, replacing what used to be a time-consuming manual process."&lt;/p&gt;

&lt;p&gt;Investment Mix shows how engineering time is distributed across different categories of work. Instead of just counting tickets, you can see the actual breakdown between feature development, code reviews, infrastructure improvements, and maintenance work. That paints a completely different picture than "closed 8 tickets this quarter."&lt;/p&gt;

&lt;p&gt;The platform also tracks metrics like PR cycle time and team velocity, helping you spot patterns in how work flows through your team. You can see if code reviews are a bottleneck or if infrastructure work is eating into feature development time.&lt;/p&gt;

&lt;p&gt;The key thing is that it uses the actual work as the source of truth. It doesn't matter if an engineer created a ticket. If they made substantial changes to the infrastructure, that work gets classified and measured automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzbj20lltmz28370xbyqe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzbj20lltmz28370xbyqe.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Identify Glue Work in Your Team
&lt;/h2&gt;

&lt;p&gt;You need to actively look for this. Your best engineers won't complain that their glue work is invisible. They're too busy doing the work.&lt;/p&gt;

&lt;p&gt;Here are the patterns to watch for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Review Burden&lt;/strong&gt;: Pull up your team's PR activity. If one engineer is reviewing 2-3x more PRs than everyone else, that's glue work. They're acting as a quality gate for the entire team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Unblocking Pattern&lt;/strong&gt;: Watch for engineers who are constantly mentioned in other people's PRs or Teams/Slack threads. If someone's name keeps coming up when people need help, they're doing glue work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Infrastructure Tax&lt;/strong&gt;: Some engineers volunteer to fix the annoying things that everyone complains about but no one wants to own. The flaky tests. The slow CI pipeline. The missing documentation. This work is nearly invisible but high-impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Onboarding Load&lt;/strong&gt;: If you have an engineer who every new hire gets paired with for their first few weeks, that's glue work. They're transferring institutional knowledge that doesn't exist anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Cross-Team Coordination&lt;/strong&gt;: Watch for engineers who spend time in meetings with other teams or helping unblock dependencies. This work often doesn't even show up in your team's metrics, but it has real business impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Credit Glue Work in Performance Reviews
&lt;/h2&gt;

&lt;p&gt;Once you identify the glue work, you need to explicitly credit it in performance reviews and compensation decisions.&lt;/p&gt;

&lt;p&gt;Instead of apologizing for an engineer's ticket count, reframe the conversation around leverage and impact. Present the review with data, not gut feelings.&lt;/p&gt;

&lt;p&gt;Show that they spent 35% of their time on developer enablement. Then show how that translated into team velocity improvements. If the team shipped 23% more features in the second half of the quarter compared to the first half, and the engineer's increased review load preceded that improvement, connect those dots.&lt;/p&gt;

&lt;p&gt;Quantify specific contributions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They reviewed 130 PRs that quarter, representing a significant portion of the team's total output. Their review feedback reduced the revision cycle time by an average of 8 hours per PR.&lt;/li&gt;
&lt;li&gt;They spent 15% of their time on infrastructure improvements that cut the CI pipeline time from 45 minutes to 12 minutes, saving the team approximately 180 hours in aggregate wait time.&lt;/li&gt;
&lt;li&gt;They pair-programmed with three junior engineers on complex features. All three shipped their work on schedule with minimal revision cycles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data doesn't just prove they were busy. It proves they were strategic about where they invested time.&lt;/p&gt;

&lt;p&gt;When you present this to your director, the response will be immediate. The engineer gets promoted instead of getting a "meets expectations" rating.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Change Your Team's Incentives
&lt;/h2&gt;

&lt;p&gt;Measuring glue work isn't enough. You need to actively reward it, or engineers will optimize for what gets measured.&lt;/p&gt;

&lt;p&gt;Change how you run performance reviews. Explicitly evaluate engineers on three dimensions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual Contribution&lt;/strong&gt;: Traditional feature development and bug fixes. This is what Jira measures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team Multiplication&lt;/strong&gt;: Code reviews, mentoring, infrastructure work, and knowledge sharing. This is what tools like Span measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-Team Impact&lt;/strong&gt;: Work that helps other teams or the company, even if it doesn't show up in your team's metrics.&lt;/p&gt;

&lt;p&gt;Weight all three dimensions equally. An engineer who excels at team multiplication but has lower individual contribution numbers can still get an "exceeds expectations" rating.&lt;/p&gt;

&lt;p&gt;Also change how you assign work. When planning sprints, explicitly allocate time for glue work. If an engineer is going to spend 30% of their time on code reviews, plan for them to close 30% fewer tickets. This prevents the trap where engineers get overloaded with both feature work and glue work, then get dinged for not completing enough tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Long-Term Impact
&lt;/h2&gt;

&lt;p&gt;Six months after implementing these changes, the results are clear.&lt;/p&gt;

&lt;p&gt;Team velocity increases by 30%+. Not because people work more hours, but because the glue work gets distributed more evenly. Junior engineers close more tickets because they get unblocked faster. Mid-level engineers contribute to code reviews and documentation. Senior engineers still do the most glue work, but they're not penalized for it.&lt;/p&gt;

&lt;p&gt;Retention improves. Engineers who were considering leaving because they felt like their contributions weren't valued become your strongest advocates and refer excellent engineers to your team.&lt;/p&gt;

&lt;p&gt;Hiring gets easier. When candidates ask how you evaluate performance, you can show them the actual data on how you measure and reward different types of contributions. Engineers who care about doing high-quality work are attracted to teams that recognize all forms of contribution, not just ticket velocity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Should Do Tomorrow
&lt;/h2&gt;

&lt;p&gt;Start by identifying who on your team is doing the most glue work. Pull up your GitHub data and look at PR review patterns. Talk to your junior engineers about who they go to when they're stuck.&lt;/p&gt;

&lt;p&gt;Then look at how you're measuring performance. If you're only looking at ticket velocity, you're missing half the picture. Find ways to quantify the other half, whether that's through manual data gathering or tools like Span.&lt;/p&gt;

&lt;p&gt;Finally, have explicit conversations with your team about glue work. Tell them you value it. Show them you're measuring it. Make it clear that doing glue work won't hurt their career trajectory.&lt;/p&gt;

&lt;p&gt;Glue work is career-making if it's visible. It's career-ending if it stays invisible. As a manager, making it visible is your job, not your engineers'.&lt;/p&gt;

&lt;p&gt;The engineers who succeed at senior levels aren't the ones who close the most tickets. They're the ones who multiply their team's effectiveness. Code review, mentoring, infrastructure work, and technical guidance are how they create that leverage.&lt;/p&gt;

&lt;p&gt;But leverage without measurement looks like low productivity. You need systems that capture the full picture of contributions, not just the subset that maps cleanly to ticket boards.&lt;/p&gt;

&lt;p&gt;If you have engineers spending time on glue work, make sure it's being captured and credited. They shouldn't have to choose between doing high-impact work and having a successful performance review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you measure glue work on your team?&lt;/strong&gt; I'd love to hear what's worked (or hasn't worked) for other engineering managers. Drop a comment below.&lt;/p&gt;

</description>
      <category>career</category>
      <category>leadership</category>
      <category>management</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>10 Proven Ways to Improve Developer Productivity (Without Burning Out Your Team)</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Tue, 13 Jan 2026 14:49:12 +0000</pubDate>
      <link>https://dev.to/luciench/10-proven-ways-to-improve-developer-productivity-without-burning-out-your-team-8ak</link>
      <guid>https://dev.to/luciench/10-proven-ways-to-improve-developer-productivity-without-burning-out-your-team-8ak</guid>
      <description>&lt;p&gt;Engineering leaders face relentless pressure to ship faster. The tempting response is to push harder, add hours, and demand more. But this approach fails. The &lt;a href="https://dora.dev/publications/" rel="noopener noreferrer"&gt;2025 DORA State of AI-Assisted Software Development report&lt;/a&gt;, surveying nearly 5,000 technology professionals, reveals that &lt;a href="https://blog.google/technology/developers/dora-report-2025/" rel="noopener noreferrer"&gt;90% now use AI tools at work&lt;/a&gt;, spending a median of two hours daily with them. Yet only 24% trust the output. More telling: extended crunch periods create an almost perfect correlation between overtime and defect rates.&lt;/p&gt;

&lt;p&gt;The path to sustainable velocity isn't raw effort. It's removing friction, protecting focus, and building systems that let your engineers do their best work. Here are ten strategies backed by recent research that actually move the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Protect focus time by clustering interruptions
&lt;/h2&gt;

&lt;p&gt;The cost of context switching is staggering. Dr. Gloria Mark's research at UC Irvine established that workers need an &lt;a href="https://briq.com/blog/the-23-minute-problem#:~:text=The%2023%2DMinute%20Reality,psychologists%20call%20%22attention%20residue.%22" rel="noopener noreferrer"&gt;average of 23 minutes and 15 seconds to fully refocus&lt;/a&gt; after an interruption. Her 2023 book &lt;em&gt;Attention Span&lt;/em&gt; updated this to roughly 25 minutes and revealed something worse: the average time spent on any screen before switching has dropped to just 47 seconds.&lt;/p&gt;

&lt;p&gt;For developers, the damage compounds. GitHub's Good Day Project found that developers have an &lt;a href="https://github.blog/2021-05-25-octoverse-spotlight-good-day-project/" rel="noopener noreferrer"&gt;82% chance of having a good day&lt;/a&gt; with minimal interruptions, but only a 7% chance when interrupted frequently. Paul Graham captured this perfectly in his &lt;a href="https://paulgraham.com/makersschedule.html" rel="noopener noreferrer"&gt;2009 essay on the maker's schedule&lt;/a&gt;: "A single meeting can blow a whole afternoon, by breaking it into two pieces each too small to do anything hard in."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Implement no-meeting days or cluster all meetings into specific blocks. A 2022 MIT Sloan study of 76 companies found that one meeting-free day per week &lt;a href="https://www.superpowers.school/p/research-into-meeting-free-days" rel="noopener noreferrer"&gt;increased productivity by 35%&lt;/a&gt;. Two meeting-free days pushed that to 71%. The optimal balance was three no-meeting days per week, protecting 60% of the work week for focused work.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cut CI/CD wait times to eliminate forced context switches
&lt;/h2&gt;

&lt;p&gt;When a build takes 20 minutes, developers switch to something else. Then they need another 25 minutes to regain context when the build completes. That single build just cost 45 minutes of cognitive overhead.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dora.dev/publications/" rel="noopener noreferrer"&gt;2025 DORA report&lt;/a&gt; shows that &lt;a href="https://cloud.google.com/blog/products/ai-machine-learning/announcing-the-2025-dora-report" rel="noopener noreferrer"&gt;AI adoption increased throughput but also instability&lt;/a&gt;, with teams shipping faster but experiencing higher change failure rates when they lack robust control systems. The difference isn't just speed, it's that fast feedback loops let developers stay in flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Invest in incremental builds, parallelized test suites, and intelligent test impact analysis. &lt;a href="https://www.launchableinc.com/use-case/in-place-shortening/" rel="noopener noreferrer"&gt;AI-driven test selection can reduce CI run times by 40-80%&lt;/a&gt; by running only tests affected by recent changes. &lt;a href="https://agilealliance.org/glossary/xp/" rel="noopener noreferrer"&gt;Kent Beck's original 10-minute rule from &lt;em&gt;Extreme Programming Explained&lt;/em&gt;&lt;/a&gt;: "Automatically build the whole system and run all of the tests in ten minutes. A build that takes longer than ten minutes will be used much less often, missing the opportunity for feedback." Every minute beyond that threshold increases the likelihood of costly context switches.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Treat internal documentation as a product
&lt;/h2&gt;

&lt;p&gt;The 2024 Stack Overflow Developer Survey found that &lt;a href="https://survey.stackoverflow.co/2024/professional-developers" rel="noopener noreferrer"&gt;61% of developers spend more than 30 minutes daily&lt;/a&gt; searching for answers, with 26% spending over an hour. That's one entire workday per week burned on hunting for answers that should be documented.&lt;/p&gt;

&lt;p&gt;The impact on teams is severe. New hires take two to three months longer to become productive when documentation is poor. The 2024 Stack Overflow survey also found that while 70% of developers know where to find answers, only 56% can find them quickly, and 53% report that waiting on answers disrupts their workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Allocate dedicated time for documentation maintenance. Treat your internal wikis like a product with owners, roadmaps, and quality standards. Organizations with poor documentation waste hundreds of hours weekly for mid-sized engineering teams, equivalent to losing multiple full-time engineers to information hunting.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Deploy AI coding tools, but measure what matters
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://techcrunch.com/2025/07/30/github-copilot-crosses-20-million-all-time-users/" rel="noopener noreferrer"&gt;GitHub Copilot has crossed 20 million users&lt;/a&gt;, with 90% of Fortune 100 companies now using it. GitHub's controlled study showed developers &lt;a href="https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/" rel="noopener noreferrer"&gt;completed tasks 55% faster&lt;/a&gt; with Copilot, and &lt;a href="https://github.com/customer-stories/duolingo" rel="noopener noreferrer"&gt;Duolingo's engineering team estimated 25% speed increases&lt;/a&gt; for engineers new to a codebase.&lt;/p&gt;

&lt;p&gt;But the data isn't universally positive. A December 2025 CodeRabbit analysis of 470 open-source PRs found AI-generated code &lt;a href="https://www.coderabbit.ai/blog/state-of-ai-vs-human-code-generation-report" rel="noopener noreferrer"&gt;produces 1.7x more issues&lt;/a&gt; than human-written code, including 1.4x more critical bugs. The 2024 DORA report noted that while AI adoption increased &lt;a href="https://swimm.io/blog/heres-what-the-2024-dora-report-has-to-say-about-code-documentation" rel="noopener noreferrer"&gt;documentation quality by 7.5%&lt;/a&gt; and &lt;a href="https://www.hashicorp.com/en/blog/ai-is-making-developers-faster-but-at-a-cost" rel="noopener noreferrer"&gt;code review speed by 3.1%&lt;/a&gt;, it was also associated with higher instability. Meanwhile, &lt;a href="https://survey.stackoverflow.co/2025/ai" rel="noopener noreferrer"&gt;only about 33% of developers trust AI code accuracy&lt;/a&gt; according to the 2025 Stack Overflow survey, with 46% actively distrusting AI-generated code.&lt;/p&gt;

&lt;p&gt;The real risk is "AI sprawl," where teams adopt multiple tools without understanding impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Roll out AI assistants to reduce boilerplate and repetitive tasks, where they excel. But measure outcomes, not just adoption. Platforms like &lt;a href="https://www.span.app/" rel="noopener noreferrer"&gt;Span&lt;/a&gt; help leaders see if AI is reducing coding time or just increasing review time, allowing you to optimize the toolchain without guessing. &lt;a href="https://www.span.app/blog/introducing-span-detect-1" rel="noopener noreferrer"&gt;Span's AI code detector&lt;/a&gt; can identify AI-generated code with &lt;a href="https://www.span.app/blog/introducing-span-detect-1" rel="noopener noreferrer"&gt;95% accuracy&lt;/a&gt;, giving you ground truth on adoption and impact. The goal is reduced toil, not just more code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Standardize the inner development loop
&lt;/h2&gt;

&lt;p&gt;"It works on my machine" remains one of the most expensive phrases in engineering. Each developer maintaining their own unique environment creates configuration drift, onboarding delays, and debugging nightmares.&lt;/p&gt;

&lt;p&gt;Cloud development environments (CDEs) and dev containers eliminate this friction. Teams using mature internal developer platforms report significant reductions in cognitive load and faster onboarding. Spotify's platform engineering team found a &lt;a href="https://backstage.io/blog/2020/03/16/announcing-backstage/" rel="noopener noreferrer"&gt;55% improvement in time-to-tenth-pull-request&lt;/a&gt; for new developers using standardized environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Standardize development environments using containers, devcontainers, or cloud-based development tools. Define the inner loop (edit, build, test, debug) as a first-class product. When any developer can clone a repo and start contributing within hours instead of days, you've removed one of the most persistent friction points in engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Set explicit code review SLAs
&lt;/h2&gt;

&lt;p&gt;Code sitting in review is inventory on a shelf. It's not delivering value, it's accumulating merge conflicts, and the author has already context-switched away. Meta's engineering research found a &lt;a href="https://engineering.fb.com/2022/11/16/culture/meta-code-review-time-improving/" rel="noopener noreferrer"&gt;correlation between time-in-review and engineer dissatisfaction&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;High-performing teams treat review latency as a key metric. Google's engineering practices mandate &lt;a href="https://google.github.io/eng-practices/review/reviewer/speed.html" rel="noopener noreferrer"&gt;responding to code reviews within one business day maximum&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Establish team SLAs for review turnaround. A reasonable target: first review within four hours for internal team PRs, full cycle time under 24 hours. Keep PRs small (under 400 lines) for higher defect discovery rates. Research shows that &lt;a href="https://arxiv.org/abs/2203.05048" rel="noopener noreferrer"&gt;reducing the time between acceptance and merge can improve code velocity by up to 63%&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Create on-call rotations to protect maker time
&lt;/h2&gt;

&lt;p&gt;The "tap on the shoulder" through Slack is insidious. It feels harmless, but it fragments focus and distributes interruptions unpredictably across the team. Shadow work, the unplanned requests and ad-hoc support that never appears on a sprint board, silently drains capacity. When everyone is "available," no one is protected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Implement explicit on-call rotations where designated engineers handle interruptions while others focus. During on-call workdays, engineers do only on-call work with no feature development expected. If your on-call engineers regularly handle &lt;a href="https://sre.google/sre-book/being-on-call/" rel="noopener noreferrer"&gt;more than 2 incidents per 12-hour shift&lt;/a&gt;, Google SRE data suggests your system has reliability problems that need addressing - either through better automation, improved monitoring, or expanding the rotation. This protects the rest of the team's time while ensuring responsive support.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Build self-service infrastructure through platform engineering
&lt;/h2&gt;

&lt;p&gt;Filing tickets for AWS access, waiting for DevOps to configure a database, or requesting permission to deploy are all symptoms of underdeveloped platform capabilities. Each handoff adds days of latency and forces developers into waiting mode.&lt;/p&gt;

&lt;p&gt;Gartner predicts that by 2026, &lt;a href="https://www.beinformed.com/gartners-top-10-tech-trends-2024-embracing-platform-engineering/" rel="noopener noreferrer"&gt;80% of large software engineering organizations will have platform engineering teams&lt;/a&gt;, up from 45% in 2022. A 2024 survey of Kubernetes experts found &lt;a href="https://www.cncf.io/blog/2024/06/06/the-voice-of-kubernetes-experts-report-2024-the-data-trends-driving-the-future-of-the-enterprise/" rel="noopener noreferrer"&gt;96% of organizations&lt;/a&gt; already have a platform engineering function, and the &lt;a href="https://dora.dev/research/2024/dora-report/" rel="noopener noreferrer"&gt;2024 DORA report showed teams using internal developer platforms saw 10% increases in team performance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Build "golden paths" for common workflows: deploying a new service, provisioning a database, setting up monitoring. &lt;a href="https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem" rel="noopener noreferrer"&gt;Spotify pioneered this approach&lt;/a&gt;, creating opinionated, well-documented pathways that reduce cognitive load while still allowing teams to deviate when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Measure systems and processes, not individual output
&lt;/h2&gt;

&lt;p&gt;Stack-ranking developers by commits or lines of code pushes them to game the numbers instead of doing good work, and it destroys psychological safety. The 2024 Stack Overflow survey found that &lt;a href="https://survey.stackoverflow.co/2024/professional-developers#3-satisfied-at-current-job" rel="noopener noreferrer"&gt;only 20% of developers report being happy at work&lt;/a&gt;, with &lt;a href="https://survey.stackoverflow.co/2024/professional-developers#2-most-common-frustrations" rel="noopener noreferrer"&gt;62% citing technical debt&lt;/a&gt; as their top frustration. The DORA research team explicitly warns against using their metrics for individual evaluation—these metrics exist to identify systemic bottlenecks, not to rank people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Use metrics to debug your engineering system, not your engineers. Look for process flaws: calendar fragmentation, review bottlenecks, deployment friction, documentation gaps. &lt;a href="https://www.span.app/" rel="noopener noreferrer"&gt;Span's platform&lt;/a&gt; is valuable here because it highlights systemic issues, like calendars full of fragmented time, rather than simply counting lines of code. This preserves psychological safety while surfacing real obstacles.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Regularly survey developers and act on feedback
&lt;/h2&gt;

&lt;p&gt;Happy developers write better code. The DORA Accelerate research found that high-performing teams are &lt;a href="https://dustinewers.com/accelerate-book-summary/" rel="noopener noreferrer"&gt;2x as likely to exceed organizational performance goals&lt;/a&gt;. Google's 2022 research established that &lt;a href="https://research.google/pubs/what-improves-developer-productivity-at-google-code-quality/" rel="noopener noreferrer"&gt;perceived code quality causally increases productivity&lt;/a&gt;. Yet most organizations never systematically ask their engineers what's getting in the way.&lt;/p&gt;

&lt;p&gt;Productivity cannot be reduced to a single metric. You need satisfaction data alongside performance data. And you need to actually fix what developers say is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action:&lt;/strong&gt; Run quarterly developer experience surveys. Ask what sucks. Then visibly address the top pain points. The investment pays compound returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with two changes this quarter
&lt;/h2&gt;

&lt;p&gt;You don't need to implement all ten strategies at once. Pick two that address your team's biggest friction points. If your calendar is a disaster, start with meeting-free days. If code rots in review, set explicit SLAs. If developers waste hours searching for answers, invest in documentation.&lt;/p&gt;

&lt;p&gt;The goal isn't maximum velocity for one sprint. It's sustainable pace that your team can maintain indefinitely. The &lt;a href="https://dora.dev/publications/" rel="noopener noreferrer"&gt;2025 DORA data&lt;/a&gt; is clear: &lt;a href="https://www.splunk.com/en_us/blog/learn/state-of-devops.html" rel="noopener noreferrer"&gt;AI functions as an amplifier&lt;/a&gt;, magnifying the strengths of high-performing teams and the dysfunctions of struggling ones. Teams that prioritize stability and developer well-being outperform those that sacrifice them for short-term speed. Build the system that lets your engineers do their best work, and the velocity will follow.&lt;/p&gt;

</description>
      <category>management</category>
      <category>mentalhealth</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The 7 Best Developer Portals for Enterprise Teams in 2025</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Thu, 08 Jan 2026 09:07:29 +0000</pubDate>
      <link>https://dev.to/luciench/the-7-best-developer-portals-for-enterprise-teams-in-2025-4kgj</link>
      <guid>https://dev.to/luciench/the-7-best-developer-portals-for-enterprise-teams-in-2025-4kgj</guid>
      <description>&lt;p&gt;Your platform team spent a year and $2 million building an internal developer portal. Or maybe you bought one off the shelf. Either way, you're probably wondering if you made the right choice.&lt;/p&gt;

&lt;p&gt;Here's what I've learned after working with dozens of companies implementing internal developer portals: the "build versus buy" decision isn't really the question anymore. The market has changed completely since 2020. Companies that went with self-hosted &lt;a href="https://roadie.io/backstage-spotify/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; discovered that "free and open-source" actually means paying for 3-12 full-time engineers. Organizations that bought proprietary platforms found themselves locked into data models they can't escape. And the ones who picked wrong are now facing painful migrations.&lt;/p&gt;

&lt;p&gt;I've spent the last few years talking to engineering leaders about their IDP implementations, and I've noticed the landscape has evolved into three distinct approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build&lt;/strong&gt;: Self-hosted Backstage installations that give you maximum flexibility but cost over $1M per year to operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy&lt;/strong&gt;: Proprietary SaaS platforms like Cortex and Port with polished interfaces but permanent vendor lock-in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid&lt;/strong&gt;: Managed Backstage solutions that give you the open-source ecosystem without the engineering overhead.&lt;/p&gt;

&lt;p&gt;In this guide, I'll walk through the seven platforms that enterprise teams are actually using at scale. I'm focusing on the strategic trade-offs that matter three years after your initial decision, when you'll discover whether you made the right choice or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Matters in an Enterprise Developer Portal
&lt;/h2&gt;

&lt;p&gt;Before I dive into specific platforms, let me share what I've learned matters most when evaluating IDPs. A lot of these platforms look great in demos but fall apart in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ecosystem and Extensibility
&lt;/h3&gt;

&lt;p&gt;Can you integrate with your entire toolchain, or are you stuck with whatever the vendor decides to support? At enterprise scale, you're probably using 50+ different tools across CI/CD, monitoring, security, and cloud infrastructure. The difference between supporting 20 integrations versus 250+ becomes critical when half your value comes from centralizing everything in one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vendor Lock-In Risk
&lt;/h3&gt;

&lt;p&gt;What happens to your data if you need to switch platforms in two years? Open-source-based solutions like &lt;a href="https://roadie.io/backstage-spotify/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; use standardized YAML entity definitions you can export and migrate anywhere. Proprietary platforms often use custom data models that trap all your organizational knowledge inside their systems forever.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintenance Overhead
&lt;/h3&gt;

&lt;p&gt;Does running the platform require a dedicated team, or can your existing platform engineers manage it alongside their other work? I've seen self-hosted solutions consume 3-5 full-time engineers just for maintenance, upgrades, and troubleshooting. This is what I call the "TypeScript tax," the hidden cost of maintaining frontend infrastructure that most DevOps teams aren't equipped to handle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enterprise Readiness
&lt;/h3&gt;

&lt;p&gt;Does the platform provide &lt;a href="https://roadie.io/product/access-control/" rel="noopener noreferrer"&gt;role-based access control&lt;/a&gt; (RBAC), single sign-on (SSO), and SOC2 compliance out of the box? For regulated industries or companies with strict security requirements, these aren't nice-to-haves. They're table stakes. Building them yourself in a self-hosted environment can take months.&lt;/p&gt;

&lt;h3&gt;
  
  
  Day 2 Operations
&lt;/h3&gt;

&lt;p&gt;Look beyond the initial setup. How difficult is it to upgrade when breaking changes occur? How do you handle search infrastructure at scale? What happens when you need to migrate to a new backend system? The platforms that look easiest on day one often become maintenance nightmares by day 700.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Roadie: Managed Backstage for Teams Who Value Their Time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Hybrid (Managed Backstage)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Teams that want Backstage's ecosystem without the operational burden&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxau0dqvpy9e7w1d7y53j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxau0dqvpy9e7w1d7y53j.png" alt=" " width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://roadie.io/" rel="noopener noreferrer"&gt;Roadie&lt;/a&gt; delivers the full power of Spotify's open-source &lt;a href="https://roadie.io/backstage-spotify/" rel="noopener noreferrer"&gt;Backstage platform&lt;/a&gt; as a managed SaaS service. This is what I call the hybrid approach. You get access to the entire Backstage ecosystem with 211 &lt;a href="https://roadie.io/backstage/plugins/" rel="noopener noreferrer"&gt;open-source plugins&lt;/a&gt;, standardized data models, and active community development. But you don't need to maintain any of the infrastructure.&lt;/p&gt;

&lt;p&gt;The platform handles everything you'd normally spend engineering time on: hosting, security patches, database management, enterprise-grade search, and those painful upgrades like the &lt;a href="https://roadie.io/blog/migrating-to-backstages-new-backend-a-step-by-step-guide/" rel="noopener noreferrer"&gt;New Backend System migration&lt;/a&gt; that challenged self-hosted teams throughout 2024. Your team focuses on configuring integrations and building workflows, not debugging TypeScript or updating React components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minimal-maintenance Backstage platform with automated upgrades&lt;/li&gt;
&lt;li&gt;Access to entire open-source plugin ecosystem (211 plugins, 82 supported out-of-the-box)&lt;/li&gt;
&lt;li&gt;Built-in &lt;a href="https://roadie.io/product/tech-insights/" rel="noopener noreferrer"&gt;Tech Insights&lt;/a&gt; for scorecards and engineering standards (paid add-on)&lt;/li&gt;
&lt;li&gt;Enterprise &lt;a href="https://roadie.io/product/access-control/" rel="noopener noreferrer"&gt;RBAC&lt;/a&gt; (basic RBAC in Teams plan, custom RBAC in Growth plan)&lt;/li&gt;
&lt;li&gt;No vendor lock-in since the data model is standard Backstage YAML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;The biggest advantage is what I call avoiding the TypeScript tax. Platform engineering teams can focus on building platform capabilities instead of &lt;a href="https://roadie.io/blog/backstage-how-much-does-it-really-cost/" rel="noopener noreferrer"&gt;maintaining TypeScript and React frontends&lt;/a&gt;. Any &lt;a href="https://roadie.io/backstage/plugins/" rel="noopener noreferrer"&gt;Backstage plugin&lt;/a&gt; works, including community-developed integrations. Complex upgrades like the New Backend System transition happen automatically. Most customers &lt;a href="https://roadie.io/blog/from-day-0-to-day-2-a-guide-to-planning-and-implementing-backstage/" rel="noopener noreferrer"&gt;see value within weeks&lt;/a&gt;, not months. Your &lt;a href="https://roadie.io/docs/catalog/modeling-entities/" rel="noopener noreferrer"&gt;catalog definitions&lt;/a&gt; are portable YAML files, not proprietary formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;You're working within Backstage's UI constraints, which means less drag-and-drop flexibility compared to tools like Port. It's more structured, which some teams see as a feature and others see as a limitation. While Roadie offers secure connectivity options like the &lt;a href="https://roadie.io/docs/integrations/broker/" rel="noopener noreferrer"&gt;Roadie Broker&lt;/a&gt; for on-premises resources, it's primarily a hosted service. If your security team absolutely requires on-premises deployment, self-hosted Backstage might be your only option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Teams plan starts at $24 per developer per month with a 50-seat minimum (50-150 developers). Growth plan pricing is custom with a 100-seat minimum (100+ developers). Only active contributors to your source control management incur costs. Non-coding team members like product managers and leadership can access for free. Tech Insights is an optional paid add-on. &lt;a href="https://roadie.io/pricing/" rel="noopener noreferrer"&gt;View pricing details&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cortex: When You're Obsessed with Scorecards
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Proprietary SaaS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Organizations focused on service maturity scorecards and reliability metrics&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo0rwp728njlishb7mono.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo0rwp728njlishb7mono.png" alt=" " width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cortex.io/" rel="noopener noreferrer"&gt;Cortex&lt;/a&gt; built a polished, opinionated developer portal that's heavily focused on measuring and improving service quality through scorecards. The platform excels at gamifying service ownership with detailed maturity models, SLO tracking, and automated scoring based on engineering best practices using Bronze/Silver/Gold levels.&lt;/p&gt;

&lt;p&gt;The UI feels modern and intuitive, especially if your team is already familiar with SaaS tools like Datadog or PagerDuty. Cortex's scorecard system is more sophisticated than most alternatives, offering fine-grained control over scoring criteria with flexible rule definitions and excellent visualization of how your services stack up against engineering standards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Advanced scorecard system with customizable rubrics using Bronze/Silver/Gold levels&lt;/li&gt;
&lt;li&gt;Strong reliability engineering focus with SLOs, incidents, and on-call tracking&lt;/li&gt;
&lt;li&gt;Polished, modern UI optimized for service discovery&lt;/li&gt;
&lt;li&gt;AI-powered features including Ownership Prediction and Velocity Dashboard for DORA metrics&lt;/li&gt;
&lt;li&gt;60+ out-of-the-box integrations with major monitoring and development tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;The scorecard sophistication is genuinely best-in-class. If you want to track service maturity with Bronze/Silver/Gold level visualization and detailed point-based scoring, nothing else comes close. The UI is beautiful and impresses stakeholders. The reliability focus is excellent for teams prioritizing SRE practices. You can get a basic catalog running quickly. New AI features for ownership prediction and metrics analysis are interesting additions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;Cortex is known for being expensive at enterprise scale, especially compared to Backstage-based alternatives. The data model is Cortex-specific, making migration difficult if you ever want to leave. You're limited to whatever integrations Cortex builds. You can't leverage community plugins like you can with Backstage. The template and workflow capabilities lag behind &lt;a href="https://roadie.io/product/scaffolder/" rel="noopener noreferrer"&gt;Backstage's Software Templates&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Not publicly disclosed. You need to sign up for a demo to get pricing information. However, the &lt;a href="https://tei.forrester.com/go/Cortex/IDP/?lang=en-us" rel="noopener noreferrer"&gt;Forrester Total Economic Impact study&lt;/a&gt; from July 2024 lists pricing at approximately $65 per user per month at scale. Multiple tiers available (Engineering Intelligence, Accelerate, Full IDP, Site License) with features scaling from basic catalog and scorecards to full platform capabilities. &lt;a href="https://www.cortex.io/pricing" rel="noopener noreferrer"&gt;Request pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Port: The Builder's Platform
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Proprietary SaaS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Teams that need to model non-standard assets or want maximum UI customization&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxolocxfjdy3adgu2sfx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxolocxfjdy3adgu2sfx.png" alt=" " width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.port.io/" rel="noopener noreferrer"&gt;Port&lt;/a&gt; takes a completely different approach. Instead of giving you an opinionated developer portal, it gives you building blocks to create your own. The platform's no-code interface lets you define custom data models (called Blueprints) for any asset type, not just services and APIs, but environments, IoT devices, or cloud resources.&lt;/p&gt;

&lt;p&gt;This flexibility makes Port uniquely suited for organizations with complex, non-standard infrastructure that doesn't fit typical service catalog patterns. You can build custom views, define relationships between any entity types, and create workflows that match your exact processes. Port recently rebranded as an "Agentic Internal Developer Portal" with enhanced AI capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully customizable data models and UI views via Blueprints&lt;/li&gt;
&lt;li&gt;No-code interface for defining entities and relationships&lt;/li&gt;
&lt;li&gt;Strong visualization capabilities for complex systems&lt;/li&gt;
&lt;li&gt;Self-service actions using Cookiecutter templates&lt;/li&gt;
&lt;li&gt;50+ integrations including DORA metrics tracking&lt;/li&gt;
&lt;li&gt;AI agent capabilities and Engineering360 dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;Port gives you ultimate flexibility to model anything your organization needs, not just traditional services. You can build exactly the interface your teams need. It's excellent for multi-cloud, hybrid environments with diverse asset types. The visual workflow builder lets you create automation without writing code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;Maximum flexibility means you're building everything from scratch. There's less out-of-the-box value compared to opinionated platforms. You can't leverage the Backstage open-source plugin ecosystem, though the Ocean Framework provides extensibility through data integrations and workflow automation. While Port supports Markdown, it lacks the full &lt;a href="https://roadie.io/docs/getting-started/technical-documentation/" rel="noopener noreferrer"&gt;TechDocs&lt;/a&gt; build pipeline and search capabilities you get with Backstage. Teams need time to master the data modeling concepts, which means a steeper learning curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free tier available (up to 15 seats, 10,000 entities). Startup tier at $30 per developer per month. Enterprise tier available with premium features including SSO, advanced RBAC, ISO 27001 and SOC2 Type 2 certifications, and dedicated support. &lt;a href="https://www.port.io/pricing" rel="noopener noreferrer"&gt;View pricing details&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. OpsLevel: Fast Setup, Limited Growth
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Proprietary SaaS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Teams focused primarily on service ownership and maturity tracking&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmizajyqu8ncvk49llzl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkmizajyqu8ncvk49llzl.png" alt=" " width="800" height="215"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.opslevel.com/" rel="noopener noreferrer"&gt;OpsLevel&lt;/a&gt; started as a service maturity and ownership tool before evolving into a broader developer portal. This shows in its excellent service ownership features and straightforward approach to tracking engineering standards through its Rubric system with Bronze/Silver/Gold levels, plus separate Scorecards for team-specific standards.&lt;/p&gt;

&lt;p&gt;The platform offers the fastest time-to-initial-value for basic service cataloging. Typical deployments complete in 30-45 days. You can have a working catalog with ownership information and basic checks running within hours. But this simplicity comes at a cost. OpsLevel's feature set is more constrained than platforms built on extensible architectures. Recent additions include AI-powered features for check generation and catalog enrichment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fast setup for basic service catalog (typical 30-45 day deployment)&lt;/li&gt;
&lt;li&gt;Strong focus on service ownership and maturity rubrics&lt;/li&gt;
&lt;li&gt;Good integration with CI/CD systems for automated checks (60+ integrations)&lt;/li&gt;
&lt;li&gt;AI-generated checks and AI-enriched catalog&lt;/li&gt;
&lt;li&gt;Package version inventories for SBOM visibility&lt;/li&gt;
&lt;li&gt;Clean, straightforward UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;You can get a basic catalog running faster than with any alternative. The service ownership focus is excellent for clarifying who owns what. Good built-in templates for measuring service quality. The interface is less complex than more feature-rich platforms, which some teams prefer. New AI features streamline catalog management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;You can't easily add community plugins or build custom integrations. The UI is less flexible than Port or Backstage for customization. The feature set is primarily focused on cataloging and checks, with less emphasis on documentation or scaffolding. The migration path is unclear if you outgrow the platform since it uses a proprietary data model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Not publicly disclosed. Pricing is based on team size with custom quotes. Per-developer pricing model with volume discounts available. Includes SOC2 Type 2 compliance and SAML-based SSO. Pricing customizable based on needs including self-hosted options and support levels. &lt;a href="https://www.opslevel.com/pricing" rel="noopener noreferrer"&gt;Request pricing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Atlassian Compass: The Jira Extension
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Proprietary SaaS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Organizations deeply invested in the Atlassian ecosystem&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl5fec5g748ru6tp1dk2t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl5fec5g748ru6tp1dk2t.png" alt=" " width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your company runs on Jira, Bitbucket, and Confluence, &lt;a href="https://www.atlassian.com/software/compass" rel="noopener noreferrer"&gt;Compass&lt;/a&gt; offers the most seamless native integration you'll find. The platform leverages Atlassian's identity system, pulls in data from other Atlassian products automatically, and feels like a natural extension of your existing toolchain.&lt;/p&gt;

&lt;p&gt;Compass provides automated service health monitoring, tracking metrics from integrated tools and surfacing problems before they escalate. For teams already paying for Atlassian products, Compass represents an incremental cost with minimal integration effort. The platform has scorecards with a new Maturity Levels feature added in 2025.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important Note&lt;/strong&gt;: Atlassian deprecated the Templates and scaffolding feature on December 1, 2025. This is a significant capability reduction for teams requiring self-service service creation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Native integration with Jira, Bitbucket, Confluence, and Opsgenie&lt;/li&gt;
&lt;li&gt;Automated service health monitoring&lt;/li&gt;
&lt;li&gt;Component tracking with Atlassian-native data models&lt;/li&gt;
&lt;li&gt;Integrated incident management through Opsgenie&lt;/li&gt;
&lt;li&gt;Scorecards with Maturity Levels feature&lt;/li&gt;
&lt;li&gt;Built on Atlassian Forge with GraphQL APIs for extensibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;The Atlassian integration is unbeatable if you use Jira for everything. The UX feels consistent with other Atlassian products, so there's minimal learning curve. Automated health monitoring works well. You can get it as a standalone product or bundled with some enterprise packages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;This is what I call the Atlassian trap. The platform struggles with non-Atlassian tools like &lt;a href="https://roadie.io/docs/integrations/github-discovery/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, &lt;a href="https://roadie.io/docs/integrations/gitlab/" rel="noopener noreferrer"&gt;GitLab&lt;/a&gt;, or CircleCI. The scaffolding feature was removed on December 1, 2025, so it's no longer available for service creation. You can't extend it with community plugins since it uses a proprietary ecosystem. It's more of a service catalog with add-ons than a complete platform interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free tier available (3 full users, unlimited basic users). Standard tier at $8 per user per month includes basic features. Premium tier at $25 per user per month includes IP Allowlisting, advanced integrations, 99.9% uptime SLA, and premium support. Discounted rates available for teams above 101 users. Compass is a standalone product with separate billing from other Atlassian tools. &lt;a href="https://www.atlassian.com/software/compass/pricing" rel="noopener noreferrer"&gt;View pricing details&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Backstage: The Industry Standard (Self-Hosted)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Open Source (Self-Hosted)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Large enterprises with dedicated platform engineering teams and specific compliance requirements&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxnfo0m06hmp5mh6086y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frxnfo0m06hmp5mh6086y.png" alt=" " width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Spotify's &lt;a href="https://backstage.io/" rel="noopener noreferrer"&gt;Backstage&lt;/a&gt; is the industry-standard open-source developer portal framework. It powers IDPs at Spotify, American Airlines, Pinterest, and thousands of other organizations. You own the code, control the infrastructure, and can customize anything.&lt;/p&gt;

&lt;p&gt;But this flexibility comes with significant operational costs. Self-hosting Backstage requires 3-5 dedicated engineers to manage infrastructure, handle upgrades, maintain search systems, and keep up with the rapidly evolving codebase. Roadie's survey of the Backstage community found that successful self-hosted deployments had at least three engineers dedicated full-time. Some companies have teams of 12 people just for Backstage.&lt;/p&gt;

&lt;p&gt;Breaking changes occur regularly with monthly releases. Major migrations like the &lt;a href="https://roadie.io/blog/migrating-to-backstages-new-backend-a-step-by-step-guide/" rel="noopener noreferrer"&gt;New Backend System&lt;/a&gt; transition that completed in 2024 consumed months of engineering time for self-hosted teams. For perspective on the true cost, &lt;a href="https://roadie.io/blog/backstage-how-much-does-it-really-cost/" rel="noopener noreferrer"&gt;Zalando invested over $4 million&lt;/a&gt; across four years developing their internal platform before open-sourcing their work as part of Backstage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully open-source with Apache 2.0 license&lt;/li&gt;
&lt;li&gt;250+ &lt;a href="https://roadie.io/backstage/plugins/" rel="noopener noreferrer"&gt;community plugins&lt;/a&gt; covering every major tool&lt;/li&gt;
&lt;li&gt;Extensible architecture for &lt;a href="https://roadie.io/docs/custom-plugins/overview/" rel="noopener noreferrer"&gt;custom plugins&lt;/a&gt; and integrations&lt;/li&gt;
&lt;li&gt;Active community and regular monthly releases&lt;/li&gt;
&lt;li&gt;CNCF Incubating project with strong enterprise adoption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;You have ultimate control and can customize without limits. There are no license costs since it's free to download and use. The ecosystem is massive with the largest community and plugin library (250+ plugins). You have no vendor dependency and can run it anywhere, modify anything. It's the industry standard backed by CNCF and major enterprises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;The operational overhead is substantial. Successful deployments require 3-5 full-time engineers minimum. Some teams reach 12 full-time employees just maintaining Backstage. Most DevOps teams lack frontend skills for React and TypeScript customization, which is what I call the TypeScript tax. Monthly breaking changes and major migrations like the New Backend System require significant engineering investment.&lt;/p&gt;

&lt;p&gt;You manage databases, search infrastructure with Elasticsearch, monitoring, and security patches. The hidden costs are enormous. At typical senior platform engineer compensation of $250K per year fully loaded, 3-5 engineers cost $750K to $1.25M annually, plus infrastructure costs of $12K to $24K per year. Total cost of ownership typically exceeds $2M+ over three years when you factor in engineering time, opportunity cost, and infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free (open source under Apache 2.0 license). However, total cost of ownership includes 3-5 engineer salaries ($750K to $1.25M+ annually) plus infrastructure costs ($12K to $24K+ annually). TCO typically exceeds $2M+ over three years.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Configure8: The Discovery Platform
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Category&lt;/strong&gt;: Proprietary SaaS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best For&lt;/strong&gt;: Organizations prioritizing discovery and cost analytics&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy4q06so63k3qz1jpfm3l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy4q06so63k3qz1jpfm3l.png" alt=" " width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://configure8-io.webflow.io/" rel="noopener noreferrer"&gt;Configure8&lt;/a&gt; positions itself as a universal catalog that can ingest and relate data from virtually any source. The platform emphasizes discovery features, helping teams understand what they have and how it's interconnected. It also offers strong cloud cost integration, surfacing spending data alongside technical resources.&lt;/p&gt;

&lt;p&gt;While Configure8 has solid core features including 30+ integrations and workflow-based Self-Serve Actions, its smaller market presence and proprietary nature make it a riskier choice than platforms with larger ecosystems or open-source foundations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Universal catalog supporting diverse asset types&lt;/li&gt;
&lt;li&gt;Strong discovery and search capabilities&lt;/li&gt;
&lt;li&gt;Cloud cost analytics integration&lt;/li&gt;
&lt;li&gt;Relationship mapping across resources&lt;/li&gt;
&lt;li&gt;Workflow-based Self-Serve Actions&lt;/li&gt;
&lt;li&gt;Available as SaaS or on-premises deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Like&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;It's good at helping teams understand what exists in their infrastructure. The cost integration is unique, combining cloud spending data with technical resources. It can pull data from many systems. You get deployment flexibility with both SaaS and on-premises options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Trade-offs&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;The ecosystem is smaller with less community support and fewer integrations (30+) than larger platforms. The data model is Configure8-specific, which means proprietary lock-in. There are fewer public case studies and enterprise deployments than alternatives. As a smaller player in a competitive market, there's some uncertainty about long-term viability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Free tier available (up to 10 users for scorecards). Paid tiers available with SOC2 certification and RBAC features. Enterprise pricing available with additional features and volume discounts. Available as both SaaS and on-premises deployment. Contact Configure8 for detailed pricing. &lt;a href="https://configure8-io.webflow.io/pricing" rel="noopener noreferrer"&gt;View pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Foundation&lt;/th&gt;
&lt;th&gt;Maintenance&lt;/th&gt;
&lt;th&gt;Ecosystem Size&lt;/th&gt;
&lt;th&gt;Lock-In Risk&lt;/th&gt;
&lt;th&gt;Enterprise Features&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Roadie&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Open Source (Backstage)&lt;/td&gt;
&lt;td&gt;Minimal (Managed)&lt;/td&gt;
&lt;td&gt;211 plugins&lt;/td&gt;
&lt;td&gt;Low (standard YAML)&lt;/td&gt;
&lt;td&gt;RBAC, SSO, SOC2 Day 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cortex&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal (SaaS)&lt;/td&gt;
&lt;td&gt;60+ integrations&lt;/td&gt;
&lt;td&gt;High (proprietary)&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Port&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal (SaaS)&lt;/td&gt;
&lt;td&gt;50+ integrations&lt;/td&gt;
&lt;td&gt;High (proprietary)&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpsLevel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal (SaaS)&lt;/td&gt;
&lt;td&gt;60+ integrations&lt;/td&gt;
&lt;td&gt;High (proprietary)&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compass&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal (SaaS)&lt;/td&gt;
&lt;td&gt;Atlassian-centric&lt;/td&gt;
&lt;td&gt;High (proprietary)&lt;/td&gt;
&lt;td&gt;Good (if Atlassian)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backstage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Open Source&lt;/td&gt;
&lt;td&gt;High (3-12 engineers)&lt;/td&gt;
&lt;td&gt;250+ plugins&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Configure8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Proprietary&lt;/td&gt;
&lt;td&gt;Minimal (SaaS)&lt;/td&gt;
&lt;td&gt;30+ integrations&lt;/td&gt;
&lt;td&gt;High (proprietary)&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Making Your Choice
&lt;/h2&gt;

&lt;p&gt;The right IDP depends on your organization's constraints, technical culture, and platform engineering maturity. Here's how I'd think about it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Self-Hosted Backstage if&lt;/strong&gt; you have a team of 10+ platform engineers, unlimited budget, and specific requirements that absolutely can't be met by managed solutions. You're willing to invest significant engineering time in maintenance and customization for maximum control. Be prepared for 3-12 dedicated engineers and $2M+ total cost of ownership over three years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Cortex or Port if&lt;/strong&gt; you value polished UI above everything else, don't mind proprietary lock-in, and want specific workflow capabilities their platforms emphasize. Budget for potentially higher costs at scale (around $65 per user per month for Cortex, around $30+ per user per month for Port enterprise tiers).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose OpsLevel if&lt;/strong&gt; you need a basic service catalog immediately and your primary use case is &lt;a href="https://roadie.io/docs/catalog/ownership/" rel="noopener noreferrer"&gt;tracking ownership&lt;/a&gt; and maturity, not building complex workflows or maintaining extensive documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Compass if&lt;/strong&gt; you live entirely in the Atlassian ecosystem and can accept its limitations with non-Atlassian tools. Note that scaffolding and templates were deprecated on December 1, 2025. The integration efficiency may outweigh the platform's constraints if you're already deep in the Atlassian world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Roadie if&lt;/strong&gt; you want the industry standard (Backstage) with its entire ecosystem and community support, but you value your engineers' time too much to spend it on infrastructure maintenance. It's what I consider the golden path for enterprises that want modern platform capabilities without the platform tax or the $2M+ cost of building from scratch.&lt;/p&gt;

&lt;p&gt;The key question isn't which platform has the most features. It's which platform lets your engineers focus on building platform capabilities instead of maintaining platform infrastructure. At the 150+ engineer scale where IDPs become critical, that distinction determines whether your portal becomes a force multiplier or just another thing to maintain, potentially at a cost exceeding $2M over three years if you go the self-hosted route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;After evaluating these platforms for the past few years, I keep coming back to a simple principle: your platform team should build platform capabilities, not maintain platform infrastructure. The $2M+ you'd spend on self-hosting Backstage could fund a lot of actual platform work.&lt;/p&gt;

&lt;p&gt;If you're serious about Backstage but want to skip the TypeScript tax, &lt;a href="https://roadie.io/request-demo/" rel="noopener noreferrer"&gt;request a personalized demo of Roadie&lt;/a&gt; that'll show you what managed Backstage looks like in practice. Worth checking out before you commit to building everything yourself.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
