Building a Receipt Generation Microservice with Minimal Code
Receipts look simple until you actually have to generate them. You start with a one-page HTML mockup, then someone asks for tax breakdowns, multi-currency totals, refunds, partial returns, QR codes, and email-friendly attachments. Before long, the "small receipt feature" is a service of its own.
This post walks through building that service properly — as a thin, isolated microservice — without writing your own PDF rendering layer.
Why a separate service
Receipt generation has a few traits that make it a good candidate for isolation:
- It's CPU-bursty (rendering) but most of the time idle.
- It's called from many places: checkout, refund flows, admin tools, cron jobs.
- It needs strong reproducibility: the same input must always produce the same document.
- It often needs to send/store the file separately from the main DB.
A small service with one job — "take receipt data, return a PDF URL" — keeps that complexity off your main API.
The contract
Start with the API your consumers will call. Keep it small:
httpPOST /receipts Content-Type: application/json { "receiptNumber": "R-2025-000142", "issuedAt": "2025-01-14T10:24:00Z", "customer": { "name": "Ada Lovelace", "email": "[email protected]" }, "items": [ { "name": "Annual subscription", "quantity": 1, "unitPrice": 9900 }, { "name": "Setup fee", "quantity": 1, "unitPrice": 2500 } ], "currency": "USD", "taxRate": 0.08 }
And the response:
json{ "id": "rcpt_01HZX...", "url": "https://files.kamy.dev/...signed...", "expiresAt": "2025-01-14T11:24:00Z" }
That's the entire public surface. Internally you'll do three things: validate input, render a PDF, and persist the result.
The rendering problem
This is where most teams burn a week. You can:
- Use a headless browser (Puppeteer/Playwright) — flexible but heavy, fragile in containers, and you own font management, page sizing, and Chrome upgrades.
- Use a PDF DSL library (PDFKit, pdf-lib) — fast, but you write layout code by hand.
- Call a PDF generation API — you hand over a template + JSON and get back a file.
For a receipt service, option 3 is usually the right call: receipts are template-driven, the volume can spike, and you don't want to babysit a Chrome process. This is what something like Kamy is built for — you upload an HTML/Handlebars template once, then render it with data over HTTP.
The service
Here's a complete Fastify implementation. It's intentionally small.
tsimport Fastify from "fastify"; import { kamy } from "@kamydev/sdk"; import { z } from "zod"; const app = Fastify({ logger: true }); const ReceiptInput = z.object({ receiptNumber: z.string(), issuedAt: z.string().datetime(), customer: z.object({ name: z.string(), email: z.string().email() }), items: z.array( z.object({ name: z.string(), quantity: z.number().int().positive(), unitPrice: z.number().int().nonnegative(), }) ), currency: z.string().length(3), taxRate: z.number().min(0).max(1), }); app.post("/receipts", async (req, reply) => { const data = ReceiptInput.parse(req.body); const subtotal = data.items.reduce( (s, i) => s + i.quantity * i.unitPrice, 0 ); const tax = Math.round(subtotal * data.taxRate); const total = subtotal + tax; const pdf = await kamy.documents.render({ template: "receipt-v1", data: { ...data, subtotal, tax, total }, idempotencyKey: data.receiptNumber, }); return reply.send({ id: pdf.id, url: pdf.url, expiresAt: pdf.expiresAt, }); }); app.listen({ port: 3000, host: "0.0.0.0" });
A few things worth noting:
- Validation up front. Zod (or your tool of choice) means a bad payload fails fast with a 400, not deep inside a renderer.
- Compute totals server-side. Never trust client-supplied totals on a financial document.
- Idempotency key = receipt number. Reissuing the same receipt should return the same PDF, not generate a duplicate.
The template
Templates live with your service repo as plain HTML. You only need one, and you can iterate on it without redeploying the service:
html<!doctype html> <html> <body> <h1>Receipt {{receiptNumber}}</h1> <p>Issued: {{issuedAt}}</p> <p>Billed to: {{customer.name}}</p> <table> {{#each items}} <tr> <td>{{name}}</td> <td>{{quantity}}</td> <td>{{formatMoney unitPrice ../currency}}</td> </tr> {{/each}} </table> <p>Subtotal: {{formatMoney subtotal currency}}</p> <p>Tax: {{formatMoney tax currency}}</p> <p><strong>Total: {{formatMoney total currency}}</strong></p> </body> </html>
Whatever rendering backend you pick, keep the template and the service code in the same repo so they version together.
Deployment notes
A few things that b