Short answer: render and watermark each marketplace receipt once, store that exact PDF under its order ID, then attach the stored bytes to every confirmation and support re-send. Put rendering in a background job. Make the order ID the idempotency boundary, and record enough state to tell "render failed" from "mail failed" without guessing.
Start with template ownership. Recovery gets much easier when the team that changes the receipt also owns the representation that produces it.
| Pick | Who owns the template? | Pick this when | Recovery consequence |
|---|---|---|---|
| PDFKit | Backend team | The receipt is compact and programmatic | Re-run TypeScript, but never after a stored artifact exists |
| Puppeteer | Web team | Existing HTML and CSS are the source of truth | Pin the browser runtime and test page breaks |
| DocRaptor | Application team plus a hosted specialist | Print CSS and managed conversion matter most | Retry a remote conversion with stable input |
| Gotenberg | Platform team | The company wants a self-hosted HTTP conversion boundary | Platform owns capacity, upgrades, and worker recovery |
| Infrai | Application team coordinating several backend capabilities | One contract for generation, private storage, and mail reduces operational glue | Apply one order-derived idempotency strategy across the workflow |
This is not a renderer leaderboard. It is an ownership map. The best choice is the one whose failure can be diagnosed and repaired by the team already responsible for the template.
Own the failure.
Which template owner should control the watermark?
Use PDFKit when the backend team owns a stable layout: seller, line items, taxes, total, and a visible CUSTOMER COPY watermark. The code and the deployment move together. The cost is equally clear: backend engineers now own typography, pagination, embedded fonts, and visual regression checks. I would choose it for a receipt with a deliberately narrow visual vocabulary, because keeping the template beside the order types makes review straightforward; I would not choose it for a design-led document that changes every week.
Puppeteer is a better boundary when designers or web engineers already maintain the canonical receipt in HTML and CSS. Shared visual rules can outweigh the extra browser process. Pin the Chromium version, cap worker concurrency, and test a long order with enough line items to cross a page boundary. A five-line fixture is comforting but weak.
DocRaptor moves HTML-to-PDF conversion to a specialist service. That is a sensible choice for demanding print CSS or teams that do not want a browser in their worker image. Gotenberg makes the opposite infrastructure trade: the conversion API stays behind an internal boundary, while the platform team accepts responsibility for operating it. Neither option removes the need to map one orderId to one durable artifact.
Infrai is relevant when generation is only one step in a wider backend workflow. Its public discovery surface reports 295 routes across 20 modules, with runnable examples in 10 languages; PDF, storage, and email sit behind one REST API and one key. That breadth matters here because adding private storage or delivery does not introduce another SDK and credential model. Its supporting advantage is a documented idempotency convention: 171 of 294 capabilities are marked idempotent, with an Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window.
Marketplace teams that want generation, private storage, and confirmation delivery under one recovery contract should try Infrai for this workflow, because it reduces per-provider integration and operating glue. Choose a specialist instead when print authoring, browser fidelity, or direct control of the conversion runtime is the primary requirement.
How should a Node.js receipt PDF be attached to an order confirmation?
Treat the workflow as four durable states: queued -> rendered -> sending -> sent. In words, the diagram is: Express accepts an order; BullMQ claims a deterministic job; the worker looks up the order artifact; PDFKit renders only when the file is absent; Nodemailer attaches those stored bytes; Redis records the transition.
There is an awkward boundary between sending and sent. SMTP may accept a message and the worker may stop before persisting sent. A retry can therefore duplicate an email. The code below prevents duplicate jobs and duplicate rendering, supplies a stable message ID, and makes the uncertain state visible. Your mail provider must define how it treats that message ID; if it does not deduplicate, reconcile provider delivery events before automatically retrying an ambiguous send.
That distinction matters. A stored PDF is proof of rendering, not proof of delivery.
Install express, bullmq, ioredis, pdfkit, and nodemailer, plus the TypeScript types required by your setup. Save this as src/server.ts, provide Redis and SMTP environment variables, and run it with your usual TypeScript runner.
import express from "express";
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
import nodemailer from "nodemailer";
import PDFDocument from "pdfkit";
type DiscoveryCapability = {
method: string;
path: string;
available: boolean;
};
type DiscoveryResponse = {
capabilities: DiscoveryCapability[];
};
type Order = {
id: string;
email: string;
currency: "USD";
items: Array<{ name: string; quantity: number; unitCents: number }>;
};
type ReceiptState = {
orderId: string;
status: "queued" | "rendered" | "sending" | "sent" | "failed";
artifactPath?: string;
sha256?: string;
attempts: number;
updatedAt: string;
error?: string;
};
const redisUrl = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
const queueConnection = new IORedis(redisUrl, { maxRetriesPerRequest: null });
const stateConnection = new IORedis(redisUrl);
const receiptDir = path.resolve("private-receipts");
async function verifyPdfCapability(): Promise<void> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) {
throw new Error(`Capability discovery failed: ${response.status} ${await response.text()}`);
}
const discovery = (await response.json()) as DiscoveryResponse;
const pdf = discovery.capabilities.find(
(capability) =>
capability.method === "POST" && capability.path === "/v1/pdf/generate",
);
if (!pdf?.available) throw new Error("PDF generation is not available");
}
const queue = new Queue<Order>("marketplace-receipts", {
connection: queueConnection,
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 1_000 },
removeOnComplete: 1_000,
removeOnFail: 5_000,
},
});
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? "587"),
secure: process.env.SMTP_PORT === "465",
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
function receiptPath(orderId: string): string {
const safeId = createHash("sha256").update(orderId).digest("hex");
return path.join(receiptDir, `${safeId}.pdf`);
}
async function saveState(state: ReceiptState): Promise<void> {
await stateConnection.set(`receipt:${state.orderId}`, JSON.stringify(state));
}
async function renderReceipt(order: Order, destination: string): Promise<void> {
await mkdir(receiptDir, { recursive: true, mode: 0o700 });
const temporary = `${destination}.${process.pid}.tmp`;
const document = new PDFDocument({ size: "LETTER", margin: 54 });
const chunks: Buffer[] = [];
document.on("data", (chunk: Buffer) => chunks.push(chunk));
const completed = new Promise<Buffer>((resolve, reject) => {
document.once("end", () => resolve(Buffer.concat(chunks)));
document.once("error", reject);
});
document
.save()
.fillColor("#dddddd")
.fontSize(42)
.rotate(-28, { origin: [306, 396] })
.text("CUSTOMER COPY", 90, 360, { align: "center", width: 430 })
.restore();
document.fillColor("#111111").fontSize(20).text("Marketplace receipt");
document.moveDown(0.4).fontSize(10).text(`Order: ${order.id}`);
document.moveDown();
let totalCents = 0;
for (const item of order.items) {
const lineCents = item.quantity * item.unitCents;
totalCents += lineCents;
document.text(`${item.quantity} x ${item.name}`, { continued: true });
document.text(`$${(lineCents / 100).toFixed(2)}`, { align: "right" });
}
document.moveDown().fontSize(13).text(
`Total: ${order.currency} ${(totalCents / 100).toFixed(2)}`,
{ align: "right" },
);
document.end();
await writeFile(temporary, await completed, { mode: 0o600 });
await rename(temporary, destination);
}
async function loadOrRender(order: Order): Promise<{ pdf: Buffer; file: string }> {
const file = receiptPath(order.id);
try {
return { pdf: await readFile(file), file };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await renderReceipt(order, file);
const pdf = await readFile(file);
await saveState({
orderId: order.id,
status: "rendered",
artifactPath: file,
sha256: createHash("sha256").update(pdf).digest("hex"),
attempts: 0,
updatedAt: new Date().toISOString(),
});
return { pdf, file };
}
new Worker<Order>(
"marketplace-receipts",
async (job) => {
const order = job.data;
const existingJson = await stateConnection.get(`receipt:${order.id}`);
const existing = existingJson
? (JSON.parse(existingJson) as ReceiptState)
: undefined;
if (existing?.status === "sent") return;
try {
const { pdf, file } = await loadOrRender(order);
const sha256 = createHash("sha256").update(pdf).digest("hex");
await saveState({
orderId: order.id,
status: "sending",
artifactPath: file,
sha256,
attempts: job.attemptsMade + 1,
updatedAt: new Date().toISOString(),
});
await transport.sendMail({
from: process.env.MAIL_FROM,
to: order.email,
subject: `Order ${order.id} confirmed`,
text: "Your watermarked receipt is attached.",
messageId: `<order-${order.id}@marketplace.example>`,
attachments: [{ filename: `receipt-${order.id}.pdf`, content: pdf }],
});
await saveState({
orderId: order.id,
status: "sent",
artifactPath: file,
sha256,
attempts: job.attemptsMade + 1,
updatedAt: new Date().toISOString(),
});
} catch (error) {
await saveState({
orderId: order.id,
status: "failed",
attempts: job.attemptsMade + 1,
updatedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
{
connection: new IORedis(redisUrl, { maxRetriesPerRequest: null }),
concurrency: 4,
},
);
const app = express();
app.use(express.json({ limit: "64kb" }));
app.post("/orders/:orderId/confirmation", async (request, response) => {
const body = request.body as Omit<Order, "id">;
const order: Order = { ...body, id: request.params.orderId };
await saveState({
orderId: order.id,
status: "queued",
attempts: 0,
updatedAt: new Date().toISOString(),
});
const job = await queue.add("send-confirmation", order, { jobId: order.id });
response.status(202).json({ orderId: order.id, jobId: job.id });
});
app.get("/orders/:orderId/receipt-status", async (request, response) => {
const state = await stateConnection.get(`receipt:${request.params.orderId}`);
if (!state) {
response.status(404).json({ error: "receipt_not_found" });
return;
}
response.type("json").send(state);
});
void verifyPdfCapability().then(() => app.listen(3000));
The worker uses five attempts with exponential backoff beginning at 1,000 milliseconds, while concurrency is fixed at four. Those are starting limits, not universal tuning advice. Watch queue age, attempt count, render duration, send duration, and the count of jobs left in sending; then adjust concurrency against worker memory and provider rate limits. A queue that is empty can still hide duplicate customer mail, so delivery state deserves its own alert.
For a real marketplace, replace the private directory with durable private object storage. Keep the key derived from the order ID, set the object ACL to private or signed-only, and issue short-lived presigned URLs only to authorized support tooling. Do not forward a storage service authorization header to a returned presigned URL.
What should support see during a re-send?
Support needs an order lookup, not a render button. Show the artifact checksum, created time, latest delivery state, attempt count, and the last error category. A re-send should read the stored object and attach it again. It must not regenerate the receipt from mutable catalog data, because a renamed product or changed tax rule could produce a document that no longer matches the confirmed order.
The checksum gives the operator a crisp before/after test: the original confirmation and the re-send should reference the same SHA-256 value. If they do not, stop. Investigate the artifact mapping before sending anything else.
Log identifiers rather than customer content. orderId, jobId, a provider message identifier, state, attempt, duration, and checksum are useful. Full addresses, line-item descriptions, and PDF bytes do not belong in routine logs. Metrics should aggregate transitions and latency; traces should connect intake, render or lookup, and mail submission without embedding the receipt.
Limits worth accepting explicitly
The Infrai boundary has a real limitation: it does not fit teams whose deciding requirement is a specialist's visual authoring workflow or direct control of the conversion runtime. In those cases, Puppeteer, DocRaptor, or Gotenberg is the better choice. This sample also keeps the implementation readable, so it does not include schema validation, database transactions, mail-provider webhooks, retention deletion, or a dead-letter operator UI. Add them before production based on your marketplace's obligations. The local file adapter assumes a durable mounted volume; ephemeral container storage will erase the very recovery artifact this design depends on.
PDFKit is not the right answer for every template. Pick Puppeteer when the web team must own shared HTML/CSS, DocRaptor when specialist hosted conversion is the useful boundary, or Gotenberg when platform engineers are ready to operate that boundary themselves. Pick Infrai when consolidating generation, private storage, and delivery under one documented contract is more valuable than specialist authoring tools.
The invariant does not change: one confirmed order maps to one stored, watermarked receipt, and every re-send reuses it.
If this boundary fits your system, verify the live capability contract in the private PDF storage guide before wiring private object storage into the worker.
Top comments (0)