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 ◀────┘──▶ emailThree APIs, with their costs from the catalog:
| Step | API | Credits per call |
|---|---|---|
| Chart of revenue by region | chartgenerator | 10 |
| QR code linking to the online report | qrcodegenerator | 10 |
| Render the HTML to a PDF | htmltopdf | 20 |
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));
}
}import os
import random
import time
import requests
COST = {"chartgenerator": 10, "qrcodegenerator": 10, "htmltopdf": 20}
ledger = []
class OutOfCredits(Exception):
pass
def backoff(attempt):
return min(8.0, 0.5 * 2 ** attempt) + random.random() * 0.25
def call(api, body, attempts=3, timeout=30):
for attempt in range(1, attempts + 1):
try:
res = requests.post(f"https://api.apiverve.com/v1/{api}", json=body, timeout=timeout,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]})
except requests.RequestException as err:
if attempt == attempts:
raise RuntimeError(f"{api}: {err} after {attempt} attempts")
time.sleep(backoff(attempt))
continue
remaining = res.headers.get("x-api-remaining-credits")
try:
data = res.json()
except ValueError:
data = {}
if res.ok and data.get("status") == "ok":
ledger.append({"api": api, "credits": COST.get(api),
"remaining": int(remaining) if remaining is not None else None})
return data["data"]
if res.status_code == 429 and remaining == "0":
raise OutOfCredits("Monthly credits exhausted")
retryable = res.status_code == 429 or res.status_code >= 500
if not retryable or attempt == attempts:
raise RuntimeError(f"{api}: {data.get('error') or 'HTTP %d' % res.status_code}")
retry_after = float(res.headers.get("retry-after") or 0)
time.sleep(retry_after if retry_after > 0 else 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.
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.
Step 3: serve the stored PDF behind a signed link
The email carries a link to your server, not to the API's storage. The API's link expires in hours; yours lasts as long as you decide, and you can revoke every outstanding link by rotating REPORT_SECRET.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
app.get('/reports/:file', (req, res) => {
const { file } = req.params;
if (!/^\d{4}-\d{2}\.pdf$/.test(file)) return res.status(404).end();
const exp = Number(req.query.exp);
const expected = createHmac('sha256', process.env.REPORT_SECRET).update(`/reports/${file}:${exp}`).digest('base64url');
const given = String(req.query.sig ?? '');
const valid = given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!valid || !(exp > Date.now())) return res.status(403).send('This link has expired or is invalid.');
res.set('Cache-Control', 'private, no-store');
res.sendFile(file, { root: 'reports' }, (err) => err && !res.headersSent && res.status(404).end());
});
app.listen(3000, () => console.log('Serving reports on http://localhost:3000'));Start it with node serve.js and open the link from the email or the console. Change one character of sig and you get a 403; wait out exp and you get the same.
A link rather than an attachment is a deliberate trade. Attachments get forwarded, sit in mailboxes forever and cannot be withdrawn; a link can expire, can be revoked, and you can log who opened it. The cost is that the reader needs to be online. For a report that contains customer or financial data, the link is usually the better default. For an invoice the customer must keep, attach it.
Step 4: account for every credit
The ledger lines and the spent total in state.json give you a per-run, per-period cost from the catalog prices. Two habits turn that into real control.
Compare the meter with the account. Each ledger entry stores x-api-remaining-credits from the response. The drop across one run should match the ledger's total. If it drops more, something else is spending on the same key, or retries of timed-out calls were billed. Either way you want to know, and a separate key per pipeline makes the attribution exact.
Budget before you run, not after. Before a bulk job (say, a report for each of 200 region managers) multiply it out: 200 runs at 40 credits is 8,000 credits. Check that against the remaining balance from any recent response, and refuse to start if a full run would not fit. A half-finished batch of reports is worse than one that waits a day.
Things this pipeline deliberately does not do: it does not retry a 400 (the input is wrong and will stay wrong), it does not catch OutOfCredits anywhere but the top, and it does not try to make email exactly-once. If the process dies between sendMail returning and state.json being saved, a rerun sends the email a second time. Making that impossible needs a transactional outbox; for a monthly report, an occasional duplicate is the cheaper failure.
Where to go from here
- Invoices through the same pipeline. Swap the HTML step for the Invoice Generator from lesson 2, keep the numbering store, and send the stored PDF as an attachment.
- Labels in bulk. The batch pool from lesson 1 slots in as a step that produces a sheet of barcodes per shipment.
- Read back what you printed. Run a generated QR code through the QR Code Reader as a pipeline self-test before a large send.
- Schedule it. A cron entry such as
0 6 1 * *runningnode pipeline.js $(date -d 'last month' +%Y-%m)produces last month's report on the first of each month, and the state file makes a manual rerun safe.
The making requests and batch requests docs cover timeouts and bulk calls across the whole catalog.
That completes the Automate Documents course and the last of the three courses in the Document Automation track. When you are ready, take the exam on the Document Automation Developer certification page.