Disclosure: I build RenderScreenshot, a hosted screenshot and PDF API that prints with Chrome. Api2Pdf is not ours and we have no paid account with it. Facts and prices about Api2Pdf come from its pricing page, its Chrome options page and its Node client README, all read on 2026-10-18. This page has no latency or output comparison of the two services, because we did not send documents through Api2Pdf. The adapters below were run against a local mock of both APIs, not the live ones.
Api2Pdf is a pay-per-use PDF API with several engines behind one key: headless Chrome, wkhtmltopdf, LibreOffice for Office files, plus merge, password and barcode tools. Its Chrome endpoints take the same option names as Puppeteer's page.pdf(), return JSON with a link to the file, and charge by output megabytes and compute seconds instead of by document.
People look for an alternative for three reasons we hear most: they want the PDF bytes in the response instead of a link that expires in 24 hours, they want a fixed monthly bill, or they also need screenshots and social cards from the same account. If none of those apply, Api2Pdf is a strong, cheap choice, and the verdict table says so.
This page is a spoke of our best HTML to PDF APIs comparison. For a cost model of Api2Pdf against a plan-based service, see PDFShift vs Api2Pdf.
Verdict
| You care most about... | Choose | Why |
|---|---|---|
| Lowest bill for small, fast documents | Api2Pdf | A few dollars a month at 10,000 small PDFs (table below) |
| Office files (DOCX, XLSX) to PDF, merging, passwords | Api2Pdf | LibreOffice and PDF tool endpoints; we have none |
| No rate limit at all | Api2Pdf | Its pricing page states none; ours is per account |
| PDF bytes in the response, no second download | RenderScreenshot | Api2Pdf returns a FileUrl |
| A file that stays available after 24 hours | RenderScreenshot | X-Cache-URL CDN link, or your own bucket |
| A fixed monthly bill, one credit per PDF of any size or length | RenderScreenshot | Credits, not MB and seconds |
| Screenshots, OG images and PDFs from one API | RenderScreenshot | Same endpoint, output.format |
| Cookie banners and ads removed before printing | RenderScreenshot | block.cookie_banners, block.ads |
| More PDFs a month than our largest plan (60,000 credits) | Api2Pdf | No listed plan above that on our side |
The option map
Api2Pdf's Chrome options sit in an options object next to url or html. Ours sit in a pdf object, plus wait and network for page loading. Every row below is checked against Api2Pdf's Chrome options page and our PDF options reference.
Api2Pdf (options.*) |
Api2Pdf default | RenderScreenshot | Our default | Notes |
|---|---|---|---|---|
width / height |
8.27in / 11.69in (A4) |
pdf.width / pdf.height, or pdf.paper: "a4" |
letter |
Different default. Send the size |
landscape |
false |
pdf.landscape |
false |
Same |
printBackground |
false |
pdf.print_background |
true |
Different default |
scale |
1 |
pdf.scale |
1.0 |
Ours accepts 0.1 to 2.0 |
marginTop, marginBottom, marginLeft, marginRight |
.4in each |
pdf.margin_top and the other sides, or one pdf.margin |
0 |
Different default. CSS length strings in both |
pageRanges |
all pages | pdf.page_ranges |
all pages | Same syntax, such as 1-5, 8 |
preferCSSPageSize |
false |
pdf.prefer_css_page_size |
false |
Same |
displayHeaderFooter + headerTemplate / footerTemplate |
false |
pdf.header / pdf.footer |
none | Setting either template turns display on. Same pageNumber, totalPages, date, title, url classes |
delay (ms) |
0 |
wait.delay (ms) |
0 |
Same unit |
puppeteerWaitForMethod + puppeteerWaitForValue |
not stated | wait.until, wait.for_selector |
load |
Map navigation waits to until, selector waits to for_selector |
extraHTTPHeaders (top level) |
none | network.headers |
none | Ours sends them to the target origin only |
omitBackground |
not stated | no PDF equivalent | — | Drop it; output.transparent exists for PNG and WebP only |
fileName / filename, inline |
not stated | output.filename; Content-Disposition is inline |
screenshot.pdf |
|
| Custom storage | per account | storage with a path that contains {hash} and {ext} |
dashboard setting | Not on the Free plan |
| — | — | pdf.fit_one_page |
false |
Scale the whole document onto one page |
| — | — | block.cookie_banners, block.ads, browser.media |
off, screen |
Clean up a live page before printing |
The three defaults that change your output
If you copy Api2Pdf requests across without the defaults, three things change at once: the paper becomes Letter instead of A4-sized, the 0.4 inch margins disappear, and backgrounds appear that Api2Pdf left out. Your invoice reflows, gains or loses a page, and suddenly shows colored table headers. The adapters below write Api2Pdf's defaults out explicitly, so the first migrated PDF matches the old one and you can change defaults later on purpose.
Response, auth and limits
| Api2Pdf | RenderScreenshot | |
|---|---|---|
| Endpoint | POST https://v2.api2pdf.com/chrome/pdf/url or /chrome/pdf/html |
POST https://api.renderscreenshot.com/v1/screenshot with output.format: "pdf" |
| Auth | Authorization: <key> (raw key) |
Authorization: Bearer rs_live_...; signed GET URLs for public links |
| Success response | JSON: Success, FileUrl, MbOut, Cost, ResponseId |
PDF bytes; X-Cache-URL header with a CDN link |
| File lifetime on their side | Deleted after 24 hours | Cached; see caching |
| Errors | Success: false with Error text |
HTTP status and JSON error.code, each with a docs page |
| Limits | No rate limit; 2 GB RAM and 90 s per request (pricing page) | Per-account rate limit shared by all keys; rate limits |
| Engines | Chrome (130), wkhtmltopdf, LibreOffice | Chrome |
The FileUrl design has a consequence for retries. If your job fails after the render but before the download, and it retries a day later, the link is gone and you pay for a second render. With bytes in the response, the render and the download are one step.
Cost at three volumes
Api2Pdf's price depends on output size and render time, which we did not measure on its service. The table uses two assumptions: 0.12 MB per PDF (the size of our 60-line test invoice printed by Chrome 148 on our machine) and either 2 or 5 compute seconds per PDF. Formula: $1 a month, plus MB × $0.001 and seconds × $0.00019551 for each document, from Api2Pdf's pricing page on 2026-10-18.
| PDFs per month | Api2Pdf, 2 s each | Api2Pdf, 5 s each | RenderScreenshot |
|---|---|---|---|
| 1,000 | $1.51 | $2.10 | Starter: $10 (2,500 credits) |
| 10,000 | $6.11 | $11.98 | Growth: $40 (12,000 credits) |
| 100,000 | $52.10 | $110.75 | More than Scale's 60,000 credits; no listed plan |
Read the table honestly: for small, quick documents, Api2Pdf costs less at every volume. Our plans cost more and in exchange give a fixed bill, bytes in the response, screenshots on the same credits, and no per-second charge for slow pages. Free tiers are left out: Api2Pdf gives one month of free credit; we give 150 credits every month. Current prices are on our pricing page.
Migration: one adapter, two providers
Move in steps, not in one deploy:
- Put every PDF call behind one function,
renderPdf(job), that still calls Api2Pdf. - Add the RenderScreenshot path behind an environment flag, with Api2Pdf's defaults written out.
- Turn the flag on for a share of jobs, compare page counts and text (
pdfinfo,pdftotext) for the same inputs, and fix template differences. - Switch fully, then remove the Api2Pdf path.
The three scripts below implement steps 1 and 2. On 2026-10-18 we ran each one against a local mock that answers both APIs, with Node.js 22.18.0, Python 3 (requests) and Ruby 4.0.7. The mock checked the request each script sent; it does not prove what either live service returns.
Node.js
// Usage: PDF_PROVIDER=renderscreenshot node pdf-provider.mjs https://example.com out.pdf import { writeFile } from 'node:fs/promises'; const API2PDF_BASE = process.env.API2PDF_BASE ?? 'https://v2.api2pdf.com'; const RS_BASE = process.env.RS_BASE ?? 'https://api.renderscreenshot.com'; // Translate the Api2Pdf Chrome options you already send into a RenderScreenshot request body. // Api2Pdf defaults differ from ours, so they are written out explicitly. export function toRenderScreenshot({ url, html, options = {} }) { const pdf = { width: options.width ?? '8.27in', height: options.height ?? '11.69in', landscape: options.landscape ?? false, print_background: options.printBackground ?? false, scale: options.scale ?? 1, margin_top: options.marginTop ?? '0.4in', margin_bottom: options.marginBottom ?? '0.4in', margin_left: options.marginLeft ?? '0.4in', margin_right: options.marginRight ?? '0.4in', prefer_css_page_size: options.preferCSSPageSize ?? false, }; if (options.pageRanges) pdf.page_ranges = options.pageRanges; if (options.displayHeaderFooter) { if (options.headerTemplate) pdf.header = options.headerTemplate; if (options.footerTemplate) pdf.footer = options.footerTemplate; } const body = { output: { format: 'pdf' }, pdf }; if (url) body.url = url; else body.html = html; if (options.delay) body.wait = { delay: options.delay }; return body; } async function viaApi2Pdf(job) { const kind = job.url ? 'url' : 'html'; const res = await fetch(`${API2PDF_BASE}/chrome/pdf/${kind}`, { method: 'POST', headers: { Authorization: process.env.API2PDF_API_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ [kind]: job.url ?? job.html, options: job.options ?? {} }), }); const json = await res.json(); if (!res.ok || !json.Success) throw new Error(`Api2Pdf: ${json.Error ?? res.status}`); const file = await fetch(json.FileUrl); // the file is deleted after 24 hours if (!file.ok) throw new Error(`Api2Pdf file download: ${file.status}`); return Buffer.from(await file.arrayBuffer()); } async function viaRenderScreenshot(job) { const res = await fetch(`${RS_BASE}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(toRenderScreenshot(job)), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(`RenderScreenshot ${res.status}: ${err.error?.code ?? 'unknown'}`); } return Buffer.from(await res.arrayBuffer()); } export function renderPdf(job) { return process.env.PDF_PROVIDER === 'renderscreenshot' ? viaRenderScreenshot(job) : viaApi2Pdf(job); } if (import.meta.url === `file://${process.argv[1]}`) { const [url, out] = process.argv.slice(2); const pdf = await renderPdf({ url, options: { printBackground: true, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: '<div style="font-size:8px;width:100%;text-align:center"><span class="pageNumber"></span></div>' } }); await writeFile(out, pdf); console.log(`${process.env.PDF_PROVIDER ?? 'api2pdf'}: ${pdf.length} bytes -> ${out}`); }
toRenderScreenshot() is exported so you can unit-test the translation on its own, and so your comparison script in step 3 can log both request bodies side by side.
Python
"""pdf_provider.py: one render_pdf() for Api2Pdf and RenderScreenshot, chosen by PDF_PROVIDER.""" import os import sys import requests API2PDF_BASE = os.environ.get("API2PDF_BASE", "https://v2.api2pdf.com") RS_BASE = os.environ.get("RS_BASE", "https://api.renderscreenshot.com") # Api2Pdf option name -> (RenderScreenshot pdf.* name, Api2Pdf default) OPTION_MAP = { "width": ("width", "8.27in"), "height": ("height", "11.69in"), "landscape": ("landscape", False), "printBackground": ("print_background", False), "scale": ("scale", 1), "marginTop": ("margin_top", "0.4in"), "marginBottom": ("margin_bottom", "0.4in"), "marginLeft": ("margin_left", "0.4in"), "marginRight": ("margin_right", "0.4in"), "preferCSSPageSize": ("prefer_css_page_size", False), } def to_renderscreenshot(url=None, html=None, options=None): options = options or {} pdf = {ours: options.get(theirs, default) for theirs, (ours, default) in OPTION_MAP.items()} if options.get("pageRanges"): pdf["page_ranges"] = options["pageRanges"] if options.get("displayHeaderFooter"): for theirs, ours in (("headerTemplate", "header"), ("footerTemplate", "footer")): if options.get(theirs): pdf[ours] = options[theirs] body = {"output": {"format": "pdf"}, "pdf": pdf} body.update({"url": url} if url else {"html": html}) if options.get("delay"): body["wait"] = {"delay": options["delay"]} return body def via_api2pdf(url=None, html=None, options=None): kind = "url" if url else "html" r = requests.post(f"{API2PDF_BASE}/chrome/pdf/{kind}", timeout=120, headers={"Authorization": os.environ["API2PDF_API_KEY"]}, json={kind: url or html, "options": options or {}}) data = r.json() if r.status_code != 200 or not data.get("Success"): raise RuntimeError(f"Api2Pdf: {data.get('Error') or r.status_code}") f = requests.get(data["FileUrl"], timeout=60) # deleted after 24 hours f.raise_for_status() return f.content def via_renderscreenshot(url=None, html=None, options=None): r = requests.post(f"{RS_BASE}/v1/screenshot", timeout=120, headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, json=to_renderscreenshot(url, html, options)) if r.status_code != 200: code = r.json().get("error", {}).get("code", "unknown") if r.headers.get("Content-Type", "").startswith("application/json") else "unknown" raise RuntimeError(f"RenderScreenshot {r.status_code}: {code}") return r.content def render_pdf(**job): if os.environ.get("PDF_PROVIDER") == "renderscreenshot": return via_renderscreenshot(**job) return via_api2pdf(**job) if __name__ == "__main__": url, out = sys.argv[1], sys.argv[2] pdf = render_pdf(url=url, options={"printBackground": True, "marginTop": "1cm"}) with open(out, "wb") as fh: fh.write(pdf) print(f"{os.environ.get('PDF_PROVIDER', 'api2pdf')}: {len(pdf)} bytes -> {out}")
Ruby
# pdf_provider.rb: one PdfProvider.render for Api2Pdf and RenderScreenshot, chosen by PDF_PROVIDER. require 'json' require 'net/http' require 'uri' module PdfProvider API2PDF_BASE = ENV.fetch('API2PDF_BASE', 'https://v2.api2pdf.com') RS_BASE = ENV.fetch('RS_BASE', 'https://api.renderscreenshot.com') # Api2Pdf option name => [RenderScreenshot pdf.* name, Api2Pdf default] OPTION_MAP = { 'width' => ['width', '8.27in'], 'height' => ['height', '11.69in'], 'landscape' => ['landscape', false], 'printBackground' => ['print_background', false], 'scale' => ['scale', 1], 'preferCSSPageSize' => ['prefer_css_page_size', false], 'marginTop' => ['margin_top', '0.4in'], 'marginBottom' => ['margin_bottom', '0.4in'], 'marginLeft' => ['margin_left', '0.4in'], 'marginRight' => ['margin_right', '0.4in'] }.freeze module_function def to_renderscreenshot(url: nil, html: nil, options: {}) pdf = OPTION_MAP.to_h { |theirs, (ours, default)| [ours, options.fetch(theirs, default)] } pdf['page_ranges'] = options['pageRanges'] if options['pageRanges'] if options['displayHeaderFooter'] pdf['header'] = options['headerTemplate'] if options['headerTemplate'] pdf['footer'] = options['footerTemplate'] if options['footerTemplate'] end body = { 'output' => { 'format' => 'pdf' }, 'pdf' => pdf } url ? body['url'] = url : body['html'] = html body['wait'] = { 'delay' => options['delay'] } if options['delay'] body end def post_json(uri, headers, body) uri = URI(uri) Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 120) do |http| http.post(uri.path, JSON.generate(body), headers.merge('Content-Type' => 'application/json')) end end def via_api2pdf(url: nil, html: nil, options: {}) kind = url ? 'url' : 'html' res = post_json("#{API2PDF_BASE}/chrome/pdf/#{kind}", { 'Authorization' => ENV.fetch('API2PDF_API_KEY') }, { kind => url || html, 'options' => options }) data = JSON.parse(res.body) raise "Api2Pdf: #{data['Error'] || res.code}" unless res.is_a?(Net::HTTPSuccess) && data['Success'] file = Net::HTTP.get_response(URI(data['FileUrl'])) # deleted after 24 hours raise "Api2Pdf file download: #{file.code}" unless file.is_a?(Net::HTTPSuccess) file.body end def via_renderscreenshot(url: nil, html: nil, options: {}) res = post_json("#{RS_BASE}/v1/screenshot", { 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}" }, to_renderscreenshot(url: url, html: html, options: options)) unless res.is_a?(Net::HTTPSuccess) code = (JSON.parse(res.body).dig('error', 'code') rescue 'unknown') raise "RenderScreenshot #{res.code}: #{code}" end res.body end def render(**job) ENV['PDF_PROVIDER'] == 'renderscreenshot' ? via_renderscreenshot(**job) : via_api2pdf(**job) end end if $PROGRAM_NAME == __FILE__ url, out = ARGV pdf = PdfProvider.render(url: url, options: { 'printBackground' => true, 'landscape' => true }) File.binwrite(out, pdf) puts "#{ENV.fetch('PDF_PROVIDER', 'api2pdf')}: #{pdf.bytesize} bytes -> #{out}" end
All three raise on any failure (an HTTP error, Success: false, or a failed file download), so a job runner sees a failed job instead of an empty PDF. Add retries in the job runner, not in the adapter: for our API, retry on 429 after the Retry-After header and on errors whose retryable field is true, as the error handling guide describes.
If you would rather not keep your own HTTP code, the official SDKs build the same request. In Node, TakeOptions.url(url).format('pdf').pdfPaperSize('a4').pdfPrintBackground().pdfMargin('0.4in') covers the common case; see the Node, Python and Ruby SDK pages.
What we have not measured
We have not yet run the head-to-head that our comparison standard asks for. Missing today:
- Latency (p50 and p95, cold and warm) for both services on the same documents.
- Success rate and output size on the same set of URLs and HTML templates.
- Side-by-side renders of the same pages from both services.
What we can say from engines alone: both print with Chrome, so the same HTML with the same size, margins and background settings should lay out the same, apart from differences between Chrome versions (Api2Pdf lists 130). Fonts are the usual source of differences between two Chrome services; ship web fonts in your HTML rather than relying on the fonts installed on either side.
When to stay with Api2Pdf
Stay if most of these are true:
- Your documents are small and render in a couple of seconds, so the per-second price stays low.
- You convert Word or Excel files, or you use its merge and password tools.
- You already copy
FileUrlto your own storage right after each call. - Your volume swings a lot from month to month and you only want to pay for what you use.
- You need more than our largest plan covers.
Move if you want the PDF in the response, a fixed bill, page cleanup (banners, ads, print media) before printing, or screenshots from the same API. To test with your own documents, create a free account and run the adapter with PDF_PROVIDER=renderscreenshot next to your current setup.