<?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: Howth Technology Factory</title>
    <description>The latest articles on DEV Community by Howth Technology Factory (@howthtechnologyfactory).</description>
    <link>https://dev.to/howthtechnologyfactory</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%2F4038014%2Fd7fe59b0-5e1b-453e-bf5f-075692c0b322.png</url>
      <title>DEV Community: Howth Technology Factory</title>
      <link>https://dev.to/howthtechnologyfactory</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/howthtechnologyfactory"/>
    <language>en</language>
    <item>
      <title>Modeling individual vs corporate entities for sanctions screening</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Tue, 25 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/modeling-individual-vs-corporate-entities-for-sanctions-screening-2naa</link>
      <guid>https://dev.to/howthtechnologyfactory/modeling-individual-vs-corporate-entities-for-sanctions-screening-2naa</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjiar8x4lytcs4kpakqie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjiar8x4lytcs4kpakqie.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;br&gt;
An individual record needs date of birth and nationality fields to disambiguate common names. A corporate record needs an ownership graph, not just a company name field. Treating both as the same schema is where a lot of screening integrations go wrong early. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Backfilling sanctions screening across an existing user base</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Mon, 24 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/backfilling-sanctions-screening-across-an-existing-user-base-3lm6</link>
      <guid>https://dev.to/howthtechnologyfactory/backfilling-sanctions-screening-across-an-existing-user-base-3lm6</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F271a4hfji50bvspwpios.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F271a4hfji50bvspwpios.png" alt=" " width="800" height="372"&gt;&lt;/a&gt;&lt;br&gt;
Submitting 10,000 existing customers to a screening API all at once doesn't fail technically, it just creates a review queue nobody can work through. Throttling the submission rate is the boring fix that actually works. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Daily batch rescan vs event-driven rescan: an architecture choice</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/daily-batch-rescan-vs-event-driven-rescan-an-architecture-choice-2p4d</link>
      <guid>https://dev.to/howthtechnologyfactory/daily-batch-rescan-vs-event-driven-rescan-an-architecture-choice-2p4d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6svx999r7bx090626sk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6svx999r7bx090626sk.png" alt=" " width="800" height="314"&gt;&lt;/a&gt;&lt;br&gt;
Daily batch is simpler to operate and reason about, with up to 24 hours of lag baked in. Event-driven rescanning triggers off list updates directly and gets that lag close to zero, at the cost of another moving part in your infra. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How fast a sanctions list update actually reaches your API call</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/how-fast-a-sanctions-list-update-actually-reaches-your-api-call-26fk</link>
      <guid>https://dev.to/howthtechnologyfactory/how-fast-a-sanctions-list-update-actually-reaches-your-api-call-26fk</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0273a8pibouzgqux2cy4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0273a8pibouzgqux2cy4.png" alt=" " width="800" height="258"&gt;&lt;/a&gt;&lt;br&gt;
A regulator publishes an update, we ingest and re-index it, then it's live in every screening call, around 12 minutes end to end in our pipeline. Worth checking this number for whatever you're using, a stale cached list is a silent failure mode. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Exact match vs fuzzy match, worked examples</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Fri, 21 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/exact-match-vs-fuzzy-match-worked-examples-5e4m</link>
      <guid>https://dev.to/howthtechnologyfactory/exact-match-vs-fuzzy-match-worked-examples-5e4m</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgm98fbt2ndl9s4ulrjx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgm98fbt2ndl9s4ulrjx.png" alt=" " width="799" height="388"&gt;&lt;/a&gt;&lt;br&gt;
Transliteration, abbreviation, and typos all break naive string equality. Running the same four name variants through exact and fuzzy matching side by side, exact match missed three of the four. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What our sandbox test suite actually covers before go-live</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Thu, 20 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/what-our-sandbox-test-suite-actually-covers-before-go-live-46ck</link>
      <guid>https://dev.to/howthtechnologyfactory/what-our-sandbox-test-suite-actually-covers-before-go-live-46ck</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6s5ogpksysq5oocle5ei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6s5ogpksysq5oocle5ei.png" alt=" " width="799" height="366"&gt;&lt;/a&gt;&lt;br&gt;
