Building a Receipt Generation Microservice with Minimal Code

A practical walkthrough for building a small, production-ready receipt generation service in Node.js — without wrestling with headless browsers or PDF layout engines.

pdfreceiptsnode.jsmicroservicesapi

Building a Receipt Generation Microservice with Minimal Code

Every checkout flow, subscription renewal, and refund needs a receipt. On paper, it's a solved problem: render some HTML, print to PDF, email it. In practice, most teams end up maintaining a fragile pipeline of Puppeteer instances, Chromium binaries, S3 uploads, and font rendering bugs that only appear on Linux.

This post walks through building a small receipt microservice that stays under 100 lines of application code by offloading the PDF rendering itself. We'll use Node.js and Express, but the pattern applies to any stack.

What the service needs to do

The requirements are simple:

  1. Accept a POST with order data (line items, totals, customer info).
  2. Generate a branded PDF receipt.
  3. Return a URL the frontend or email service can use.
  4. Be idempotent, so retries don't produce duplicate receipts.

Everything else — templating, layout, storage — is undifferentiated work.

Project setup

Start with a minimal Express service:

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

We'll use zod for request validation and the Kamy SDK for the PDF rendering. Kamy handles the template rendering and hosting; we just send it JSON.

The receipt template

Rather than shipping HTML from our service, we define the template once in Kamy and reference it by name. The template lives in your Kamy dashboard and looks roughly like this:

html
<div class="receipt"> <header> <h1>{{ merchant.name }}</h1> <p>Receipt #{{ receiptNumber }}</p> </header> <table> {{#each lineItems}} <tr> <td>{{ description }}</td> <td>{{ quantity }}</td> <td>{{ formatCurrency amount }}</td> </tr> {{/each}} </table> <p class="total">Total: {{ formatCurrency total }}</p> </div>

Keeping the template out of code means designers and product folks can iterate on layout without a deploy.

The service

Here's the core service — a single route:

ts
import express from "express"; import { z } from "zod"; import { kamy } from "@kamydev/sdk"; const app = express(); app.use(express.json()); const ReceiptSchema = z.object({ orderId: z.string(), customer: z.object({ name: z.string(), email: z.string().email(), }), lineItems: z.array( z.object({ description: z.string(), quantity: z.number().int().positive(), amount: z.number(), }) ), total: z.number(), currency: z.string().length(3), }); app.post("/receipts", async (req, res) => { const parsed = ReceiptSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ errors: parsed.error.issues }); } const data = parsed.data; try { const pdf = await kamy.documents.render({ template: "receipt", idempotencyKey: `receipt-${data.orderId}`, data: { receiptNumber: data.orderId, merchant: { name: "Acme Coffee Co." }, ...data, }, }); res.status(201).json({ url: pdf.url, expiresAt: pdf.expiresAt, }); } catch (err) { console.error(err); res.status(502).json({ error: "render_failed" }); } }); app.listen(3000);

That's the whole service. A few things worth highlighting:

  • Idempotency: passing idempotencyKey means retries for the same orderId return the same PDF instead of generating a new one. This matters when a payment webhook is delivered twice.
  • No storage code: Kamy returns a signed URL to the rendered PDF. If you need long-term storage, you can either extend the URL's TTL or download and push to your own S3 bucket.
  • Validation up front: zod catches malformed requests before we spend time (and API credits) on a render.

Calling it from your app

Once the service is running, generating a receipt from your checkout worker is a single HTTP call:

ts
const res = await fetch("http://receipts.internal/receipts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orderId: order.id, customer: { name: order.customerName, email: order.customerEmail }, lineItems: order.items, total: order.total, currency: "USD", }), }); const { url } = await res.json(); await emailQueue.enqueue({ to: order.customerEmail, receiptUrl: url });

Handling failure modes

A PDF microservice sits in a critical path, so a few production concerns:

Timeouts. Set an aggressive timeout on the outbound call. Receipts should render in well under a second; anything slower is likely a transient issue worth retrying.

ts
const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const res = await fetch(url, { signal: controller.signal, ... }); clearTimeout(timeout);

Retries with backoff. Because the render call is idempotent, safe to retry. Wrap it in your existing job runner.

Async mode for bulk jobs. If you're generating end-of-month statements for thousands of customers at once, use an async job pattern rather than blocking on each render. Push receipt jobs into a queue and let workers call the render API in parallel.

What you didn't have to build

Compared to the DI