Building a Receipt Generation Microservice with Minimal Code
Receipt generation sounds trivial until you ship it. You start with a quick HTML-to-PDF script, then requirements creep in: multi-currency formatting, tax breakdowns, QR codes, email delivery, retention policies, and the occasional "can we add our logo?" from marketing. Before you know it, there's a service team maintaining a Puppeteer cluster.
This post walks through building a lean receipt microservice that handles the real-world concerns — idempotency, retries, storage — without turning into a side project. We'll use Kamy for rendering so we don't maintain a browser farm, but the architecture applies to any template-based PDF backend.
The scope
The service exposes one endpoint:
httpPOST /receipts Content-Type: application/json Idempotency-Key: <uuid> { "orderId": "ord_8812", "customer": { "name": "Jane Doe", "email": "[email protected]" }, "items": [ { "sku": "SKU-1", "name": "Keyboard", "qty": 1, "price": 89.00 } ], "currency": "USD", "total": 89.00 }
It returns a URL to the generated PDF and (optionally) emails it. That's it. No admin UI, no template editor — templates live in version control alongside the service.
Project layout
Keep it boring:
txtreceipts-service/ ├── src/ │ ├── server.ts │ ├── render.ts │ └── store.ts ├── templates/ │ └── receipt.html └── package.json
Install the essentials:
bashnpm install fastify @kamydev/sdk @aws-sdk/client-s3 zod
The template
Templates are plain HTML with a templating syntax. If you're using Kamy, it uses Handlebars-style tags. Keep it simple — print styles only, no JS:
html<!doctype html> <html> <head> <style> body { font-family: -apple-system, sans-serif; padding: 40px; } .total { font-weight: bold; border-top: 1px solid #000; } table { width: 100%; border-collapse: collapse; } td { padding: 6px 0; } </style> </head> <body> <h1>Receipt #{{orderId}}</h1> <p>{{customer.name}} — {{customer.email}}</p> <table> {{#each items}} <tr> <td>{{name}}</td> <td>{{qty}}</td> <td>{{currency ../currency price}}</td> </tr> {{/each}} <tr class="total"> <td colspan="2">Total</td> <td>{{currency currency total}}</td> </tr> </table> </body> </html>
Upload once from your CI pipeline:
bashnpx kamy templates push ./templates/receipt.html --name receipt
The render module
A single function wraps the PDF call. This is where swapping providers happens if you ever need to:
tsimport { kamy } from "@kamydev/sdk"; export async function renderReceipt(data: unknown) { const pdf = await kamy.documents.render({ template: "receipt", data, options: { format: "A4", margin: "20mm" }, }); return { url: pdf.url, // signed URL, valid 1h bytes: pdf.sizeBytes, id: pdf.documentId, }; }
Keep rendering synchronous for small documents. If receipts grow (hundreds of line items, batched statements), move to a queue.
The HTTP layer
Validation up front, idempotency check, render, persist, respond:
tsimport Fastify from "fastify"; import { z } from "zod"; import { renderReceipt } from "./render.js"; import { getByKey, saveReceipt } from "./store.js"; const schema = z.object({ orderId: z.string(), customer: z.object({ name: z.string(), email: z.string().email() }), items: z.array(z.object({ sku: z.string(), name: z.string(), qty: z.number().int().positive(), price: z.number().nonnegative(), })), currency: z.string().length(3), total: z.number().nonnegative(), }); const app = Fastify({ logger: true }); app.post("/receipts", async (req, reply) => { const key = req.headers["idempotency-key"] as string | undefined; if (!key) return reply.code(400).send({ error: "Idempotency-Key required" }); const existing = await getByKey(key); if (existing) return existing; const body = schema.parse(req.body); const result = await renderReceipt(body); const record = { idempotencyKey: key, orderId: body.orderId, url: result.url, documentId: result.id, createdAt: new Date().toISOString(), }; await saveReceipt(record); return record; }); app.listen({ port: 3000, host: "0.0.0.0" });
A few things worth noting:
- Idempotency is non-negotiable. Payment flows retry. Without a key, you'll generate duplicate receipts for every network blip.
- Validate before rendering. PDF generation is the expensive step; don't pay for it when the input is garbage.
- Return the stored URL on replay. Don't re-render for the same key.
Storage and retention
The signed URL from the render call is short-lived — convenient for immediate delivery but not for a receipts archive. For long-term storage, download once and push to object storage:
tsimport { S3Client, PutObject