Known test names, a deliberately misspelled variant of each, a name that should never match, and simulated timeouts and errors. Test only the happy path and you'll find out about the rest in production. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Stop collapsing match confidence into a boolean</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Wed, 19 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/stop-collapsing-match-confidence-into-a-boolean-naf</link>
      <guid>https://dev.to/howthtechnologyfactory/stop-collapsing-match-confidence-into-a-boolean-naf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4hbtimtrfnfhcks4z6qj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4hbtimtrfnfhcks4z6qj.png" alt=" " width="800" height="279"&gt;&lt;/a&gt;&lt;br&gt;
Returning true/false from a screening endpoint throws away the one piece of data a caller actually needs to build a good review UI: how close the match was. We return the raw score so callers can set their own thresholds. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Sanctions screening latency, benchmarked</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Tue, 18 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/sanctions-screening-latency-benchmarked-fo2</link>
      <guid>https://dev.to/howthtechnologyfactory/sanctions-screening-latency-benchmarked-fo2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dtkvaqsrpumxpuon3rz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3dtkvaqsrpumxpuon3rz.png" alt=" " width="800" height="332"&gt;&lt;/a&gt;&lt;br&gt;
Self-hosted batch jobs land around 4 seconds. A generic REST wrapper gets under a second. Ours runs under 200ms because it's built as an event-priced API from the ground up, not bolted onto a batch pipeline. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Handling a sanctions API timeout without guessing</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Mon, 17 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/handling-a-sanctions-api-timeout-without-guessing-40c9</link>
      <guid>https://dev.to/howthtechnologyfactory/handling-a-sanctions-api-timeout-without-guessing-40c9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5k2iciea3qtj6un84t5u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5k2iciea3qtj6un84t5u.png" alt=" " width="799" height="324"&gt;&lt;/a&gt;&lt;br&gt;
When a screening call times out mid-request, there are three real options: fail closed, fail open, or queue and retry. We built explicit configuration for this instead of leaving it to whatever a generic HTTP client happens to default to. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>PDF Conversion Formats Compared: When to Use Image, DOCX, or Flattened Output</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Sun, 16 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/pdf-conversion-formats-compared-when-to-use-image-docx-or-flattened-output-1dd9</link>
      <guid>https://dev.to/howthtechnologyfactory/pdf-conversion-formats-compared-when-to-use-image-docx-or-flattened-output-1dd9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogip2jjl0toe7hncsdfs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogip2jjl0toe7hncsdfs.png" alt=" " width="800" height="438"&gt;&lt;/a&gt;&lt;br&gt;
"Convert this PDF" is an underspecified request until someone answers convert it to what, and the three most common answers, image, DOCX, and flattened PDF, aren't interchangeable. Picking the wrong one doesn't usually fail loudly. It just produces an output that's technically correct and practically useless for whatever the next step in the pipeline actually needed.&lt;/p&gt;

&lt;p&gt;Image output: when the PDF's content doesn't matter, only its appearance&lt;/p&gt;

&lt;p&gt;Rendering a PDF page to PNG or JPEG throws away every notion of text, structure, and searchability, and keeps only exact visual appearance. That's a bad trade for most conversion use cases and exactly the right one for a specific few: thumbnail previews in a document list, a quick visual diff between two versions of a file, or embedding a snapshot of a page somewhere that can't render PDFs natively. If nothing downstream ever needs to search, copy, or edit the content, image output is the cheapest and most reliable option, because there's no layout-fidelity tradeoff to worry about, the image is by definition a pixel-accurate copy.&lt;/p&gt;

&lt;p&gt;result = pdf_api.run({"action": "convert", "file": file, "to": "png", "pages": "1"})&lt;/p&gt;

&lt;p&gt;The failure mode with image output is almost always someone reaching for it out of habit and then getting stuck later needing to search or edit content that got flattened into pixels with no way back.&lt;/p&gt;

&lt;p&gt;DOCX output: when someone needs to actually edit the content&lt;/p&gt;

