Building a Receipt Generation Microservice with Minimal Code

A practical guide to building a lean receipt generation microservice that handles templating, storage, and delivery without turning into a maintenance burden.

pdfreceiptsmicroservicesnode.jsapi-design

Building a Receipt Generation Microservice with Minimal Code

Receipts are one of those deceptively simple artifacts. A checkout completes, an email needs to go out with a PDF attached, and someone downstream (accounting, the customer, a tax authority) needs a stable, printable record. The requirement sounds trivial until you actually build it: HTML-to-PDF rendering, headless browsers, font issues, storage, retries, and versioning all show up quickly.

This post walks through a minimal receipt microservice: one HTTP endpoint, one template, and one dependency for PDF rendering. The goal is a service you can ship in an afternoon and forget about.

The scope

We want a service that:

  1. Accepts a POST /receipts request with order data.
  2. Renders a PDF receipt.
  3. Stores it and returns a URL.
  4. Optionally emails it.

That's it. No admin UI, no template editor, no user accounts. Keeping scope narrow is how these services stay minimal.

Project setup

We'll use Node.js with Fastify because it's fast to boot and has good schema validation. Any HTTP framework works.

bash
mkdir receipts-service && cd receipts-service npm init -y npm install fastify @kamydev/sdk zod

Create server.ts:

ts
import Fastify from "fastify"; import { kamy } from "@kamydev/sdk"; import { z } from "zod"; const app = Fastify({ logger: true }); const ReceiptSchema = z.object({ orderId: z.string(), customer: z.object({ name: z.string(), email: z.string().email(), }), items: z.array(z.object({ description: z.string(), quantity: z.number().int().positive(), unitPrice: z.number(), })), currency: z.string().default("USD"), paidAt: z.string().datetime(), });

The rendering choice

The single biggest decision in a receipt service is how to render the PDF. The usual options:

  • Headless Chromium (Puppeteer, Playwright): Full CSS support but heavy. You're managing a browser process, memory limits, and cold starts.
  • PDF libraries (pdfkit, pdf-lib): Fast, but you're drawing rectangles and positioning text by hand. Painful for anything that needs to look good.
  • Hosted rendering API (Kamy, etc.): You POST data, you get a PDF. No browser to babysit.

For a small service that shouldn't own infrastructure it doesn't need, offloading rendering makes sense. Here's the handler using Kamy's SDK:

ts
app.post("/receipts", async (req, reply) => { const parsed = ReceiptSchema.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ errors: parsed.error.issues }); } const data = parsed.data; const total = data.items.reduce( (sum, i) => sum + i.quantity * i.unitPrice, 0, ); const pdf = await kamy.documents.render({ template: "receipt", data: { orderId: data.orderId, customer: data.customer, items: data.items, currency: data.currency, paidAt: data.paidAt, total, }, fileName: `receipt-${data.orderId}.pdf`, }); return reply.send({ orderId: data.orderId, url: pdf.url, expiresAt: pdf.expiresAt, }); }); app.listen({ port: 3000 });

The receipt template is defined once in the Kamy dashboard (HTML + CSS, with Handlebars-style placeholders). You don't ship the template in your service repo, which means design changes don't require a redeploy.

The template

Templates live outside your code, but they're still your responsibility. A minimal receipt template looks like this:

html
<!DOCTYPE html> <html> <head> <style> body { font-family: system-ui, sans-serif; padding: 40px; } table { width: 100%; border-collapse: collapse; margin-top: 20px; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; } .total { font-weight: bold; text-align: right; margin-top: 20px; } </style> </head> <body> <h1>Receipt #{{orderId}}</h1> <p>Paid by {{customer.name}} on {{paidAt}}</p> <table> <thead> <tr><th>Item</th><th>Qty</th><th>Price</th></tr> </thead> <tbody> {{#each items}} <tr> <td>{{description}}</td> <td>{{quantity}}</td> <td>{{unitPrice}} {{../currency}}</td> </tr> {{/each}} </tbody> </table> <p class="total">Total: {{total}} {{currency}}</p> </body> </html>

Calling the service

From your checkout backend:

http
POST /receipts HTTP/1.1 Content-Type: application/json { "orderId": "ord_9F2A", "customer": { "name": "Ada Lovelace", "email": "[email protected]" }, "items": [ { "description": "Pro Plan (Annual)", "quantity": 1, "unitPrice": 240 } ], "currency": "USD", "paidAt": "2025-01-15T10:00:00Z" }

Response:

json
{ "orderId": "ord_9F2A", "url": "https://cdn.