Api2Pdf Alternative: Option Map, Defaults and Migration

Moving off Api2Pdf? Map every Chrome PDF option, catch the three defaults that change your output, compare costs at 3 volumes, and use tested adapters.

By Simon Chiu · Updated

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:

  1. Put every PDF call behind one function, renderPdf(job), that still calls Api2Pdf.
  2. Add the RenderScreenshot path behind an environment flag, with Api2Pdf's defaults written out.
  3. Turn the flag on for a share of jobs, compare page counts and text (pdfinfo, pdftotext) for the same inputs, and fix template differences.
  4. 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 FileUrl to 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.

Frequently asked questions

How much does Api2Pdf cost?
As listed on Api2Pdf's pricing page on 2026-10-18: $1 a month, plus $0.001 per MB of output and $0.00019551 per second of compute. New accounts get one month of free credit. Its own example puts 10,000 PDFs of 0.1 MB at 2 seconds each at about $5.91 a month.
What does Api2Pdf return?
JSON with a FileUrl link to the PDF, plus fields such as Success, MbOut and Cost. The hosted file is deleted after 24 hours, so you download it in a second request. RenderScreenshot returns the PDF bytes in the response body.
Which Chrome version does Api2Pdf use?
Its Chrome options page listed Chrome 130 when we read it on 2026-10-18.
Will my Api2Pdf options work unchanged on another Chrome API?
The names map one to one, but three defaults differ: Api2Pdf defaults to an A4-sized page, 0.4 inch margins and no backgrounds. RenderScreenshot defaults to Letter, zero margins and backgrounds on. Send all three explicitly and the output matches.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.