Building a Receipt Generation Microservice with Minimal Code

A practical walkthrough for building a small, reliable receipt generation service that your checkout flow, billing system, or POS can call without hand-rolling PDF rendering.

pdfreceiptsmicroservicesnode.jsapi design

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:

http
POST /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:

  1. Use a headless browser (Puppeteer/Playwright) — flexible but heavy, fragile in containers, and you own font management, page sizing, and Chrome upgrades.
  2. Use a PDF DSL library (PDFKit, pdf-lib) — fast, but you write layout code by hand.
  3. 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.

ts
import 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