ScreenshotNeo

BlogHTML to image & PDF

How to generate PDFs from URLs with Html2Pdf.app in Node.js

Generate a PDF from a public URL with Html2Pdf.app in Node.js. Learn synchronous and callback flows, rendering options, error handling, and safer file delivery.

By the ScreenshotNeo team4 October 20269 min read

To generate a PDF from a URL with Html2Pdf.app in Node.js, send a server-side JSON POST to https://api.html2pdf.app/v1/generate, authenticate with the X-API-Key header, and put the publicly reachable URL in the required html field. For a successful synchronous response, read the response as binary bytes and save them as a .pdf file. Do not parse that response as JSON.

This guide covers a runnable Node.js implementation, rendering options, an asynchronous callback flow, security and troubleshooting. Html2Pdf.app’s [documentation](https://html2pdf.app/documentation/) and [Node.js examples](https://html2pdf.app/code-examples/nodejs/) are the sources for its API behavior and parameters.

1. Generate a PDF synchronously in Node.js

The following example uses native fetch, available in Node.js 18 and newer. Keep the API key in a server-side environment variable. The source URL must be reachable by the rendering service.

import { writeFile } from 'node:fs/promises';

const apiKey = process.env.HTML2PDF_API_KEY;
if (!apiKey) {
  throw new Error('Set HTML2PDF_API_KEY before running this script');
}

const response = await fetch('https://api.html2pdf.app/v1/generate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': apiKey,
  },
  body: JSON.stringify({ html: 'https://www.example.com' }),
});

if (!response.ok) {
  const details = await response.text();
  throw new Error(`PDF generation failed (${response.status}): ${details}`);
}

const pdf = Buffer.from(await response.arrayBuffer());
await writeFile('document.pdf', pdf);
console.log('Saved document.pdf');

Save this as an ES module, for example generate-pdf.mjs, set HTML2PDF_API_KEY in your server environment, and run node generate-pdf.mjs. The error body is read as text only on failure; the successful body is treated as PDF bytes.

cURL equivalent

curl --fail-with-body \
  -X POST 'https://api.html2pdf.app/v1/generate' \
  -H 'Content-Type: application/json' \
  -H "X-API-Key: $HTML2PDF_API_KEY" \
  --data '{"html":"https://www.example.com"}' \
  --output document.pdf

POST is useful here because it avoids query-string length and escaping issues. The key belongs in a protected environment variable or secret store, not in browser code or a public repository.

Python equivalent

import os
import requests

api_key = os.environ['HTML2PDF_API_KEY']
response = requests.post(
    'https://api.html2pdf.app/v1/generate',
    headers={
        'Content-Type': 'application/json',
        'X-API-Key': api_key,
    },
    json={'html': 'https://www.example.com'},
    timeout=90,
)
response.raise_for_status()
with open('document.pdf', 'wb') as pdf_file:
    pdf_file.write(response.content)

As with Node.js, the successful response is binary content, so write response.content directly.

2. Choose a URL or raw HTML input

The required html property accepts either a publicly reachable page URL or raw HTML markup. For URL conversion, send the full URL, including its scheme, such as https://www.example.com. A page that requires a private network, an interactive login, or access unavailable to the rendering service may not load as expected.

For a controlled document you already have as a string, pass markup instead:

const body = {
  html: '<!doctype html><html><body><h1>Invoice</h1></body></html>',
};

When the markup references stylesheets, images, fonts, or scripts by URL, those resources also need to be accessible to the renderer. For production output, try representative pages from your own site because remote resources, CSS modes, fonts, and JavaScript timing can change the result.

3. Tune page size, margins, and rendering

Html2Pdf.app documents these request options. Use only the options your document needs, then inspect the resulting PDF to confirm its layout.

