<?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: Zero Lopp Labs</title>
    <description>The latest articles on DEV Community by Zero Lopp Labs (@zerolooplabs).</description>
    <link>https://dev.to/zerolooplabs</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810612%2Fa33b06b1-c0a7-48dd-9351-6a1e5398aa5a.png</url>
      <title>DEV Community: Zero Lopp Labs</title>
      <link>https://dev.to/zerolooplabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zerolooplabs"/>
    <language>en</language>
    <item>
      <title>SWIFT Is Killing MT940 — Here's How to Future-Proof Your Bank Statement Pipeline</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Mon, 23 Mar 2026 17:06:11 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/swift-is-killing-mt940-heres-how-to-future-proof-your-bank-statement-pipeline-267i</link>
      <guid>https://dev.to/zerolooplabs/swift-is-killing-mt940-heres-how-to-future-proof-your-bank-statement-pipeline-267i</guid>
      <description>&lt;p&gt;On November 22, 2025, SWIFT pulled the plug on legacy MT payment messages. Cross-border payments now run exclusively on ISO 20022's MX format.&lt;/p&gt;

&lt;p&gt;MT940 — the bank account statement format your reconciliation pipeline probably depends on — wasn't part of that first wave. It's a reporting message, not a payment message. But it's next. SWIFT has formally deprecated MT940, stopped maintaining it, and announced that disincentives for continued usage are coming.&lt;/p&gt;

&lt;p&gt;If your application parses bank statements, you need a migration plan. Here's what's actually happening, what the formats look like under the hood, and how to build a pipeline that handles both.&lt;/p&gt;




&lt;h2&gt;
  
  
  What MT940 Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;Before we talk about replacing MT940, let's look at what we're replacing. Here's a real MT940 statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;:20:STMT2603230001
