Building a Receipt Generation Microservice with Minimal Code

A step-by-step guide to building a lean receipt generation microservice in Node.js that handles templating, PDF rendering, and storage without the usual boilerplate.

pdfreceiptsnode.jsmicroservicesapi

Building a Receipt Generation Microservice with Minimal Code

Every e-commerce platform, SaaS billing system, and POS backend eventually needs the same thing: a reliable way to turn structured data into a PDF receipt. The naive approach — spinning up headless Chromium, wrangling CSS print styles, and babysitting a rendering queue — turns a one-day task into a multi-week project.

This post walks through building a small receipt microservice that stays under 100 lines of code, exposes a clean HTTP interface, and offloads the hard parts (rendering, storage, retries) to a dedicated service.

What we're building

A single-endpoint service that accepts a JSON payload describing a receipt and returns a URL to a generated PDF. Something like this:

http
POST /receipts Content-Type: application/json { "orderId": "ORD-7781", "customer": { "name": "Jane Smith", "email": "[email protected]" }, "items": [ { "sku": "SKU-1", "name": "Mechanical Keyboard", "qty": 1, "price": 129.00 }, { "sku": "SKU-2", "name": "USB-C Cable", "qty": 2, "price": 12.50 } ], "currency": "USD" }

Response:

json
{ "receiptId": "rcpt_01HX...", "url": "https://cdn.kamy.dev/receipts/rcpt_01HX....pdf", "expiresAt": "2025-02-14T10:00:00Z" }

Project setup

A minimal Node.js project with Fastify for the HTTP layer and the Kamy SDK for PDF generation:

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

Set up environment variables for your Kamy API key:

bash
export KAMY_API_KEY=sk_live_xxx

The template

Rather than generating HTML in code, define the receipt template once in Kamy's dashboard (or via the templates API). The template uses Handlebars-style placeholders and knows nothing about your business logic — it just renders whatever structured data it's given.

A template registration looks like this:

ts
import { kamy } from "@kamydev/sdk"; await kamy.templates.create({ name: "receipt-v1", html: ` <h1>Receipt #{{orderId}}</h1> <p>{{customer.name}} — {{customer.email}}</p> <table> {{#each items}} <tr> <td>{{name}}</td> <td>{{qty}}</td> <td>{{price}}</td> </tr> {{/each}} </table> <p><strong>Total: {{total}} {{currency}}</strong></p> `, pageSize: "A5", });

Doing this once means your service code never touches HTML or CSS.

The service

Here's the entire microservice:

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({ sku: z.string(), name: z.string(), qty: z.number().int().positive(), price: z.number().nonnegative(), }) ), currency: z.string().length(3), }); app.post("/receipts", async (req, reply) => { const parsed = ReceiptSchema.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: parsed.error.flatten() }); } const data = parsed.data; const total = data.items.reduce((sum, i) => sum + i.qty * i.price, 0); const result = await kamy.documents.render({ template: "receipt-v1", data: { ...data, total: total.toFixed(2) }, storage: { persist: true, ttlHours: 24 * 30 }, }); return { receiptId: result.id, url: result.url, expiresAt: result.expiresAt, }; }); app.listen({ port: 3000, host: "0.0.0.0" });

That's the whole service. Validation, rendering, storage, and signed URL generation — in roughly 40 lines.

Handling async workloads

For high-volume systems (think thousands of receipts per minute), synchronous rendering is a bottleneck. Switch to async mode and provide a webhook:

ts
const job = await kamy.documents.renderAsync({ template: "receipt-v1", data: { ...data, total: total.toFixed(2) }, webhook: { url: "https://api.example.com/webhooks/receipt-ready", secret: process.env.WEBHOOK_SECRET, }, }); return { receiptId: job.id, status: "queued" };

Your webhook handler then stores the final URL against the receipt ID:

ts
app.post("/webhooks/receipt-ready", async (req, reply) => { const sig = req.headers["x-kamy-signature"]; if (!kamy.webhooks.verify(req.rawBody, sig, process.env.WEBHOOK_SECRET)) { return reply.code(401).send(); } const { id, url } = req.body; await db.receipts.update(id, { url, status: "ready" }); return reply.code(200).send(); });

Dockerizing

A single-file service deserves a single-file Dockerfile:

txt
F