Building a Receipt Generation Microservice with Minimal Code

A practical guide to building a small, production-ready receipt generation service that turns transaction data into clean PDF receipts — without owning a rendering stack.

pdfreceiptsmicroservicesnode.jsapi

Building a Receipt Generation Microservice with Minimal Code

Every product that takes money eventually needs to email a receipt. It sounds trivial until you actually build it: you need a template engine, a renderer, fonts, storage, retries, and a way to keep designers from having to file a ticket every time the logo changes. Most teams either over-engineer this or bolt it onto the checkout service where it doesn't belong.

This post walks through building a small, focused receipt microservice. It accepts a transaction payload, generates a PDF, stores it, and returns a URL. The whole thing is a few hundred lines of code because we offload the actual rendering to a PDF API.

The shape of the service

We want a single HTTP endpoint:

http
POST /receipts Content-Type: application/json { "transactionId": "txn_8a2f", "customer": { "name": "Ada Lovelace", "email": "[email protected]" }, "items": [ { "description": "Pro plan (monthly)", "amount": 2900 } ], "currency": "USD", "paidAt": "2025-01-12T10:31:22Z" }

And we want a response like:

json
{ "receiptId": "rcp_01HW...", "url": "https://files.example.com/receipts/rcp_01HW....pdf", "expiresAt": "2025-01-12T11:31:22Z" }

Everything else — template rendering, font embedding, paginating long item lists — is a problem we'd rather not own.

Project setup

A minimal Node service with Fastify:

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

Set up the entry point:

ts
// src/server.ts import Fastify from "fastify"; import { receiptsRoute } from "./routes/receipts.js"; const app = Fastify({ logger: true }); app.register(receiptsRoute); app.listen({ port: 3000, host: "0.0.0.0" });

Validating the input

Receipts are financial documents. A bad payload should fail loudly, not silently produce a broken PDF.

ts
// src/schema.ts import { z } from "zod"; export const ReceiptInput = z.object({ transactionId: z.string().min(1), customer: z.object({ name: z.string().min(1), email: z.string().email(), }), items: z .array( z.object({ description: z.string().min(1), amount: z.number().int().nonnegative(), }) ) .min(1), currency: z.string().length(3), paidAt: z.string().datetime(), }); export type ReceiptInput = z.infer<typeof ReceiptInput>;

The rendering step

Instead of hand-rolling HTML-to-PDF with a headless browser, we'll use Kamy. The receipt template lives in the Kamy dashboard, version-controlled, and the service just passes data to it.

ts
// src/render.ts import { kamy } from "@kamydev/sdk"; import type { ReceiptInput } from "./schema.js"; export async function renderReceipt(input: ReceiptInput) { const total = input.items.reduce((sum, i) => sum + i.amount, 0); const result = await kamy.documents.render({ template: "receipt-v1", data: { transactionId: input.transactionId, customer: input.customer, items: input.items.map((i) => ({ description: i.description, amount: formatMoney(i.amount, input.currency), })), total: formatMoney(total, input.currency), currency: input.currency, paidAt: input.paidAt, }, storage: { retain: "7d" }, }); return { url: result.url, id: result.documentId, expiresAt: result.expiresAt }; } function formatMoney(cents: number, currency: string) { return new Intl.NumberFormat("en-US", { style: "currency", currency, }).format(cents / 100); }

A few things to note:

  • The template receipt-v1 is updated in one place. Bumping it to receipt-v2 is a config change, not a deploy.
  • Amounts are pre-formatted server-side. Templates should not do math.
  • We ask the API to retain the file for 7 days, which is enough for a customer to click the link in their email.

The route handler

ts
// src/routes/receipts.ts import type { FastifyInstance } from "fastify"; import { ReceiptInput } from "../schema.js"; import { renderReceipt } from "../render.js"; export async function receiptsRoute(app: FastifyInstance) { app.post("/receipts", async (req, reply) => { const parsed = ReceiptInput.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: parsed.error.flatten() }); } try { const receipt = await renderReceipt(parsed.data); return reply.code(201).send({ receiptId: receipt.id, url: receipt.url, expiresAt: receipt.expiresAt, }); } catch (err) { req.log.error({ err }, "receipt render failed"); return reply.code(502).send({ error: "render_failed" }); } }); }

That's the entire service. The interesting question now is how it behaves under real load.

Making it production-worthy

Three things matter once this is in production: idempotency, retries, and observability.

Idempotency. If your checkout service retries the call after a network blip, you don't want two receipts for the same transaction.