&lt;p&gt;Converting to DOCX aims at the opposite goal: preserving editable text and structure, at the cost of exact visual fidelity. A PDF built from a complex multi-column layout, custom fonts, or precise absolute positioning will come out of a PDF-to-DOCX conversion looking approximately right, not pixel-identical, because DOCX's layout model doesn't work the same way a PDF's does. For a contract that needs redlining, a report someone needs to update the numbers in, or any document whose whole point is further editing, that tradeoff is obviously worth it. For a signed, finalized document nobody should be able to modify, it's the wrong tool entirely.&lt;/p&gt;

&lt;p&gt;result = pdf_api.run({"action": "convert", "file": file, "to": "docx"})&lt;/p&gt;

&lt;p&gt;The most common mistake here is treating DOCX conversion as a universal "make this PDF editable" button and being surprised when a heavily designed PDF, a marketing one-pager with precise graphic layout, say, comes back looking noticeably different from the source once it's been reflowed into a Word document's layout model.&lt;/p&gt;

&lt;p&gt;Flattened PDF output: when you need a PDF that stays a PDF&lt;/p&gt;

&lt;p&gt;Flattening isn't a format change at all, it's a PDF that goes in and a PDF that comes out, but with form fields, layers, and annotations merged permanently into the page content so nothing about it can be edited or filled in afterward. This is the right output for anything that needs to look and behave exactly like the source but can no longer be modified: a signed contract, an approved invoice, a filled-out form that's now final. Where image output sacrifices content and DOCX sacrifices exact layout, flattening sacrifices nothing about appearance and instead removes editability on purpose, which is the entire point.&lt;/p&gt;

&lt;p&gt;result = pdf_api.run({"action": "flatten", "file": file})&lt;br&gt;
Matching the format to what happens next, not what's easiest to generate&lt;/p&gt;

&lt;p&gt;The recurring mistake across all three isn't picking a bad format in isolation, it's picking a format based on what's simplest to generate right now instead of what the next step in the pipeline actually consumes. A document management system that stores flattened PDFs for the archive but needs image thumbnails for the browse view needs both outputs from the same source file, not one format doing double duty badly. Building the conversion step around "what does the consumer need" rather than "what's the default" avoids having to redo the conversion later once the real requirement surfaces.&lt;/p&gt;

&lt;p&gt;Combining formats instead of picking just one&lt;/p&gt;

&lt;p&gt;Nothing requires committing to a single output per document. A common pattern is generating an image for the list view thumbnail, keeping a flattened PDF as the canonical archived copy, and offering DOCX conversion only on demand when someone explicitly requests an editable copy. Three conversion calls against the same source file, each serving a different downstream need, cost more in API calls than picking one format and hoping it covers every case, but far less than discovering midway through a project that the format chosen up front doesn't support a feature that turned out to matter.&lt;/p&gt;

&lt;p&gt;Getting the source quality right before converting at all&lt;/p&gt;

&lt;p&gt;None of these three outputs can recover information that wasn't legible in the source PDF to begin with. A scanned document with no underlying text layer converts to DOCX with garbled or missing text no matter how good the conversion engine is, because there was no text to preserve in the first place, only pixels. Checking whether a PDF has real text content before promising a clean DOCX conversion saves the awkward conversation of explaining afterward why the "editable" version isn't actually editable.&lt;/p&gt;

&lt;p&gt;Conversion failures don't look the same across formats&lt;/p&gt;

&lt;p&gt;A failed image conversion is usually obvious immediately, a blank or corrupted file that nobody would mistake for success. A failed or degraded DOCX conversion is often much quieter: the file opens fine, looks mostly right, and the missing table or misplaced paragraph only surfaces when someone actually reads it closely later. That difference matters for how much manual review each format needs before an output gets treated as final. Image and flattened PDF outputs can generally be trusted on a status-code check alone, since there isn't much room for a subtly wrong success. DOCX output benefits from at least a spot check on complex source documents, tables, multi-column layouts, embedded images, since "the conversion succeeded" and "the conversion is faithful" aren't quite the same claim for that format the way they are for the other two.&lt;/p&gt;

&lt;p&gt;One API, three outputs, one decision to make correctly&lt;/p&gt;

