Generate invoice PDFs from JSON in Node.js
Your app already holds the invoice as JSON, with a client, a list of items, and a total. This guide turns that JSON into a branded PDF with plain Node.js and the built-in fetch. You copy one invoice template, send the data with one request, wait for the job, and save the file. There is no SDK to install and no browser to run on your server.
What you need
- Node.js 18 or newer, which includes
fetch. - A thirds.ai account with an API key. The free plan gives you 25 credits a month, and one finished PDF costs one credit.
- A copy of the Client invoice template. Open it in the gallery, choose Copy to my account, and read the template ID from Templates. The ID starts with
tpl_.
Keep the key in an environment variable such as THIRDS_API_KEY. Never put it in a file that you commit.
The invoice data
The Client invoice template reads twelve fields, and its schema requires every one of them. Send the same names in your JSON. The template formats each amount with the currency filter, so 2400 prints as $2,400.00, and it formats issued_on with the date filter.
| Field | Type |
|---|---|
business_name | string, 1 to 80 characters |
business_email | |
client_name | string, 1 to 80 characters |
client_email | |
invoice_number | string, 1 to 30 characters |
issued_on | date as YYYY-MM-DD |
items | 1 to 12 objects with description and amount |
subtotal | number |
tax | number |
total | number |
payment_terms | string, 1 to 120 characters |
payment_note | string, 1 to 180 characters |
Work out the subtotal, tax, and total in your app and send the results. The template prints numbers and does not add them up. This is the JSON that the rest of the guide sends.
{
"business_name": "Northline Studio",
"business_email": "hello@northline.example",
"client_name": "Fieldnote Labs",
"client_email": "accounts@fieldnote.example",
"invoice_number": "NS-1042",
"issued_on": "2026-09-03",
"items": [
{ "description": "Product launch design", "amount": 2400 },
{ "description": "Campaign image set", "amount": 850 }
],
"subtotal": 3250,
"tax": 650,
"total": 3900,
"payment_terms": "Due within 14 days",
"payment_note": "Please use NS-1042 as the payment reference."
}
Send the request with fetch
Save this as invoice.mjs. It posts the template ID, the version, and the data to POST /v1/pdf. The Idempotency-Key header is the invoice number, so a retry after a dropped connection returns the same job instead of a second PDF and a second credit.
const API = "https://thirds.ai";
const headers = {
Authorization: `Bearer ${process.env.THIRDS_API_KEY}`,
"Content-Type": "application/json",
};
export async function createInvoicePdf(invoice) {
const response = await fetch(`${API}/v1/pdf`, {
method: "POST",
headers: {
...headers,
"Idempotency-Key": `invoice-${invoice.invoice_number}`,
},
body: JSON.stringify({
template_id: process.env.THIRDS_INVOICE_TEMPLATE,
version: 1,
data: invoice,
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${response.status} ${error.code}`);
}
return response.json();
}
The request body is the same one that curl would send.
{
"template_id": "tpl_00000000000000000000000000000000",
"version": 1,
"data": {
"business_name": "Northline Studio",
"business_email": "hello@northline.example",
"client_name": "Fieldnote Labs",
"client_email": "accounts@fieldnote.example",
"invoice_number": "NS-1042",
"issued_on": "2026-09-03",
"items": [
{ "description": "Product launch design", "amount": 2400 },
{ "description": "Campaign image set", "amount": 850 }
],
"subtotal": 3250,
"tax": 650,
"total": 3900,
"payment_terms": "Due within 14 days",
"payment_note": "Please use NS-1042 as the payment reference."
}
}
Send version: 1 in any request that you may retry. If you leave the version out, thirds.ai picks the newest version when it prepares the job, so a later publish can change which design a retry uses. The page size comes from the template, which is A4.
Poll the job
The create request waits briefly for the result. A 200 response means the job has finished, and a 202 response means it is still queued or running. Both carry an id and a status. Keep the ID, then read GET /v1/pdf/{id} with a delay between checks until the status is succeeded, failed, or cancelled.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function waitForJob(job, deadlineMs = 120_000) {
const stop = Date.now() + deadlineMs;
while (["queued", "running"].includes(job.status)) {
if (Date.now() > stop)
throw new Error(`job ${job.id} is still ${job.status}`);
await sleep(1500);
const response = await fetch(`${API}/v1/pdf/${job.id}`, { headers });
job = await response.json();
}
if (job.status !== "succeeded") {
throw new Error(`${job.status}: ${job.error.category} ${job.error.code}`);
}
return job;
}
Set a deadline that fits your app. If the deadline ends first, save the job ID beside the invoice and check it later. The job never restarts on its own, and you never pay for a failed one.
Download and check the file
A finished job holds download.url. It is a relative path such as /v1/downloads/..., so add https://thirds.ai in front of it. The signed link lasts 15 minutes and needs no API key, so anyone who has it can fetch the file. The file itself stays in Renders for 30 days. Save it to your own storage, and compare its size and SHA-256 with artifact.byte_size and artifact.sha256 before you mark the invoice as sent.
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
export async function saveInvoicePdf(job, path) {
const response = await fetch(`${API}${job.download.url}`);
const bytes = Buffer.from(await response.arrayBuffer());
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (
bytes.length !== job.artifact.byte_size ||
sha256 !== job.artifact.sha256
) {
throw new Error("the download did not match the job manifest");
}
await writeFile(path, bytes);
}
Put the three functions together.
const invoice = JSON.parse(await readFile("invoice.json", "utf8"));
const job = await waitForJob(await createInvoicePdf(invoice));
await saveInvoicePdf(job, `${invoice.invoice_number}.pdf`);
This is the PDF for the data above, made from the Client invoice template.
Handle the errors you will meet
A request that fails validation never creates a job, and a failed render costs nothing. Read error.code on a request error, or error.category and error.code on a failed job. The message text can change, so branch on the code.
| Code | What to do |
|---|---|
422 template_data_invalid | The data fails the schema. The error lists up to 16 field paths such as data/items/0/amount. |
402 insufficient_credits | Add credits or wait for the next monthly grant, then retry with the same key. |
409 idempotency_conflict | The same key was used with different input. Use a new key for a new invoice. |
429 rate_limited | Wait for Retry-After, then retry with the same key. |
Test the template with one item, twelve items, and one long description before you connect it to live billing. Retry requests and download files covers every code.
Let a webhook call you instead
Polling is fine for a few invoices an hour. When you send hundreds, register a webhook once with POST /v1/webhooks, and thirds.ai posts a signed render.succeeded or render.failed event to your server when each job ends. Your server verifies the signature, reads the job with its API key, and downloads the file. Every finished file also appears in Renders, where you can download it again for 30 days.

Read next
- Use templates with your data explains fields, schemas, and versions.
- Make invoice PDFs from template data shows the same flow with
curl. - The invoices page shows the full workflow from design to delivery.
Sign in to copy the Client invoice template and send your first request with the free monthly credits.