Option Purpose and documented behavior
format Paper size: Letter, Legal, Tabloid, Ledger, or A0 through A6. Default: A4.
landscape Boolean orientation switch. Default: false.
width, height Custom pixel dimensions; provide them together.
marginTop, marginRight, marginBottom, marginLeft Margins in pixels. Documented defaults are zero.
media CSS media mode, print or screen. Default: screen.
scale Render scale from 0.1 to 2. Default: 1.
waitFor Extra wait in seconds for JavaScript or asynchronous resources; documented range: 0–10.
filename Returned filename.
headerTemplate, footerTemplate HTML snippets for page headers and footers. Allow enough top or bottom margin for them to fit.
callBackUrl, state Callback URL for asynchronous completion and an optional correlation value echoed back.

Example with a print layout, landscape orientation, margins, a longer JavaScript wait, and a filename:

const options = {
  html: 'https://www.example.com/report',
  format: 'Letter',
  landscape: true,
  marginTop: 36,
  marginRight: 24,
  marginBottom: 36,
  marginLeft: 24,
  media: 'print',
  scale: 1,
  waitFor: 3,
  filename: 'quarterly-report.pdf',
};

Place these fields alongside html in the JSON body. The correct values depend on the source page: media: 'print' may activate print-specific CSS, while screen uses screen styles. Increasing waitFor can help when client-side rendering or remote resources need time, but it cannot make an inaccessible resource available.

4. Return the PDF from an application route

For a web application, stream the generated bytes to the caller and set a PDF content type. This example uses a Node.js server route handler style; validate the requested URL against your application’s allowed destinations before forwarding it to a rendering service.

export async function GET() {
  const apiKey = process.env.HTML2PDF_API_KEY;
  if (!apiKey) {
    return new Response('PDF service is not configured', { status: 500 });
  }

  const upstream = await fetch('https://api.html2pdf.app/v1/generate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': apiKey,
    },
    body: JSON.stringify({
      html: 'https://www.example.com/report',
      format: 'A4',
      media: 'print',
    }),
  });

  if (!upstream.ok) {
    const details = await upstream.text();
    return new Response(`PDF generation failed: ${details}`, {
      status: upstream.status,
    });
  }

  const bytes = await upstream.arrayBuffer();
  return new Response(bytes, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="report.pdf"',
    },
  });
}

Do not accept arbitrary user-supplied URLs without controls. An endpoint that fetches caller-selected URLs can be abused to make requests to destinations your application should not expose. Apply an allowlist or other URL validation appropriate to your service.

5. Use callback jobs for background generation

A synchronous request holds the connection open until conversion finishes. If your application prefers background work, provide callBackUrl. The API documentation describes an accepted request as returning 202 Accepted; that is queue confirmation, not the completed PDF. On completion, the service POSTs JSON to the callback endpoint with the document base64-encoded in document. An optional state value is returned unchanged.

Submit the job from Node.js:

const response = await fetch('https://api.html2pdf.app/v1/generate', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.HTML2PDF_API_KEY,
  },
  body: JSON.stringify({
    html: 'https://www.example.com/report',
    callBackUrl: 'https://app.example.com/webhooks/pdf-ready',
    state: 'job-12345',
  }),
});

if (response.status !== 202) {
  const details = await response.text();
  throw new Error(`Job was not accepted (${response.status}): ${details}`);
}

console.log('PDF job accepted; wait for the callback.');

A minimal Express callback handler can decode the document and save it. Configure JSON parsing with a body size limit suitable for the files you expect, and make the handler idempotent because webhook delivery can be retried up to three times if delivery fails.

import express from 'express';
import { writeFile } from 'node:fs/promises';

const app = express();
app.use(express.json({ limit: '20mb' }));