:25:NL91ABNA0417164300
:28C:15/1
:60F:C260322EUR1234,56
:61:2603220322D45,00NTRFNONREF//ACME-INV-2026-042
:86:999~00SEPA OVERBOEKING~20KENMERK: ACME-INV-2026-042
~21Acme Corp Ltd~22PAYMENT FOR SERVICES~23March 2026
~30DEUTDEDB~31DE89370400440532013000~32Acme Corp Ltd
~33Frankfurt
:62F:C260322EUR1189,56
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've never parsed this, here's a field-by-field breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:20:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Transaction Reference&lt;/td&gt;
&lt;td&gt;Message identifier (max 16 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:25:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Account ID&lt;/td&gt;
&lt;td&gt;Your IBAN or account number (max 35 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:28C:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statement Number&lt;/td&gt;
&lt;td&gt;Sequence number (&lt;code&gt;15/1&lt;/code&gt; = statement 15, page 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:60F:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opening Balance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;C&lt;/code&gt; = Credit, &lt;code&gt;260322&lt;/code&gt; = 22 Mar 2026, &lt;code&gt;EUR&lt;/code&gt;, &lt;code&gt;1234,56&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:61:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statement Line&lt;/td&gt;
&lt;td&gt;Date + D/C + amount + type code + reference (max 80 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:86:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Information&lt;/td&gt;
&lt;td&gt;Free-text transaction details (up to 6 lines × 65 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:62F:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Closing Balance&lt;/td&gt;
&lt;td&gt;Same format as opening balance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;:86:&lt;/code&gt; field is where the real pain lives. Banks cram debtor names, payment references, creditor IBANs, and remittance information into a single text block. There is no universal standard for how this data is structured.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;~&lt;/code&gt; delimiters you see above? That's one bank's convention (common in SEPA countries). Other banks use &lt;code&gt;/&lt;/code&gt; prefixes, &lt;code&gt;?&lt;/code&gt; codes, or just dump everything as plain text. You end up writing bank-specific regex patterns to extract structured data — and they break every time the bank changes their layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CAMT.053 Looks Like Instead
&lt;/h2&gt;

&lt;p&gt;Here's the same transaction in CAMT.053:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Ntry&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Amt&lt;/span&gt; &lt;span class="na"&gt;Ccy=&lt;/span&gt;&lt;span class="s"&gt;"EUR"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;45.00&lt;span class="nt"&gt;&amp;lt;/Amt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;CdtDbtInd&amp;gt;&lt;/span&gt;DBIT&lt;span class="nt"&gt;&amp;lt;/CdtDbtInd&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;BkgDt&amp;gt;&amp;lt;Dt&amp;gt;&lt;/span&gt;2026-03-22&lt;span class="nt"&gt;&amp;lt;/Dt&amp;gt;&amp;lt;/BkgDt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;VlDt&amp;gt;&amp;lt;Dt&amp;gt;&lt;/span&gt;2026-03-22&lt;span class="nt"&gt;&amp;lt;/Dt&amp;gt;&amp;lt;/VlDt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;AcctSvcrRef&amp;gt;&lt;/span&gt;ACME-INV-2026-042&lt;span class="nt"&gt;&amp;lt;/AcctSvcrRef&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;NtryDtls&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TxDtls&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;Refs&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;EndToEndId&amp;gt;&lt;/span&gt;ACME-INV-2026-042&lt;span class="nt"&gt;&amp;lt;/EndToEndId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/Refs&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RltdPties&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;Cdtr&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;Nm&amp;gt;&lt;/span&gt;Acme Corp Ltd&lt;span class="nt"&gt;&amp;lt;/Nm&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;PstlAdr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;TwnNm&amp;gt;&lt;/span&gt;Frankfurt&lt;span class="nt"&gt;&amp;lt;/TwnNm&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/PstlAdr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/Cdtr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;CdtrAcct&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;Id&amp;gt;&amp;lt;IBAN&amp;gt;&lt;/span&gt;DE89370400440532013000&lt;span class="nt"&gt;&amp;lt;/IBAN&amp;gt;&amp;lt;/Id&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/CdtrAcct&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RltdPties&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RltdAgts&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;CdtrAgt&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;FinInstnId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;BIC&amp;gt;&lt;/span&gt;DEUTDEDB&lt;span class="nt"&gt;&amp;lt;/BIC&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/FinInstnId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/CdtrAgt&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RltdAgts&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RmtInf&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;Ustrd&amp;gt;&lt;/span&gt;PAYMENT FOR SERVICES March 2026&lt;span class="nt"&gt;&amp;lt;/Ustrd&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RmtInf&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/TxDtls&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/NtryDtls&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Ntry&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No regex. No bank-specific parsing rules. The creditor name is in &lt;code&gt;&amp;lt;Cdtr&amp;gt;&amp;lt;Nm&amp;gt;&lt;/code&gt;. The IBAN is in &lt;code&gt;&amp;lt;CdtrAcct&amp;gt;&amp;lt;Id&amp;gt;&amp;lt;IBAN&amp;gt;&lt;/code&gt;. The BIC is in &lt;code&gt;&amp;lt;CdtrAgt&amp;gt;&amp;lt;FinInstnId&amp;gt;&amp;lt;BIC&amp;gt;&lt;/code&gt;. The remittance info has its own dedicated &lt;code&gt;&amp;lt;RmtInf&amp;gt;&lt;/code&gt; element — with support for both unstructured text and structured sub-fields.&lt;/p&gt;

&lt;p&gt;The full CAMT.053 document wraps everything in a clear hierarchy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document
  └─ BkToCstmrStmt (Bank-to-Customer Statement)
      ├─ GrpHdr       → Message ID, creation timestamp
      └─ Stmt         → One per account
          ├─ Acct      → IBAN, BIC, account name
          ├─ Bal[]     → OPBD, CLBD, CLAV, FWAV (all timestamped)
          └─ Ntry[]    → One per transaction
              └─ NtryDtls
                  └─ TxDtls → Refs, parties, agents, remittance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the technical comparison 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;Feature&lt;/th&gt;
&lt;th&gt;MT940&lt;/th&gt;
&lt;th&gt;CAMT.053&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;Proprietary text (SWIFT FIN)&lt;/td&gt;
&lt;td&gt;XML (ISO 20022, XSD-validated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction details&lt;/td&gt;
&lt;td&gt;Packed into &lt;code&gt;:61:&lt;/code&gt; (80 chars) + &lt;code&gt;:86:&lt;/code&gt; (6×65 chars)&lt;/td&gt;
&lt;td&gt;Dedicated elements: &lt;code&gt;Refs&lt;/code&gt;, &lt;code&gt;RltdPties&lt;/code&gt;, &lt;code&gt;RltdAgts&lt;/code&gt;, &lt;code&gt;RmtInf&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remittance info&lt;/td&gt;
&lt;td&gt;Crammed into &lt;code&gt;:86:&lt;/code&gt;, bank-specific delimiters&lt;/td&gt;
&lt;td&gt;Structured &lt;code&gt;&amp;lt;RmtInf&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;Strd&amp;gt;&lt;/code&gt; sub-fields, unlimited length&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balance types&lt;/td&gt;
&lt;td&gt;Opening (&lt;code&gt;:60F:&lt;/code&gt;) and Closing (&lt;code&gt;:62F:&lt;/code&gt;) only&lt;/td&gt;
&lt;td&gt;OPBD, CLBD, CLAV, PRCD, FWAV — all with timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency&lt;/td&gt;
&lt;td&gt;Single currency per statement&lt;/td&gt;
&lt;td&gt;Multi-currency with exchange rate details per transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Character set&lt;/td&gt;
&lt;td&gt;SWIFT X charset (A-Z, 0-9, basic punctuation — no accents)&lt;/td&gt;
&lt;td&gt;Full UTF-8 / Unicode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date format&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;YYMMDD&lt;/code&gt; (ambiguous century)&lt;/td&gt;
&lt;td&gt;ISO 8601: &lt;code&gt;YYYY-MM-DD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;Manual — hope the parser handles edge cases&lt;/td&gt;
&lt;td&gt;XSD schema validation built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Migration Timeline — What's Actually Happening
&lt;/h2&gt;

&lt;p&gt;There's a lot of confusion around "SWIFT is killing MT940" because the migration is happening in waves. Here's the accurate picture:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What already happened (November 22, 2025):&lt;/strong&gt;&lt;br&gt;
SWIFT ended the MT/ISO 20022 coexistence period for &lt;strong&gt;payment messages&lt;/strong&gt;. MT103 (credit transfers) and MT202 (institution transfers) are formally retired. All cross-border payments now use ISO 20022 MX format exclusively via the FINplus service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's happening now (2026):&lt;/strong&gt;&lt;br&gt;
MT940 and other &lt;strong&gt;reporting messages&lt;/strong&gt; are deprecated and no longer maintained by SWIFT — but they haven't been withdrawn yet. J.P. Morgan's ISO 20022 FAQ puts it clearly: "Reporting and statement messages will not be immediately withdrawn from the FIN service. Although these message types are deprecated and no longer maintained by SWIFT, disincentives for their use will be introduced at a later date."&lt;/p&gt;

&lt;p&gt;Banks are transitioning on their own timelines. J.P. Morgan has been accepting CAMT.052/053/054 since Q4 2024. Bank of America completed its Fedwire ISO 20022 implementation in July 2025. 44% of banks are behind schedule on their November 2026 milestones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's coming (2027+):&lt;/strong&gt;&lt;br&gt;
SWIFT's roadmap targets full MT message retirement, including enquiry and investigation messages (MT199/MT299 → camt.110/camt.111) by November 2027. The reporting messages (MT940 → CAMT.053) will follow.&lt;/p&gt;

&lt;p&gt;The bottom line: MT940 still works today, but no one is maintaining or improving it. New features, new validation rules, new regulatory requirements — all of that goes into CAMT.053. Building on MT940 now is building on a dead-end.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your Options as a Developer
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Option 1: Build It Yourself
&lt;/h3&gt;

&lt;p&gt;There are open-source libraries for individual formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mt940-rs&lt;/code&gt; (Rust) — MT940 parser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pycamt&lt;/code&gt; / &lt;code&gt;camt_parser&lt;/code&gt; (Python) — CAMT.053 parsers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ofxstatement&lt;/code&gt; (Python) — OFX converter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cmxl&lt;/code&gt; (Ruby) — MT940 parser with extensible design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem: you need &lt;strong&gt;multiple libraries&lt;/strong&gt; plus glue code to normalize outputs into a common model. You need to handle bank-specific &lt;code&gt;:86:&lt;/code&gt; field variations, character encoding edge cases (MT940's SWIFT X charset vs. UTF-8), date format conversions (&lt;code&gt;YYMMDD&lt;/code&gt; → &lt;code&gt;YYYY-MM-DD&lt;/code&gt;), and amount parsing (comma vs. dot decimal separators).&lt;/p&gt;

&lt;p&gt;For a single-format, single-bank integration, this works. For anything multi-bank or multi-format, you're signing up for ongoing maintenance as banks change their implementations.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option 2: Enterprise Aggregators
&lt;/h3&gt;

&lt;p&gt;Plaid (12,000+ institutions), Finicity (Mastercard), and Wise offer bank data APIs. But they solve a different problem — they connect to &lt;strong&gt;live bank accounts&lt;/strong&gt; via OAuth. If you already have statement files (SFTP drops, email attachments, file exports), these platforms are overkill. They're also expensive and come with heavy onboarding.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option 3: A Dedicated Conversion API
&lt;/h3&gt;

&lt;p&gt;This is the gap. You have files in format A, you need them in format B. No bank connections, no OAuth flows, no aggregation. Just conversion — stateless, fast, spec-compliant.&lt;/p&gt;

&lt;p&gt;That's what we're building.&lt;/p&gt;
&lt;h2&gt;
  
  
  Introducing FinConvert
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://finconvert.dev" rel="noopener noreferrer"&gt;FinConvert&lt;/a&gt; is a REST API that converts bank statement files between financial formats. One endpoint, any supported format in, any supported format out.&lt;/p&gt;

&lt;p&gt;The core architecture uses a &lt;strong&gt;Universal Transaction Model&lt;/strong&gt;: every input format is parsed and normalized into a single internal representation, then serialized to the requested output format. This means adding new formats requires N+M adapters, not N×M conversion paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currently supported:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Formats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MT940, CAMT.053 (OFX, BAI2, QIF coming soon)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Output&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CAMT.053, CSV, JSON, OFX (MT940 output coming soon)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Design principles:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-first&lt;/strong&gt; — No files are stored. Conversion is stateless. Your financial data is processed in memory and discarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spec-compliant&lt;/strong&gt; — Output is validated against official SWIFT and ISO 20022 XSD schemas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; — Sub-200ms average conversion time. Pure computation, no I/O bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;London-hosted&lt;/strong&gt; — EU data residency for compliance-conscious teams.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Show Me the Code
&lt;/h2&gt;

&lt;p&gt;Convert an MT940 file to structured JSON:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;curl:&lt;/strong&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 https://api.finconvert.dev/v1/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer fc_your_api_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@statement.mt940"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"output_format=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; converted.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TypeScript:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConvertedStatement&lt;/span&gt;&lt;span class="o"&gt;&amp;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="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;formData&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;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;formData&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;output_format&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;json&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.finconvert.dev/v1/convert&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer fc_your_api_key&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&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;`Conversion failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&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="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;return&lt;/span&gt; &lt;span class="nx"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you get back&lt;/strong&gt; — structured, typed, no regex required:&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;"statement"&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;"account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NL91ABNA0417164300"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"opening_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"closing_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1189.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"statement_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;"15/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;"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;"2026-03-22"&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;"transactions"&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;"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;"2026-03-22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-45.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;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&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;"DEBIT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reference"&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-INV-2026-042"&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;"PAYMENT FOR SERVICES March 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"creditor"&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;"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 Corp Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"bic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEUTDEDB"&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;"transaction_count"&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;"format_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MT940"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format_output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JSON"&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 MT940 &lt;code&gt;:86:&lt;/code&gt; field with bank-specific &lt;code&gt;~&lt;/code&gt; delimiters? Parsed into clean, typed JSON. The creditor IBAN that was buried in &lt;code&gt;~31&lt;/code&gt;? Extracted into &lt;code&gt;creditor.iban&lt;/code&gt;. No bank-specific logic on your end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;Usage-based — you pay for what you convert:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Conversions/month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Business&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$149/mo&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The free tier is enough for testing and low-volume integrations. No credit card required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;FinConvert is currently in early access. We're onboarding developers from the waitlist and expanding format support based on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the roadmap:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OFX, BAI2, and QIF input support&lt;/li&gt;
&lt;li&gt;MT940 output (for systems that still require it during the transition)&lt;/li&gt;
&lt;li&gt;Auto-format detection — upload any file, we figure out what it is&lt;/li&gt;
&lt;li&gt;Batch conversion endpoint for bulk processing&lt;/li&gt;
&lt;li&gt;Bank-specific &lt;code&gt;:86:&lt;/code&gt; field profiles for higher extraction accuracy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything that touches bank statement data — accounting software, reconciliation tools, fintech integrations, ERP connectors — the MT940 deprecation is real. It still works today, but the writing is on the wall: SWIFT has stopped maintaining it, banks are migrating, and every new feature goes into CAMT.053.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://finconvert.dev" rel="noopener noreferrer"&gt;Join the waitlist at finconvert.dev&lt;/a&gt;&lt;/strong&gt; to get early access and lock in free-tier usage during beta.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://zerolooplabs.dev" rel="noopener noreferrer"&gt;Zero Loop Labs&lt;/a&gt; — the same team behind &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;SealTrail&lt;/a&gt; (tamper-proof audit trails) and &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;PDFForge&lt;/a&gt; (document generation API).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>api</category>
      <category>banking</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Generate PDFs from FileMaker Data Using Python</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Mon, 23 Mar 2026 10:48:23 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/how-to-generate-pdfs-from-filemaker-data-using-python-5cgj</link>
      <guid>https://dev.to/zerolooplabs/how-to-generate-pdfs-from-filemaker-data-using-python-5cgj</guid>
      <description>&lt;p&gt;You already use &lt;a href="https://github.com/davidhamann/python-fmrest" rel="noopener noreferrer"&gt;python-fmrest&lt;/a&gt; to pull data from FileMaker. Now what?&lt;/p&gt;

&lt;p&gt;Maybe you're exporting records for a report. Maybe you're building invoices at month-end. Maybe you just need to turn FileMaker data into something you can email to a client without sending them a &lt;code&gt;.fmp12&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;This tutorial shows you how to build a clean pipeline: &lt;strong&gt;python-fmrest&lt;/strong&gt; to fetch your FileMaker data, &lt;strong&gt;PDFForge&lt;/strong&gt; to turn it into professional PDFs. No wkhtmltopdf. No Puppeteer. No LaTeX. Just Python, JSON, and an API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.6+&lt;/strong&gt; (python-fmrest requirement)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A FileMaker Server&lt;/strong&gt; with the Data API enabled (FileMaker Server 17+ or Claris FileMaker Server)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A PDFForge account&lt;/strong&gt; — &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;free tier&lt;/a&gt; gives you 25 documents/month, no credit card required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A PDF or DOCX template&lt;/strong&gt; uploaded to your PDFForge dashboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-fmrest requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Connect to FileMaker with python-fmrest
&lt;/h2&gt;

&lt;p&gt;If you've used python-fmrest before, this is familiar. If not — it's a clean Python wrapper around the FileMaker Data API that handles authentication, sessions, and response parsing for you.&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;fmrest&lt;/span&gt;

&lt;span class="c1"&gt;# Connect to your FileMaker Server
&lt;/span&gt;&lt;span class="n"&gt;fms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fmrest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;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://your-filemaker-server.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoicing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;verify_ssl&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="c1"&gt;# Log in — python-fmrest handles the token for you
&lt;/span&gt;&lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;api_version="v1"&lt;/code&gt;&lt;/strong&gt; works for FileMaker Server 17-19. For FileMaker Server 2023+, use &lt;code&gt;"vLatest"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;verify_ssl=True&lt;/code&gt;&lt;/strong&gt; is the default and you should keep it. If you're hitting self-signed certs in development, pass &lt;code&gt;verify_ssl=False&lt;/code&gt; temporarily — but fix that before production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session token&lt;/strong&gt; is managed automatically. python-fmrest requests a token on &lt;code&gt;login()&lt;/code&gt; and includes it in subsequent requests. It also handles token expiry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: Fetch the records you need
&lt;/h2&gt;

&lt;p&gt;Let's say you want to generate invoices for all unpaid orders. python-fmrest's &lt;code&gt;find()&lt;/code&gt; method maps directly to the FileMaker Data API's find endpoint:&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="c1"&gt;# Find all unpaid invoices
&lt;/span&gt;&lt;span class="n"&gt;find_query&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;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;Unpaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="n"&gt;unpaid_invoices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;find_query&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;Found &lt;/span&gt;&lt;span class="si"&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;unpaid_invoices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unpaid invoices&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;The result is an iterable of &lt;code&gt;Record&lt;/code&gt; objects. Each record gives you attribute-style access to your FileMaker fields:&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;unpaid_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;Invoice #&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="n"&gt;InvoiceNumber&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;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerName&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;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Total&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;h3&gt;
  
  
  Getting related records (line items)
&lt;/h3&gt;

&lt;p&gt;For invoices, you typically need data from a related table — line items, for example. python-fmrest supports portals:&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="c1"&gt;# Get a specific invoice with portal data
# Make sure your layout includes a portal to InvoiceLines
&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;portals&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;name&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;InvoiceLines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}])&lt;/span&gt;

&lt;span class="c1"&gt;# Access portal records — python-fmrest stores portals as record attributes
# with a "portal_" prefix: record.portal_&amp;lt;PortalName&amp;gt;
# Convert to list since Foundset is a lazy iterator
&lt;/span&gt;&lt;span class="n"&gt;line_items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&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;span class="n"&gt;portal_InvoiceLines&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line_items&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;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines&lt;/span&gt;&lt;span class="si"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — qty &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines&lt;/span&gt;&lt;span class="si"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Quantity&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&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;Portal field names follow FileMaker's convention: &lt;code&gt;TableOccurrence::FieldName&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate a PDF with PDFForge
&lt;/h2&gt;

&lt;p&gt;Now the interesting part. You have your FileMaker data in Python objects. Let's turn it into a PDF.&lt;/p&gt;

&lt;p&gt;PDFForge works with templates — you upload a PDF form, a DOCX file with placeholders, or an HTML template to your dashboard, and then fill it with data via the API. For this tutorial, we'll use a DOCX invoice template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upload a template (one-time setup)
&lt;/h3&gt;

&lt;p&gt;In your &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;PDFForge dashboard&lt;/a&gt;, upload a DOCX file with &lt;code&gt;{placeholder}&lt;/code&gt; tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INVOICE #{invoice_number}
Date: {date}
Due: {due_date}

Bill to:
{customer_name}
{customer_address}

{items_table}

Subtotal: {subtotal}
VAT ({tax_rate}%): {tax_amount}
Total: {total} {currency}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After uploading, you'll get a &lt;code&gt;template_id&lt;/code&gt; (like &lt;code&gt;tpl_abc123&lt;/code&gt;). Save that — you'll need it in the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make the API call
&lt;/h3&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;requests&lt;/span&gt;

&lt;span class="n"&gt;PDFFORGE_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pk_live_your_api_key_here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Use env vars in production
&lt;/span&gt;&lt;span class="n"&gt;TEMPLATE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tpl_your_template_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_invoice_pdf&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;line_items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate a PDF from a FileMaker invoice record.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="c1"&gt;# Build the data payload from the FileMaker record
&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;template_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;TEMPLATE_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;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="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="nf"&gt;str&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;InvoiceNumber&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&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;InvoiceDate&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="nf"&gt;str&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;DueDate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_name&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="n"&gt;CustomerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_address&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="n"&gt;CustomerAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&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="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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::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;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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::Quantity&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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::UnitPrice&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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::LineTotal&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;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line_items&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxRate&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxAmount&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="n"&gt;GrandTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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="n"&gt;Currency&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Call PDFForge API
&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdfforge.dev/v1/documents/fill&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;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;PDFFORGE_API_KEY&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="n"&gt;json&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes a &lt;code&gt;download_url&lt;/code&gt; where you can fetch the generated document:&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_invoice_pdf&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;line_items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Download the PDF
&lt;/span&gt;&lt;span class="n"&gt;pdf_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="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;download_url&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoice_&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="n"&gt;InvoiceNumber&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="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;pdf_response&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="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;Saved Invoice_&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="n"&gt;InvoiceNumber&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;h2&gt;
  
  
  Step 4: Put it all together
&lt;/h2&gt;

&lt;p&gt;Here's the complete pipeline — connect, fetch, generate, save:&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;fmrest&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;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="c1"&gt;# -- Configuration --
&lt;/span&gt;&lt;span class="n"&gt;FM_SERVER&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://your-filemaker-server.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;FM_USER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;FM_PASSWORD&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;FM_PASSWORD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Never hardcode passwords
&lt;/span&gt;&lt;span class="n"&gt;FM_DATABASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invoicing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;PDFFORGE_API_KEY&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;PDFFORGE_API_KEY&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_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tpl_your_template_id&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Connect to FileMaker
&lt;/span&gt;    &lt;span class="n"&gt;fms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fmrest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FM_SERVER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FM_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FM_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FM_DATABASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoicesWithPortal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Find unpaid invoices
&lt;/span&gt;    &lt;span class="n"&gt;unpaid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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;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;Unpaid&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;Found &lt;/span&gt;&lt;span class="si"&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;unpaid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unpaid invoices&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="nf"&gt;makedirs&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;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;generated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&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;unpaid&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="c1"&gt;# Get portal data for line items
&lt;/span&gt;            &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_record&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;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;portals&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;name&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;InvoiceLines&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;line_items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&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;span class="n"&gt;portal_InvoiceLines&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;line_items&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;  Skipping Invoice #&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="n"&gt;InvoiceNumber&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — no line items&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="c1"&gt;# Generate PDF
&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;generate_invoice_pdf&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;span class="n"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="c1"&gt;# Download and save
&lt;/span&gt;            &lt;span class="n"&gt;pdf_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="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;download_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;filepath&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;path&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;OUTPUT_DIR&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;Invoice_&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="n"&gt;InvoiceNumber&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="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;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;pdf_response&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="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;  Generated: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;filepath&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;generated&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

        &lt;span class="k"&gt;except&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;exceptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPError&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 Invoice #&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="n"&gt;InvoiceNumber&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="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&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 Invoice #&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="n"&gt;InvoiceNumber&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="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="c1"&gt;# Clean up FileMaker session
&lt;/span&gt;    &lt;span class="n"&gt;fms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logout&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Done. Generated: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;generated&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, Errors: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;errors&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;def&lt;/span&gt; &lt;span class="nf"&gt;generate_invoice_pdf&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;line_items&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate a PDF from a FileMaker invoice record.&lt;/span&gt;&lt;span class="sh"&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;template_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;TEMPLATE_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;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="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="nf"&gt;str&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;InvoiceNumber&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&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;InvoiceDate&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="nf"&gt;str&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;DueDate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_name&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="n"&gt;CustomerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_address&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="n"&gt;CustomerAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&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="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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::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;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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::Quantity&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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::UnitPrice&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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InvoiceLines::LineTotal&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;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line_items&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxRate&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="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxAmount&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="n"&gt;GrandTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&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="n"&gt;Currency&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdfforge.dev/v1/documents/fill&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;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;PDFFORGE_API_KEY&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="n"&gt;json&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&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;FM_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_fm_password"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PDFFORGE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pk_live_your_api_key"&lt;/span&gt;
python generate_invoices.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Found 12 unpaid invoices
  Generated: ./invoices/Invoice_2026-0031.pdf
  Generated: ./invoices/Invoice_2026-0032.pdf
&lt;/span&gt;&lt;span class="gp"&gt;  Skipping Invoice #&lt;/span&gt;2026-0033 — no line items
&lt;span class="go"&gt;  Generated: ./invoices/Invoice_2026-0034.pdf
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="go"&gt;Done. Generated: 11, Errors: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Beyond invoices: other template types
&lt;/h2&gt;

&lt;p&gt;The same pattern works for any document type. Only the template and data shape change.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTML templates (for complex layouts)
&lt;/h3&gt;

&lt;p&gt;If your documents need advanced formatting — tables that span pages, conditional sections, dynamic charts — use an HTML template with Handlebars syntax. Upload it via the API or dashboard, then call &lt;code&gt;/v1/documents/generate&lt;/code&gt; instead of &lt;code&gt;/fill&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="c1"&gt;# For HTML templates, use the /generate endpoint
&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdfforge.dev/v1/documents/generate&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;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;PDFFORGE_API_KEY&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="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;template_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;tpl_html_report_template&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&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;Monthly Sales Report&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;period&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;March 2026&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;records&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="n"&gt;ProductName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;revenue&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="n"&gt;Revenue&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="n"&gt;sales_records&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  PDF form filling
&lt;/h3&gt;

&lt;p&gt;Got existing PDF forms with AcroForm fields? Upload the PDF, and PDFForge maps your JSON keys to the form field names:&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="c1"&gt;# Fill a PDF form — same /fill endpoint, different template
&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pdfforge.dev/v1/documents/fill&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;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;PDFFORGE_API_KEY&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="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;template_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;tpl_application_form&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;applicant_name&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="n"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_of_birth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&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;span class="n"&gt;DOB&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&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;span class="n"&gt;Address&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_date&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-03-23&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;python-fmrest&lt;/strong&gt; is open source and free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDFForge&lt;/strong&gt; has a free tier at 25 documents/month — enough to build and test your pipeline. If you need more:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Documents/month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;$29/mo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$79/mo&lt;/td&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;$199/mo&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a typical FileMaker solution generating monthly invoices, the free tier or Starter plan covers most scenarios. Compare that to per-seat plugin licenses that can run $200+ per developer.&lt;/p&gt;

&lt;p&gt;Current pricing is always at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-offs
&lt;/h2&gt;

&lt;p&gt;No tool is perfect. Here's what you should consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network dependency.&lt;/strong&gt; Every PDF generation requires an API call. If your environment has no internet access, this approach won't work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; An API call adds 1-3 seconds per document compared to local generation. For batch jobs running overnight, this doesn't matter. For real-time, click-and-wait UX, it's noticeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data leaves your server.&lt;/strong&gt; Your document data is sent to PDFForge's servers for rendering. PDFForge retains documents temporarily (7 days on free tier) then deletes them. If your data is highly sensitive, review their security practices or consider whether this fits your compliance requirements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FileMaker Data API limits.&lt;/strong&gt; The Data API has its own rate limits and concurrent session caps. If you're processing thousands of records, you may need to batch your &lt;code&gt;find()&lt;/code&gt; calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install the dependencies&lt;/strong&gt;: &lt;code&gt;pip install python-fmrest requests&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test your FileMaker connection&lt;/strong&gt; with a simple &lt;code&gt;find()&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign up at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;&lt;/strong&gt; and upload a template&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with one document type&lt;/strong&gt; — get it working end-to-end before expanding&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use environment variables&lt;/strong&gt; for all credentials (never hardcode API keys or passwords)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pipeline is simple by design: fetch data, shape it as JSON, send it to an API, get back a document. Once you have this pattern, you can extend it to any document your FileMaker solution needs — contracts, reports, shipping labels, compliance forms.&lt;/p&gt;

&lt;p&gt;If you're already using python-fmrest, you're one &lt;code&gt;pip install requests&lt;/code&gt; away from professional PDF generation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;PDFForge&lt;/a&gt; is a REST API for document generation. &lt;a href="https://github.com/davidhamann/python-fmrest" rel="noopener noreferrer"&gt;python-fmrest&lt;/a&gt; is an open-source Python wrapper for the FileMaker Data API by David Hamann.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>filemaker</category>
      <category>pdf</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Generate PDFs from FileMaker with a REST API</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Sun, 22 Mar 2026 17:02:44 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/how-to-generate-pdfs-from-filemaker-with-a-rest-api-5a4n</link>
      <guid>https://dev.to/zerolooplabs/how-to-generate-pdfs-from-filemaker-with-a-rest-api-5a4n</guid>
      <description>&lt;p&gt;FileMaker's built-in PDF generation works — until it doesn't.&lt;/p&gt;

&lt;p&gt;You build a layout. You use "Save Records as PDF." It looks fine on your machine. Then a client opens the same file on Windows and the fonts are wrong, the margins shifted, and that carefully aligned logo is now floating somewhere in the header wilderness.&lt;/p&gt;

&lt;p&gt;Sound familiar? Let's fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with FileMaker's native PDF generation
&lt;/h2&gt;

&lt;p&gt;FileMaker's "Save Records as PDF" script step is layout-based. That means your PDF output is a screenshot of a layout, not a document built from structured data. This creates a cascade of problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layout dependency.&lt;/strong&gt; Every PDF format requires its own layout. Need an invoice, a quote, and a packing slip? That's three layouts to maintain, each with pixel-perfect alignment that breaks when you change a field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-platform inconsistency.&lt;/strong&gt; FileMaker renders layouts differently on Mac vs. Windows vs. Server. Font substitution, line height differences, and margin calculations vary between platforms. What looks perfect on your MacBook might be a mess on a client's Windows server running a scheduled script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No batch processing.&lt;/strong&gt; Want to generate 200 invoices at once? You'll need a loop script that navigates to each record, switches to the right layout, saves the PDF, and moves on. It's slow, fragile, and ties up the FileMaker client while running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limited formatting.&lt;/strong&gt; Conditional formatting exists, but try building a multi-page document with dynamic tables, page breaks at logical points, and different headers per section. You'll spend more time fighting the layout engine than building your solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side limitations.&lt;/strong&gt; FileMaker Server can run "Save Records as PDF," but only with layouts that use server-compatible fonts. Get a font wrong and you get blank pages — with no useful error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API approach: send JSON, get PDF
&lt;/h2&gt;

&lt;p&gt;What if PDF generation worked like this instead?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your FileMaker script collects the data (customer name, line items, totals — whatever you need)&lt;/li&gt;
&lt;li&gt;It sends that data as JSON to an API endpoint&lt;/li&gt;
&lt;li&gt;It gets back a ready-to-use PDF file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No layouts to maintain. No platform inconsistencies. The same JSON payload produces the exact same PDF whether you send it from Mac, Windows, FileMaker Server, or a scheduled script.&lt;/p&gt;

&lt;p&gt;This is what a REST API for PDF generation does. The template lives on the API side. Your FileMaker solution just provides the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native PDF vs Plugin vs REST API
&lt;/h2&gt;

&lt;p&gt;Before we dive into the tutorial, here's an honest comparison of the three main approaches:&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;Native "Save as PDF"&lt;/th&gt;
&lt;th&gt;FileMaker Plugin&lt;/th&gt;
&lt;th&gt;REST API (PDFForge)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in, zero setup&lt;/td&gt;
&lt;td&gt;Install plugin on every client + server&lt;/td&gt;
&lt;td&gt;API key, no install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Template control&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Layout-based&lt;/td&gt;
&lt;td&gt;Varies by plugin&lt;/td&gt;
&lt;td&gt;HTML/CSS or DOCX templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cross-platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inconsistent rendering&lt;/td&gt;
&lt;td&gt;Plugin must support each OS&lt;/td&gt;
&lt;td&gt;Identical output everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Batch generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Loop script, slow&lt;/td&gt;
&lt;td&gt;Depends on plugin&lt;/td&gt;
&lt;td&gt;Parallel API calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server compatibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Font limitations&lt;/td&gt;
&lt;td&gt;Plugin must be server-compatible&lt;/td&gt;
&lt;td&gt;No server-side install needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (included)&lt;/td&gt;
&lt;td&gt;$50-500+ license per seat&lt;/td&gt;
&lt;td&gt;Free tier, then usage-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Maintenance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Layouts break with schema changes&lt;/td&gt;
&lt;td&gt;Plugin updates, version locks&lt;/td&gt;
&lt;td&gt;API versioned, no client updates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network dependency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Requires internet connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Learning curve&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Familiar&lt;/td&gt;
&lt;td&gt;Plugin-specific API&lt;/td&gt;
&lt;td&gt;Standard REST/JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The honest trade-off:&lt;/strong&gt; The API approach adds a network dependency. If your FileMaker solution runs in an environment with no internet access, this won't work. For everything else — which is most modern deployments — the flexibility and consistency gains are significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step: calling PDFForge from a FileMaker script
&lt;/h2&gt;

&lt;p&gt;Let's build a complete FileMaker script that generates a PDF invoice. We'll use &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;PDFForge&lt;/a&gt;, a REST API designed specifically for document generation.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A PDFForge account (free tier available at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Your API key (found in your dashboard after signup)&lt;/li&gt;
&lt;li&gt;FileMaker Pro 16+ or Claris Pro (for &lt;code&gt;Insert from URL&lt;/code&gt; with cURL options)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Store your API key
&lt;/h3&gt;

&lt;p&gt;Create a table called &lt;code&gt;AppSettings&lt;/code&gt; (if you don't have one already) with a field for &lt;code&gt;PDFForge_API_Key&lt;/code&gt;. Store your API key there. Never hardcode API keys in scripts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt; &lt;span class="err"&gt;In&lt;/span&gt; &lt;span class="err"&gt;your&lt;/span&gt; &lt;span class="err"&gt;AppSettings&lt;/span&gt; &lt;span class="py"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="py"&gt;PDFForge_API_Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"your_api_key_here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Build the JSON payload
&lt;/h3&gt;

&lt;p&gt;FileMaker's &lt;code&gt;JSONSetElement&lt;/code&gt; function is your best friend here. Create a script called "Generate Invoice PDF" and start building the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# ============================================
# Script: Generate Invoice PDF
# Purpose: Collect invoice data, call PDFForge API, save PDF
# ============================================

# -- Step 1: Build the JSON payload from current record --

Set Variable [ $json ; Value: "{}" ]

# Company details
Set Variable [ $json ; Value:
  JSONSetElement ( $json ;
    [ "template_id" ; "tpl_YOUR_TEMPLATE_ID" ; JSONString ] ;
        [ "data.company.name" ; AppSettings::CompanyName ; JSONString ] ;
    [ "data.company.address" ; AppSettings::CompanyAddress ; JSONString ] ;
    [ "data.company.email" ; AppSettings::CompanyEmail ; JSONString ] ;
    [ "data.company.phone" ; AppSettings::CompanyPhone ; JSONString ] ;
    [ "data.company.logo_url" ; AppSettings::LogoURL ; JSONString ]
  )
]

# Customer details
Set Variable [ $json ; Value:
  JSONSetElement ( $json ;
    [ "data.customer.name" ; Invoices::CustomerName ; JSONString ] ;
    [ "data.customer.address" ; Invoices::CustomerAddress ; JSONString ] ;
    [ "data.customer.email" ; Invoices::CustomerEmail ; JSONString ]
  )
]

# Invoice metadata
Set Variable [ $json ; Value:
  JSONSetElement ( $json ;
    [ "data.invoice.number" ; Invoices::InvoiceNumber ; JSONString ] ;
    [ "data.invoice.date" ; GetAsText ( Invoices::InvoiceDate ) ; JSONString ] ;
    [ "data.invoice.due_date" ; GetAsText ( Invoices::DueDate ) ; JSONString ] ;
    [ "data.invoice.currency" ; Invoices::Currency ; JSONString ]
  )
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Add line items from a related table
&lt;/h3&gt;

&lt;p&gt;Line items need to be built as a JSON array. Here's how to loop through a portal or related records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# -- Step 2: Build line items array --

Set Variable [ $items ; Value: "[]" ]
Set Variable [ $i ; Value: 0 ]

Go to Related Record [ From table: "InvoiceLines" ; Using layout: "InvoiceLines" ]

Loop
  Set Variable [ $item ; Value:
    JSONSetElement ( "{}" ;
      [ "description" ; InvoiceLines::Description ; JSONString ] ;
      [ "quantity" ; InvoiceLines::Quantity ; JSONNumber ] ;
      [ "unit_price" ; InvoiceLines::UnitPrice ; JSONNumber ] ;
      [ "amount" ; InvoiceLines::LineTotal ; JSONNumber ]
    )
  ]

  Set Variable [ $items ; Value:
    JSONSetElement ( $items ; $i ; $item ; JSONObject )
  ]

  Set Variable [ $i ; Value: $i + 1 ]

  Go to Record/Request/Page [ Next ; Exit after last ]
End Loop

# Navigate back to invoice layout
Go to Layout [ "Invoices" ; Animation: None ]

# Add line items and totals to payload
Set Variable [ $json ; Value:
  JSONSetElement ( $json ;
    [ "data.items" ; $items ; JSONArray ] ;
    [ "data.totals.subtotal" ; Invoices::Subtotal ; JSONNumber ] ;
    [ "data.totals.tax_rate" ; Invoices::TaxRate ; JSONNumber ] ;
    [ "data.totals.tax_amount" ; Invoices::TaxAmount ; JSONNumber ] ;
    [ "data.totals.total" ; Invoices::GrandTotal ; JSONNumber ]
  )
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Call the API with Insert from URL
&lt;/h3&gt;

&lt;p&gt;Now the actual API call. FileMaker's &lt;code&gt;Insert from URL&lt;/code&gt; script step supports cURL options, which gives you full control over the HTTP request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# -- Step 3: Call PDFForge API --

Set Variable [ $api_key ; Value: AppSettings::PDFForge_API_Key ]

Set Variable [ $curl_options ; Value:
  "--request POST" &amp;amp;
  " --header \"Content-Type: application/json\"" &amp;amp;
  " --header \"Authorization: Bearer pk_live_" &amp;amp; $api_key &amp;amp; "\"" &amp;amp;
  " --data @$json"
]

Set Variable [ $url ; Value: "https://api.pdfforge.dev/v1/documents/generate" ]

Insert from URL [ Select ; With dialog: Off ;
  Target: $pdf_result ;
  URL: $url ;
  cURL options: $curl_options
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Handle the response
&lt;/h3&gt;

&lt;p&gt;The API returns a JSON response containing a &lt;code&gt;download_url&lt;/code&gt;. You'll need to parse the JSON, then fetch the PDF from that URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# -- Step 4: Handle the response --

Set Variable [ $http_code ; Value: Get ( LastExternalErrorDetail ) ]

If [ $http_code = 0 ]

  # Parse the JSON response to get the download URL
  Set Variable [ $download_url ; Value:
    JSONGetElement ( $pdf_result ; "download_url" )
  ]
  Set Variable [ $doc_id ; Value:
    JSONGetElement ( $pdf_result ; "id" )
  ]

  # Download the actual PDF file
  Insert from URL [ Select ; With dialog: Off ;
    Target: $pdf_binary ;
    URL: $download_url
  ]

  # Save PDF to container field
  Set Field [ Invoices::PDF_Document ; $pdf_binary ]

  # Optionally export to disk
  Set Variable [ $filename ; Value:
    "Invoice_" &amp;amp; Invoices::InvoiceNumber &amp;amp; ".pdf"
  ]
  Set Variable [ $filepath ; Value:
    Get ( DocumentsPath ) &amp;amp; $filename
  ]
  Export Field Contents [ Invoices::PDF_Document ; "$filepath" ]

  Show Custom Dialog [ "Success" ;
    "PDF generated: " &amp;amp; $filename
  ]

Else

  # Error handling
  Show Custom Dialog [ "Error" ;
    "PDF generation failed. HTTP status: " &amp;amp; $http_code &amp;amp;
    "¶Response: " &amp;amp; $pdf_result
  ]

End If
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The complete cURL equivalent
&lt;/h2&gt;

&lt;p&gt;If you want to test outside FileMaker first (or you're integrating from another platform), here's the equivalent cURL command:&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 https://api.pdfforge.dev/v1/documents/generate &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer pk_live_YOUR_API_KEY"&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;'{
    "template_id": "tpl_YOUR_TEMPLATE_ID",
    "data": {
      "company": {
        "name": "Acme Corp",
        "address": "123 Business Street, London EC1A 1BB",
        "email": "billing@acme.example.com"
      },
      "customer": {
        "name": "Jane Smith",
        "address": "456 Client Road, Manchester M1 1AA",
        "email": "jane@example.com"
      },
      "invoice": {
        "number": "INV-2026-0042",
        "date": "2026-03-22",
        "due_date": "2026-04-21",
        "currency": "GBP"
      },
      "items": [
        {
          "description": "FileMaker Development - Phase 1",
          "quantity": 40,
          "unit_price": 95.00,
          "amount": 3800.00
        },
        {
          "description": "PDF Template Design",
          "quantity": 1,
          "unit_price": 500.00,
          "amount": 500.00
        }
      ],
      "totals": {
        "subtotal": 4300.00,
        "tax_rate": 20,
        "tax_amount": 860.00,
        "total": 5160.00
      }
    }
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;' | jq .
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API returns a JSON response with a &lt;code&gt;download_url&lt;/code&gt; — fetch that URL to get the actual PDF file:&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="c"&gt;# Download the generated PDF&lt;/span&gt;
curl &lt;span class="nt"&gt;-o&lt;/span&gt; invoice.pdf &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.pdfforge.dev/v1/documents/fill &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;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer pk_live_YOUR_API_KEY"&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;'...'&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.download_url'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data, same template, same result, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error handling and resilience
&lt;/h2&gt;

&lt;p&gt;Production scripts need more than a happy path. Here are the patterns that matter:&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeout handling
&lt;/h3&gt;

&lt;p&gt;FileMaker's &lt;code&gt;Insert from URL&lt;/code&gt; has a default timeout. For large documents, set it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set Variable [ $curl_options ; Value:
  $curl_options &amp;amp; " --max-time 30"
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Retry logic
&lt;/h3&gt;

&lt;p&gt;Network requests can fail transiently. A simple retry loop helps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Set Variable [ $max_retries ; Value: 3 ]
Set Variable [ $attempt ; Value: 1 ]

Loop
  Insert from URL [ Select ; With dialog: Off ;
    Target: $pdf_result ;
    URL: $url ;
    cURL options: $curl_options
  ]

  Exit Loop If [ Get ( LastExternalErrorDetail ) = 0 or $attempt &amp;gt;= $max_retries ]

  Set Variable [ $attempt ; Value: $attempt + 1 ]

  # Brief pause before retry
  Pause/Resume Script [ Duration (seconds): 2 ]
End Loop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Validate before sending
&lt;/h3&gt;

&lt;p&gt;Don't waste API calls on incomplete data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Pre-flight checks
If [ IsEmpty ( Invoices::CustomerName ) or IsEmpty ( Invoices::InvoiceNumber ) ]
  Show Custom Dialog [ "Missing Data" ;
    "Customer name and invoice number are required."
  ]
  Exit Script [ Text Result: "error" ]
End If
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;Once you have the basic pattern working, there's a lot more you can do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom templates
&lt;/h3&gt;

&lt;p&gt;PDFForge supports three template types, each uploaded via the dashboard or API (&lt;code&gt;POST /v1/templates&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PDF Forms&lt;/strong&gt; — AcroForms with fillable fields (text, checkboxes, dropdowns). Use endpoint &lt;code&gt;/v1/documents/fill&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DOCX Files&lt;/strong&gt; — Word documents with &lt;code&gt;{placeholder}&lt;/code&gt; tags, including image support. Use endpoint &lt;code&gt;/v1/documents/fill&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML Templates&lt;/strong&gt; — Handlebars syntax, rendered to PDF via Chromium. Use endpoint &lt;code&gt;/v1/documents/generate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your FileMaker solution sends the same JSON structure — only the &lt;code&gt;template_id&lt;/code&gt; changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Switch template by changing the template_id
Set Variable [ $json ; Value:
  JSONSetElement ( $json ;
    [ "template_id" ; "tpl_YOUR_OTHER_TEMPLATE" ; JSONString ]
  )
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  DOCX templates
&lt;/h3&gt;

&lt;p&gt;Need to generate editable Word documents? Upload a DOCX template with &lt;code&gt;{placeholder}&lt;/code&gt; tags in the dashboard, then call &lt;code&gt;/v1/documents/fill&lt;/code&gt; with your data. PDFForge merges the data into the template and returns the filled document. This is useful when clients need to edit documents before sending.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch processing
&lt;/h3&gt;

&lt;p&gt;Need to generate 200 invoices at month-end? Instead of looping through layouts, loop through records and make API calls. Each call is independent — no layout switching, no render context to maintain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Batch generation script
Go to Record/Request/Page [ First ]

Loop
  Perform Script [ "Generate Invoice PDF" ]
  Go to Record/Request/Page [ Next ; Exit after last ]
End Loop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the API is stateless, you could even run multiple FileMaker scripts in parallel (using &lt;code&gt;Perform Script on Server&lt;/code&gt; calls) for faster batch processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Email delivery
&lt;/h3&gt;

&lt;p&gt;Combine PDF generation with an email API (like Resend or SendGrid) to generate and send invoices in one automated workflow. Your FileMaker script becomes: collect data, generate PDF, attach to email, send. All without a user clicking through layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;PDFForge has a free tier (25 documents/month, no credit card required) that's enough to test and build your integration. Paid plans start at $29/month with higher document quotas and extended retention. For most FileMaker solutions, that's a fraction of what you'd pay for a per-seat plugin license.&lt;/p&gt;

&lt;p&gt;Check current pricing at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sign up&lt;/strong&gt; at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt; and grab your API key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with cURL&lt;/strong&gt; first — paste the command above into your terminal to see the output&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the FileMaker script&lt;/strong&gt; — start with the code in this article and adapt it to your schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customize a template&lt;/strong&gt; — upload your own HTML/CSS or DOCX template in the dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate&lt;/strong&gt; — add the script to your existing workflows (record creation, month-end batch, email triggers)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you hit any issues or want to share what you've built, find us at &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;. We're building this for developers who actually ship FileMaker solutions — your feedback shapes the product.&lt;/p&gt;

</description>
      <category>filemaker</category>
      <category>pdf</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your INSERT INTO audit_log proves nothing</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Fri, 13 Mar 2026 11:11:08 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/why-your-insert-into-auditlog-proves-nothing-1ohf</link>
      <guid>https://dev.to/zerolooplabs/why-your-insert-into-auditlog-proves-nothing-1ohf</guid>
      <description>&lt;p&gt;You've seen this pattern. You might have written it yourself. I know I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;   &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;Every time something important happens — a user signs a contract, an admin changes permissions, a payment goes through — you &lt;code&gt;INSERT INTO audit_log&lt;/code&gt;. Job done. You have an audit trail.&lt;/p&gt;

&lt;p&gt;Except you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem nobody talks about
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;audit_log&lt;/code&gt; table sits in the same database as everything else. Anyone with write access can do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oops. Never happened.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invoice.viewed'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invoice.deleted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Or just make it disappear entirely&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin_42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'permission.escalated'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No trace. No alert. No way to know it ever happened.&lt;/p&gt;

&lt;p&gt;This isn't a theoretical attack. It's the &lt;strong&gt;default state&lt;/strong&gt; of every audit log built on a regular database table. Your DBA can do it. A compromised admin account can do it. A SQL injection exploit can do it. And nobody will ever know.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But we have backups"
&lt;/h2&gt;

&lt;p&gt;Sure. But backups tell you what the data &lt;em&gt;was&lt;/em&gt;, not whether it was &lt;em&gt;tampered with&lt;/em&gt; between now and then. If someone edits a row on Monday and you check the backup on Friday, you're comparing against a backup that might already include the edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  "We use triggers and permissions"
&lt;/h2&gt;

&lt;p&gt;Better. But triggers can be disabled by superusers. &lt;code&gt;ALTER TABLE ... DISABLE TRIGGER ALL&lt;/code&gt; is one command. And PostgreSQL &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions can bypass row-level security.&lt;/p&gt;

&lt;p&gt;The fundamental issue isn't about access control. It's about &lt;strong&gt;provability&lt;/strong&gt;. Can you &lt;em&gt;prove&lt;/em&gt; — cryptographically, not just organizationally — that a log entry hasn't been modified since it was written?&lt;/p&gt;

&lt;p&gt;With a regular INSERT? No. You can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What auditors actually want
&lt;/h2&gt;

&lt;p&gt;When a compliance auditor asks "show me the audit trail for this transaction," they're not asking for a database dump. They want to know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Completeness&lt;/strong&gt; — Are all events present? Can you prove nothing was deleted?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt; — Can you prove nothing was modified after the fact?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordering&lt;/strong&gt; — Can you prove the sequence of events hasn't been rearranged?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A plain &lt;code&gt;audit_log&lt;/code&gt; table answers none of these questions with certainty.&lt;/p&gt;

&lt;p&gt;SOC 2, HIPAA, PCI-DSS Requirement 10, GDPR Article 32 — they all expect or require audit records with demonstrable integrity. "We have a database table" doesn't cut it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash chains: the fix
&lt;/h2&gt;

&lt;p&gt;The solution has existed since the 1990s (Haber &amp;amp; Stornetta, the paper that later inspired Bitcoin's blockchain). The idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each log entry includes a cryptographic hash of itself &lt;em&gt;and&lt;/em&gt; the previous entry.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event 1: hash("payload_1" + GENESIS_HASH)        → hash_1
Event 2: hash("payload_2" + hash_1)               → hash_2
Event 3: hash("payload_3" + hash_2)               → hash_3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if someone modifies Event 2, &lt;code&gt;hash_2&lt;/code&gt; changes. But Event 3 was computed using the &lt;em&gt;original&lt;/em&gt; &lt;code&gt;hash_2&lt;/code&gt;. The chain breaks. The tampering is immediately detectable.&lt;/p&gt;

&lt;p&gt;You can't silently edit a single row without invalidating every subsequent hash in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example. You're logging invoice events in a SaaS app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SealTrail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sealtrail&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;st&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;SealTrail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&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;SEALTRAIL_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Log an event — hash chain is built automatically&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finance_user_42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_12345&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// "a1b2c3d4e5f6..."&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event gets a SHA-256 hash computed from the canonicalized event payload (actor, action, resource, context), the previous event's hash, and the event timestamp.&lt;/p&gt;

&lt;p&gt;The hash and chain position are returned with every event. They're not just metadata — they're &lt;strong&gt;cryptographic proof&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification: the part that matters
&lt;/h2&gt;

&lt;p&gt;Logging isn't enough. The whole point is that you — or an auditor, or an automated compliance check — can &lt;em&gt;verify&lt;/em&gt; that nothing was touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evt_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Event integrity verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Chain intact:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chainIntact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TAMPER DETECTED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;computedHash&lt;/span&gt;&lt;span class="p"&gt;);&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Got:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventHash&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;Verification recomputes the hash from scratch. If the stored hash doesn't match the computed one, someone changed the data. If &lt;code&gt;chainIntact&lt;/code&gt; is false, someone broke the link between events — meaning an event was inserted, deleted, or reordered.&lt;/p&gt;

&lt;p&gt;This isn't trust. It's math.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY trap
&lt;/h2&gt;

&lt;p&gt;At this point you might be thinking: "I can build this myself. SHA-256, a &lt;code&gt;previous_hash&lt;/code&gt; column, done."&lt;/p&gt;

&lt;p&gt;I thought the same thing. Here's what I ran into:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent writes.&lt;/strong&gt; Two events arrive at the same millisecond. Both read the same &lt;code&gt;previous_hash&lt;/code&gt;. Both compute their hash against it. You now have a forked chain. Fix: atomic transactions with unique constraints on chain position. Retry on conflict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canonical serialization.&lt;/strong&gt; &lt;code&gt;JSON.stringify({ a: 1, b: 2 })&lt;/code&gt; and &lt;code&gt;JSON.stringify({ b: 2, a: 1 })&lt;/code&gt; produce different strings, therefore different hashes. You need deterministic JSON canonicalization, or your verification breaks when key order varies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chain partitioning.&lt;/strong&gt; One global chain becomes a bottleneck. You need per-resource or per-tenant chains. But then you need to manage chain creation, head tracking, and cross-chain references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination at scale.&lt;/strong&gt; Offset-based pagination breaks when events are being inserted. You need cursor-based pagination with stable sort keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry logic.&lt;/strong&gt; Rate limits, network failures, concurrent conflicts — your client needs exponential backoff with jitter, and it needs to handle 409 Conflict responses specifically for chain contention.&lt;/p&gt;

&lt;p&gt;It's solvable. But it's a week of work to build, and a lifetime of maintenance to keep secure. And if you get the canonicalization wrong, your entire chain is silently invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop building underpants
&lt;/h2&gt;

&lt;p&gt;There's a term in engineering for components that every team rebuilds from scratch despite it not being their core business: &lt;strong&gt;underpants&lt;/strong&gt; (as in, everyone needs them, nobody should be hand-stitching them).&lt;/p&gt;

&lt;p&gt;Your audit trail is underpants. It needs to work. It needs to be tamper-proof. But it's not what your users are paying you for.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;SealTrail&lt;/a&gt; because I needed this for my own SaaS products and got tired of reimplementing it. It's an API — &lt;code&gt;npm install sealtrail&lt;/code&gt;, log events, verify integrity. Hash chains, cursor pagination, isolated chains per resource or tenant, and cryptographic verification are handled for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Your entire audit trail integration&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SealTrail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sealtrail&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;st&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;SealTrail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&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;SEALTRAIL_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Log (events are chained per chain — default or custom)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&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.signed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resource&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="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;documents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;// optional — isolates chains per resource type&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Verify&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proof&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// proof.valid === true | false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines to log. One line to verify. Hash chain built automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&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;&lt;code&gt;INSERT INTO audit_log&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Hash chain audit trail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Can admin silently edit?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — hash changes, chain breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can rows be deleted?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Detectable — position gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can order be changed?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — each hash includes previous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cryptographic proof?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;SHA-256 chain verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance-ready?&lt;/td&gt;
&lt;td&gt;Questionable&lt;/td&gt;
&lt;td&gt;Verifiable record&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your audit log is a regular database table, it proves nothing. It's a convenience log, not an audit trail.&lt;/p&gt;

&lt;p&gt;If that's fine for your use case, carry on. But if you're handling financial data, healthcare records, legal documents, or anything where "we can prove this wasn't tampered with" matters — you need hash chains.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sylvain, solo founder at &lt;a href="https://zerolooplabs.dev" rel="noopener noreferrer"&gt;Zero Loop Labs&lt;/a&gt;. I build developer tools. SealTrail is my latest — a tamper-proof audit trail API for developers. &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;Try it free&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>typescript</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I Built a Document Generation API with Bun, Hono &amp; Playwright</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Fri, 06 Mar 2026 21:55:52 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/how-i-built-a-document-generation-api-with-bun-hono-playwright-do0</link>
      <guid>https://dev.to/zerolooplabs/how-i-built-a-document-generation-api-with-bun-hono-playwright-do0</guid>
      <description>&lt;p&gt;Every developer has dealt with PDF generation at some point. Whether it's invoices, contracts, reports, or government forms — the moment a client says "we need this as a PDF," you know you're in for a ride.&lt;/p&gt;

&lt;p&gt;I've been through the usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Puppeteer&lt;/strong&gt; with headless Chromium — powerful, but managing memory leaks and container sizing in production is a full-time job&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wkhtmltopdf&lt;/strong&gt; — reliable for years, but unmaintained and stuck with a 2015-era WebKit engine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-level libraries&lt;/strong&gt; like pdfkit or jsPDF — great control, but you're positioning text at pixel coordinates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expensive SaaS solutions&lt;/strong&gt; — they work, but pricing per page adds up fast&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After re-solving this problem on multiple projects, I decided to build it once and expose it as an API. The result is &lt;strong&gt;PDFForge&lt;/strong&gt; — a REST API that handles three document operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTML to PDF&lt;/strong&gt; — design templates in HTML/CSS, render to pixel-perfect PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill PDF forms&lt;/strong&gt; — send JSON data to fill AcroForm fields&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill Word documents&lt;/strong&gt; — .docx templates with placeholder tags&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast startup, native crypto &amp;amp; file I/O, built-in test runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hono&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lightweight, middleware-friendly, great TypeScript support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF Rendering&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Playwright&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Chromium-based, reliable CSS rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF Form Filling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pdf-lib&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pure JS, no native deps, handles AcroForms well&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word Documents&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;docxtemplater&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Mature, handles loops/conditionals/images in .docx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Templating&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Handlebars&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple syntax, custom helpers for formatting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Drizzle + PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Type-safe ORM, great migration story&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;S3-compatible&lt;/strong&gt; (Tigris)&lt;/td&gt;
&lt;td&gt;Signed URLs for document delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fly.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Edge deployment, machine suspend for cost control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Upload a template
&lt;/h3&gt;

&lt;p&gt;Templates are HTML files with Handlebars syntax. You use variables like &lt;code&gt;clientName&lt;/code&gt;, loops with &lt;code&gt;each items&lt;/code&gt;, and conditionals. Standard HTML/CSS — no proprietary syntax to learn.&lt;/p&gt;

&lt;p&gt;Upload it once via the API or the dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generate a document
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -X POST &lt;a href="https://api.pdfforge.dev/v1/documents/generate" rel="noopener noreferrer"&gt;https://api.pdfforge.dev/v1/documents/generate&lt;/a&gt; \&lt;br&gt;
  -H "Authorization: Bearer pk_live_xxx" \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{&lt;br&gt;
    "templateId": "tpl_abc123",&lt;br&gt;
    "data": {&lt;br&gt;
      "invoiceNumber": "INV-2026-042",&lt;br&gt;
      "clientName": "Acme Corp",&lt;br&gt;
      "items": [&lt;br&gt;
        {"description": "API Integration", "amount": "$2,500"},&lt;br&gt;
        {"description": "Support Plan", "amount": "$500"}&lt;br&gt;
      ],&lt;br&gt;
      "total": "$3,000"&lt;br&gt;
    }&lt;br&gt;
  }'&lt;br&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;br&gt;
  Get your PDF&lt;br&gt;
&lt;/h3&gt;

&lt;p&gt;The API returns a signed download URL — no auth needed to download, URL expires automatically based on your plan's retention policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interesting Technical Challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Chromium Concurrency
&lt;/h3&gt;

&lt;p&gt;The biggest challenge was managing concurrent Playwright instances. Each PDF render spins up a browser context with Chromium, which is memory-hungry. I built a &lt;strong&gt;priority semaphore&lt;/strong&gt; that queues render jobs based on the user's plan tier — paid users get priority over free tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bun + Playwright in Docker
&lt;/h3&gt;

&lt;p&gt;Playwright's bundled Chromium doesn't play well with Bun in Docker. The fix: install &lt;strong&gt;Google Chrome Stable&lt;/strong&gt; separately and point Playwright to it via &lt;code&gt;PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH&lt;/code&gt;. Counterintuitive, but it works reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signed URLs vs Streaming
&lt;/h3&gt;

&lt;p&gt;I initially considered streaming PDFs directly in the API response. But signed URLs turned out to be better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clients can retry failed downloads without re-generating&lt;/li&gt;
&lt;li&gt;URLs can be shared (temporarily) with end users&lt;/li&gt;
&lt;li&gt;Decouples generation from delivery&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Testing with Bun
&lt;/h3&gt;

&lt;p&gt;Bun's test runner is fast, but &lt;code&gt;mock.module()&lt;/code&gt; has a gotcha: if two test files mock the same module differently, the first mock wins globally. The solution was mocking at the &lt;code&gt;lib/&lt;/code&gt; level to isolate dependencies properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;PDFForge is currently in &lt;strong&gt;public beta&lt;/strong&gt; with a free tier (50 documents/month). I'm actively looking for feedback from developers who deal with document generation regularly.&lt;/p&gt;

&lt;p&gt;If you've ever struggled with PDF generation in your projects, I'd love to hear about your use case — it helps me prioritize what to build next.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;pdfforge.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pdfforge.dev/docs" rel="noopener noreferrer"&gt;API Documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bunjs</category>
      <category>pdf</category>
      <category>api</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
