In one sentence
A Node.js guide to turning HTML or a public web address into a PDF with fetch or axios and the render API, with tested code and a comparison with Puppeteer, Playwright and PDFKit.
Key facts
- POST /v1/render answers 202 with a job id; poll GET /v1/jobs/:id until the status is succeeded, failed or timeout, then fetch download_url (a short-lived signed link, no API key needed).
- Polls count toward the per-minute rate limit of the plan (30 requests per minute on the free plan); a 429 answer carries a Retry-After header.
- Every example was run against the gateway and render engine on 2026-10-11 with Node.js 24.11.1 (built-in fetch, ES modules).
- There is no batch endpoint, no template_id support and no webhooks yet; each document is one request.
HTML to PDF nodejs: how the API works
The AIIPWorld API turns HTML or a public web address into a PDF. From Node.js that takes three steps with the built-in fetch function: send the HTML to POST /v1/render, wait for the job to finish, and download the file.
The API answers the first request with status 202 and a job id, not with the PDF. The PDF is made by a job that runs headless Chromium inside a resource-limited worker container, with a hard time limit. You poll GET /v1/jobs/:id until the status is succeeded, failed or timeout. A succeeded job carries a download_url, a short-lived signed link that you fetch without your API key.
There is no mode that returns the PDF in the first response, and webhooks are not available yet. The small client below hides the waiting, so the rest of your code can call one function and get the file back. There is nothing to install for it, and no Chromium to maintain.
- Node.js 20 or newer, which has fetch built in. We ran the examples with Node.js 24.11.1. The files are ES modules with the .mjs extension, so they can use await at the top level.
- An API key from your dashboard. Keep it in an environment variable named AIIPWORLD_API_KEY, never in your source code.
- axios (npm install axios) for the axios example only. Puppeteer (npm install puppeteer) for the comparison example only.
We ran every example on this page against our own gateway and render engine on 2026-10-11 before publishing it. The base address in the code is https://api.aiipworld.com; the output shown is what the scripts printed.
Set up Node.js and your API key
Create a key in the dashboard and export it, for example export AIIPWORLD_API_KEY=your-key on macOS and Linux. The code reads the key from process.env, so it never appears in your repository.
The API accepts the key as Authorization: Bearer followed by the key, or in an x-api-key header. The examples use the Authorization header. A free account includes 100 credits a month, and a simple render costs 1 credit.
A small Node.js client for the render API
Save this file as aiipworld-pdf.mjs. The other examples import it.
import { writeFile } from "node:fs/promises";
const API = "https://api.aiipworld.com";
const headers = {
Authorization: `Bearer ${process.env.AIIPWORLD_API_KEY}`,
"Content-Type": "application/json",
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class RenderError extends Error {
constructor(code, detail) {
super(`${code}: ${detail}`);
this.code = code; // an HTTP status or a job status
this.detail = detail;
}
}
// One API request. On 429 (rate limited) wait for Retry-After and try again.
async function call(method, path, body, retries = 3) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${API}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429 && attempt < retries) {
await sleep(Number(res.headers.get("retry-after") ?? 1) * 1000);
continue;
}
return res;
}
}
// POST /v1/render. The API answers 202 with a job id; return that id.
export async function submit(payload) {
const res = await call("POST", "/v1/render", payload);
if (res.status === 202) return (await res.json()).job_id;
const body = await res.json().catch(() => ({}));
throw new RenderError(res.status, body.message ?? body.error ?? res.statusText);
}
// Poll GET /v1/jobs/:id until the job has ended, then return the job.
export async function wait(jobId, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
let pause = 500;
while (Date.now() < deadline) {
await sleep(pause);
pause = Math.min(pause * 1.5, 5000); // every poll counts toward your rate limit
const res = await call("GET", `/v1/jobs/${jobId}`);
if (!res.ok) throw new RenderError(res.status, res.statusText);
const job = await res.json();
if (job.status !== "queued" && job.status !== "running") return job;
}
throw new RenderError("client_timeout", `job ${jobId} did not finish in ${timeoutMs} ms`);
}
// Save the file of a succeeded job. download_url is signed, so no API key is sent.
export async function download(job, path) {
if (job.status !== "succeeded") throw new RenderError(job.status, job.error);
const res = await fetch(job.download_url);
if (!res.ok) throw new RenderError(res.status, "download failed");
const bytes = Buffer.from(await res.arrayBuffer());
await writeFile(path, bytes);
return bytes.length;
}
// Submit, wait and save in one call. Returns the finished job.
export async function render(payload, path, timeoutMs) {
const job = await wait(await submit(payload), timeoutMs);
await download(job, path);
return job;
}How it works:
- submit() sends POST /v1/render and returns the job id. Any status other than 202 becomes a RenderError with the API's message.
- wait() polls GET /v1/jobs/:id until the job has ended. It starts at half a second and waits longer between polls, up to five seconds. Every request counts toward your plan's rate limit, polls included, so a tight loop wastes requests.
- call() sends one request and, on 429 (rate limited), waits for the number of seconds in the Retry-After header and tries again.
- download() fetches the signed link. It sends no API key, because the link already carries its own signature. If the job did not succeed it throws a RenderError with the job status and the error text.
- render() runs the three steps in order and returns the finished job, which holds the page count and the size of the file.
Node.js HTML to PDF from a string
Most nodejs html to pdf code builds the HTML in memory, from a template literal or a view engine, and html to pdf node scripts then need the PDF as a file or as bytes. This example sends a small HTML document and saves report.pdf.
import { render } from "./aiipworld-pdf.mjs";
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: "Liberation Sans", Arial, sans-serif; color: #0f1222; }
h1 { color: #4338ca; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #d9dbe8; padding: 6px; text-align: left; }
</style>
</head>
<body>
<h1>Monthly report</h1>
<p>Made with Node.js and the AIIPWorld API.</p>
<table>
<tr><th>Item</th><th>Amount</th></tr>
<tr><td>Subscriptions</td><td>$1,200.00</td></tr>
<tr><td>Support</td><td>$300.00</td></tr>
</table>
</body>
</html>`;
const job = await render({ html, output: "pdf", options: { format: "A4" } }, "report.pdf");
console.log(job.status, job.result.pages, "page,", job.result.bytes, "bytes");Run it with node html-to-pdf.mjs. When we ran it, it printed succeeded 1 page, 17392 bytes. The options object holds the PDF settings. format sets the paper size and is A4 by default. The default margin is 10 mm on all four sides, and backgrounds are printed unless you set print_background to false.
To convert a file from disk, read it with readFile and pass the text as html. The API cannot see your disk, so relative links to images and stylesheets inside that file will not load. Inline them with data URLs, use absolute https addresses, or set base_url to a public site so that relative paths resolve against it.
Fonts on your own machine are not on the render server. It has the Noto and Liberation families and colour emoji; Arial and Helvetica map to Liberation Sans. Load any other font with @font-face from a public URL or a data URL.
HTML to PDF JavaScript: convert a web address
To convert html to pdf javascript code on a server can send the page's public address: use url instead of html. Send exactly one of the two. This script prints the pricing page of this site on Letter paper with narrower margins.
import { render } from "./aiipworld-pdf.mjs";
const payload = {
url: "https://aiipworld.com/pricing/",
output: "pdf",
options: {
format: "Letter",
margin: { top: "15mm", bottom: "15mm", left: "12mm", right: "12mm" },
wait_until: "networkidle",
media: "print",
},
};
const job = await render(payload, "pricing.pdf");
console.log(job.status, job.result.pages, "pages,", job.result.bytes, "bytes");When we ran it, it printed succeeded followed by the page count and file size. wait_until decides when the page counts as loaded: load (the default), domcontentloaded, networkidle or commit. For pages that draw their content late, add wait_for_selector with a CSS selector, or delay_ms for an extra pause of up to 10000 ms. JavaScript on the page runs by default, so pages built with a front-end framework work too.
The PDF uses print styles by default. Set media to screen if you want the page as it looks on a monitor. Pages behind a login show the login screen, because no cookies are sent. You can send up to 20 custom headers. Requests to private networks, localhost and cloud metadata addresses are blocked, including redirects.
This runs on the server side. Keep the API key out of browser code: a script that runs in a web page would show the key to every visitor.
Page numbers, headers and footers
Set display_header_footer to true and pass header_template and footer_template as HTML strings. Inside them, an element with the class pageNumber shows the current page and one with the class totalPages shows the page count. Leave room for both in the top and bottom margin.
import { render } from "./aiipworld-pdf.mjs";
const rows = Array.from(
{ length: 120 },
(_, i) => `<tr><td>Order ${1001 + i}</td><td>$${((i + 1) * 37) % 500 + 20}.00</td></tr>`,
).join("");
const html = `<!doctype html>
<html><head><meta charset="utf-8">
<style>
body { font-family: "Liberation Sans", Arial, sans-serif; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #ddd; padding: 4px 6px; text-align: left; }
</style></head>
<body><h1>Orders</h1><table><thead><tr><th>Order</th><th>Total</th></tr></thead>
<tbody>${rows}</tbody></table></body></html>`;
const header = '<div style="font-size:9px; width:100%; text-align:center;">Orders report</div>';
const footer =
'<div style="font-size:9px; width:100%; text-align:center;">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>';
const job = await render(
{
html,
output: "pdf",
options: {
display_header_footer: true,
header_template: header,
footer_template: footer,
margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
},
},
"orders.pdf",
);
console.log(job.status, job.result.pages, "pages");We checked the PDF this script made with pdftotext: every page had Orders report at the top and Page 1 of 4 to Page 4 of 4 at the bottom, and the table header repeated on each page. The sample below is that file.
Style the templates inline, as the example does. They are rendered apart from your page, so your stylesheet does not reach them. In a check we made, a header template without an inline font-size came out about one point tall, even though the page text was set to 40 px.
Example output
Generate PDF nodejs: many documents at once
A node js generate pdf job usually means one document for each record in a list. Build one HTML string per record and send it as html. The example below renders six invoices with three jobs in flight at a time, using a small pool of workers built from Promise.all. The esc() function turns characters such as & and < in your data into plain text, so a customer name cannot break the layout.
Queue priority follows the plan, with Business first and Free last, and the jobs wait in the queue until a worker is free.
import { RenderError, download, submit, wait } from "./aiipworld-pdf.mjs";
const invoices = [
{ number: "INV-1001", customer: "Harbor Lane Studio", total: 1250.0 },
{ number: "INV-1002", customer: "Bluebird Supplies", total: 89.5 },
{ number: "INV-1003", customer: "Maple & Pine Co.", total: 4300.0 },
{ number: "INV-1004", customer: "Northgate Design", total: 610.25 },
{ number: "INV-1005", customer: "Lakeside Bakery", total: 175.0 },
{ number: "INV-1006", customer: "Orchard Works", total: 920.0 },
];
// Escape values that come from your data before putting them into HTML.
const esc = (text) =>
String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const invoiceHtml = (inv) => `<!doctype html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: 'Liberation Sans', Arial, sans-serif; margin: 0">
<h1>Invoice ${esc(inv.number)}</h1>
<p>Bill to: ${esc(inv.customer)}</p>
<p>Total: $${inv.total.toFixed(2)}</p>
</body></html>`;
const queue = [...invoices];
// Each worker takes the next invoice, renders it and saves it.
async function worker() {
for (let inv = queue.shift(); inv; inv = queue.shift()) {
try {
const job = await wait(await submit({ html: invoiceHtml(inv), output: "pdf" }));
const size = await download(job, `${inv.number}.pdf`);
console.log(inv.number, "saved,", size, "bytes");
} catch (e) {
if (!(e instanceof RenderError)) throw e;
console.log(inv.number, "failed:", e.message);
}
}
}
// Three jobs in flight at a time.
await Promise.all([worker(), worker(), worker()]);It printed one saved line per invoice, in the order the jobs finished, not the order of the list. The API has no batch endpoint and does not take a template id yet: the template_id field is reserved and returns an error. Each document is its own request, and results are collected by polling.
When you size a batch against your plan's rate limit, count at least two requests per document: one POST and at least one GET. Keep the pool small on the free plan.
Using axios instead of fetch
If your project already uses axios, the calls are the same. Two details differ from fetch. axios throws on any answer outside the 200 range, so the API's error is in e.response.data. And the signed download link must be fetched with plain axios, not with the instance that carries your key.
import axios from "axios";
import { writeFile } from "node:fs/promises";
const api = axios.create({
baseURL: "https://api.aiipworld.com",
headers: { Authorization: `Bearer ${process.env.AIIPWORLD_API_KEY}` },
});
try {
const { data: queued } = await api.post("/v1/render", {
html: "<h1>Hello from axios</h1>",
output: "pdf",
});
let job;
do {
await new Promise((resolve) => setTimeout(resolve, 1000));
({ data: job } = await api.get(`/v1/jobs/${queued.job_id}`));
} while (job.status === "queued" || job.status === "running");
if (job.status !== "succeeded") throw new Error(`${job.status}: ${job.error}`);
// download_url is signed: use plain axios, not the instance that sends your key
const { data } = await axios.get(job.download_url, { responseType: "arraybuffer" });
await writeFile("axios.pdf", data);
console.log("saved axios.pdf,", data.byteLength, "bytes");
} catch (e) {
// axios throws on any non-2xx answer; the API's error is in e.response.data
if (e.response) console.error(e.response.status, e.response.data);
else throw e;
}It saved axios.pdf. This version polls once a second. Use the longer pauses from the client above if you render many files, because every poll counts toward your rate limit.
Handle errors and rate limits
Errors come in two kinds. A request can be refused straight away with an HTTP status, or a job can be accepted and then end as failed or timeout. RenderError carries both: code is the HTTP status or the job status, and detail is the message.
import { RenderError, render } from "./aiipworld-pdf.mjs";
const attempts = {
"unknown option": { html: "<h1>Hi</h1>", output: "pdf", options: { colour: true } },
"private address": { url: "http://127.0.0.1/", output: "pdf" },
"html and url together": { html: "<h1>Hi</h1>", url: "https://aiipworld.com/", output: "pdf" },
};
for (const [name, payload] of Object.entries(attempts)) {
try {
const job = await render(payload, "out.pdf");
console.log(name, "->", job.status);
} catch (e) {
if (!(e instanceof RenderError)) throw e;
// e.code is an HTTP status (401, 402, 429 ...) or a job status (failed, timeout)
console.log(`${name} -> ${e.code}: ${e.detail}`);
}
}| Where | Code | Meaning | What to do |
|---|---|---|---|
| Request | 400 | The request is not valid, for example html and url sent together | Read the message and fix the payload |
| Request | 401 invalid_api_key | The key is missing or not recognised | Check AIIPWORLD_API_KEY |
| Request | 402 insufficient_credits | The account has too few credits for this render | Wait for next month's credits or add credits |
| Request | 429 rate_limited | Too many requests this minute | Wait for Retry-After seconds; the client does |
| Job | failed, invalid_input | An option name or value was rejected by the engine | Fix the option |
| Job | failed, ssrf_blocked | The address is a private network, localhost or metadata address | Use a public address |
| Job | failed, navigation_failed | The page could not be loaded | Check the address and whether the site is up |
| Job | failed, output_limit or memory_limit | The result or the page was too large | Split or simplify the document |
| Job | timeout | The job ran out of time | Simplify the page or split the work |
Credits for failed and timed-out jobs are returned. With a valid key, errors.mjs printed unknown option -> failed: invalid_input: params: Unrecognized key(s) in object: 'colour', then private address -> failed: ssrf_blocked: blocked address 127.0.0.1, then html and url together -> 400: provide exactly one of template_id, html, url. With a wrong key every line ended in 401: invalid_api_key, and with an account that had no credits the first two ended in 402: insufficient_credits.
Retry a request only when it makes sense. A 429 is safe to retry after the pause. A 400, a failed job or a 401 will fail the same way until you change something.
Puppeteer PDF or an API: how they compare
Many Node.js projects start with Puppeteer HTML to PDF scripts: a puppeteer pdf call is short, and the Chromium that prints the page is on your machine. The question is who runs the browser. The facts below come from each project's own pages, checked on 2026-10-11.
| Option | How it works | Licence | Worth knowing |
|---|---|---|---|
| Puppeteer | You run Chrome and call page.pdf() | Apache-2.0 | npm install puppeteer downloads a compatible Chrome during installation. Version 25.13.0 asks for Node.js 22.12 or newer. |
| Playwright for Node.js | You run a browser and call page.pdf() | Apache-2.0 | Version 1.64.0 asks for Node.js 20 or newer. You install browsers with the playwright command. |
| PDFKit | A library that draws a PDF from code, not from HTML | MIT | Described on npm as a JavaScript PDF generation library for Node and the browser. It does not convert HTML. |
| The AIIPWorld API | You send HTTP requests and we run headless Chromium | A hosted service | Nothing to install. Your HTML is sent to our servers and deleted after the retention time of your plan. |
Sources: pptr.dev and npm (puppeteer, playwright, pdfkit, checked 2026-10-11); github.com/puppeteer/puppeteer, github.com/microsoft/playwright and github.com/foliojs/pdfkit for the licences.
This is the Puppeteer version of the first example. We ran it with Puppeteer 25.13.0 and a local Chrome, and it wrote a one-page A4 PDF.
import puppeteer from "puppeteer";
const html = "<h1>Monthly report</h1><p>Made with Puppeteer on this machine.</p>";
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: "load" });
await page.pdf({
path: "self-hosted.pdf",
format: "A4",
printBackground: true,
margin: { top: "10mm", right: "10mm", bottom: "10mm", left: "10mm" },
});
} finally {
await browser.close();
}Puppeteer's documentation says page.pdf() prints with the print media type, and that you call page.emulateMediaType('screen') first if you want screen styles. The API does the same with its media option. What you add with a self-run Puppeteer is the work around the script: installing and updating Chrome, setting a timeout for each job, cleaning up when a page hangs, and installing the fonts you need. If your documents must not leave your network, that is the right trade. If you do not want to run a browser in production, the API takes that work off your hands in exchange for credits.
If you came here for a Puppeteer alternative that needs no browser on your server, the client above is short and has no dependencies. PDFKit is a different kind of tool: pdfkit node code draws text and shapes at coordinates, so it suits documents you build in code and not HTML you already have.
Node.js HTML to PDF: common problems
- The PDF has the wrong fonts. The render server has a fixed set of fonts. Load a web font with @font-face from a public https address or a data URL.
- Images or styles are missing. Relative paths cannot be read from your disk. Use absolute https addresses, data URLs or base_url.
- Content that a script draws is missing. Add wait_for_selector with a selector that only exists once the content is there, or use delay_ms.
- A table row or a card is split between two pages. Add break-inside: avoid to it. Our print CSS guide covers page breaks, @page sizes and margins.
- The download fails after a while. The download_url is short-lived. Download the file as soon as the job succeeds, or read the job again to get a fresh link. Results are deleted when their retention time ends.
- Your key appears in a repository or in browser code. Keep it in an environment variable or a secrets manager and send it only from server code.
Limits, credits and how long files are kept
A simple render costs 1 credit. PDFs longer than 10 pages cost one more credit for each further 10 pages, and a result over 5 MB costs one more credit for each further 5 MB. Credits for failed and timed-out jobs are returned. These costs are provisional until launch.
Each plan has its own limit on requests per minute: 30 on the free plan, 60 on Starter, 180 on Pro and 600 on Business.
Results are kept for 1 day on the free plan, 7 days on Starter and Pro, and 30 days on Business. See pricing for the plans.
Questions
How do I convert HTML to PDF in Node.js?
Send your HTML to POST /v1/render with output set to pdf using fetch, poll GET /v1/jobs/:id until the status is succeeded, then download the file from download_url. The client on this page does all three in one call and needs no packages.
Do I need Puppeteer or Chromium to use the API?
No. The API renders in headless Chromium on our servers, so your Node.js project needs no browser and no extra packages. Puppeteer is the choice when you want to run Chrome yourself.
How do I get a puppeteer pdf with page numbers?
In Puppeteer you pass displayHeaderFooter and a footerTemplate to page.pdf(). With this API, set display_header_footer to true and pass footer_template, using elements with the classes pageNumber and totalPages. Style the template inline and leave room in the bottom margin.
Why does the API return a job id instead of the PDF?
Rendering takes a moment, so the first response is 202 with a job id and the work happens in a queue. Poll GET /v1/jobs/:id until the job has ended. Webhooks are not available yet.
Can I generate a PDF in nodejs from a template and JSON data?
Not through the API yet: the template_id field is reserved and returns an error. Build the HTML yourself with a template literal or a view engine and send the finished HTML, as the batch example does.
Can I convert a URL to PDF with JavaScript?
Yes. From Node.js, send url instead of html. The address must be public http or https. Pages behind a login show the login screen, and private network addresses are blocked.
Is there an html to pdf js library for the browser?
You can call this API from any JavaScript code that can send HTTP requests, but do not call it from browser code: the key would be visible to every visitor. Call it from a Node.js server and send the PDF to the browser from there.
Does JavaScript run on the page before the PDF is made?
Yes, by default. If the page draws its content late, use wait_for_selector or delay_ms. You can turn scripts off with javascript set to false.
How do I handle rate limits?
A 429 answer has a Retry-After header with the seconds to wait. The client on this page waits and retries. Polls count toward the limit, so poll with growing pauses and keep the number of parallel jobs small.
Can I use axios or node-fetch instead of fetch?
Yes. The API is plain HTTP and JSON, so any client works. The page shows fetch and axios because those are the two we ran.
Should I use Puppeteer, Playwright or the API?
It depends on where your HTML may go and who should run the browser. Puppeteer and Playwright run on your own machine, which suits documents that must not leave your network, but you maintain the browser. The API needs nothing installed but sends your HTML to our servers.
Is PDFKit a way to convert HTML to PDF?
No. PDFKit draws a PDF from code, with text and shapes placed by you. npm describes it as a JavaScript PDF generation library for Node and the browser. To print existing HTML, use a browser engine or an API.
How long are the PDFs kept?
Results are kept for 1 day on the free plan, 7 days on Starter and Pro, and 30 days on Business. Download links are short-lived, so save the file soon after the job succeeds.
Which Node.js version did you test?
The examples ran on Node.js 24.11.1. The client uses the built-in fetch, available in Node.js 20 and newer, and ES modules with top-level await.
