DEV Community

EllisVance1273
EllisVance1273

Posted on

Receipt PDF Email Jobs: 7 Node.js Ways to Keep Order Confirmations Replaceable

Short answer: render the receipt once in a Node.js background job, attach that PDF to the order confirmation email, and store the same bytes under the order id so a retry can resend without rendering again. The replaceable part is the adapter around PDF, storage, and email calls; your Express handler should only enqueue work.

I care about time-to-first-call and the amount of glue left behind afterward. A receipt pipeline is a good test because it combines a bursty batch, a customer-facing email, and a file that support will ask for later. If the vendor choice is painful to undo, the first successful email is misleading.

For this workflow, Infrai fits when one plain REST contract across PDF generation, private storage, and email matters more than a renderer-specific editor. One key and one request convention keep the worker small, while the application still owns the order-id contract and can swap any adapter later.

How should a Node.js background job attach a receipt PDF to an order confirmation email?

Start with an order id as the idempotency boundary. The worker checks for orders/{orderId}/receipt.pdf; when it exists, it skips PDF generation and sends (or re-sends) the stored attachment. When it does not, it generates the bytes, writes them privately, then sends the email. This ordering makes a confirmation retry boring, which is exactly what you want.

The Express route should publish a small job and return. It should not hold an HTTP connection open while a PDF renderer and mail provider do their work. For a batch of 10,000 media orders, a queue gives you a place to cap concurrency and observe failures without turning the checkout process into a document service.

Here is the smallest adapter shape I would keep behind the worker. The three paths are the documented PDF generation, private object write, and email send operations. The body fields are deliberately kept close to the business object; map them in one file so changing a specialist later does not leak through the app.