&lt;p&gt;None of the three formats is a default anyone should reach for without first checking what happens to the output next. All three are handled by the same multi-format PDF conversion API alongside merge, split, compress, rotate, and watermark, priced per successful result, which means the cost of picking the right format for each specific use case rather than one format for everything is a design decision, not an infrastructure one.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What counts as a "successful" sanctions screening API call</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Sun, 16 Aug 2026 23:00:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/what-counts-as-a-successful-sanctions-screening-api-call-24o1</link>
      <guid>https://dev.to/howthtechnologyfactory/what-counts-as-a-successful-sanctions-screening-api-call-24o1</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foqonp190j6wmueqnas24.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foqonp190j6wmueqnas24.png" alt=" " width="800" height="282"&gt;&lt;/a&gt;&lt;br&gt;
A definitive match/no-match, an inconclusive result, and a technical error are three different outcomes, but plenty of integrations just check for HTTP 200 and call it done. Getting billing and audit logs right means treating those three differently from the start. Sanctions Screening API&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Wrapped a PDF API in a CLI Tool So I'd Never Touch the Docs Again</title>
      <dc:creator>Howth Technology Factory</dc:creator>
      <pubDate>Sun, 16 Aug 2026 09:46:00 +0000</pubDate>
      <link>https://dev.to/howthtechnologyfactory/i-wrapped-a-pdf-api-in-a-cli-tool-so-id-never-touch-the-docs-again-5901</link>
      <guid>https://dev.to/howthtechnologyfactory/i-wrapped-a-pdf-api-in-a-cli-tool-so-id-never-touch-the-docs-again-5901</guid>
      <description>&lt;p&gt;Every PDF API I've ever used has the same rough edges the second or third time I need it for something quick: open the docs again, remember the auth header format, remember whether it's files or file_urls, write a throwaway script, run it once, delete it. That workflow is fine the first time. It gets old by the fifth.&lt;/p&gt;

&lt;p&gt;So I wrapped the whole thing in a fifteen-minute CLI tool, and it turned out to be one of the higher-leverage things I've built this year, mostly because of how often "quick PDF task" shows up in a normal week of backend work.&lt;/p&gt;

&lt;p&gt;What it actually needed to do&lt;/p&gt;

&lt;p&gt;Nothing fancy. A thin command-line wrapper around a handful of PDF operations, merge, split, compress, watermark, rotate, convert, that takes file paths as arguments and writes the result to disk. No server, no UI, just pdftk   -o , callable from a terminal, a shell script, or a Makefile target.&lt;/p&gt;

&lt;p&gt;The implementation&lt;/p&gt;

&lt;p&gt;The whole thing is built on Python's argparse, with one subcommand per operation:&lt;/p&gt;

&lt;p&gt;import argparse, sys&lt;br&gt;
import pdf_api&lt;/p&gt;

&lt;p&gt;def main():&lt;br&gt;
    parser = argparse.ArgumentParser(prog="pdftk")&lt;br&gt;
    sub = parser.add_subparsers(dest="action", required=True)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;merge_p = sub.add_parser("merge")
merge_p.add_argument("files", nargs="+")
merge_p.add_argument("-o", "--output", required=True)

compress_p = sub.add_parser("compress")
compress_p.add_argument("file")
compress_p.add_argument("-o", "--output", required=True)

watermark_p = sub.add_parser("watermark")
watermark_p.add_argument("file")
watermark_p.add_argument("--text", required=True)
watermark_p.add_argument("-o", "--output", required=True)

args = parser.parse_args()

if args.action == "merge":
    result = pdf_api.run({"action": "merge", "files": args.files})
elif args.action == "compress":
    result = pdf_api.run({"action": "compress", "file": args.file})
elif args.action == "watermark":
    result = pdf_api.run({"action": "watermark", "file": args.file, "text": args.text})

if result.status != "success":
    print(f"✗ {args.action} failed: {result.status}", file=sys.stderr)
    sys.exit(1)

result.save(args.output)
print(f"✓ {args.action} → {args.output}")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    main()&lt;/p&gt;

&lt;p&gt;That's most of it. Each subcommand maps directly onto one API call, and the whole file is short enough to read top to bottom in under a minute, which matters more than it sounds like it should.&lt;/p&gt;

&lt;p&gt;Why this was worth building&lt;/p&gt;

