<?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>Top DocuSign API alternatives for developers in 2026</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:35:33 +0000</pubDate>
      <link>https://dev.to/luciench/top-docusign-api-alternatives-for-developers-in-2026-5361</link>
      <guid>https://dev.to/luciench/top-docusign-api-alternatives-for-developers-in-2026-5361</guid>
      <description>&lt;p&gt;DocuSign &lt;a href="https://www.docusign.com/blog/developers/docusign-api-basic-user-password-authentication-retirement" rel="noopener noreferrer"&gt;retired basic user password authentication&lt;/a&gt; on September 30, 2024, ending support for the &lt;code&gt;X-DocuSign-Authentication&lt;/code&gt; header that integrations had relied on since the early days of the eSign REST API. Any team still sending that header had to rebuild its auth layer on &lt;a href="https://developers.docusign.com/docs/esign-rest-api/esign101/auth/" rel="noopener noreferrer"&gt;OAuth 2.0&lt;/a&gt;, where DocuSign supports three grant type families (Authorization Code, split into confidential and public variants, plus JWT and Implicit).&lt;/p&gt;

&lt;p&gt;Most of those migrations shipped under deadline pressure, so the quickest path usually won and teams re-authenticated against DocuSign without weighing anything else. That forced work is now behind you, and nothing is forcing the next decision, which makes this a better moment to ask whether DocuSign still fits your SaaS application or whether another e-signature API serves it better.&lt;/p&gt;

&lt;p&gt;This guide applies a consistent technical framework to six alternatives, evaluating each against five criteria (authentication model, embedded signing implementation, SDK language coverage, webhook event reliability, and compliance footprint) to give you a decision matrix you can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five technical criteria that drive the right choice
&lt;/h2&gt;

&lt;p&gt;These five dimensions determine whether an API works in production, not just in a proof of concept. Each one maps to a real constraint you'll hit during integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth2 grant type support.&lt;/strong&gt; Most e-signature APIs support Authorization Code Grant for user-delegated flows and &lt;code&gt;client_credentials&lt;/code&gt; for server-to-server automation. Embedded SaaS workflows typically run server-side, without a human in the auth loop at signing time. An API that requires Authorization Code Grant for every token request adds friction to automated pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded signing model.&lt;/strong&gt; The divide between iFrame embedding and redirect-based signing determines whether your signers ever leave your application. Redirect-based flows push users to a third-party domain, then return them via a callback URL. iFrame embedding keeps the signing UI inside your product's chrome. For SaaS teams building a white-labeled experience, iFrame is the stronger choice, but it requires the API to generate a signed session URL with correct CORS headers and sandbox attribute permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SDK language coverage.&lt;/strong&gt; An API without an SDK in your stack adds overhead, because you build and maintain HTTP clients, handle retry logic, and parse response schemas manually. SDK coverage signals how seriously a vendor treats developer experience at the integration layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook event schema and reliability.&lt;/strong&gt; Envelope status changes (signed, declined, voided) need to reach your application reliably and in a verifiable format. Webhook payloads vary across providers in schema structure, authentication method (HMAC-signed vs. unsigned), and retry behavior on delivery failure. A provider that signs payloads with HMAC and retries on failure is safer to build against than one that delivers once with no retry guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance footprint.&lt;/strong&gt; Your legal and security teams will flag this regardless. ESIGN and UETA cover US enforceability. GDPR applies whenever signers are EU residents. HIPAA and FDA 21 CFR Part 11 are hard requirements in healthcare and life sciences. Identify the standards your contracts and signers require before you run your first sandbox test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded signing alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Foxit eSign
&lt;/h3&gt;

&lt;p&gt;Foxit eSign uses the OAuth2 &lt;code&gt;client_credentials&lt;/code&gt; grant, which works well for server-to-server SaaS workflows. You POST your &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; to the regional OAuth2 endpoint to exchange credentials for a Bearer token. Foxit routes requests to regional infrastructure, with &lt;code&gt;na1.foxitesign.foxit.com&lt;/code&gt; for US-hosted data and &lt;code&gt;eu1.foxitesign.foxit.com&lt;/code&gt; for EU. Every subsequent API call carries that token in the &lt;code&gt;Authorization: Bearer&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;The iFrame embedding model uses two parameters on the Create Envelope call. Setting &lt;code&gt;createEmbeddedSigningSession&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; suppresses email delivery and generates a session token. Setting &lt;code&gt;createEmbeddedSigningSessionForAllParties&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; generates individual signed URLs for each recipient. The resulting URL takes the form &lt;code&gt;https://HOST_NAME/embedded/embeddedsign?eetid={URL-ENCODED-TOKEN}&lt;/code&gt;, which you drop into an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; tag inside your application. After a recipient signs or declines, Foxit eSign redirects to the return URL you specified in the Create Envelope request, appending &lt;code&gt;folderId&lt;/code&gt; and an &lt;code&gt;event&lt;/code&gt; parameter (&lt;code&gt;signing_success&lt;/code&gt; or &lt;code&gt;signing_declined&lt;/code&gt;), so your application can act on the outcome without polling.&lt;/p&gt;

&lt;p&gt;Published API sample code covers cURL, PHP, C#, and Java. Webhook channels fire on envelope status changes including &lt;code&gt;EXECUTED&lt;/code&gt;, and you can configure a &lt;code&gt;webhookSecret&lt;/code&gt; so your endpoint can validate HMAC-signed delivery payloads. The compliance footprint covers ESIGN, UETA, GDPR, HIPAA, CCPA, and FDA 21 CFR Part 11, making Foxit eSign viable for healthcare and life-sciences workflows that require Part 11 audit trails and long-term record validation.&lt;/p&gt;

&lt;p&gt;A free developer account is available at &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;app.developer-api.foxit.com/sign-up&lt;/a&gt; with no credit card required. Full API reference lives at &lt;a href="https://docs.developer-api.foxit.com/" rel="noopener noreferrer"&gt;docs.developer-api.foxit.com&lt;/a&gt;. For current plan pricing, see the &lt;a href="https://app.developer-api.foxit.com/reference/tag/credits-explained" rel="noopener noreferrer"&gt;Foxit eSign API credits page&lt;/a&gt;.&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%2Fra959k6lgcd2x1mqm2if.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%2Fra959k6lgcd2x1mqm2if.png" alt="Foxit eSign embedded signing session loaded inside an application, showing a Service Agreement with interactive Text Tag fields and a Next Required Field control" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Dropbox Sign
&lt;/h3&gt;

&lt;p&gt;Dropbox Sign (formerly HelloSign) supports OAuth2 Authorization Code Grant alongside API key authentication. For embedded signing, the API generates a &lt;code&gt;sign_url&lt;/code&gt; via an embedded signing request. You render that URL inside an iFrame using Dropbox Sign's JavaScript client library, which manages the signer handshake and posts completion events back to the parent page.&lt;/p&gt;

&lt;p&gt;Official SDKs cover Node, Python, Ruby, Java, PHP, and .NET. Webhooks deliver signature request events validated via a callback hash. The compliance footprint includes ESIGN, UETA, GDPR, and HIPAA, though HIPAA support requires an annual Standard or Premium plan plus a signed Business Associate Agreement rather than being available on every tier. A free Test mode plan lets you build and exercise the full API before committing, with paid tiers starting once you send live signature requests. For current plan details, see the &lt;a href="https://sign.dropbox.com/products/dropbox-sign-api/pricing" rel="noopener noreferrer"&gt;Dropbox Sign API pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Dropbox Sign's embedded model requires loading the JavaScript SDK in the parent page, adding a client-side dependency. Teams that want a purely server-rendered iFrame approach without a JS dependency will find that coupling inconvenient, particularly in server-side-rendered stacks where loading a third-party client script creates CSP friction.&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%2Fw1kwndk9nbnduxvbop89.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%2Fw1kwndk9nbnduxvbop89.png" alt="Dropbox Sign API documentation homepage, showing the API reference and getting-started navigation" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  SignNow
&lt;/h3&gt;

&lt;p&gt;SignNow, an airSlate product, supports the OAuth2 &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;refresh_token&lt;/code&gt;, and &lt;code&gt;authorization_code&lt;/code&gt; grants, where the authorization code grant covers applications acting on behalf of an individual SignNow user rather than the signing flow itself. Embedded signing runs on a bearer token or API key, so the signer never touches OAuth. You create an embedded invite via the API, generate a link for that invite, then render it in an iFrame inside your application.&lt;/p&gt;

&lt;p&gt;SignNow publishes SDKs for PHP, .NET, Java, Python, and Node.js, with an Android SDK added more recently. Compliance certifications cover ESIGN, UETA, HIPAA, GDPR, and SOC 2 Type II, alongside 21 CFR Part 11, PCI DSS, eIDAS, and CCPA. For developer plans, see the &lt;a href="https://snseats.signnow.com/purchase/api/pricing" rel="noopener noreferrer"&gt;SignNow API pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The webhook layer delivers &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, and &lt;code&gt;document_group&lt;/code&gt; events, and you can have them HMAC-signed by supplying your own secret when you create the subscription, which arrives as an &lt;code&gt;X-SignNow-Signature&lt;/code&gt; header holding a base64 HMAC-SHA256 digest. Signing is opt-in rather than the default, so an integration that skips the secret receives unauthenticated callbacks. Recipient identity verification is configured per recipient on the invite and offers a password, an SMS code, or a phone-call code.&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%2Fl43y60qmv03wonswh8f5.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%2Fl43y60qmv03wonswh8f5.png" alt="SignNow API documentation homepage, showing the " start="" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Document automation and open-source alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  DocuSeal
&lt;/h3&gt;

&lt;p&gt;DocuSeal is an open-source e-signature platform designed for self-hosting, making it the primary option for teams with strict data-residency requirements. Documents stay inside your infrastructure rather than transiting a third-party cloud. The API authenticates with an API key sent in a custom &lt;code&gt;X-Auth-Token&lt;/code&gt; header rather than an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header, which is the one place a migration script written against the other providers in this guide needs a different code path. The embedded signing UI ships as a &lt;code&gt;&amp;lt;docuseal-form&amp;gt;&lt;/code&gt; JavaScript web component, with official React, Vue, and Angular wrappers you mount in your front end.&lt;/p&gt;

&lt;p&gt;The open-source repository is available on &lt;a href="https://github.com/docusealco/docuseal" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; under AGPLv3 with additional attribution terms, and a managed cloud version is also offered. First-party SDKs cover Ruby, JavaScript and TypeScript, Python, PHP, Java, C# and .NET, and Go, so the self-hosted path is not limited to the two languages the project started with. Because you control the infrastructure, you control data location, which addresses GDPR data-residency obligations that cloud-only providers cannot satisfy without a Data Processing Agreement and region-locked storage commitments. For cloud pricing and self-hosting configuration details, see the &lt;a href="https://www.docuseal.com/pricing" rel="noopener noreferrer"&gt;DocuSeal pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Read that compliance list with the deployment model in mind. The SOC 2 Type II and ISO 27001 certifications cover DocuSeal's own managed cloud, so when you self-host, the audit evidence for your environment becomes your responsibility rather than the vendor's. Running self-hosted signing infrastructure also means your team owns uptime, secret key management, and upgrade cycles, and that operational burden is the direct trade-off for the data control DocuSeal provides.&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%2Fb9e2r72deycb7u83h4a6.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%2Fb9e2r72deycb7u83h4a6.png" alt="DocuSeal documentation hub homepage, showing the Developer Guides, API and Webhooks, and Embedded documentation sections" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  BoldSign
&lt;/h3&gt;

&lt;p&gt;BoldSign targets SaaS developers embedding signing into customer-facing products. Authentication supports OAuth2 (&lt;code&gt;client_credentials&lt;/code&gt; and Authorization Code Grant) and API key. The embedded signing flow generates a signed URL you render in an iFrame, with no client-side JS SDK dependency required.&lt;/p&gt;

&lt;p&gt;Official SDKs are published for .NET, Java, Python, Node.js, and PHP. Webhooks fire on document status events and support HMAC-signed payload validation. The compliance footprint covers ESIGN, UETA, HIPAA, and GDPR, though HIPAA support sits on the Business tier rather than the entry plans. A genuinely free plan is available rather than a time-boxed trial. For full plan details, see the &lt;a href="https://boldsign.com/electronic-signature-pricing/" rel="noopener noreferrer"&gt;BoldSign pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;BoldSign's API documentation covers the full envelope lifecycle with code samples across its published SDK languages, and the free plan's limits make it a workable tier for integration testing without a time-boxed trial window.&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%2Fry9lf7qhndrhisibekjr.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%2Fry9lf7qhndrhisibekjr.png" alt="BoldSign Developer Hub homepage, showing the API sandbox and documentation entry points" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  PandaDoc
&lt;/h3&gt;

&lt;p&gt;PandaDoc covers a wider scope than pure e-signature. The platform addresses full document automation, including template management, proposal generation, and CPQ (configure, price, quote) document creation, with e-signature as one output in that pipeline. The API supports OAuth2 Authorization Code Grant and API key authentication. Embedded signing runs on a session URL, which PandaDoc now recommends rendering through its &lt;code&gt;pandadoc-signing&lt;/code&gt; client library rather than wiring the iFrame by hand.&lt;/p&gt;