app.post('/webhooks/pdf-ready', async (req, res) => {
  const { document, state } = req.body;
  if (typeof document !== 'string') {
    return res.status(400).send('Missing document');
  }

  const pdf = Buffer.from(document, 'base64');
  const safeId = String(state ?? 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
  await writeFile(`./${safeId}.pdf`, pdf);
  return res.sendStatus(204);
});

app.listen(3000);

The callback URL must be publicly reachable over HTTPS. In a real application, validate callback authenticity using the mechanism supported by the service and your deployment, store files in an appropriate durable location, and record processed job identifiers so a repeated delivery does not create duplicate work. The documented retry behavior makes idempotency important.

6. Troubleshooting common failures

Symptom or status Likely cause What to do
400 Source URL is inaccessible or a parameter is invalid. Check the complete URL, reachability, option names, and allowed values. Fix the request before retrying.
401 API key is missing or invalid. Confirm the environment variable is set and the X-API-Key header is sent by the server.
403 Plan limit or access restriction. Review the account and plan limits; repeated identical requests will not resolve an account restriction.
500 Unhandled service error. Retry after a short delay, increasing the interval between attempts. Avoid rapid retry loops.
PDF is blank The source or its important assets were inaccessible, or the page had not rendered its content. Check public reachability of the page and its assets; try a suitable waitFor value within 0–10 seconds.
Styles or fonts are missing Stylesheets, font files, or images could not be loaded by the renderer. Check that each resource is reachable without your local session and that the chosen CSS media mode includes the expected styles.
Node saves an unusable file The response was parsed as text or JSON, or an error response was saved with a PDF extension. Check response.ok first; for success use arrayBuffer() and save the resulting bytes.
Callback body is rejected The webhook body is larger than the server parser limit, the endpoint is not publicly reachable, or processing is not retry-safe. Review body size configuration, expose an HTTPS callback endpoint, and make repeated deliveries idempotent.

For 400, 401, and 403, correct the request, credentials, or plan issue rather than retrying unchanged. For a 500, use bounded retries with increasing delays.

7. Security, reliability, and cost considerations

  • Protect credentials: keep the API key in server-side configuration and never return it to a browser.
  • Validate source URLs: if users choose what to convert, restrict destinations to the URLs your application intends to support.
  • Use timeouts and bounded retries: conversion depends on loading a page and its resources. Choose request timeouts for your application and retry only transient failures such as server errors.
  • Plan for binary size: PDFs can be much larger than the page’s HTML. Avoid logging document bytes and choose storage and webhook body limits accordingly.
  • Protect sensitive documents: the vendor documents a userPassword option for encryption and says its PDF encryption uses 128-bit AES; permissions can control printing, modification, copying, form filling, and related actions. Choose settings based on the document’s access needs.
  • Understand data handling: Html2Pdf.app states that generated PDFs are processed temporarily and not permanently stored on its servers, while selected request metadata and a supplied source URL may be retained in logs. This is the vendor’s statement; consult its Privacy Policy and Data Processing Agreement for details relevant to your data.
  • Check current pricing: the vendor homepage, accessed in 2026, lists 100 monthly credits and up to 1 MB per file on Free; Startup at $9/month for 1,000 credits, Standard at $25/month for 5,000, and Scale at $39/month for 10,000. It says each 5 MB chunk of generated document size counts as one credit. Pricing and quotas can change, so verify the [current homepage](https://html2pdf.app/) before estimating production cost.

For reliability, log the request correlation value, HTTP status, elapsed time, and file size rather than sensitive page content or PDF bytes. Test pages with the fonts, image sizes, client-side rendering, and print styles your real documents use. The documentation does not establish a latency threshold at which callback jobs become necessary; choose synchronous or callback handling based on your application’s request lifecycle and user experience.

8. Or skip the browser setup

If your task is to capture a page as a PDF, ScreenshotNeo is a website screenshot API and MCP server for developers. It can also return PDFs through a single request. For a URL capture:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

See the ScreenshotNeo API documentation for request options, including PDF output. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000 screenshots. Sign up free and capture 1,000 screenshots a month with no card.

9. Frequently asked questions

Can I send a URL that requires authentication?

The documented URL input is a publicly reachable page. A page that depends on a private session may not be accessible to the renderer; use an accessible source or supply controlled HTML and resources.

Does a 202 response contain the PDF?

No. With callBackUrl, 202 indicates that the job was accepted. The PDF arrives later in the callback payload as base64 in document.

Which Node.js version does the native-fetch example need?

The vendor’s Node.js guide specifies Node.js 18 or newer.

Will the output exactly match what I see in my browser?

Not necessarily. The renderer’s available resources, selected CSS media mode, and page JavaScript timing can affect the rendered document, so inspect representative outputs.

Can I use the service from frontend JavaScript?

Keep the API key server-side. Have your frontend call your own backend, which makes the authenticated conversion request.