&lt;p&gt;The honest case for a CLI wrapper isn't that it's technically impressive, it clearly isn't. It's that "quick PDF task" shows up constantly in ordinary backend work, cleaning up a batch of scanned files before a demo, merging a few reports before sending them somewhere, watermarking a draft before a review, and every one of those used to mean either opening a script I'd half-forgotten or writing a new one from scratch. Now it means one command, tab-completed, with output I can immediately check with ls and open.&lt;/p&gt;

&lt;p&gt;There's a second, quieter benefit: once the CLI existed, it became trivial to drop pdftk calls into shell scripts, Makefiles, and pre-commit-style hooks, places where writing a full script every time would never have felt worth it, but running one existing command absolutely does.&lt;/p&gt;

&lt;p&gt;What I'd do differently building it again&lt;/p&gt;

&lt;p&gt;I'd add a --json flag sooner than I did. The human-readable success and failure lines are great for interactive use and terrible for anything that wants to parse the output programmatically, and I ended up needing both within the first week of using it inside other scripts. A --json flag that switches the output to structured status objects would have saved me from grepping stdout, which is exactly the kind of thing that's obvious in hindsight and easy to skip when you're building something "just for yourself" on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;Handling config and auth without making it annoying&lt;/p&gt;

&lt;p&gt;The other thing worth getting right early is where the API key lives. My first version took it as a command-line flag, which is exactly as annoying as it sounds the second time you type pdftk merge a.pdf b.pdf -o c.pdf --key sk_live_xxxxx into a terminal history that other processes on the machine can read. It moved to an environment variable almost immediately, PDFTK_API_KEY, read once at startup, with a clear error message if it's missing rather than a stack trace three calls deep into the API client:&lt;/p&gt;

&lt;p&gt;import os&lt;/p&gt;

&lt;p&gt;def get_api_key():&lt;br&gt;
    key = os.environ.get("PDFTK_API_KEY")&lt;br&gt;
    if not key:&lt;br&gt;
        print("✗ PDFTK_API_KEY is not set. Export it and try again.", file=sys.stderr)&lt;br&gt;
        sys.exit(1)&lt;br&gt;
    return key&lt;/p&gt;

&lt;p&gt;That's a small thing, but it's the difference between a tool that feels safe to hand to a teammate and one that quietly encourages people to paste secrets into shell history. A CLI tool that's going to get used more than once by more than one person is worth treating like a real piece of internal tooling, even when it started as a fifteen-minute Friday project.&lt;/p&gt;

&lt;p&gt;Packaging it so it's actually easy to install&lt;/p&gt;

&lt;p&gt;The last step that made this genuinely sticky on the team was packaging it as an installable command rather than a script people had to remember the path to. A minimal pyproject.toml with an entry point turns python pdftk.py merge ... into just pdftk merge ..., installable with pip install -e . from the repo, or published internally if it needs to reach more than one machine. It's a small amount of extra setup, and it's the difference between a tool a couple of people know about and a tool that shows up in everyone's $PATH without anyone having to explain it twice. Once that happened, I started seeing it show up in other people's shell scripts and Makefiles without me having anything to do with it, which is usually a decent signal that a small internal tool earned its place.&lt;/p&gt;

&lt;p&gt;Where error handling actually needs the most care&lt;/p&gt;

&lt;p&gt;The failure mode that mattered most in practice wasn't a missing file or a bad flag, both of which argparse handles cleanly on its own. It was a batch of files where one file in the middle was corrupted or password-protected, and the CLI needed to say exactly which one, rather than failing the whole batch with a generic error and leaving me to guess. That meant surfacing the API's structured failure reason directly in the CLI's output instead of collapsing every non-success response into the same flat "failed" message, a small design choice that made the tool meaningfully more useful the first time a real, messy batch of files hit it.&lt;/p&gt;

&lt;p&gt;The API underneath it&lt;/p&gt;

&lt;p&gt;None of this would be a fifteen-minute project if the CLI also had to implement merge, split, compress, watermark, rotate, and convert itself. It's a thin wrapper around a PDF API for CLI tools, covering all six operations as simple calls, priced per successful result, which meant the entire build was argument parsing and output formatting, nothing else. If you're reaching for the same API from a terminal more than twice a week, it's worth the fifteen minutes.&lt;/p&gt;

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

</description>
    </item>
  </channel>
</rss>
