Building a Receipt Generation Microservice with Minimal Code
Receipts are deceptively simple. A customer buys something, you send them a PDF. But once you start building it, the requirements pile up: consistent branding, tax breakdowns, multi-currency, retries when email bounces, archival for audits, regional compliance. What started as "just generate a PDF" becomes a small but real system.
This post walks through building a receipt microservice that stays small. We'll cover the HTTP surface, template rendering, async processing, and storage — keeping the code count low by offloading PDF rendering to a service designed for it.
What the service actually needs to do
Before writing any code, define the boundaries. A receipt microservice typically needs to:
- Accept a structured receipt payload from upstream services (checkout, billing).
- Render a PDF that matches your brand.
- Store the PDF somewhere durable.
- Return a URL the caller can email or display.
- Optionally queue the work if traffic spikes.
That's it. Anything else — sending email, calculating tax, generating order IDs — belongs to other services.
The API surface
A single endpoint is enough. Keep the input shape stable; you'll regret making it too flexible later.
httpPOST /receipts Content-Type: application/json { "receiptId": "RCPT-2024-00123", "issuedAt": "2024-11-12T14:32:00Z", "customer": { "name": "Ada Lovelace", "email": "[email protected]" }, "items": [ { "description": "Pro Plan (monthly)", "quantity": 1, "unitPrice": 2900 } ], "currency": "USD", "tax": { "rate": 0.08, "amount": 232 }, "total": 3132 }
All money values are in minor units (cents). This is the boring choice and the right one.
Skip writing PDF code yourself
The biggest time sink in this kind of service is the rendering itself. Libraries like Puppeteer work, but you end up maintaining a headless browser, font installation, memory limits, and a queue of zombie Chromium processes. For a microservice that's supposed to stay small, that's the wrong trade.
A document API like Kamy handles the rendering side. You define an HTML/CSS template once, then call an endpoint with data. That removes most of the operational surface from your service.
Here's the rendering call:
tsimport { kamy } from "@kamydev/sdk"; export async function renderReceipt(payload: ReceiptPayload) { const result = await kamy.documents.render({ template: "receipt-v1", data: payload, output: { format: "pdf", store: true }, }); return { url: result.url, expiresAt: result.expiresAt, }; }
The template lives in Kamy, versioned by name. When marketing wants a logo tweak, you push a new template version instead of redeploying the service.
Wiring the HTTP handler
A minimal Fastify handler ties it together:
tsimport Fastify from "fastify"; import { renderReceipt } from "./render"; import { receiptSchema } from "./schema"; const app = Fastify({ logger: true }); app.post("/receipts", async (req, reply) => { const payload = receiptSchema.parse(req.body); const { url, expiresAt } = await renderReceipt(payload); return reply.code(201).send({ receiptId: payload.receiptId, url, expiresAt, }); }); app.listen({ port: 3000 });
Validation up front, render, return. About 15 lines of glue.
Handling spikes with a queue
Synchronous rendering is fine until Black Friday. If your upstream callers can tolerate "we'll email it shortly," put a queue between the request and the render.
BullMQ on Redis is enough:
tsimport { Queue, Worker } from "bullmq"; import { renderReceipt } from "./render"; const connection = { host: "redis", port: 6379 }; export const receiptQueue = new Queue("receipts", { connection }); new Worker( "receipts", async (job) => { const result = await renderReceipt(job.data); await notifyDownstream(job.data.receiptId, result.url); }, { connection, concurrency: 10 } );
The HTTP handler now just enqueues:
tsapp.post("/receipts", async (req, reply) => { const payload = receiptSchema.parse(req.body); await receiptQueue.add("render", payload, { attempts: 5, backoff: { type: "exponential", delay: 2000 }, }); return reply.code(202).send({ receiptId: payload.receiptId, status: "queued" }); });
Retries are now free, and a downstream outage won't drop receipts.
Storage and retention
If Kamy stores the rendered PDF, you get a signed URL back. That's usually enough for the email-the-customer path. For long-term archival — tax authorities often require 7+ years — copy the PDF into your own object storage on success:
tsimport { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({}); export async function archiveReceipt(receiptId: string, sourceUrl: string) { const res = await fetch(sourceUrl); const buf = Buffer.from(await res.arrayBuffer()); await s3.send(new PutObjectCommand({ Bucket: "receipts-archive", Key: `${receiptId}.pdf`, Body: buf, ContentType: "application/pdf", })); }
Run that inside the worker, after rendering succeeds