<?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: Inkspan.dev</title>
    <description>The latest articles on DEV Community by Inkspan.dev (@inkspan).</description>
    <link>https://dev.to/inkspan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4040716%2F894f51bd-79f4-4156-90a6-4e50f5800aa0.png</url>
      <title>DEV Community: Inkspan.dev</title>
      <link>https://dev.to/inkspan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/inkspan"/>
    <language>en</language>
    <item>
      <title>I kept rebuilding the same PDF plumbing — so I turned it into one API</title>
      <dc:creator>Inkspan.dev</dc:creator>
      <pubDate>Tue, 21 Jul 2026 20:25:39 +0000</pubDate>
      <link>https://dev.to/inkspan/i-kept-rebuilding-the-same-pdf-plumbing-so-i-turned-it-into-one-api-1b9a</link>
      <guid>https://dev.to/inkspan/i-kept-rebuilding-the-same-pdf-plumbing-so-i-turned-it-into-one-api-1b9a</guid>
      <description>&lt;p&gt;Every app eventually needs documents. Invoices. Reports. Exports. Parsing whatever a user uploads. And every single time, you end up doing the same unglamorous work: wiring up headless Chrome or &lt;code&gt;wkhtmltopdf&lt;/code&gt;, fighting font installs, babysitting it under load, and then writing a brittle parser to get data back &lt;em&gt;out&lt;/em&gt; of PDFs.&lt;/p&gt;

&lt;p&gt;I did this three times across different projects before admitting it should just be infrastructure. So I built &lt;strong&gt;&lt;a href="https://inkspan.dev" rel="noopener noreferrer"&gt;Inkspan&lt;/a&gt;&lt;/strong&gt; — one API for the whole document lifecycle: generate, manipulate, and extract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generate&lt;/strong&gt; — HTML, a template + JSON data, or Markdown → pixel-faithful PDF.&lt;br&gt;
&lt;strong&gt;Manipulate&lt;/strong&gt; — merge, split, compress, watermark, rasterize to images, fill AcroForm fields.&lt;br&gt;
&lt;strong&gt;Extract&lt;/strong&gt; — pull text and tables into structured JSON.&lt;/p&gt;

&lt;p&gt;Authenticate with a key, POST to an endpoint, get a document (or JSON) back. No rendering fleet to run.&lt;/p&gt;
&lt;h2&gt;
  
  
  Generating a PDF from a template
&lt;/h2&gt;

&lt;p&gt;The endpoint I use most is &lt;code&gt;generate/template&lt;/code&gt; — pass an HTML template with &lt;code&gt;{{ }}&lt;/code&gt; placeholders and a JSON data object:&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;requests&lt;/span&gt;

&lt;span class="n"&gt;res&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.inkspan.dev/v1/generate/template&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;X-API-Key&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;ink_live_...&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&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;&amp;lt;h1&amp;gt;Invoice {{ number }}&amp;lt;/h1&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{% for item in items %}&amp;lt;p&amp;gt;{{ item.desc }}: ${{ item.amt }}&amp;lt;/p&amp;gt;{% endfor %}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h3&amp;gt;Total: ${{ total }}&amp;lt;/h3&amp;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;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;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INV-1042&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desc&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;API Pro Plan&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;amt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;49&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;desc&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;Overage&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;amt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&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="mi"&gt;61&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="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.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="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. No Chromium in your container, no &lt;code&gt;libpango&lt;/code&gt; in your Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I actually care about: honest extraction
&lt;/h2&gt;

&lt;p&gt;Parsing PDFs is famously unreliable, and my real gripe with existing tools is that they return &lt;em&gt;confident-looking garbage&lt;/em&gt; on a document they couldn't actually read. So Inkspan's extraction endpoints return a &lt;strong&gt;confidence score&lt;/strong&gt; and a &lt;code&gt;review_recommended&lt;/code&gt; flag:&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;res&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.inkspan.dev/v1/extract/tables&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;X-API-Key&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;ink_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.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;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="c1"&gt;# {
#   "table_count": 1,
#   "tables": [{"page": 1, "rows": [["Item","Qty","Price"], ["Widget","5","$10"]]}],
#   "confidence": 0.95,
#   "review_recommended": false
# }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Born-digital PDFs (anything generated by software — most invoices, statements, exports) extract cleanly and score high. When confidence is low, you get told to review it rather than silently shipping wrong data into your database. That single design choice has saved me more headaches than any accuracy benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Nothing exotic, deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; on Railway for the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WeasyPrint&lt;/strong&gt; for HTML→PDF, &lt;strong&gt;PyMuPDF&lt;/strong&gt; + &lt;strong&gt;pypdf&lt;/strong&gt; for manipulation and extraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for API keys and usage metering.&lt;/li&gt;
&lt;li&gt;Usage-based billing so it scales with what you actually use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The value isn't a novel algorithm — it's that you don't have to run and maintain any of this yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;There's a free tier (100 documents/month), and signup is self-serve — you get a key on the spot at &lt;strong&gt;&lt;a href="https://inkspan.dev" rel="noopener noreferrer"&gt;inkspan.dev&lt;/a&gt;&lt;/strong&gt;. It's also on &lt;a href="https://rapidapi.com/inkspanops/api/inkspan" rel="noopener noreferrer"&gt;RapidAPI&lt;/a&gt; if that's your preferred billing.&lt;/p&gt;

&lt;p&gt;I'd genuinely like feedback on the API design — especially the extraction confidence model, and which document operations you'd want next. What's the PDF task you're tired of rebuilding?&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>webdev</category>
      <category>pdf</category>
    </item>
  </channel>
</rss>