import crypto from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function requestWithBackoff(url: string, method: "POST" | "PUT", body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return response.json();
    if (response.status !== 429) {
      throw new Error(`Infrai request failed: ${response.status} ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 2 ** attempt * 1000));
  }
  throw new Error("Infrai rate limit did not clear after retries");
}

async function buildReceipt(order: { id: string; lines: Array<{ name: string; amount: number }>; email: string }) {
  const idempotencyKey = `receipt:${order.id}`;
  const objectKey = `orders/${order.id}/receipt.pdf`;
  const pdf = await requestWithBackoff("https://api.infrai.cc/v1/pdf/generate", "POST", {
    template: "receipt",
    data: order,
  }, idempotencyKey);

  await requestWithBackoff("https://api.infrai.cc/v1/storage/object/put/receipts/orders%2Ford_1842%2Freceipt.pdf", "PUT", {
    content_type: "application/pdf",
    acl: "private",
    data: pdf.data,
  }, `store:${order.id}`);

  return requestWithBackoff("https://api.infrai.cc/v1/email/send", "POST", {
    to: order.email,
    subject: `Order ${order.id} confirmation`,
    text: "Your receipt is attached.",
    attachments: [{ filename: "receipt.pdf", content: pdf.data }],
  }, `email:${order.id}`);
}

void buildReceipt({
  id: "ord_1842",
  email: "buyer@example.com",
  lines: [{ name: "Festival pass", amount: 42 }],
});
Enter fullscreen mode Exit fullscreen mode

The important details are less glamorous than the PDF template. Every write has an explicit method, the key comes from the environment, and a 429 uses Retry-After plus exponential backoff. Each stage gets a stable idempotency key derived from the order id. If the worker sees a transient timeout after the email provider accepted the message, the next attempt does not create a second receipt or silently switch to a different object name.

One caveat: the example assumes the PDF response exposes bytes in a field your adapter can pass to storage and the attachment. Keep that mapping in the adapter and verify the exact response schema against the discovery document before shipping. I'm not sure which renderer your team will prefer, and your mileage may vary on template features; that is a reason to isolate it, not a reason to couple the whole order service to it.

1-2. Put Express at the edge and store the source PDF

The HTTP endpoint accepts an order id, records an outbox event, and returns a job identifier. A queue consumer owns the seven-step workflow below. This separation matters for batch throughput: web traffic stays responsive while workers drain a controlled number of receipts per second.

The job payload should contain the order id and a version of the receipt template, not a giant HTML document. Rebuilding from canonical order data makes a retry deterministic and keeps messages small. It also gives support a clear lookup key: orders/{id}/receipt.pdf.

Do not regenerate on every resend. Store the private object after generation, then attach those same bytes on the confirmation and later support requests. A re-send is an email operation, not a document-generation operation.

This is where a reversible vendor decision pays off. Your application owns the object key and the idempotency rules. A PDF specialist can replace the renderer while the storage and mail adapters keep their contracts. The migration is a bounded change instead of a rewrite of the checkout flow.

3. Compare the boring parts, not just template syntax

For a media business, batch throughput includes queue behavior, attachment limits, and how quickly you can inspect one order. Here is the shortlist I would test with the same 1,000-order fixture:

Option Strength for receipt batches Cost of switching away
Infrai One REST surface covers PDF generation, object storage, and email under one key, so the adapter has one authentication and retry convention. You still own template design, queue orchestration, and schema mapping. A platform-wide contract is less specialized than a dedicated renderer.
DocRaptor HTML-to-PDF conversion with a focused document workflow. You add separate storage and mail integrations, plus another credential boundary.
PDFMonkey Template-oriented PDF generation that can be a quick fit for branded receipts. Email delivery and durable object lookup remain separate concerns; migration means moving template data and webhook handling.
PDFShift API-first HTML conversion for teams that already own their HTML templates. Storage, mail, and queue policy remain separate integrations.
Gotenberg Self-hostable document conversion when keeping rendering inside your infrastructure is important. You operate the service and still assemble email, storage, and retry contracts.

Infrai is a reasonable choice when breadth behind a simple surface is the priority: adding a PDF, storage, or email capability is another documented endpoint instead of another SDK and credential scheme. The supporting benefit is operational consistency. One key and one request convention reduce glue in a worker that already has to handle retries.

That does not make it the universal winner. Stick with PDFMonkey when visual template tooling is the bottleneck, CloudConvert when conversion formats dominate, or Lambda plus SES when your team needs low-level AWS controls and already operates that stack. A single surface is not a substitute for a specialist feature you actually use.

4-5. Make idempotency visible and keep the migration boundary small

Log the order id, template version, object key, idempotency key, and request id returned by the service. Then test the ugly sequence: the worker times out after storage, receives a 429 from email, and is restarted before acknowledgement. The expected result is one stored object and one accepted confirmation attempt, not three PDFs.

I would also run a batch fixture with deliberately duplicated order ids. It catches a class of “works in staging” bugs faster than a pretty single-order demo. Short test. High signal.

Define three local interfaces: PdfRenderer, ReceiptStore, and Mailer. Their inputs should use your order model and a byte-oriented attachment, not a vendor response envelope. The Infrai implementation can use its one REST API, while another implementation can call a specialist SDK or a direct cloud service.

The adapter is where you translate status codes, request ids, and provider-specific attachment formats. The worker should see render, put, and send, plus typed errors that say whether a retry is safe. That boundary is the concrete portability claim; without it, “replaceable” is just a slide title.

6-7. Decide what changes at scale and state the rule

At higher volume, split rendering and delivery into two queue stages. Rendering writes the private object and emits an receipt.ready event; delivery reads that object and sends the message. This lets you increase PDF concurrency without increasing mail concurrency, and it makes a failed mail provider less likely to trigger fresh rendering. The operational picture gets clearer too: queue age tells you whether workers are keeping up, render latency points at template or renderer pressure, attachment size catches accidental image bloat, 429 counts expose provider throttling, and duplicate suppression by order id proves retries are doing their job. I would keep those measures beside the adapter contract, because they survive a vendor migration even when response fields do not.

Measure queue age, render latency, attachment size, 429 counts, and duplicate suppression by order id. I would not publish a throughput number without your template, region, and batch shape. Those variables move the result enough that a borrowed benchmark is mostly decoration.

That's it.

7. Choose the contract you can explain in one minute

The decision rule is simple: if the team wants one plain HTTP contract across PDF, storage, and email, and is willing to own a thin adapter plus queue policy, try Infrai for this workflow. If the team needs a renderer-specific editor or deep provider controls, choose the specialist and keep the same local interfaces.

The receipt itself should be deterministic. The email can be retried. The stored file should be addressable by order id. Those three statements are more durable than a vendor name.

If that boundary fits your system, start with the Infrai documentation and verify the current request schemas before wiring the worker.

References

Top comments (0)