Sole engineer · 2026 · live
Whipdoc
A metered PDF compression API with the SaaS around it: teams, billing, keys, quotas.
- Next.js 16
- React 19
- TypeScript
- PostgreSQL
- Drizzle
- NextAuth v5
- jose
- Stripe 18
- Vercel KV
- Resend
- Sentry
- Vercel
The problem
PDF compression is a thing every document workflow eventually needs and almost nobody wants to own. The good tool for it, qpdf, is a native binary — which means either running servers to host it or accepting that a lossy consumer web app is going to touch your documents.
What I built
A metered REST API that losslessly compresses PDFs, wrapped in the full SaaS shell around it: accounts, teams, subscriptions, API keys with usage analytics, and Stripe billing. A public, IP-throttled endpoint powers the homepage, so a visitor can compress a file and see the result before there is any signup in the way.
Next.js 16 and React 19 in TypeScript on Vercel, PostgreSQL through Drizzle,
dual authentication — NextAuth v5 beta over a hand-rolled jose HS256 sliding
session — Stripe 18 for billing, Vercel KV for the API key cache, rate limits,
and webhook idempotency, Resend for mail, Sentry for errors.
Decisions
WASM instead of a native qpdf binary. The native build is faster and it is what everyone reaches for. Compiling qpdf to WebAssembly means the entire product — marketing site, dashboard, API, billing webhooks, compression engine — deploys as a single Vercel project with no second piece of infrastructure, no container registry, and no separate thing to keep patched and paid for. Given up: native speed, and any ability to stream. The WASM module works on a whole buffer, so a large document is a large allocation inside a function, and there is no path where bytes flow out while more are still coming in. I traded throughput for the entire operational surface of a second deployment target, knowingly.
A SHA-256 lookup column sitting beside the bcrypt hash. API keys are stored bcrypt-hashed, which is correct and also useless for lookup — with only a bcrypt hash, validating a presented key means scanning every active key in the table and running bcrypt against each one, so authentication cost grows with customer count on the hottest path in the product. So each key also carries a deterministic SHA-256 of itself as an indexed column: find the row in one indexed read, then verify with bcrypt exactly once. The cost is stated plainly — that column is an unsalted hash of a credential living in the database. It is a fast, offline-guessable handle for anyone who reads the table, and it exists because O(1) key validation was worth more than removing that particular exposure on top of a high-entropy random key.
Fail-open on KV, everywhere. When the cache layer is unavailable, requests proceed rather than being refused. This is availability chosen over enforcement, deliberately and in every place KV is consulted — a dependency that can take the product down is a worse product than one that lets a request through.
Hard part
Running qpdf as WASM inside a serverless bundle, where nothing about module resolution works the way the toolchain assumes.
The Emscripten build locates its .wasm file at runtime, relative to the
path of its own JavaScript. That single fact breaks two systems at once. The
bundler wants to inline and rewrite the module, which moves the JavaScript
away from the binary it is going to go looking for — so the package has to be
marked external and left alone. And because the lookup happens through
require.resolve at runtime rather than a static import, Vercel's file
tracer cannot see the .wasm as a dependency at all, so it is simply not
included in the deployed function. Both failures appear only after deploy;
locally, everything resolves. The fix was to hand-pin the binary into the
function bundle, per route, so the file is physically present next to the
JavaScript that will ask for it.
The compression pipeline needed the same distrust of defaults. qpdf's return
code 3 means success-with-warnings, and treating it as failure — which is
what a plain non-zero check does — rejects a large share of real-world PDFs
that compressed perfectly well. So rc 3 is success. The pipeline then re-runs
aggressively with --optimize-images, but keeps that lossy output only if
it actually beats the lossless result, falls back to a pdf-lib structural
re-save when qpdf cannot produce anything better, and enforces one
unconditional rule at the end: never return bytes larger than the input. A
compression API that hands back a bigger file has failed at the only thing it
promised, and every path in the pipeline is checked against that.
Input handling assumes hostility. Uploads are sniffed for the real %PDF-
magic rather than trusting a client-declared MIME type. The URL-fetch mode —
give us a link, we compress what is behind it — is a server-side request
forgery primitive by construction, so it carries a real guard: resolve the
hostname first, reject RFC1918, loopback, and link-local addresses including
169.254.169.254, and set redirect: 'error' so a 302 to an internal
address cannot launder a check that already passed.
Outcome
Live at whipdoc.com, running the full loop end to end — a stranger compresses a file on the homepage without an account, a customer's key authenticates in one indexed read, and the same billing loop carries them from that first request through a metered subscription.