&lt;p&gt;Official client SDKs are published for Python, Node, Java, and PHP, with Ruby and .NET available only as community-maintained wrappers. Webhooks fire on document events with callback validation. The compliance footprint includes ESIGN, UETA, HIPAA, GDPR, and SOC 2 Type 2, though transmitting protected health information requires signing a Business Associate Agreement with PandaDoc rather than relying on the platform default. Sandbox API calls are free of charge for development, though the sandbox key itself is only available on the Business or Enterprise plan, and a production key requires Enterprise plus manual activation by PandaDoc, so factor both gates into any timeline. For current rates, see the &lt;a href="https://www.pandadoc.com/pricing/" rel="noopener noreferrer"&gt;PandaDoc pricing page&lt;/a&gt;.&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%2Fty1pq3m59thpv9ck4blb.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%2Fty1pq3m59thpv9ck4blb.png" alt="PandaDoc API for Developers homepage, showing the Documentation, API Reference, and Changelog navigation" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Teams that need document generation, CRM-driven template population, and signing in a single workflow will find the broader platform scope worth the integration surface area. Teams evaluating PandaDoc for e-signature alone should factor in that breadth before committing, since it drives both API complexity and plan cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison at a glance
&lt;/h2&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;Auth Model&lt;/th&gt;
&lt;th&gt;Embedded Signing&lt;/th&gt;
&lt;th&gt;SDK Languages&lt;/th&gt;
&lt;th&gt;Compliance&lt;/th&gt;
&lt;th&gt;Free API Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foxit eSign&lt;/td&gt;
&lt;td&gt;OAuth2 &lt;code&gt;client_credentials&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;iFrame via &lt;code&gt;createEmbeddedSigningSession&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;cURL, PHP, C#, Java&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, GDPR, HIPAA, CCPA, FDA 21 CFR Part 11&lt;/td&gt;
&lt;td&gt;Yes (no credit card; 30-day trial in test mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropbox Sign&lt;/td&gt;
&lt;td&gt;OAuth2 + API key&lt;/td&gt;
&lt;td&gt;iFrame + JS client library&lt;/td&gt;
&lt;td&gt;Node, Python, Ruby, Java, PHP, .NET&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, GDPR, HIPAA (Standard/Premium plan + BAA)&lt;/td&gt;
&lt;td&gt;Yes (free Test mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SignNow&lt;/td&gt;
&lt;td&gt;OAuth2 (password, auth code, refresh) + API key&lt;/td&gt;
&lt;td&gt;iFrame via embedded invite&lt;/td&gt;
&lt;td&gt;PHP, .NET, Java, Python, Node.js, Android&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, HIPAA, GDPR, SOC 2, 21 CFR Part 11, eIDAS&lt;/td&gt;
&lt;td&gt;Yes (free Dev Mode test tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocuSeal&lt;/td&gt;
&lt;td&gt;API key (&lt;code&gt;X-Auth-Token&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;iFrame (web component)&lt;/td&gt;
&lt;td&gt;Ruby, JS/TS, Python, PHP, Java, C#/.NET, Go&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, eIDAS, GDPR, HIPAA, 21 CFR Part 11, SOC 2, ISO 27001&lt;/td&gt;
&lt;td&gt;Yes (open source / limited cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BoldSign&lt;/td&gt;
&lt;td&gt;OAuth2 + API key&lt;/td&gt;
&lt;td&gt;iFrame&lt;/td&gt;
&lt;td&gt;.NET, Java, Python, Node.js, PHP&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, HIPAA, GDPR&lt;/td&gt;
&lt;td&gt;Yes (free plan)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PandaDoc&lt;/td&gt;
&lt;td&gt;OAuth2 Auth Code + API key&lt;/td&gt;
&lt;td&gt;iFrame via &lt;code&gt;pandadoc-signing&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Python, Node, Java, PHP&lt;/td&gt;
&lt;td&gt;ESIGN, UETA, HIPAA (BAA), GDPR, SOC 2&lt;/td&gt;
&lt;td&gt;Sandbox calls free (Business/Enterprise plan); production requires Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Verify all cells against each vendor's current documentation before making a selection, as SDK and compliance details change with platform updates.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to migrate from DocuSign to a new API
&lt;/h2&gt;

&lt;p&gt;The five steps below apply regardless of which alternative you choose. Each step is designed to be verified in isolation so you catch provider-specific behavioral differences before touching production.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[Generate&amp;lt;br/&amp;gt;OAuth2 Credentials] --&amp;gt; B[Test Token&amp;lt;br/&amp;gt;Exchange in Sandbox]
    B --&amp;gt; C[Repoint&amp;lt;br/&amp;gt;Authorization&amp;lt;br/&amp;gt;Header]
    C --&amp;gt; D[Remap Webhook&amp;lt;br/&amp;gt;Event Payloads]
    D --&amp;gt; E[Swap SDK&amp;lt;br/&amp;gt;Dependency]
    E --&amp;gt; F[Run Test Suite&amp;lt;br/&amp;gt;Against New Sandbox]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;Step 1. Generate new OAuth2 credentials.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every provider in this guide uses a &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt; as the starting point. Create a sandbox account, retrieve your credentials from the developer portal, and export them as environment variables so they never end up in your shell history or a committed file. Then use &lt;a href="https://www.postman.com/" rel="noopener noreferrer"&gt;Postman&lt;/a&gt; or a direct &lt;a href="https://curl.se/" rel="noopener noreferrer"&gt;cURL&lt;/a&gt; call to confirm you can exchange them for a Bearer token. For a &lt;code&gt;client_credentials&lt;/code&gt; flow such as Foxit eSign, the request looks like this:&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;--location&lt;/span&gt; &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$REGIONAL_ENDPOINT&lt;/span&gt;&lt;span class="s2"&gt;/api/oauth2/access_token"&lt;/span&gt; &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/x-www-form-urlencoded'&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'grant_type=client_credentials'&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"client_id=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s1"&gt;'scope=read-write'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this command, you post your credentials to the provider's token endpoint as a form-encoded body rather than JSON, which matters because Foxit eSign rejects a JSON body on this endpoint with HTTP 415. The &lt;code&gt;grant_type&lt;/code&gt; and &lt;code&gt;scope&lt;/code&gt; values tell the provider you want a server-to-server token with read and write access. Read the credentials from &lt;code&gt;$CLIENT_ID&lt;/code&gt; and &lt;code&gt;$CLIENT_SECRET&lt;/code&gt; in your environment so the values stay out of the command itself.&lt;/p&gt;

&lt;p&gt;A successful exchange returns the token alongside its type and remaining lifetime:&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="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"REDACTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;23480263&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"instance_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://na1.foxitesign.foxit.com/"&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;The &lt;code&gt;instance_url&lt;/code&gt; confirms which regional host issued the token, and every call you make afterwards sends &lt;code&gt;Authorization: Bearer &amp;lt;access_token&amp;gt;&lt;/code&gt; against that same host.&lt;/p&gt;

&lt;p&gt;For Authorization Code flows (Dropbox Sign, SignNow, PandaDoc), follow that provider's OAuth2 redirect sequence to obtain your initial token. Confirm the token arrives before writing any application code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2. Repoint the Authorization header at your new provider.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Since the legacy header stopped working in 2024, your DocuSign integration already sends an OAuth 2.0 Bearer token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Authorization: Bearer {DOCUSIGN_ACCESS_TOKEN}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every provider in this guide uses that same header shape, so what changes is which service issued the token, not how you attach it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Authorization: Bearer {ACCESS_TOKEN}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the transport change small and the token-acquisition change the real work, which is why Step 1 comes first. If your codebase predates the retirement, grep for &lt;code&gt;X-DocuSign-Authentication&lt;/code&gt; as well and delete any leftover credential-construction logic that still assembles the old JSON payload, since that code is dead and only obscures which auth path is live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3. Remap webhook event payloads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DocuSign's Connect service uses its own envelope status event schema. Your new provider uses a different schema with different field names, status strings, and nesting. Map the fields your current handler consumes (envelope ID, signer email, completion timestamp, event type) to the new provider's equivalent fields. Build a translation layer that converts the new provider's payload into the shape your downstream code already expects. This approach keeps your business logic untouched and isolates the mapping delta to a single module you can test independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4. Swap the SDK dependency.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remove the DocuSign SDK from your package manager and install the equivalent SDK for your new provider. Compile-time errors and type mismatches surface immediately. Work through them method by method, keeping business logic (what you do with a completed envelope) separate from transport logic (how you call the API), so the two don't share state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5. Run your existing test suite against the new sandbox.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your existing integration tests should pass against the new provider's sandbox before you touch production. Any test that fails points to a schema delta or a behavioral difference you need to handle explicitly. Fix the underlying mismatch rather than disabling the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three variables that actually decide
&lt;/h2&gt;

&lt;p&gt;Your embedded signing model (iFrame vs. async redirect), your required SDK language coverage, and your compliance obligations are the three variables that determine the right e-signature API for your stack.&lt;/p&gt;

&lt;p&gt;DocuSeal is the right answer when data residency and self-hosted control are non-negotiable. PandaDoc fits teams that need document automation alongside signatures as part of a single pipeline. BoldSign and Foxit eSign both target embedded SaaS use cases, with BoldSign standing out for a free tier that sends real envelopes rather than test-only traffic, and Foxit eSign covering the regulated-industry compliance surface (including HIPAA and FDA 21 CFR Part 11) that healthcare and life-sciences teams require.&lt;/p&gt;

&lt;p&gt;Read the free tiers carefully, because they are not equivalent. Dropbox Sign's Test mode and SignNow's Dev Mode give you an unlimited test environment but no live signatures, PandaDoc's sandbox is free while production is gated behind the Enterprise plan, and Foxit eSign's self-serve developer activation provisions a 30-day trial in test mode with watermarked envelopes before production access goes through Foxit. BoldSign's free plan is the outlier, allowing a small monthly volume of genuine envelopes. A tier that looks generous in a comparison table can still stop at the exact moment you want to ship.&lt;/p&gt;

&lt;p&gt;If Foxit eSign's iFrame-embedded model and compliance depth fit your stack, the free developer account at &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;app.developer-api.foxit.com/sign-up&lt;/a&gt; gives you API keys and a full sandbox environment with no credit card required.&lt;/p&gt;

&lt;p&gt;Which of the five evaluation criteria is the hardest constraint in your current stack? Share in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>comparison</category>
      <category>esignature</category>
    </item>
    <item>
      <title>How to extract invoice data from PDFs into structured JSON</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:35:22 +0000</pubDate>
      <link>https://dev.to/luciench/how-to-extract-invoice-data-from-pdfs-into-structured-json-249d</link>
      <guid>https://dev.to/luciench/how-to-extract-invoice-data-from-pdfs-into-structured-json-249d</guid>
      <description>&lt;p&gt;Invoice PDFs are structurally unpredictable. Vendor A ships a five-column line-item table, vendor B embeds the same information in a paragraph block, and half the scanned copies in a legacy archive have no text layer at all. Generic text extraction reads characters in the order they were written to the file rather than the reading order a human sees, so field values drift with every layout variation.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://app.developer-api.foxit.com/reference/tag/pdf-structural-extraction-trial" rel="noopener noreferrer"&gt;Foxit PDF Structural Extraction API&lt;/a&gt; addresses this directly. You submit a PDF invoice and receive a typed, hierarchical JSON document with named element types, bounding regions, and an addressable table cell grid. This guide covers the four REST calls that turn a raw PDF into a &lt;code&gt;StructureInfo.json&lt;/code&gt; file, the &lt;a href="https://www.python.org/" rel="noopener noreferrer"&gt;Python&lt;/a&gt; post-processing that maps that output onto a clean invoice schema, and the edge cases your pipeline will hit on real vendor documents. Every response shape and field name below comes from a live run against the API, not from the reference docs alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why invoice PDFs break generic parsers
&lt;/h2&gt;

&lt;p&gt;Three structural problems cause most invoice parsing failures, and OCR accuracy is only one of them.&lt;/p&gt;

&lt;p&gt;The first is layout variance across vendors. A PDF's text layer records characters in the order they were drawn, which often follows vector rendering order rather than left-to-right, top-to-bottom reading order. Extract raw text from a five-column line-item table and you frequently get interleaved fragments, where description text from one column mixes with unit prices from another because the writer rendered all rows of one column before moving to the next. No string-parsing logic reliably recovers column boundaries from that flattened sequence.&lt;/p&gt;

&lt;p&gt;The second is merged and multi-row cells. Line-item tables routinely span cells across rows for items with multi-line descriptions. Text extraction collapses those cell boundaries into a flat string and drops the row-to-total relationship an accounting system needs.&lt;/p&gt;

&lt;p&gt;The third is rasterized scans with no text layer. A PDF created by scanning a paper invoice contains only an embedded image, so anything that reads the text layer alone comes back empty. Adobe's own Acrobat documentation puts it plainly, noting that a scanned file "contains only image data, not searchable text." Tools built for scanned input bundle an OCR step rather than skipping it, and any pipeline you build has to do the same before extraction can happen.&lt;/p&gt;

&lt;p&gt;Structure-aware extraction addresses all three by classifying document regions into typed elements before exposing their content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invoice fields to target before touching the API
&lt;/h2&gt;

&lt;p&gt;Define the target schema before writing code. A concrete target tells you which elements to read from &lt;code&gt;StructureInfo.json&lt;/code&gt; and which to skip, which saves iteration time on every invoice you process.&lt;/p&gt;

&lt;p&gt;A workable invoice schema covers three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Header fields, including vendor name, invoice number, invoice date, due date, and payment terms&lt;/li&gt;
&lt;li&gt;Line items, a repeating array of description, quantity, unit price, and line total&lt;/li&gt;
&lt;li&gt;Footer totals, including subtotal, tax amount, and total amount due
&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;"vendor_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"invoice_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"payment_terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"line_items"&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;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"line_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"total_due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;Keep every value a string at extraction time. Type conversion, currency parsing, and date normalization belong downstream, after validation, where a bad value can be rejected with context instead of raising inside the parser.&lt;/p&gt;

&lt;p&gt;The sample invoice used throughout this guide is &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_full_test.pdf" rel="noopener noreferrer"&gt;&lt;code&gt;invoice_full_test.pdf&lt;/code&gt;&lt;/a&gt;, so you can run every call below against the same document.&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%2F0l4m4sivvbil2tkprkkw.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%2F0l4m4sivvbil2tkprkkw.png" alt="The sample invoice PDF, showing header fields, a five-column line-item table, and four footer rows whose labels sit in the second-to-last column" width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The input document. Notice that the Subtotal, Tax Rate, Tax Amount, and Total Due labels sit in the second-to-last column rather than the first. That detail determines how the post-processing code has to find them.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;You need the following before the first API call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python 3.9 or newer&lt;/a&gt; and &lt;a href="https://pip.pypa.io/en/stable/installation/" rel="noopener noreferrer"&gt;pip&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A virtual environment via &lt;a href="https://docs.python.org/3/library/venv.html" rel="noopener noreferrer"&gt;venv&lt;/a&gt;, so the dependency below stays isolated&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://requests.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;requests&lt;/a&gt; library for HTTP calls&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;
&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;free Foxit developer account&lt;/a&gt;
&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;invoice-extraction &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;invoice-extraction &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; 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 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then download the sample invoice into that folder:&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;-L&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; invoice_full_test.pdf https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_full_test.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Foxit API authentication and setup
&lt;/h2&gt;

&lt;p&gt;Signing up activates a free Developer plan that includes 500 credits per year with no credit card required. A structural extraction call costs one credit, while the upload, polling, and download calls are not billed, so a full run of the workflow below costs a single credit.&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%2F8oz0hxy2z7a6v9afy9fp.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%2F8oz0hxy2z7a6v9afy9fp.png" alt="The Foxit developer sign-up page, showing that no credit card is required and that the free Developer plan includes 500 credits per year" width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The account creation screen. The free Developer plan is enough to work through this entire guide.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Foxit authenticates PDF Services requests with a client ID and client secret passed as HTTP headers, so there is no OAuth token exchange to implement. Both values come from the default application created in your Developer Portal dashboard, alongside the base URL your calls need.&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%2Ftx48zcsa70rkounxos1x.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%2Ftx48zcsa70rkounxos1x.png" alt="The Foxit developer portal credentials panel, showing the base URL, client ID, and a masked client secret" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The credentials panel. Copy the Client ID and Client Secret into environment variables rather than pasting them into source files.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Export them into your shell so no credential is ever committed:&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;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;The structural extraction reference page carries a &lt;strong&gt;Test Request&lt;/strong&gt; button that fires live calls straight from the browser, which is the quickest way to confirm your credentials work before writing any Python. The endpoint is currently labelled Trial in the reference, so expect its surface to evolve.&lt;/p&gt;

&lt;p&gt;Pin your parser to the &lt;code&gt;version&lt;/code&gt; field inside the &lt;code&gt;analyzeResult&lt;/code&gt; response. The current schema ships as &lt;code&gt;1.0.7&lt;/code&gt;, and pinning prevents silent breakage if that changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-step PDF to JSON invoice extraction workflow
&lt;/h2&gt;

&lt;p&gt;The API is asynchronous. You upload a document, start a task, poll until the task completes, then download the result.&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%2Fkiv25pjev1d7yyf0k0ew.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%2Fkiv25pjev1d7yyf0k0ew.png" alt="The four-call Foxit structural extraction workflow, showing upload returning a documentId, extract returning a taskId, polling until COMPLETED, and download returning a ZIP" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All four paths sit under &lt;code&gt;https://na1.fusion.foxit.com/pdf-services&lt;/code&gt;. Calling them without that prefix returns 404.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The path prefix matters more than it looks. The four endpoints live under &lt;code&gt;/pdf-services/api/...&lt;/code&gt;, and requesting a bare &lt;code&gt;/documents/{id}/download&lt;/code&gt; returns 404 rather than a helpful error.&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&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="n"&gt;POLL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;POLL_TIMEOUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_structure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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="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="c1"&gt;# Step 1: upload the PDF (multipart/form-data, 100 MB maximum)
&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;pdf_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;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;upload&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;/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;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="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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;handle&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/pdf&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;upload&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;document_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upload&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="c1"&gt;# Step 2: start the structural extraction task
&lt;/span&gt;    &lt;span class="n"&gt;started&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;/api/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="n"&gt;HEADERS&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;started&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_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;started&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;# Step 3: poll until COMPLETED, bounded, and handle FAILED
&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;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;POLL_TIMEOUT&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;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/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="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;task&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;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;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;break&lt;/span&gt;
        &lt;span class="k"&gt;if&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;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 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;task&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&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;deadline&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; stuck at &lt;/span&gt;&lt;span class="si"&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;status&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;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;POLL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 4: download the result ZIP and read StructureInfo.json
&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;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;/api/documents/&lt;/span&gt;&lt;span class="si"&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="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;result&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;result&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;archive&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;archive&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, you read both credentials from the environment, upload the PDF as multipart form data and capture the returned &lt;code&gt;documentId&lt;/code&gt;, hand that id to the extraction endpoint to receive a &lt;code&gt;taskId&lt;/code&gt;, then poll the task endpoint every two seconds. The loop is bounded by a deadline and checks explicitly for &lt;code&gt;FAILED&lt;/code&gt;, so a rejected document raises instead of spinning forever. Once the status reads &lt;code&gt;COMPLETED&lt;/code&gt;, the task payload carries a &lt;code&gt;resultDocumentId&lt;/code&gt;, which you exchange for a ZIP archive and read &lt;code&gt;StructureInfo.json&lt;/code&gt; out of in memory.&lt;/p&gt;

&lt;p&gt;Status values are uppercase (&lt;code&gt;PENDING&lt;/code&gt;, &lt;code&gt;IN_PROGRESS&lt;/code&gt;, &lt;code&gt;COMPLETED&lt;/code&gt;, &lt;code&gt;FAILED&lt;/code&gt;), and there is no synchronous variant of this endpoint. The download returns &lt;code&gt;application/zip&lt;/code&gt; containing &lt;code&gt;StructureInfo.json&lt;/code&gt; plus one PNG for each detected table region.&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%2F6dvgoi7pj3mwa6pv58nt.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%2F6dvgoi7pj3mwa6pv58nt.png" alt="Terminal output from a live run, showing the four calls returning 200, 202, COMPLETED, and a 20,487-byte ZIP, followed by the parsed invoice JSON" width="800" height="910"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A real run against the sample invoice. The task reports &lt;code&gt;IN_PROGRESS&lt;/code&gt; at 20 percent before reaching &lt;code&gt;COMPLETED&lt;/code&gt;, and the final object carries every field from the schema defined earlier.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to map raw output to a clean invoice schema
&lt;/h2&gt;

&lt;p&gt;Choosing an invoice data extraction API is only half the work. The other half is mapping whatever it returns onto a schema your backend already understands, and that mapping is where the shape of the response starts to matter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StructureInfo.json&lt;/code&gt; wraps everything in an &lt;code&gt;analyzeResult&lt;/code&gt; object with four top-level keys, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;pages&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;, and &lt;code&gt;elements&lt;/code&gt;. The &lt;code&gt;elements&lt;/code&gt; array is where the work happens. Each element carries a &lt;code&gt;type&lt;/code&gt; drawn from twelve values, including &lt;code&gt;paragraph&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;form&lt;/code&gt;, and &lt;code&gt;formula&lt;/code&gt;, along with its bounding region and content.&lt;/p&gt;

&lt;p&gt;Two details in that structure cause most of the bugs in a first implementation, and neither is obvious from the field names.&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%2F1ofo0u0p9ooct23xn3sc.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%2F1ofo0u0p9ooct23xn3sc.png" alt="The real StructureInfo.json shape, annotated to show that cells live at content.body.cells, cell text at paragraph.content.text, and boundingBox as an eight-number polygon" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The actual response shape from a live extraction. A table's cells are nested at &lt;code&gt;content.body.cells&lt;/code&gt;, cell text sits at &lt;code&gt;paragraph.content.text&lt;/code&gt;, and &lt;code&gt;region.boundingBox&lt;/code&gt; is an eight-number polygon rather than an x, y, width, height rectangle.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A table element does not expose a top-level &lt;code&gt;cells&lt;/code&gt; array. Its grid is nested at &lt;code&gt;content.body.cells&lt;/code&gt;, where each cell carries &lt;code&gt;rowIndex&lt;/code&gt;, &lt;code&gt;columnIndex&lt;/code&gt;, and a &lt;code&gt;paragraph&lt;/code&gt; object. Cell text then sits one level deeper still, at &lt;code&gt;paragraph.content.text&lt;/code&gt;, because &lt;code&gt;content&lt;/code&gt; is an object rather than a string. Reaching for &lt;code&gt;cell["paragraph"]["content"]&lt;/code&gt; returns a dict, not the text you want.&lt;/p&gt;

&lt;p&gt;Blank cells are the second detail. When a vendor leaves a cell empty, the API still returns the cell with its indices and a &lt;code&gt;paragraph&lt;/code&gt; object, but that paragraph has no &lt;code&gt;content&lt;/code&gt; key at all. An unguarded read raises &lt;code&gt;KeyError&lt;/code&gt; partway through a document that looked fine in testing.&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;element_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&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="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;Return an element&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s text, or an empty string when it carries none.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;element&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;cell_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&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="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;Return a table cell&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s text, or an empty string when the cell is blank.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;element_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paragraph&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, &lt;code&gt;element_text&lt;/code&gt; reads the nested &lt;code&gt;content.text&lt;/code&gt; value and normalizes its whitespace, which matters because cell text can contain a literal &lt;code&gt;\r\n&lt;/code&gt; where a label wraps across two lines. Using &lt;code&gt;" ".join(text.split())&lt;/code&gt; collapses those into single spaces, whereas &lt;code&gt;.strip()&lt;/code&gt; leaves a mid-string newline untouched. &lt;code&gt;cell_text&lt;/code&gt; then reuses that helper for table cells, returning an empty string for a blank cell instead of raising.&lt;/p&gt;

&lt;p&gt;With the accessors in place, build a grid and read it row by row.&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;re&lt;/span&gt;

&lt;span class="n"&gt;FOOTER_LABELS&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;subtotal&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;tax rate&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;tax amount&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;tax&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;total due&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;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;HEADER_PATTERNS&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;vendor_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bill to:\s*(.+)&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_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice number:\s*(.+)&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_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice date:\s*(.+)&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;due_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;due date:\s*(.+)&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;payment_terms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment is due within (.+?) of&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;parse_invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;structure_info&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="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;elements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;structure_info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analyzeResult&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;elements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HEADER_PATTERNS&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line_items&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="o"&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;tax&lt;/span&gt;&lt;span class="o"&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;total_due&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Header fields come from paragraph elements above the table
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;elements&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;element&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;paragraph&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;element_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&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;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HEADER_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&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;match&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&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;field&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;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;table&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tables&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&lt;/span&gt;

    &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;=&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;cell&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tables&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&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;body&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;cells&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rowIndex&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;cell&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columnIndex&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="nf"&gt;cell_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Resolve columns from the header row instead of assuming positions
&lt;/span&gt;    &lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;grid&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&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;name&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;column_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&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;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;name&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;index&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;

    &lt;span class="n"&gt;description_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;column_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&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;item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&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="n"&gt;quantity_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;column_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;qty&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;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;unit_price_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;column_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&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;line_total_col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;column_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&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;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row_index&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row_index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;strip&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;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FOOTER_LABELS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&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;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;label&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtotal&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtotal&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="n"&gt;value&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tax amount&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tax&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="n"&gt;value&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total due&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;total&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_due&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="n"&gt;value&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;description_col&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_items&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;description_col&lt;/span&gt;&lt;span class="p"&gt;,&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;quantity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;quantity_col&lt;/span&gt;&lt;span class="p"&gt;,&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;unit_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;unit_price_col&lt;/span&gt;&lt;span class="p"&gt;,&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;line_total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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="n"&gt;line_total_col&lt;/span&gt;&lt;span class="p"&gt;,&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;return&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this code, you first walk the &lt;code&gt;paragraph&lt;/code&gt; elements and pull header fields out with labelled regular expressions, which works because Foxit exposes each header line as its own element with its reading order preserved. You then flatten the table into a &lt;code&gt;{rowIndex: {columnIndex: text}}&lt;/code&gt; grid and resolve column positions from the header row by name, so a vendor who adds a leading row-number column does not shift every field by one. Each subsequent row is classified before it is read, so that if any cell in the row matches a known footer label the row is treated as a total and its value taken from the last populated column, and otherwise the row becomes a line item. Scanning the whole row for the label is the part that matters, because footer labels do not sit in the first column.&lt;/p&gt;

&lt;p&gt;Footer totals living inside the line-item table is convenient rather than awkward, since one pass over the cell grid covers line items and totals together. Form elements do not appear on a typical invoice, so there is no need to look for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Before and after
&lt;/h3&gt;

&lt;p&gt;The two blocks below show the same data on either side of that mapping. First, one real cell exactly as the API returns it, taken verbatim from the run above:&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;"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;"API Integration&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;Consulting"&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;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;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;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;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;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;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;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;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="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;"paragraph12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"paragraphOrder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&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;"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;"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;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;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;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;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;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;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;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;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="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.8555269837379456&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;That fragment is the shape of every cell you will handle. The text is nested at &lt;code&gt;paragraph.content.text&lt;/code&gt; rather than sitting directly on the cell. The position arrives as an eight-number &lt;code&gt;boundingBox&lt;/code&gt; polygon on both the cell and its paragraph, not as a rectangle. And the value itself contains a literal &lt;code&gt;\r\n&lt;/code&gt; where the description wrapped onto a second line in the source table, which is the case &lt;code&gt;.strip()&lt;/code&gt; silently fails to clean.&lt;/p&gt;

&lt;p&gt;Second, the complete object &lt;code&gt;parse_invoice&lt;/code&gt; returns for the whole document, which is what your backend actually consumes:&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;"vendor_name"&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 Corporation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&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-2025-0042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"07/15/2025"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"08/14/2025"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payment_terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"30 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&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;"API Integration 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;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8"&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_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$ 195.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line_total"&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="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;"Document Automation Setup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&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;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$ 750.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line_total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$ 750.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="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$2,310.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$ 184.80"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$2,494.80"&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;The wrapped description has become the single clean string &lt;code&gt;"API Integration Consulting"&lt;/code&gt;, the header fields have been lifted out of the paragraph elements above the table, and the four footer rows have been separated from the two genuine line items. That output is backend-ready, so you can write it straight to a database, push it to a reporting pipeline, or validate it against an accounts-payable schema without further parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dropping the path prefix.&lt;/strong&gt; All four endpoints sit under &lt;code&gt;/pdf-services/api/...&lt;/code&gt;. A bare &lt;code&gt;/documents/{id}/download&lt;/code&gt; returns 404.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;cells&lt;/code&gt; off the table element.&lt;/strong&gt; The grid is nested at &lt;code&gt;content.body.cells&lt;/code&gt;. A top-level &lt;code&gt;cells&lt;/code&gt; lookup raises &lt;code&gt;KeyError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating &lt;code&gt;paragraph.content&lt;/code&gt; as a string.&lt;/strong&gt; It is an object, so the text is at &lt;code&gt;paragraph.content.text&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming footer labels are in column 0.&lt;/strong&gt; On real invoices they commonly sit in the second-to-last column, with the value beside them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting blank cells.&lt;/strong&gt; A blank cell keeps its &lt;code&gt;paragraph&lt;/code&gt; object but carries no &lt;code&gt;content&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polling without a bound.&lt;/strong&gt; A &lt;code&gt;FAILED&lt;/code&gt; task never becomes &lt;code&gt;COMPLETED&lt;/code&gt;, so an unbounded &lt;code&gt;while True&lt;/code&gt; loop hangs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting &lt;code&gt;info.basicInfo.elementCounts&lt;/code&gt;.&lt;/strong&gt; It can disagree with the length of the &lt;code&gt;elements&lt;/code&gt; array, so size loops from the array itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;.strip()&lt;/code&gt; to clean cell text.&lt;/strong&gt; A wrapped label contains a mid-string &lt;code&gt;\r\n&lt;/code&gt; that &lt;code&gt;.strip()&lt;/code&gt; leaves in place. Use &lt;code&gt;" ".join(text.split())&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is invoice data extraction from PDF?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Invoice data extraction from PDF is the programmatic conversion of semi-structured PDF invoice content into a schema-typed data object. The source can be a digital-native PDF with an embedded text layer or a scanned image-only PDF that needs OCR first. The output is a structured record, typically JSON, with typed fields for header values, line items, and totals that downstream systems consume without manual parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I extract line items from a PDF invoice?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Line items live in table elements inside &lt;code&gt;StructureInfo.json&lt;/code&gt;. Read the grid from &lt;code&gt;content.body.cells&lt;/code&gt;, where every cell carries a &lt;code&gt;rowIndex&lt;/code&gt; and &lt;code&gt;columnIndex&lt;/code&gt;, build a &lt;code&gt;{rowIndex: {columnIndex: text}}&lt;/code&gt; dictionary, resolve the column positions from the header row, then read each data row in column order. Classify rows before reading them so footer totals are not appended as line items.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can an extraction API handle scanned PDF invoices?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The PDF Structural Extraction API expects a PDF that already has a text layer. Tested against an image-only PDF, it returns no text and an empty table rather than an error. To handle scans, run the document through Foxit's OCR endpoint first (&lt;code&gt;POST /pdf-services/api/documents/analyze/pdf-ocr&lt;/code&gt; with &lt;code&gt;outputFormat&lt;/code&gt; set to &lt;code&gt;PDF&lt;/code&gt;), then pass the OCR output through the four-step workflow. That makes five calls rather than four, and the OCR step is billed as its own credit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the JSON output from PDF invoice extraction look like?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The downloaded ZIP unpacks to &lt;code&gt;StructureInfo.json&lt;/code&gt;, holding an &lt;code&gt;analyzeResult&lt;/code&gt; object with &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;pages&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;, and &lt;code&gt;elements&lt;/code&gt; keys. The &lt;code&gt;elements&lt;/code&gt; array carries every classified region, each with a &lt;code&gt;type&lt;/code&gt; drawn from twelve values. A table element nests its grid at &lt;code&gt;content.body.cells&lt;/code&gt;, and each cell's text sits at &lt;code&gt;paragraph.content.text&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why build a grid instead of iterating the cells array directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A grid keyed by row and column decouples your field mapping from the order the API happens to return cells in, and it lets you address a specific position directly, which is what the footer-label check needs. It also makes missing cells visible as absent keys rather than as silently shifted values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What element types does the API classify?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The API classifies regions into twelve element types, including &lt;code&gt;paragraph&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;form&lt;/code&gt;, and &lt;code&gt;formula&lt;/code&gt;. On a typical invoice, &lt;code&gt;paragraph&lt;/code&gt; elements carry the header fields while a single &lt;code&gt;table&lt;/code&gt; element carries both line items and footer totals, so filtering by type lets you target only what your schema needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How should I handle invoices where footer totals sit outside the line-item table?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some layouts place totals in a separate table element or in standalone paragraph elements below the main table. Keep the row-classification logic in its own function so you can apply it to a second table element, then fall back to scanning &lt;code&gt;paragraph&lt;/code&gt; elements for currency-formatted strings next to known label text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The four-step workflow of upload, extract, poll, and download produces a typed, hierarchical JSON document from any digital-native invoice PDF. Post-processing the &lt;code&gt;elements&lt;/code&gt; array through a row and column grid turns that into a clean invoice object your backend can consume directly. The same four calls extend to purchase orders, receipts, and any other tabular financial document, with only the mapping logic changing to match the target schema.&lt;/p&gt;

&lt;p&gt;The Foxit PDF Structural Extraction API is part of the broader &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit PDF Services API&lt;/a&gt;, which covers conversion, compression, OCR, and other document operations under the same credential set. The infrastructure is SOC 2 Type II certified, with GDPR-supporting features and HIPAA-aligned controls including BAA availability, which matters for teams processing financial documents under compliance review.&lt;/p&gt;

&lt;p&gt;The complete script from this guide is available as &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/images/invoice-json-extraction/extract_invoice.py" rel="noopener noreferrer"&gt;&lt;code&gt;extract_invoice.py&lt;/code&gt;&lt;/a&gt; if you want to run it before adapting it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;Create your free developer account&lt;/a&gt; and start turning invoice PDFs into structured JSON today. No credit card required, and the 500 credits on the free Developer plan are enough to build and test something real.&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>json</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to turn PDFs into structured data with a PDF data extraction API</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:35:11 +0000</pubDate>
      <link>https://dev.to/luciench/how-to-turn-pdfs-into-structured-data-with-a-pdf-data-extraction-api-f9f</link>
      <guid>https://dev.to/luciench/how-to-turn-pdfs-into-structured-data-with-a-pdf-data-extraction-api-f9f</guid>
      <description>&lt;p&gt;The document renders fine in a PDF viewer. The moment your code tries to read it, you're looking at a flat string with no table structure, no reading order, and no way to tell which values belong to which labels.&lt;/p&gt;

&lt;p&gt;PDFs dominate data-intensive workflows, including invoices, contracts, financial reports, and scanned intake forms. The format was designed for visual fidelity, not machine readability. Text extraction libraries can pull words off a page, but they discard the structure that makes those words useful, including which column a number belongs to, whether a line is a table header or a paragraph, and whether a field is a form input or surrounding prose.&lt;/p&gt;

&lt;p&gt;This guide shows you how to solve that with a REST-based PDF data extraction API. You'll see the four-step request lifecycle, the practical difference between basic text extraction and AI-assisted structural extraction, and how to route the resulting JSON into a downstream system. Every request and response below comes from a live run against &lt;a href="https://developer-api.foxit.com/" rel="noopener noreferrer"&gt;Foxit&lt;/a&gt;'s PDF Services API, with examples in both shell and &lt;a href="https://www.python.org/" rel="noopener noreferrer"&gt;Python&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why basic PDF parsing breaks down
&lt;/h2&gt;

&lt;p&gt;PDFs don't have a single internal format. A word-processor export has a proper text layer. A scanned invoice is a bitmap with no text whatsoever. A multi-column research paper has text objects positioned on the page without semantic grouping. Form fields sit in a separate data structure from the body text around them.&lt;/p&gt;

&lt;p&gt;Naive extraction libraries treat all of these the same way, walking the PDF content stream and concatenating text objects in order. The output is readable but structurally blind. A two-column table becomes a jumbled stream that interleaves values from the left and right columns. Reading order is lost. Headers are indistinguishable from body text. Form field labels merge with their values.&lt;/p&gt;

&lt;p&gt;For simple use cases such as keyword search or full-text indexing, that's often acceptable. If you need to populate a database field from an invoice line item, compare figures across financial reports, or feed structured records into a retrieval augmented generation (RAG) pipeline, flat text output breaks the downstream system before it starts. Modern AI and business intelligence tools expect schema-consistent JSON, not raw strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose the right extraction approach
&lt;/h2&gt;

&lt;p&gt;Three broad approaches exist for getting structured data out of PDFs, covering rule-based, template-based, and AI-assisted structural extraction. Tools like &lt;a href="https://developer.adobe.com/document-services/apis/pdf-extract/" rel="noopener noreferrer"&gt;Adobe PDF Extract API&lt;/a&gt;, &lt;a href="https://aws.amazon.com/textract/" rel="noopener noreferrer"&gt;Amazon Textract&lt;/a&gt;, &lt;a href="https://cloud.google.com/document-ai" rel="noopener noreferrer"&gt;Google Document AI&lt;/a&gt;, &lt;a href="https://www.nutrient.io/" rel="noopener noreferrer"&gt;Nutrient&lt;/a&gt;, and &lt;a href="https://apryse.com/" rel="noopener noreferrer"&gt;Apryse&lt;/a&gt; all land somewhere on this spectrum.&lt;/p&gt;

&lt;p&gt;Rule-based extraction uses regular expressions and positional heuristics, finding the number 10 pixels to the right of the label "Invoice Total" and returning it. This works well for identical, high-volume layouts from a single source (one vendor's purchase orders, for example) but breaks the moment a layout changes by even a few pixels.&lt;/p&gt;

&lt;p&gt;Template-based extraction pre-defines field positions for known document types. It's more flexible than pure rules but still requires a template per document variant. Maintenance overhead climbs quickly when you're processing documents from dozens of suppliers.&lt;/p&gt;

&lt;p&gt;AI-assisted structural extraction uses machine learning to classify content regions regardless of layout. It recognizes a block of cells as a table even when that table has merged headers, irregular spacing, or no visible grid lines. It handles documents with variable layouts without requiring per-template configuration, and it applies optical character recognition (OCR) to scanned documents that have no text layer at all.&lt;/p&gt;

&lt;p&gt;For consistent, known layouts at high volume, rule-based or template-based approaches are viable and predictable. For mixed-layout documents, variable vendor formats, or content headed into AI or BI pipelines that expect typed JSON, AI-assisted structural extraction is the right default.&lt;/p&gt;

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

&lt;p&gt;Before running any of the code below, get the following in place.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt; : &lt;a href="https://www.python.org/downloads/" rel="noopener noreferrer"&gt;Python 3.8 or later&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package manager&lt;/strong&gt; : &lt;a href="https://pip.pypa.io/en/stable/" rel="noopener noreferrer"&gt;pip&lt;/a&gt;, bundled with modern Python installs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment isolation&lt;/strong&gt; : &lt;a href="https://docs.python.org/3/library/venv.html" rel="noopener noreferrer"&gt;venv&lt;/a&gt;, part of the standard library&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP library&lt;/strong&gt; : &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;requests&lt;/a&gt;, installed with pip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLI tool&lt;/strong&gt; : &lt;a href="https://curl.se/" rel="noopener noreferrer"&gt;cURL&lt;/a&gt; for the raw HTTP examples&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code editor&lt;/strong&gt; : &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; is a reasonable default, and &lt;a href="https://www.jetbrains.com/pycharm/" rel="noopener noreferrer"&gt;PyCharm&lt;/a&gt; or &lt;a href="https://www.sublimetext.com/" rel="noopener noreferrer"&gt;Sublime Text&lt;/a&gt; work equally well&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foxit developer account&lt;/strong&gt; : sign up at &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;app.developer-api.foxit.com/sign-up&lt;/a&gt; for a client ID and client secret, with no credit card required&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-extraction &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;pdf-extraction
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands above create a project directory, build an isolated virtual environment inside it, activate that environment so installs stay local to the project, and add the one third-party library the Python examples need.&lt;/p&gt;

&lt;h2&gt;
  
  
  API access and authentication setup
&lt;/h2&gt;

&lt;p&gt;Once you're logged in, copy the client ID and client secret from the default application in the developer portal. Every request passes both values as lowercase headers, and there is no separate token exchange step, so a request is authenticated as soon as those two headers are present.&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%2F8oz0hxy2z7a6v9afy9fp.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%2F8oz0hxy2z7a6v9afy9fp.png" alt="Foxit developer sign-up page showing the email field and the list of what an account provides, including live client ID and client secret credentials and 500 free credits a year" width="800" height="389"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The sign-up page the CTA points at. The panel on the right is where the client ID and client secret come from, and the free tier is enough to complete this whole walkthrough.&lt;/em&gt;&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;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;Exporting the credentials as environment variables keeps them out of your source files and out of your shell history if you set them in a profile. Every example below reads them from the environment rather than hard-coding a literal.&lt;/p&gt;

&lt;p&gt;The base URL for every call in this guide is &lt;code&gt;https://na1.fusion.foxit.com/pdf-services/api&lt;/code&gt;, and it stays constant across upload, extraction, polling, and download. The &lt;a href="https://app.developer-api.foxit.com/reference/tag/pdf-structural-extraction-trial" rel="noopener noreferrer"&gt;interactive API reference&lt;/a&gt; documents each endpoint's request and response shape if you need more detail than this walkthrough covers.&lt;/p&gt;

&lt;p&gt;Foxit also provides an on-premise PDF SDK for air-gapped or offline environments, though this guide covers the cloud REST API only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling the PDF data extraction API endpoints
&lt;/h2&gt;

&lt;p&gt;Every extraction job runs asynchronously and follows four steps, covering upload, job start, polling, and download. There is no synchronous variant, because layout analysis on a multi-page document takes longer than a typical request timeout allows.&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%2Fyg8m08n2p4agk8v48vfk.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%2Fyg8m08n2p4agk8v48vfk.png" alt="Diagram of the four step extraction flow, showing upload then extract then poll then download with the endpoint for each step" width="800" height="230"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The four calls in order. Each one returns exactly the identifier the next one needs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each call returns exactly what the next one needs. An upload produces a &lt;code&gt;documentId&lt;/code&gt;, starting a job produces a &lt;code&gt;taskId&lt;/code&gt;, and a finished job produces a &lt;code&gt;resultDocumentId&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1: Upload the document
&lt;/h3&gt;

&lt;p&gt;Send the PDF as multipart form data, with the file attached under the field name &lt;code&gt;file&lt;/code&gt;. Use this &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_table_test.pdf" rel="noopener noreferrer"&gt;sample invoice&lt;/a&gt; if you'd rather not supply your own document. It carries a five-column line-item table, which exercises both row and column indexing later on.&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%2Fudumxy84kfdvoagmqh21.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%2Fudumxy84kfdvoagmqh21.png" alt="Rendered sample invoice showing a five column line item table with a subtotal row and a total due line" width="800" height="652"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The source document. Notice that "API Integration Consulting" wraps onto two lines inside its cell, which matters once you start comparing cell text.&lt;/em&gt;&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/pdf-services/api/documents/upload"&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="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="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@invoice_table_test.pdf"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command above posts the PDF as multipart form data and returns a JSON body containing a &lt;code&gt;documentId&lt;/code&gt;. Capture that value, because every later call in the flow depends on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Start the extraction job
&lt;/h3&gt;

&lt;p&gt;Foxit provides two extraction endpoints, and the right one depends on what your pipeline consumes.&lt;/p&gt;

&lt;p&gt;Basic extraction (&lt;code&gt;POST /pdf-services/api/documents/modify/pdf-extract&lt;/code&gt;) returns flat text, image, or page-level output. It requires an &lt;code&gt;extractType&lt;/code&gt; parameter, and the accepted values are &lt;code&gt;TEXT&lt;/code&gt;, &lt;code&gt;IMAGE&lt;/code&gt;, and &lt;code&gt;PAGE&lt;/code&gt;, in uppercase. Sending a lowercase value returns a &lt;code&gt;VALIDATION_ERROR&lt;/code&gt; rather than falling back to a default. Use this endpoint when you want raw text or image assets and your own code handles the parsing.&lt;/p&gt;

&lt;p&gt;Structural extraction (&lt;code&gt;POST /pdf-services/api/documents/pdf-structural-extract&lt;/code&gt;) applies OCR, layout recognition, and classification to sort content into twelve element types, covering &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;paragraph&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;headerFooter&lt;/code&gt;, &lt;code&gt;form&lt;/code&gt;, &lt;code&gt;hyperlink&lt;/code&gt;, &lt;code&gt;footnote&lt;/code&gt;, &lt;code&gt;sidebar&lt;/code&gt;, &lt;code&gt;annotation&lt;/code&gt;, and &lt;code&gt;formula&lt;/code&gt;. It preserves reading order, spatial position, and table cell grids, including for scanned documents with no existing text layer.&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/pdf-services/api/documents/pdf-structural-extract"&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="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="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"documentId": "YOUR_DOCUMENT_ID"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This request starts the analysis job and returns HTTP 202 with a &lt;code&gt;taskId&lt;/code&gt; rather than a finished result. The 202 is the signal that work has been queued, so treat any expectation of inline output as a bug in your integration rather than a slow response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Poll for completion
&lt;/h3&gt;

&lt;p&gt;Poll the task endpoint until the status reaches a terminal value. Status strings are uppercase, and the sequence runs &lt;code&gt;PENDING&lt;/code&gt;, &lt;code&gt;IN_PROGRESS&lt;/code&gt;, then either &lt;code&gt;COMPLETED&lt;/code&gt; or &lt;code&gt;FAILED&lt;/code&gt;.&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; GET &lt;span class="s2"&gt;"https://na1.fusion.foxit.com/pdf-services/api/tasks/YOUR_TASK_ID"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries &lt;code&gt;taskId&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;progress&lt;/code&gt;, and, once the work finishes, &lt;code&gt;resultDocumentId&lt;/code&gt;. A three-second interval between polls is a sensible default for single-page documents.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Download the result
&lt;/h3&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; GET &lt;span class="s2"&gt;"https://na1.fusion.foxit.com/pdf-services/api/documents/YOUR_RESULT_DOC_ID/download"&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="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="nt"&gt;-o&lt;/span&gt; result.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The download returns &lt;code&gt;application/zip&lt;/code&gt;. Inside are two kinds of file, &lt;code&gt;StructureInfo.json&lt;/code&gt; holding the extracted content, and one PNG per detected table region. Despite a filename like &lt;code&gt;page_p0.pdf_0.png&lt;/code&gt;, that image is a crop of the table rather than a render of the whole page.&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%2Fqvgoymnxu59t8l2wrh6c.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%2Fqvgoymnxu59t8l2wrh6c.png" alt="Terminal showing the four calls run in sequence, returning a document ID, then HTTP 202 with a task ID, then a COMPLETED status, then a downloaded ZIP listing two files" width="800" height="347"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The whole sequence against the live API. Note the &lt;code&gt;202&lt;/code&gt; on the extract call, the &lt;code&gt;COMPLETED&lt;/code&gt; status before any download is attempted, and the archive listing exactly two files.&lt;/em&gt;&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%2F8uix1dvzvxl40uqg8ylt.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%2F8uix1dvzvxl40uqg8ylt.png" alt="Cropped table region returned by the extraction job, showing the invoice line items and subtotal row" width="437" height="84"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The PNG that shipped in the ZIP for the invoice above, at 437 by 84 pixels. It shows the detected table only, which is a useful way to confirm the API found the region you expected.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the shape of the JSON, trimmed to one element of each kind:&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;"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;"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;"e1"&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.99&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="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;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&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;"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;"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;"e7"&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;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;512&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;"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;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;"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;"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;"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;"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="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;"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;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;"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;"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;"API Integration&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;Consulting"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that payload decide whether your parsing code works. Every element sits under &lt;code&gt;analyzeResult&lt;/code&gt;, so there is no top-level &lt;code&gt;elements&lt;/code&gt; key to index. Element text lives at &lt;code&gt;content.text&lt;/code&gt; rather than on the element itself. And &lt;code&gt;region.boundingBox&lt;/code&gt; is an eight-number polygon describing four corner pairs, not a four-number rectangle, so unpacking it into &lt;code&gt;x, y, width, height&lt;/code&gt; raises an error.&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%2F7fxzmgrgoe652yu8465y.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%2F7fxzmgrgoe652yu8465y.png" alt="Abbreviated view of a real StructureInfo.json, with analyzeResult, elements, boundingBox, rowIndex and columnIndex highlighted" width="800" height="1435"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same file from the run above, abbreviated. The highlighted keys are the four that parsing code gets wrong most often, and the eight numbers under &lt;code&gt;boundingBox&lt;/code&gt; are what a four-value unpack trips over.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Processing structured JSON output in your pipeline
&lt;/h2&gt;

&lt;p&gt;Tables are the part most likely to surprise you. A table element exposes &lt;code&gt;content.body&lt;/code&gt; with &lt;code&gt;rowCount&lt;/code&gt;, &lt;code&gt;columnCount&lt;/code&gt;, and a flat &lt;code&gt;cells&lt;/code&gt; array, where each cell carries its own &lt;code&gt;rowIndex&lt;/code&gt; and &lt;code&gt;columnIndex&lt;/code&gt;. There is no two-dimensional &lt;code&gt;rows&lt;/code&gt; array and no separate &lt;code&gt;headers&lt;/code&gt; array, so you build the grid yourself from those indexes.&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;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Normalize cell text.

    A label that wraps inside its cell arrives with a literal &lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;n in the
    middle of the string, so .strip() leaves it untouched. Splitting and
    rejoining collapses any internal whitespace run into a single space.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;table_to_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&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&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;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;grid&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&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;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columnCount&lt;/span&gt;&lt;span class="sh"&gt;"&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;_&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;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rowCount&lt;/span&gt;&lt;span class="sh"&gt;"&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;cell&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cells&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cell&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paragraph&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="nf"&gt;get&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&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rowIndex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]][&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;columnIndex&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="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result.zip&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;archive&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;archive&lt;/span&gt;&lt;span class="p"&gt;.&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;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;as&lt;/span&gt; &lt;span class="n"&gt;handle&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;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="o"&gt;=&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;analyzeResult&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;elements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;paragraphs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;paragraph&lt;/span&gt;&lt;span class="sh"&gt;"&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;table&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tables&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;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;table_to_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&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="n"&gt;record&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 downloaded archive and read &lt;code&gt;StructureInfo.json&lt;/code&gt; directly out of it without unzipping to disk, index into &lt;code&gt;analyzeResult&lt;/code&gt; to reach the element list, then filter that list by &lt;code&gt;type&lt;/code&gt; to separate tables from prose. For each table, &lt;code&gt;table_to_records&lt;/code&gt; allocates an empty grid from the reported row and column counts, drops each cell into position using its own indexes, and finally zips the first row against the remaining rows to produce one dictionary per line item. Those dictionaries map directly onto database columns or a &lt;a href="https://pandas.pydata.org/" rel="noopener noreferrer"&gt;pandas&lt;/a&gt; DataFrame.&lt;/p&gt;

&lt;p&gt;Running it against the sample invoice prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'#': '1', 'Description': 'API Integration Consulting', 'Qty': '10', 'Unit Price': '$ 150.00', 'Line Total': '$1,500.00'}
{'#': '2', 'Description': 'Compliance Review', 'Qty': '5', 'Unit Price': '$ 200.00', 'Line Total': '$1,000.00'}
{'#': '', 'Description': '', 'Qty': '', 'Unit Price': 'Subtotal:', 'Line Total': '$2,500.00'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that &lt;code&gt;Description&lt;/code&gt; reads "API Integration Consulting" on one line, because &lt;code&gt;clean&lt;/code&gt; collapsed the wrapped &lt;code&gt;\r\n&lt;/code&gt; that the source cell contained. Notice too that the subtotal row arrives as a normal row with empty leading cells, so a pipeline writing straight to a database should skip rows whose key columns are blank rather than assuming every row is a line item.&lt;/p&gt;

&lt;p&gt;Filtering by &lt;code&gt;type&lt;/code&gt; is the pattern that generalizes. Paragraph and title elements drop into a chunking function for a RAG pipeline, already separated by semantic type. Table records go to a warehouse or a BI dashboard. Each element also carries a &lt;code&gt;score&lt;/code&gt;, and a low value is worth a manual check before you trust the result downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and troubleshooting
&lt;/h2&gt;

&lt;p&gt;Most failures in this flow come from a small set of predictable causes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;data["elements"]&lt;/code&gt;&lt;/strong&gt; : there is no top-level &lt;code&gt;elements&lt;/code&gt; key. The correct path is &lt;code&gt;data["analyzeResult"]["elements"]&lt;/code&gt;, and skipping that level raises a &lt;code&gt;KeyError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unpacking &lt;code&gt;boundingBox&lt;/code&gt; as four numbers&lt;/strong&gt; : it is an eight-number polygon of four corner pairs, so code expecting &lt;code&gt;x, y, width, height&lt;/code&gt; raises a &lt;code&gt;ValueError&lt;/code&gt;. There is no &lt;code&gt;bbox&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;table["rows"]&lt;/code&gt; or &lt;code&gt;table["headers"]&lt;/code&gt;&lt;/strong&gt; : neither key exists. Build the grid from &lt;code&gt;rowIndex&lt;/code&gt; and &lt;code&gt;columnIndex&lt;/code&gt; as shown above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;label&lt;/code&gt; and &lt;code&gt;value&lt;/code&gt; from a form element&lt;/strong&gt; : form elements do not carry those fields. Read the element's text and position instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing status in the wrong case&lt;/strong&gt; : status values are uppercase, so a test against &lt;code&gt;"completed"&lt;/code&gt; never matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calling &lt;code&gt;.strip()&lt;/code&gt; on cell text&lt;/strong&gt; : a wrapped label contains &lt;code&gt;\r\n&lt;/code&gt; in the middle of the string, where &lt;code&gt;strip&lt;/code&gt; has no effect. Normalize with &lt;code&gt;" ".join(text.split())&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sizing a loop from &lt;code&gt;info.basicInfo.elementCounts&lt;/code&gt;&lt;/strong&gt; : that summary and the &lt;code&gt;elements&lt;/code&gt; array can disagree. On the sample invoice it reported 25 paragraphs while &lt;code&gt;elements&lt;/code&gt; held 5, so count the array you are about to iterate rather than trusting the metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Omitting &lt;code&gt;extractType&lt;/code&gt; on basic extraction&lt;/strong&gt; : &lt;code&gt;modify/pdf-extract&lt;/code&gt; rejects the request with &lt;code&gt;VALIDATION_ERROR&lt;/code&gt; until you pass &lt;code&gt;TEXT&lt;/code&gt;, &lt;code&gt;IMAGE&lt;/code&gt;, or &lt;code&gt;PAGE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downloading before the task reports &lt;code&gt;COMPLETED&lt;/code&gt;&lt;/strong&gt; : polling once and assuming success yields a &lt;code&gt;resultDocumentId&lt;/code&gt; that does not exist yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this workflow handles end to end
&lt;/h2&gt;

&lt;p&gt;The four-step PDF data extraction API workflow handles everything from a single invoice to thousands of mixed-layout reports processed overnight. Use basic extraction when you want raw text or images, and structural extraction when your pipeline needs typed JSON with preserved tables, reading order, and element classification. Processing a batch means looping the same sequence per file and tracking each &lt;code&gt;taskId&lt;/code&gt; independently, so one slow document does not stall the rest.&lt;/p&gt;

&lt;p&gt;On the compliance side, Foxit encrypts API traffic with TLS 1.2 or higher and protects documents at rest with AES-256, on SOC 2 Type II certified infrastructure. Support for HIPAA (through a business associate agreement), GDPR, and CCPA is documented on Foxit's &lt;a href="https://www.foxit.com/api/api-security-compliance/" rel="noopener noreferrer"&gt;API security and compliance page&lt;/a&gt;, which is worth reading before you route regulated documents through any hosted pipeline.&lt;/p&gt;

&lt;p&gt;Create a free account at &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;app.developer-api.foxit.com/sign-up&lt;/a&gt; and run the upload, extract, poll, and download sequence against one of your own documents. What extraction problems have you hit in your own pipelines? Drop them in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>A developer's guide to eSignature API integration</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:34:39 +0000</pubDate>
      <link>https://dev.to/luciench/a-developers-guide-to-esignature-api-integration-2al9</link>
      <guid>https://dev.to/luciench/a-developers-guide-to-esignature-api-integration-2al9</guid>
      <description>&lt;p&gt;Most signing integrations follow the same shortcut, redirecting the user to a hosted signing page, then polling an endpoint until the status flips to "completed." That works well enough for simple workflows, but breaks down when enterprise customers expect to stay inside your product, when a healthcare deployment requires a HIPAA-compliant audit trail your team actually controls, and when eIDAS 2.0 compliance across the EU means you can't treat conformance as an afterthought.&lt;/p&gt;

&lt;p&gt;This guide covers the full integration path for a production-grade eSignature API, from OAuth2 token acquisition and PDF field placement to embedded signing sessions and webhook-driven completion handling. You'll come away with working patterns for every layer of the stack, all verified against the live &lt;a href="https://developer-api.foxit.com" rel="noopener noreferrer"&gt;Foxit eSign API&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Everything here runs against real endpoints, so set up a workspace before the first request:&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;, plus a virtual environment so the dependencies stay isolated.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://requests.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;requests&lt;/a&gt; library for the API calls and &lt;a href="https://flask.palletsprojects.com/" rel="noopener noreferrer"&gt;Flask&lt;/a&gt; for the webhook receiver.&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;Foxit eSign developer account&lt;/a&gt;, created with no credit card. Activate the API tab in your account settings to get a &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A tagged sample PDF, so you don't have to author one. This guide uses &lt;a href="https://github.com/lucienchemaly/foxit-demo-templates/raw/main/esign/agreement-signable.pdf" rel="noopener noreferrer"&gt;agreement-signable.pdf&lt;/a&gt;, which already carries Text Tags for a single signer.&lt;/li&gt;
&lt;li&gt;A code editor. &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; is a good default; any editor works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scaffold the workspace in one shot, then store your credentials as environment variables so they never land in source control:&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-esign &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;foxit-esign
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 flask
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ESIGN_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;ESIGN_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;
  
  
  API concepts and compliance baseline
&lt;/h2&gt;

&lt;p&gt;The Foxit eSign API organizes signing workflows around &lt;strong&gt;folders&lt;/strong&gt;. A folder holds one or more documents, a list of &lt;strong&gt;parties&lt;/strong&gt; (signers, approvers, carbon-copy recipients), and the metadata that governs how those parties interact with those documents.&lt;/p&gt;

&lt;p&gt;Fields inside a document (signature boxes, text inputs, date stamps) are each assigned to a specific party. You can define that assignment in two ways, either by embedding &lt;strong&gt;Text Tags&lt;/strong&gt; directly in the PDF to bake field definitions into the file itself, or by specifying field ownership in the API call. When both the API request and the PDF tags supply recipient or party information, the API call values take precedence.&lt;/p&gt;

&lt;p&gt;Signing order is controlled by three workflow modes, all driven by the &lt;code&gt;signInSequence&lt;/code&gt; parameter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential: parties sign one after another in a defined order, and the next signer receives access only when the previous one completes.&lt;/li&gt;
&lt;li&gt;Parallel: all parties receive signing access simultaneously (&lt;code&gt;signInSequence&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Hybrid: a mix of sequential stages, each of which may contain multiple parallel signers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you write a line of code, establish your compliance scope. HIPAA, eIDAS Advanced Electronic Signatures (AES) and Qualified Electronic Signatures (QES), ESIGN, and UETA are supported out of the box. For healthcare deployments, confirm HIPAA configurations with your account team before go-live. For EU deployments, choose the &lt;code&gt;eu1&lt;/code&gt; regional endpoint to keep data residency inside the EU and satisfy eIDAS 2.0 requirements. Make these architecture decisions at the start, because waiting until a customer's legal team raises them costs you a re-architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;Foxit eSign uses the &lt;a href="https://datatracker.ietf.org/doc/html/rfc6749" rel="noopener noreferrer"&gt;OAuth 2.0&lt;/a&gt; client-credentials grant. You exchange a &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;client_secret&lt;/code&gt;, available under the API tab in your Foxit eSign account settings, for a short-lived Bearer token that authorizes all subsequent calls.&lt;/p&gt;

&lt;p&gt;Get these two things right before you hit the endpoint:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The request body must be &lt;strong&gt;form-encoded&lt;/strong&gt; (&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;). Sending a JSON body returns HTTP 415. Use &lt;code&gt;requests.post(url, data={...})&lt;/code&gt;, not &lt;code&gt;json={...}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose your regional host at token time: &lt;code&gt;na1.foxitesign.foxit.com&lt;/code&gt; for US deployments, &lt;code&gt;eu1.foxitesign.foxit.com&lt;/code&gt; for EU. Both return the same error shape for bad credentials.
&lt;/li&gt;
&lt;/ol&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="c1"&gt;# Regional endpoint (swap na1 for eu1 for EU data residency)
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_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.foxitesign.foxit.com/api/oauth2/access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Body must be form-encoded. A JSON body returns HTTP 415.
&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;TOKEN_URL&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="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_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;client_credentials&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_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;ESIGN_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;ESIGN_CLIENT_SECRET&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;scope&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;read-write&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;token_data&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="c1"&gt;# Response fields: access_token, token_type, expires_in, instance_url
&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_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;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;instance_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_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;instance_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# base URL for all calls
&lt;/span&gt;
&lt;span class="c1"&gt;# Attach the Bearer token to every downstream API request
&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;Authorization&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;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;access_token&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code above reads your credentials from the environment, posts them as a form-encoded body to the regional token endpoint, and unpacks the response. The &lt;code&gt;instance_url&lt;/code&gt; it returns is already a full URL, so use it directly as the base for every downstream call rather than prepending a scheme yourself. Cache the token and schedule a refresh before &lt;code&gt;expires_in&lt;/code&gt; seconds elapse, because re-acquiring on every request adds unnecessary overhead. For account activation steps, the &lt;a href="https://developer-api.foxit.com" rel="noopener noreferrer"&gt;Foxit eSign developer quickstart&lt;/a&gt; covers those without repetition here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preparing and sending a document
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create the document
&lt;/h3&gt;

&lt;p&gt;You can supply a PDF two ways. Pass a publicly accessible HTTPS URL in &lt;code&gt;fileUrls&lt;/code&gt; and Foxit fetches the file, or send the bytes inline by setting &lt;code&gt;inputType&lt;/code&gt; to &lt;code&gt;"base64"&lt;/code&gt; and passing the encoded string in a &lt;code&gt;base64FileString&lt;/code&gt; array when the PDF lives behind authentication or hasn't been published externally.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define recipients and field ownership
&lt;/h3&gt;

&lt;p&gt;Define each signer as a party with a first name, last name, email address (&lt;code&gt;emailId&lt;/code&gt;), a &lt;code&gt;sequence&lt;/code&gt; number, and a permission such as &lt;code&gt;FILL_FIELDS_AND_SIGN&lt;/code&gt;. Assign field ownership either via Text Tags embedded in the PDF or by specifying field coordinates in the API call. A Text Tag follows the syntax &lt;code&gt;${fieldtype:party_number:required:field_name:width}&lt;/code&gt;, so a required signature for the first party looks like &lt;code&gt;${signfield:1:y:____}&lt;/code&gt;, where &lt;code&gt;y&lt;/code&gt; marks the field required, the party number maps to a signer's &lt;code&gt;sequence&lt;/code&gt;, and width is expressed as underscores. If the API call and the PDF tags both specify party information, the API call wins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Send modes
&lt;/h3&gt;

&lt;p&gt;Two request parameters control how a document goes out. Choose based on whether you need a human review step or an in-app signing experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draft&lt;/strong&gt; creates the document but holds it for review. Set &lt;code&gt;sendNow&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; and no invitation email goes out, which suits flows where a user confirms the recipient list before the envelope is dispatched.&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;"folderName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Service Agreement - 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;"sendNow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileUrls"&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="s2"&gt;"https://your-storage.example.com/agreement.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;"fileNames"&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="s2"&gt;"agreement.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;"parties"&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;"firstName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lastName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Smith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"emailId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jane@acme.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FILL_FIELDS_AND_SIGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&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="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;&lt;strong&gt;Direct send&lt;/strong&gt; dispatches immediately with no intermediate review step. Flip &lt;code&gt;sendNow&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; and Foxit emails the signers right away.&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;"folderName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NDA - Standard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sendNow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileUrls"&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="s2"&gt;"https://your-storage.example.com/nda.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;"fileNames"&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="s2"&gt;"nda.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;"parties"&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;"firstName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lastName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rivera"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"emailId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alex@partner.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FILL_FIELDS_AND_SIGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&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="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;&lt;strong&gt;Embedded signing&lt;/strong&gt; adds &lt;code&gt;createEmbeddedSigningSession&lt;/code&gt; and a list of &lt;code&gt;embeddedSignersEmailIds&lt;/code&gt;, and the response returns a session URL you load inside your application, keeping the signer in your UI from start to finish.&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;"folderName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Onboarding - User #4421"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sendNow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createEmbeddedSigningSession"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"embeddedSignersEmailIds"&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="s2"&gt;"sam@yourapp.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signSuccessUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp.example.com/signed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fileUrls"&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="s2"&gt;"https://your-storage.example.com/onboarding.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;"fileNames"&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="s2"&gt;"onboarding.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;"parties"&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;"firstName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sam"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"lastName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Lee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"emailId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sam@yourapp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FILL_FIELDS_AND_SIGN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sequence"&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="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;One nuance to expect here. &lt;code&gt;sendNow: false&lt;/code&gt; on its own produces a &lt;code&gt;DRAFT&lt;/code&gt; folder, but pairing it with &lt;code&gt;createEmbeddedSigningSession&lt;/code&gt; returns a &lt;code&gt;folderStatus&lt;/code&gt; of &lt;code&gt;SHARED&lt;/code&gt;, since the folder has to be live for the session URL to open. No email goes out either way. The response carries an &lt;code&gt;embeddedSigningSessions&lt;/code&gt; array, and each entry holds &lt;code&gt;emailIdOfSigner&lt;/code&gt;, &lt;code&gt;embeddedToken&lt;/code&gt;, and the &lt;code&gt;embeddedSessionURL&lt;/code&gt; you render in the next step. Omitting &lt;code&gt;embeddedSignersEmailIds&lt;/code&gt; returns &lt;code&gt;email id of embedded signer(s) not submitted&lt;/code&gt;, so always list your embedded signers explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded signing and custom branding
&lt;/h2&gt;

&lt;p&gt;With an embedded signing session, the signer never leaves your application. Load the &lt;code&gt;embeddedSessionURL&lt;/code&gt; in an iframe or a dedicated view, with no redirect, no hosted page from another domain, and no disorienting context switch mid-workflow.&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%2Fbkwjlyxzyk4x7davvuhm.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%2Fbkwjlyxzyk4x7davvuhm.png" alt="The Foxit eSign embedded signing session rendered inside a host application, showing the document with interactive signature and date fields and no redirect to an external domain" width="800" height="527"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An embedded session loaded in-app. The signer completes every field without leaving your product.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The session exposes configurable UI options that give you control over the signing surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom logo and colors: pass branding parameters at session creation to match your product's visual identity.&lt;/li&gt;
&lt;li&gt;Hidden controls: suppress UI elements like the "Add Parties" button when operating in draft or template mode, preventing signers from modifying the recipient list.&lt;/li&gt;
&lt;li&gt;Self-sign via API: trigger a signing action programmatically without user interaction, which is useful for automated counter-signature workflows where your system is one of the parties.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a document where every recipient signs inside your app, &lt;code&gt;createEmbeddedSigningSessionForAllParties&lt;/code&gt; set to &lt;code&gt;true&lt;/code&gt; covers all of them at once rather than naming each email individually.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant App as Your Application
    participant API as Foxit eSign API
    participant Signer as Signer
    App-&amp;gt;&amp;gt;API: POST /api/oauth2/access_token&amp;lt;br/&amp;gt;(form-encoded client credentials)
    API--&amp;gt;&amp;gt;App: access_token + instance_url
    App-&amp;gt;&amp;gt;API: POST /api/folders/createfolder&amp;lt;br/&amp;gt;(PDF + party definitions)
    API--&amp;gt;&amp;gt;App: folderId + embeddedSessionURL
    App-&amp;gt;&amp;gt;Signer: Render embeddedSessionURL in iframe&amp;lt;br/&amp;gt;(no redirect)
    Signer-&amp;gt;&amp;gt;API: Complete signing session
    API-&amp;gt;&amp;gt;App: POST webhook&amp;lt;br/&amp;gt;(folder_executed event)
    App-&amp;gt;&amp;gt;App: Validate HMAC, trigger&amp;lt;br/&amp;gt;CRM update or next workflow step&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Webhooks and bulk automation
&lt;/h2&gt;

&lt;p&gt;Polling for document status doesn't scale, and it creates unnecessary load on both sides. Register a webhook endpoint instead, and Foxit eSign will POST an event payload to your server whenever a folder status changes, including the &lt;code&gt;folder_executed&lt;/code&gt; event you need to trigger downstream actions.&lt;/p&gt;

&lt;p&gt;Registration lives on the eSign portal's API settings page under Configure Webhooks, where you set the callback URL, a webhook secret, and the events you want. Store the secret the platform generates, because it authenticates every inbound event. That page is visible only to the account owner, so an admin-level user will not find it, and your endpoint has to be reachable over public HTTPS.&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%2F9oe494jdc0avwwrf898e.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%2F9oe494jdc0avwwrf898e.png" alt="The Foxit eSign Configure Webhooks section of the API settings page, showing the callback URL field, the webhook secret, and per-event checkboxes" width="799" height="494"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The owner-only webhook settings. The event checkboxes control which callbacks reach your endpoint.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The events you can subscribe to are &lt;code&gt;folder_sent&lt;/code&gt;, &lt;code&gt;folder_viewed&lt;/code&gt;, &lt;code&gt;folder_signed&lt;/code&gt;, &lt;code&gt;folder_cancelled&lt;/code&gt;, &lt;code&gt;folder_executed&lt;/code&gt;, &lt;code&gt;folder_deleted&lt;/code&gt;, &lt;code&gt;folder_completed&lt;/code&gt;, &lt;code&gt;folder_assigned&lt;/code&gt;, and &lt;code&gt;folder_access_code_failure&lt;/code&gt;. The event to build on is &lt;code&gt;folder_executed&lt;/code&gt;. &lt;code&gt;folder_completed&lt;/code&gt; fires once every party has signed, but &lt;code&gt;folder_executed&lt;/code&gt; fires after Foxit applies the digital signature and locks the audit trail, so it is the point at which a download gives you the final document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implement HMAC secret-key validation on your webhook handler before you move to production.&lt;/strong&gt; Foxit delivers each callback as &lt;code&gt;&amp;lt;your-url&amp;gt;?signature=&amp;lt;base64&amp;gt;&lt;/code&gt;, where the signature is the base64 of an HMAC-SHA-256 over the raw request body keyed with your webhook secret. Verify it against the unparsed bytes, since re-serializing the JSON changes whitespace or key order and breaks the comparison. An annotated Python handler shows how each piece fits together:&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;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&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;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;WEBHOOK_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;ESIGN_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# from the Foxit eSign portal
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Compute the base64 HMAC-SHA256 of the raw body and compare in constant time
&lt;/span&gt;    &lt;span class="n"&gt;expected&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;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhook/foxit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&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;POST&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;foxit_webhook&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Read the unparsed bytes and verify against the signature query parameter
&lt;/span&gt;    &lt;span class="n"&gt;raw_body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;verify_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signature&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="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;invalid signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;

    &lt;span class="c1"&gt;# Signature valid, so parse the event and branch on folder_executed
&lt;/span&gt;    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;silent&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="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;folder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;folder&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;if&lt;/span&gt; &lt;span class="n"&gt;payload&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_name&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;folder_executed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;handle_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;folderId&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="nf"&gt;jsonify&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;received&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;  &lt;span class="c1"&gt;# acknowledge before heavy work
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder_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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Move heavy work to a task queue to keep response times low
&lt;/span&gt;    &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this handler you read the unparsed body first, recompute the base64 HMAC-SHA256 over those exact bytes with your webhook secret, and compare it against the &lt;code&gt;signature&lt;/code&gt; query parameter using &lt;code&gt;hmac.compare_digest&lt;/code&gt; so the check runs in constant time and never leaks the correct value one character at a time. A mismatch returns 403 before any business logic runs, which stops a spoofed POST to your public URL from triggering downstream work. Only after the signature passes do you parse the JSON, read &lt;code&gt;event_name&lt;/code&gt; from the payload and the folder from its &lt;code&gt;data&lt;/code&gt; object, and branch on &lt;code&gt;folder_executed&lt;/code&gt;. Return 200 promptly and move &lt;code&gt;handle_completion&lt;/code&gt; to a task queue if it touches a database or calls an external API.&lt;/p&gt;

&lt;p&gt;For bulk automation, the same API surface scales to thousands of agreements. Define a template once, supply per-recipient field pre-fill values for each signer, and the completion webhook fires individually per recipient, giving you fine-grained control over downstream actions without any polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Authenticate once with the client-credentials grant, create a folder with your PDF and party definitions, choose how it goes out (&lt;code&gt;sendNow&lt;/code&gt; for draft versus direct, &lt;code&gt;createEmbeddedSigningSession&lt;/code&gt; for in-app signing), and let the &lt;code&gt;folder_executed&lt;/code&gt; webhook drive everything that follows. The same patterns that work for a single NDA scale to bulk send campaigns with dynamic per-recipient field values, with no changes to the API surface.&lt;/p&gt;

&lt;p&gt;To run these patterns against a live API before wiring them into production, Foxit eSign offers a free developer account with a full API playground, no credit card required. Create one at &lt;a href="https://app.developer-api.foxit.com/sign-up" rel="noopener noreferrer"&gt;app.developer-api.foxit.com/sign-up&lt;/a&gt; and make your first envelope call in minutes. Which signing workflow has given you the most trouble to integrate? Share your experience in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>tutorial</category>
      <category>esignature</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Best churn management software in 2026, sorted by the job it actually does</title>
      <dc:creator>Lucien Chemaly</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:32:00 +0000</pubDate>
      <link>https://dev.to/luciench/best-churn-management-software-in-2026-sorted-by-the-job-it-actually-does-164d</link>
      <guid>https://dev.to/luciench/best-churn-management-software-in-2026-sorted-by-the-job-it-actually-does-164d</guid>
      <description>&lt;p&gt;That is not a bad product. It is a good product bought for the wrong job. And it keeps happening because almost every "best churn management software" list on the internet puts four unrelated categories of product in one ranked column, then compares their prices as though they were alternatives to each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure, since you should weigh the rest of this accordingly.&lt;/strong&gt; We build &lt;a href="https://outro.so/" rel="noopener noreferrer"&gt;Outro&lt;/a&gt;, which is one of the tools in the interception category below. I have tried to write the piece I wanted when I was choosing, which means naming the cases where a competitor is the better buy and where Outro is the wrong answer. There is a section near the end doing exactly that.&lt;/p&gt;

&lt;p&gt;Every price and feature claim here was checked against the vendor's own pricing page in August 2026. Where a vendor does not publish pricing, this says so rather than estimating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four jobs hiding inside "churn software"
&lt;/h2&gt;

&lt;p&gt;Read the current roundups and you will find &lt;a href="https://churnzero.com/" rel="noopener noreferrer"&gt;ChurnZero&lt;/a&gt;, &lt;a href="https://www.gainsight.com/" rel="noopener noreferrer"&gt;Gainsight&lt;/a&gt;, &lt;a href="https://churnbuster.io/" rel="noopener noreferrer"&gt;Churn Buster&lt;/a&gt;, &lt;a href="https://prosperstack.com/" rel="noopener noreferrer"&gt;ProsperStack&lt;/a&gt; and &lt;a href="https://www.pecan.ai/" rel="noopener noreferrer"&gt;Pecan AI&lt;/a&gt; in a single list. Those products do not compete. They solve four different problems that happen at four different moments.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure why churn happened.&lt;/strong&gt; Reporting after the fact. Tells you the size and shape of the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intervene at the cancel moment.&lt;/strong&gt; Sits between the cancel button and the billing provider, asks why, and offers something before the subscription ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recover failed payments.&lt;/strong&gt; Handles involuntary churn, where the customer never chose to leave and their card simply failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manage accounts before they ever reach a cancel button.&lt;/strong&gt; Health scores and playbooks for a human success team, which presumes you have one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A tool built for job 1 cannot do job 2, no matter how good its charts are. A tool built for job 3 is solving a problem that has nothing to do with why customers want to leave. Buying across categories by comparing monthly price is how the founder above ended up with excellent reporting and no saves.&lt;/p&gt;

&lt;p&gt;So work out which job you are hiring for first. The rest of this is organised that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 1: measuring why churn happened
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://baremetrics.com/" rel="noopener noreferrer"&gt;Baremetrics&lt;/a&gt;&lt;/strong&gt; is the reference tool here. It reads your billing data and turns it into MRR, churn rate, LTV and cohort reporting. Its Cancellation Insights add-on collects feedback with in-app popups and automates post-cancel follow-up emails.&lt;/p&gt;

&lt;p&gt;The pricing deserves care, because it is the most commonly misquoted number in this category. Baremetrics plans start at $75/mo for Launch, covering $0 to $360K ARR, then $255/mo for Growth and $1,152/mo for Scale. &lt;strong&gt;Cancellation Insights and Recover are separate add-ons at $129/mo each&lt;/strong&gt;, on top of a base plan. Lists that describe Baremetrics as "from $129/mo" for churn purposes are quoting the add-on in isolation. The real floor to get cancellation feedback is $75 plus $129, so a little over $200/mo.&lt;/p&gt;

&lt;p&gt;What it does not do is intervene. In-app popups and post-cancel emails reach the customer either side of the decision, not during it. If your goal is a report, this is the right category. If your goal is a save, it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 2: intervening at the cancel moment
&lt;/h2&gt;

&lt;p&gt;This is the category most people mean by "churn tool", and it is where the real differences sit. All of these products put a step between the cancel button and the billing provider.&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%2Fxzhf6cobt8r38z55cml0.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%2Fxzhf6cobt8r38z55cml0.png" alt="Outro's branded cancel page showing five radio button cancellation reasons alongside a voice recording option" width="480" height="756"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The interception moment. Whatever the tool, this is the screen doing the work, and the design of this one screen decides how much you learn.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://churnkey.co/" rel="noopener noreferrer"&gt;Churnkey&lt;/a&gt;&lt;/strong&gt; is the most complete retention suite of the group. Cancel flows, payment recovery and metrics on the entry plan, then A/B testing, segmentation and rules-based retry logic above that, then AI features on the top tier. Its Starter plan is $250 per month billed yearly and is gated by volume, described as available to teams with less than $5k/mo churn volume. Core and Intelligence are quote-only. There is a 14-day trial with no credit card.&lt;/p&gt;

&lt;p&gt;Two details matter when you compare it. The volume gate means Starter is explicitly not for teams with larger churn, so growing past $5k/mo monthly churn moves you to a quoted plan. And the AI capabilities, including Feedback AI and AI-powered translations, sit on the Intelligence tier rather than the entry plan, so an AI comparison against Churnkey's cheapest plan is not comparing like with like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ProsperStack&lt;/strong&gt; offers hosted, customisable cancellation flows with deep billing integrations. Grow starts at $200/mo and covers 50 to 500 cancel sessions per month, Prosper is $750/mo for 500 or more, and Enterprise is custom. Note that 500 is the ceiling of the Grow band rather than an allowance, which is a distinction several roundups get backwards.&lt;/p&gt;

&lt;p&gt;Its A/B testing and AI Autopilot offers begin on Prosper, and &lt;strong&gt;multi-language support is Enterprise-only&lt;/strong&gt;. If you sell in more than one language, ProsperStack's entry tier will not cover you, and that is a pricing question rather than a feature question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.subjolt.com/" rel="noopener noreferrer"&gt;SubJolt&lt;/a&gt;&lt;/strong&gt; is the budget option and is genuinely cheap. Starter is $0/month for up to 50 cancel sessions per month, and Pro is $99/month for unlimited sessions with a 30-day free trial. It integrates with &lt;a href="https://stripe.com/" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;, Chargebee and &lt;a href="https://rechargepayments.com/" rel="noopener noreferrer"&gt;Recharge&lt;/a&gt;. It offers write-in reason sentiment analysis, though it does not describe that as AI, and it advertises no interface languages. For a small subscription business that wants a cancel flow with offers and does not need much else, the free tier alone makes it worth trying first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chargebee Retention&lt;/strong&gt; is the enterprise end. Pricing is not public and the call to action is a demo request. Two things about it have changed recently enough that most comparisons are out of date. It is now presented as part of Chargebee Growth rather than a standalone product. And it is &lt;strong&gt;not restricted to &lt;a href="https://www.chargebee.com/" rel="noopener noreferrer"&gt;Chargebee&lt;/a&gt; billing customers&lt;/strong&gt;, since its own page describes integrating billing platforms including Chargebee, Recurly and Stripe. It has A/B testing including ML-based smart targeting. If you were avoiding it because you bill through Stripe, that reason no longer holds.&lt;/p&gt;

&lt;p&gt;One thing to know if you are researching this category is that &lt;strong&gt;brightback.com no longer serves a website.&lt;/strong&gt; DNS still resolves but the domain returns no HTTP response. Brightback became Chargebee Retention, so any roundup still listing Brightback as a current separate product is working from old notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outro&lt;/strong&gt; is the tool we build. It replaces the cancel button with a short spoken question, transcribes and classifies the reason with AI, then shows the save offer most likely to work before the cancellation goes through. Plans are $29/mo Starter, $79/mo Pro and $199/mo Scale, with a 14-day trial on every plan and no card required.&lt;/p&gt;

&lt;p&gt;The specific bet is that a dropdown loses the information you needed. When somebody picks "too expensive" from a list, you learn nothing you can act on. When they say it out loud, you get the sentence behind it, and those sentences are usually about something other than price.&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%2F384ztk088ib0b4457pj0.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%2F384ztk088ib0b4457pj0.png" alt="Outro insights panel titled Needs attention, listing three AI-generated response summaries with Negative sentiment badges and Bug report tags" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The output side. Each row is a real spoken answer, transcribed, classified and sentiment-scored, which is the part a fixed dropdown cannot produce.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I checked every vendor in this list for this, and &lt;strong&gt;none of the others advertise voice exit interviews.&lt;/strong&gt; That is the honest scope of the differentiation. It is one capability, not a general claim of superiority, and the sections below are where it stops mattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 3: recovering failed payments
&lt;/h2&gt;

&lt;p&gt;Involuntary churn is a separate problem with separate tools. The customer did not decide to leave. Their card expired, or a payment failed, and the subscription lapsed. No amount of asking why they cancelled helps, because they did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Churn Buster&lt;/strong&gt; specialises here, and &lt;strong&gt;Churnkey&lt;/strong&gt; includes payment recovery from its entry plan, which is a genuine advantage of buying a suite. &lt;strong&gt;Baremetrics&lt;/strong&gt; sells Recover as a $129/mo add-on.&lt;/p&gt;

&lt;p&gt;Outro does not do this at all. If failed payments are your main leak, fix that first, and this article is not about your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 4: managing accounts before the cancel button
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ChurnZero&lt;/strong&gt; and &lt;strong&gt;Gainsight&lt;/strong&gt; are customer success platforms for B2B SaaS with named accounts and human CSMs. They track product adoption and account health so a person can intervene weeks before a renewal.&lt;/p&gt;

&lt;p&gt;They are excellent and almost certainly irrelevant to a self-serve business. If your customers sign up with a credit card and never speak to anyone, there is no CSM to route a health score to, and the cancel moment is the only conversation you will ever get. That is precisely why the interception category exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison, with everything verified
&lt;/h2&gt;

&lt;p&gt;Checked against each vendor's own pricing page in August 2026. "Not advertised" means the vendor does not claim it publicly, which is not the same as absence.&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;Entry price&lt;/th&gt;
&lt;th&gt;Voice interviews&lt;/th&gt;
&lt;th&gt;AI reason detection&lt;/th&gt;
&lt;th&gt;Failed-payment recovery&lt;/th&gt;
&lt;th&gt;A/B testing&lt;/th&gt;
&lt;th&gt;Free option&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Outro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$29/mo&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;14-day trial, no card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Churnkey&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$250/mo billed yearly, under $5k/mo churn&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;On Intelligence tier&lt;/td&gt;
&lt;td&gt;Yes, entry plan&lt;/td&gt;
&lt;td&gt;On Core tier&lt;/td&gt;
&lt;td&gt;14-day trial, no card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ProsperStack&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$200/mo, 50-500 sessions&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;AI Autopilot on Prosper&lt;/td&gt;
&lt;td&gt;Revenue recovery&lt;/td&gt;
&lt;td&gt;On Prosper tier&lt;/td&gt;
&lt;td&gt;Not advertised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SubJolt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0 up to 50 sessions, $99/mo unlimited&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Write-in sentiment, not labelled AI&lt;/td&gt;
&lt;td&gt;Smart payment retries&lt;/td&gt;
&lt;td&gt;Not advertised&lt;/td&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chargebee Retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not public, demo&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not explicitly&lt;/td&gt;
&lt;td&gt;Not mentioned&lt;/td&gt;
&lt;td&gt;Yes, incl. ML targeting&lt;/td&gt;
&lt;td&gt;Sign up for free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Baremetrics&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$75/mo plus $129/mo add-on&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Recover add-on&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Free trial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row that should decide most purchases is not price. It is which job the tool does.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Outro is the wrong choice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Churnkey instead if&lt;/strong&gt; you need failed-payment recovery, reactivation campaigns and A/B-tested flows in one platform and the budget supports it. Outro has none of those three. A suite you only partly use still beats stitching three tools together for most teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose ProsperStack or Chargebee Retention instead if&lt;/strong&gt; A/B testing your cancellation flow is the point. We do not have it. If you have enough cancellation volume to reach significance, testing offer variants will beat any qualitative insight, and you should buy the tool that tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose SubJolt instead if&lt;/strong&gt; budget is the binding constraint. Its free tier covers 50 sessions a month at $0. If you are pre-revenue or validating, start there and come back when the reasons matter more than the cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Baremetrics instead if&lt;/strong&gt; what you actually want is subscription analytics. Outro reports on cancellations it handled, and nothing else. It is not an MRR analytics platform and will not replace one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not choose Outro at all if&lt;/strong&gt; you bill anywhere other than Stripe or &lt;a href="https://www.lemonsqueezy.com/" rel="noopener noreferrer"&gt;Lemon Squeezy&lt;/a&gt;. That is a hard constraint, not a roadmap item. No Chargebee, Recurly, Paddle, RevenueCat or App Store. And on Lemon Squeezy, pause and downgrade offers auto-apply but discounts fall back to manual because of a limitation in the Lemon Squeezy API, so a Lemon Squeezy team should expect that manual step.&lt;/p&gt;

&lt;p&gt;Three more limits, since they come up after purchase rather than before. Page count is plan-limited, with one cancellation page on Starter, up to five on Pro and unlimited on Scale. There is no conditional branching in the flow, so a follow-up question cannot appear based on which reason a customer picked. And a save is only counted after a 14-day grace window and only if the subscription is still live, which makes our numbers look worse than a tool counting saves at the moment of the click.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually choose
&lt;/h2&gt;

&lt;p&gt;A short procedure that avoids the mistake at the top of this article.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate voluntary from involuntary churn.&lt;/strong&gt; Look at how many cancellations were card failures rather than decisions. If it is most of them, buy job 3 and stop reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide whether you need a number or a reason.&lt;/strong&gt; If you cannot say what your top cancellation reason is, no dashboard will tell you, because the categories are supplied by you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the billing constraint before anything else.&lt;/strong&gt; It eliminates more options faster than price does, and it is not negotiable in any of these tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your monthly cancel sessions.&lt;/strong&gt; Most pricing in this category is banded by volume, and the bands are where the surprises live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then compare price&lt;/strong&gt;, within one job, between tools that clear the first four.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to size the problem before shopping, our &lt;a href="https://outro.so/tools/churn-calculator" rel="noopener noreferrer"&gt;churn calculator&lt;/a&gt; will tell you what a point of monthly churn is worth to you annually, which is usually the number that decides whether any of this is worth buying.&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%2Fdcycdifa3yxsfsxjrxqr.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%2Fdcycdifa3yxsfsxjrxqr.png" alt="Outro revenue recovery view listing accepted save offers with the MRR each represents and its verification status" width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Verified saves rather than claimed ones. A save counts here only after a 14-day grace window and only if the subscription is still active.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comparing an entry plan against a competitor's top tier.&lt;/strong&gt; Churnkey's AI is on Intelligence, ProsperStack's A/B testing is on Prosper, and ProsperStack's multi-language support is Enterprise-only. Comparing feature lists without comparing tiers produces a table that is wrong in both directions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quoting add-on pricing as platform pricing.&lt;/strong&gt; Baremetrics at "$129/mo" is not a thing you can buy on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming Chargebee Retention needs Chargebee billing.&lt;/strong&gt; It integrates Stripe and Recurly, and this is the single most out-of-date claim in the current roundups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting any list that has not been re-checked this quarter.&lt;/strong&gt; Brightback's site is gone and Chargebee Retention has moved under Chargebee Growth, both recently enough that most published comparisons still have it wrong. Ours was checked in August 2026 and will also decay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buying interception when your churn is involuntary.&lt;/strong&gt; Ask what proportion of last month's cancellations were payment failures before you shop at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;The category is four categories. Once they are separated, the choice gets much simpler, because most tools are immediately irrelevant to the job you have.&lt;/p&gt;

&lt;p&gt;If your churn is mostly failed payments, buy payment recovery. If you have a success team and named accounts, buy a success platform. If you want reporting, buy analytics and accept that it reports rather than intervenes. And if your customers leave through a cancel button without ever telling you why, the interception category is the only one that gets you a conversation, and the choice inside it comes down to whether you need a full suite, the cheapest option, flow testing, or the actual sentence the customer would have said.&lt;/p&gt;

&lt;p&gt;If it is that last one, &lt;a href="https://outro.so/" rel="noopener noreferrer"&gt;try Outro free for 14 days&lt;/a&gt; with no card. And if one of the other four sections described your situation better, buy that instead. A tool bought for the wrong job is more expensive than the one you did not buy.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>churn</category>
    </item>
    <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>
  </channel>
</rss>
