Building a Receipt Generation Microservice with Minimal Code

A pragmatic walkthrough of building a lean receipt generation microservice in Node.js, with templating, async delivery, and PDF rendering handled by an external API.

pdfreceiptsmicroservicesnode.jstypescriptapi

Building a Receipt Generation Microservice with Minimal Code

Receipts look trivial until you actually have to generate them. Then you discover the long tail: unicode currency symbols, multi-page overflow, tax breakdowns, right-to-left locales, email attachments, retries, S3 uploads, and the inevitable "can we add our logo to it?" ticket. This post walks through building a small, production-shaped receipt service in Node.js that handles all of that without turning into a 10,000-line beast.

The goal: a single HTTP endpoint that accepts a transaction payload, generates a PDF receipt, stores it, and returns a URL. We'll keep the business logic in our service and offload the rendering to a PDF API.

The architecture

Three moving parts:

  1. An HTTP server (Fastify) that receives receipt requests.
  2. A rendering layer that turns structured data into a PDF.
  3. A storage/delivery step that persists the file and optionally emails it.

Rather than wiring up Puppeteer, Chromium binaries, font files, and a Dockerfile that balloons past 1 GB, we'll call out to Kamy for the rendering. That's the one piece that's genuinely annoying to run yourself.

Project setup

bash
mkdir receipt-service && cd receipt-service npm init -y npm install fastify @kamydev/sdk zod npm install -D typescript tsx @types/node npx tsc --init

Set your API key as an environment variable:

bash
export KAMY_API_KEY=sk_live_...

Defining the receipt payload

Validate input at the edge. If a downstream system sends you a malformed receipt, you want to fail at the HTTP boundary, not inside the renderer.

ts
import { z } from "zod"; export const ReceiptSchema = z.object({ receiptNumber: z.string(), issuedAt: z.string().datetime(), customer: z.object({ name: z.string(), email: z.string().email().optional(), }), items: z.array( z.object({ description: z.string(), quantity: z.number().int().positive(), unitPrice: z.number().nonnegative(), }) ).min(1), currency: z.string().length(3), taxRate: z.number().min(0).max(1).default(0), }); export type Receipt = z.infer<typeof ReceiptSchema>;

The rendering layer

Kamy templates are HTML with a templating syntax, uploaded once and referenced by name. Here's a minimal receipt template you might create in the dashboard or via API:

html
<!doctype html> <html> <body style="font-family: system-ui; padding: 32px;"> <h1>Receipt {{receiptNumber}}</h1> <p>Issued: {{formatDate issuedAt}}</p> <p>Billed to: {{customer.name}}</p> <table width="100%"> {{#each items}} <tr> <td>{{description}}</td> <td>{{quantity}}</td> <td>{{money unitPrice ../currency}}</td> </tr> {{/each}} </table> <p><strong>Total: {{money total currency}}</strong></p> </body> </html>

The rendering code stays short:

ts
import { Kamy } from "@kamydev/sdk"; import type { Receipt } from "./schema"; const kamy = new Kamy({ apiKey: process.env.KAMY_API_KEY! }); export async function renderReceipt(receipt: Receipt) { const subtotal = receipt.items.reduce( (sum, i) => sum + i.quantity * i.unitPrice, 0 ); const total = subtotal * (1 + receipt.taxRate); return kamy.documents.render({ template: "receipt", data: { ...receipt, subtotal, total }, output: { format: "pdf", store: true }, }); }

Because store: true is set, Kamy returns a signed URL instead of raw bytes. That saves you from streaming megabytes through your own service and uploading them to S3 yourself.

The HTTP endpoint

ts
import Fastify from "fastify"; import { ReceiptSchema } from "./schema"; import { renderReceipt } from "./render"; const app = Fastify({ logger: true }); 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 result = await renderReceipt(parsed.data); return reply.code(201).send({ receiptNumber: parsed.data.receiptNumber, url: result.url, expiresAt: result.expiresAt, }); }); app.listen({ port: 3000, host: "0.0.0.0" });

That's the whole service. Call it:

bash
curl -X POST http://localhost:3000/receipts \ -H "Content-Type: application/json" \ -d '{ "receiptNumber": "RCPT-001", "issuedAt": "2025-01-15T10:30:00Z", "customer": { "name": "Acme Corp", "email": "[email protected]" }, "items": [ { "description": "Pro plan — Jan", "quantity": 1, "unitPrice": 49.00 } ], "currency": "USD", "taxRate": 0.08 }'

Response:

json
{ "receiptNumber": "RCPT-001", "url": "https://cdn.kamy.dev/d/