The Invoice Generator turns a JSON body into a finished PDF in one call. That part takes ten minutes to wire up. The rest of this lesson is about the three things that make invoicing hard regardless of how the PDF is drawn: every invoice needs a unique number from an unbroken sequence, the tax on it has to be right and reproducible, and the copy you sent has to exist, unchanged, when someone asks for it years later.
The design principle that holds it together: your database is the invoice; the PDF is a rendering of it. You decide the number, compute the totals and store them before you ever call the API. The API draws what you decided.
What the generator does, and what it assumes
It is a POST endpoint costing 20 credits per call. Required fields are the invoice number, both parties' name, street, city, state and ZIP, and an items array of { qty, description, unit_price }. Optional fields include date, dueDate, paymentTerms, job, discount, salesTax, currency and logoURL. The response is small: pdfName, expires (milliseconds) and a signed downloadURL. When we called it, the link expired about 48 hours later.
Notice what is not in the response: the subtotal, tax or total. The API computes them to draw the PDF but does not hand them back. That alone is a good reason to compute them yourself.
We generated a few invoices and read the PDFs back to pin down the behaviour you cannot see in the reference:
| Input | What the PDF showed |
|---|---|
discount: 10 on a 59.97 subtotal | Discount: 10.00. It is a flat amount, not a percentage |
salesTax: 8.25 | Sales Tax: 4.12, which is 8.25% of 49.97, the subtotal after the discount, rounded to the cent |
paymentTerms omitted | Due on Receipt |
dueDate omitted | The invoice date |
job omitted | Service Rendered |
currency: "EUR" or "USD" | Plain numbers with two decimals; the currency does not appear on the page |
Two practical consequences. First, if you bill in more than one currency, the reader of the PDF cannot tell which, so say it in a field that is printed, for example paymentTerms: "Net 30 - amounts in EUR". Second, the address fields are US-shaped: from_state and to_state allow at most two characters, and the ZIP fields need at least five. That fits US billing well. For EU customers who need VAT numbers, a VAT breakdown or reverse-charge wording on the page, build the invoice as HTML and render it with HTML to PDF instead; the Generate PDFs course covers that route.
curl -X POST "https://api.apiverve.com/v1/invoicegenerator" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"invoiceNumber":"INV-2026-000042","date":"2026-10-22","dueDate":"2026-11-21","paymentTerms":"Net 30","from_name":"Northwind Studio","from_street":"12 Harbor Rd","from_city":"Austin","from_state":"TX","from_zip":"78701","to_name":"Contoso Ltd","to_street":"400 Pine St","to_city":"Dallas","to_state":"TX","to_zip":"75201","items":[{"qty":10,"description":"Design hours","unit_price":85},{"qty":1,"description":"Hosting setup","unit_price":150}],"salesTax":6.25}'Open downloadURL: subtotal 1000.00, tax 62.50, total 1062.50. None of those numbers are in the JSON.
An invoice has items totalling 200.00, discount: 20 and salesTax: 10. What total does the generated PDF show?
Answer the check above to continue.
Issue the invoice and store the PDF
Now the pieces join. issue() reserves the number and stores the totals, calls the generator with exactly the payload it saved, downloads the PDF straight away, writes it under a path derived from the invoice number, records a SHA-256 of the bytes, and only then marks the invoice issued.
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { db, reserve } from './store.js';
import { totals, money } from './money.js';
const SELLER = { from_name: 'Northwind Studio', from_street: '12 Harbor Rd', from_city: 'Austin', from_state: 'TX', from_zip: '78701' };
async function api(path, init) {
const res = await fetch(`https://api.apiverve.com/v1/${path}`, {
...init,
headers: { 'x-api-key': process.env.APIVERVE_API_KEY, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(30000),
});
const json = await res.json().catch(() => null);
if (json?.status !== 'ok') throw new Error(json?.error || `HTTP ${res.status}`);
return json.data;
}
export async function issue(order) {
const tax = await api(`salestaxcalculator?amount=1&zip=${encodeURIComponent(order.customer.zip)}`);
const inv = reserve(order.id, (number) => {
const t = totals(order.items, order.discountCents ?? 0, tax.taxRatePercent);
const today = new Date().toISOString().slice(0, 10);
const due = new Date(Date.now() + 30 * 864e5).toISOString().slice(0, 10);
const payload = {
invoiceNumber: number, date: today, dueDate: due, paymentTerms: `Net 30 - amounts in ${order.currency}`,
...SELLER,
to_name: order.customer.name, to_street: order.customer.street, to_city: order.customer.city,
to_state: order.customer.state, to_zip: order.customer.zip,
items: order.items, discount: (order.discountCents ?? 0) / 100, salesTax: tax.taxRatePercent,
currency: order.currency,
};
return { ...t, currency: order.currency, tax_rate: tax.taxRatePercent, payload: JSON.stringify(payload) };
});
if (inv.status !== 'pending') return inv; // already issued (or void): never regenerate
const pdf = await api('invoicegenerator', { method: 'POST', body: inv.payload });
const res = await fetch(pdf.downloadURL, { signal: AbortSignal.timeout(30000) }); // storage link: no API key
if (!res.ok) throw new Error(`PDF download failed: HTTP ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
const dir = `invoices/${inv.number.slice(4, 8)}`;
await mkdir(dir, { recursive: true });
const path = `${dir}/${inv.number}.pdf`;
await writeFile(path, bytes, { flag: 'wx' }).catch((e) => { if (e.code !== 'EEXIST') throw e; });
const sha = createHash('sha256').update(bytes).digest('hex');
db.prepare(`UPDATE invoices SET status = 'issued', pdf_path = ?, pdf_sha256 = ?, issued_at = ?
WHERE number = ? AND status = 'pending'`).run(path, sha, new Date().toISOString(), inv.number);
console.log(`${inv.number}: ${money(inv.total_cents, inv.currency)} -> ${path}`);
return db.prepare('SELECT * FROM invoices WHERE number = ?').get(inv.number);
}
await issue({
id: 'order-1001', currency: 'USD', discountCents: 1000,
customer: { name: 'Contoso Ltd', street: '400 Pine St', city: 'Austin', state: 'TX', zip: '78701' },
items: [{ qty: 3, description: 'Widget', unit_price: 19.99 }],
});Run node issue.js. Run it again: the second run finds order-1001 already issued and spends nothing. Kill it between the API call and the update, and the next run reuses the same number from the pending row.
A few judgment calls in that code are worth naming:
- The sales tax lookup uses
amount=1. You only need the rate; the amounts come from your own cents arithmetic. That keeps one source of truth for the numbers. - The payload is stored before the call. If the PDF ever needs to be explained, you have the exact input that produced it.
- Logos are fetched by the API.
logoURLmust be a public URL, under 2 MB, that answers within 5 seconds, or the PDF gets a placeholder. Host the logo somewhere fast and stable, not behind your app's login. - Customer data leaves your server. Names and addresses go to the generator and the PDF sits on a signed link until it expires. Mention invoice rendering among your processors in your privacy notice, and don't put anything on an invoice that does not need to be there.
Sending it
Email the customer a link to an authenticated page on your own site that serves the stored file, or attach the stored file itself. Do not email the API's downloadURL: it expires within about two days, and the customer who opens the email next week gets an error instead of an invoice. The pipeline lesson builds exactly this kind of link.
Invoices are mostly numbers. Reports need pictures of numbers, which is next: Charts for reports.