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.
01What 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?
02Numbering: unique, sequential, never reused
Most tax authorities expect invoice numbers to be unique and to follow a sequence, and an auditor who sees 41, 42, 44 will ask what happened to 43. So the rules are:
- Allocate the number in a database transaction, before calling the API. Never derive it from a timestamp, a random ID, or the API's
pdfName. - Write the invoice row in the same transaction, with status
pending. If the API call fails, the row stays and the next attempt reuses its number. A number is never handed out twice and never silently skipped. - Make issuing idempotent. Key each invoice to the thing it bills (an order ID). A double-click or a retried job finds the existing row and continues it rather than allocating a new number.
- Never delete an issued invoice. If one was wrong, void it and issue a credit note. The gap is then explained by a record, not by an absence.
A per-year series such as INV-2026-000042 keeps numbers short and resets cleanly each January. Here is the store, using SQLite so the example runs anywhere:
mkdir invoicing && cd invoicing
npm init -y && npm pkg set type=module
npm install better-sqlite3
export APIVERVE_API_KEY=YOUR_API_KEYimport Database from 'better-sqlite3';
export const db = new Database('invoices.db');
db.exec(`
CREATE TABLE IF NOT EXISTS counters (series TEXT PRIMARY KEY, next INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS invoices (
number TEXT PRIMARY KEY,
order_id TEXT UNIQUE NOT NULL,
status TEXT NOT NULL, -- pending | issued | void
currency TEXT NOT NULL,
subtotal_cents INTEGER NOT NULL,
discount_cents INTEGER NOT NULL,
tax_rate REAL NOT NULL,
tax_cents INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
payload TEXT NOT NULL, -- the exact JSON sent to the generator
pdf_path TEXT,
pdf_sha256 TEXT,
issued_at TEXT
);
`);
// Returns the existing invoice for this order, or allocates the next number and inserts a pending row.
export const reserve = db.transaction((orderId, build) => {
const existing = db.prepare('SELECT * FROM invoices WHERE order_id = ?').get(orderId);
if (existing) return existing;
const series = `INV-${new Date().getUTCFullYear()}`;
db.prepare('INSERT OR IGNORE INTO counters (series, next) VALUES (?, 1)').run(series);
const { next } = db.prepare('SELECT next FROM counters WHERE series = ?').get(series);
db.prepare('UPDATE counters SET next = next + 1 WHERE series = ?').run(series);
const number = `${series}-${String(next).padStart(6, '0')}`;
const row = { number, order_id: orderId, status: 'pending', ...build(number) };
db.prepare(`INSERT INTO invoices (number, order_id, status, currency, subtotal_cents, discount_cents,
tax_rate, tax_cents, total_cents, payload)
VALUES (@number, @order_id, @status, @currency, @subtotal_cents, @discount_cents,
@tax_rate, @tax_cents, @total_cents, @payload)`).run(row);
return row;
});SQLite serialises writers, so two processes cannot draw the same number. On Postgres or MySQL the same shape works with SELECT ... FOR UPDATE on the counter row, or with a dedicated sequence if you accept that a rolled-back transaction can leave a gap.
03Tax lines you can reproduce
US sales tax by ZIP
The Sales Tax Calculator (2 credits) takes an amount and a zip or state and returns taxRate, taxRatePercent, taxAmount and totalAmount. With only state, you get the state rate: Texas came back as 6.25%. With a ZIP you get the combined local rate: 90210 returned 10.25%. On plans that include it, a breakdown object splits that into state, county, city and special-district parts, which is what you need if you file by jurisdiction.
curl "https://api.apiverve.com/v1/salestaxcalculator?amount=49.97&zip=78701" \
-H "x-api-key: YOUR_API_KEY"Pass taxRatePercent to the invoice generator as salesTax.
Use the ship-to or service location, not your own address, and look the rate up at invoice time, then store the rate on the invoice row. Rates change; an invoice must keep the rate it was issued with. A ZIP code can straddle tax jurisdictions, so treat a ZIP lookup as a good default, not a ruling, and if you sell taxable goods across many states, the question of where you must collect at all (nexus) is one for an accountant, not an API.
Compute the money in cents
Floating-point money produces invoices that are off by a cent, and a cent is enough to fail a reconciliation. Work in integer minor units, apply the discount, then tax the discounted amount, rounding once:
export function totals(items, discountCents, ratePercent) {
const subtotal = items.reduce((sum, it) => sum + Math.round(it.qty * Math.round(it.unit_price * 100)), 0);
const taxable = Math.max(0, subtotal - discountCents);
const tax = Math.round((taxable * ratePercent) / 100);
return { subtotal_cents: subtotal, discount_cents: discountCents, tax_cents: tax, total_cents: taxable + tax };
}
export const money = (cents, currency, locale = 'en-US') =>
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100);from decimal import Decimal, ROUND_HALF_UP
def totals(items, discount_cents, rate_percent):
subtotal = sum(int(Decimal(str(it["qty"])) * Decimal(str(it["unit_price"])) * 100) for it in items)
taxable = max(0, subtotal - discount_cents)
tax = int((Decimal(taxable) * Decimal(str(rate_percent)) / 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
return {"subtotal_cents": subtotal, "discount_cents": discount_cents,
"tax_cents": tax, "total_cents": taxable + tax}
print(totals([{"qty": 3, "description": "Widget", "unit_price": 19.99}], 1000, 8.25))
# {'subtotal_cents': 5997, 'discount_cents': 1000, 'tax_cents': 412, 'total_cents': 5409}That matches the PDF we generated for the same input: tax 4.12, total 54.09. During development, generate a handful of invoices with awkward amounts and compare the PDF's numbers against yours (pdftotext from poppler prints them). If they ever disagree, your stored figures are the ones you must be able to defend, so investigate before going live rather than after.
Intl.NumberFormat is for everything you render yourself: the email, the customer portal, the CSV export. It knows that EUR in de-DE is written 1.062,50 € and that JPY has no minor unit, so don't hand-roll symbols. (The PDF printed two decimals for both USD and EUR in our tests, so check how a zero-decimal currency such as JPY renders before you invoice in it.)
EU VAT
For EU customers, VAT Rates (2 credits) takes a two-letter country and returns rates.standard, rates.reduced and, where they exist, reduced2, superReduced and parking, plus the country's currency and an effectiveFrom date. Germany returned a 19% standard and 7% reduced rate; France returned 20%, with 5.5, 10 and 2.1 as its reduced tiers. Which tier applies depends on what you sell, and B2B sales across borders often carry no VAT at all under reverse charge, so this lookup feeds a decision you make, rather than making it. Looking rates up on a specific past date or by postcode for regional exceptions is a premium feature.
curl "https://api.apiverve.com/v1/vatrates?country=DE" \
-H "x-api-key: YOUR_API_KEY"Store rates.standard and effectiveFrom with the invoice.
Six months after issuing an invoice, the customer's local sales tax rate rises. They ask for a copy of the original invoice. What should your system do?
04Issue 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.
05Sending 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.