Build a document pipeline from data to emailed PDF

One job, five steps, three APIs: data becomes a chart and a table, the table becomes a PDF, the PDF is stored and a link goes out by email. Then you make it survive failure and account for what it spent.

Every previous lesson made one kind of document. A real reporting job chains several: the monthly sales report for each region manager needs a chart, a table, a PDF that holds both, a place to keep that PDF, and an email telling someone it exists. Chaining is where the new failure modes live. A step can fail after an earlier one already spent credits. A rerun can send the same email twice. A temporary link created in step two can expire before step four uses it.

You will build the monthly report pipeline end to end, then harden it against exactly those problems.

The shape of the job

orders.json ──▶ aggregate ──▶ Chart Generator ──┐
                                                 ├─▶ HTML ──▶ HTML to PDF ──▶ reports/2026-09.pdf
              online URL ──▶ QR Code Generator ─┘                                    │
                                                                    signed link ◀────┘──▶ email

Three APIs, with their costs from the catalog:

StepAPICredits per call
Chart of revenue by regionchartgenerator10
QR code linking to the online reportqrcodegenerator10
Render the HTML to a PDFhtmltopdf20

So a clean run costs 40 credits. Twelve monthly reports a year is 480, which is nothing, until a bug reruns the job every five minutes for a weekend. The design goals follow from that: every step is skipped if its output already exists, only failures that can succeed are retried, and the job records what it spent.

Set up the project:

mkdir report-pipeline && cd report-pipeline
npm init -y && npm pkg set type=module
npm install express@4 nodemailer
mkdir -p lib runs reports
export APIVERVE_API_KEY=YOUR_API_KEY
export REPORT_SECRET="a-long-random-string"
export PUBLIC_URL="http://localhost:3000"

Step 1: one client that retries and keeps a ledger

All three APIs go through one function. It does four jobs: sends the request with a timeout, retries rate limits, server errors and network failures with backoff, stops dead when the account is out of credits, and writes a ledger line for every call that succeeded.

const COST = { chartgenerator: 10, qrcodegenerator: 10, htmltopdf: 20 }; // tokenUse per call
export const ledger = [];
export class OutOfCredits extends Error {}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt) => Math.min(8000, 500 * 2 ** attempt) + Math.random() * 250;

export async function call(api, body, { attempts = 3, timeout = 30000 } = {}) {
  for (let attempt = 1; ; attempt++) {
    let res, json;
    try {
      res = await fetch(`https://api.apiverve.com/v1/${api}`, {
        method: 'POST',
        headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(timeout),
      });
      json = await res.json().catch(() => null);
    } catch (err) {
      if (attempt >= attempts) throw new Error(`${api}: ${err.message} after ${attempt} attempts`);
      await sleep(backoff(attempt));
      continue;
    }
    const remaining = res.headers.get('x-api-remaining-credits');
    if (res.ok && json?.status === 'ok') {
      ledger.push({ api, credits: COST[api] ?? null, remaining: remaining === null ? null : Number(remaining) });
      return json.data;
    }
    if (res.status === 429 && remaining === '0') throw new OutOfCredits('Monthly credits exhausted');
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= attempts) throw new Error(`${api}: ${json?.error || `HTTP ${res.status}`}`);
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

export async function download(url, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(30000) }); // signed storage link: no API key
      if (res.ok) return Buffer.from(await res.arrayBuffer());
      if (res.status < 500 || attempt >= attempts) throw new Error(`download HTTP ${res.status}`);
    } catch (err) {
      if (attempt >= attempts) throw err;
    }
    await sleep(backoff(attempt));
  }
}

Three decisions in there deserve a second look.

Downloads retry separately from generation. If the PDF was rendered but the download of its downloadURL failed, the right move is to retry the download, which is free, not the render, which costs 20 credits again. Folding both into one retry loop is the most common way pipelines quietly double their bill.

Attempts are capped low. Failed calls are not billed, so retrying a 500 is cheap. A timeout is different: the server may have finished the work and charged for it after your client gave up. Three attempts bounds what an unlucky timeout can cost.

Out of credits is its own exception. It is not an error in a step; it is a reason to stop the whole run and alert someone. Nothing downstream should catch it and carry on.

Check yourself

The HTML to PDF call succeeds, but downloading its downloadURL fails with a network reset. What should the pipeline do?

Answer the check above to continue.

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales