PDFShift Alternative: Option Map, Prices and Translator

Move off PDFShift without rewriting calls: a tested translator for its request body in Node, Python and Ruby, prices at three volumes and Chrome versions.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product. PDFShift is not ours, and we do not have a PDFShift account, so this page contains no PDFs rendered by PDFShift and no PDFShift timings. Its parameters and prices come from its convert endpoint reference and pricing page, read on 2026-10-18. The Chrome tests and code checks are ours, from the same day.

PDFShift and RenderScreenshot both print with Chrome, so the layout of your documents will not change much when you move. What changes is the request shape, the billing unit and a handful of defaults that bite on the first day: paper size, header placeholders and the Chrome version. This page maps every PDFShift option, prices both services at three volumes, and gives you a translator that turns an existing PDFShift request body into ours, so your call sites stay as they are.

For the wider market, start with the HTML to PDF API roundup. If you are weighing PDFShift against a pay-per-use service, PDFShift vs Api2Pdf has a cost model for that. Already on Api2Pdf? See the Api2Pdf alternative.

The short answer

Stay with PDFShift if Move to RenderScreenshot if
You send around 100,000 PDFs a month: it has a list price there, we do not You send up to tens of thousands a month and want the lower price at 1,000 and 10,000
You need password protection or a watermark on the PDF You want a public CDN link to every PDF (X-Cache-URL) without a storage setup
You need to pin Chrome 116 for old templates You want a flat price per document even when PDFs are large
You use the is_hipaa or is_gdpr switches You want screenshots, OG images and PDFs from one key and one endpoint
Pages need up to 900 seconds to load Pages load within 60 seconds

The Chrome version is part of your template

PDFShift lets you choose the Chromium build with the X-Processor-Version header (116 or 142). That matters more than it sounds. We printed the same three templates with five Chrome builds on 2026-10-18 (Puppeteer 24.43.1, page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true }), macOS on an Apple M1 Pro):

Chrome build Report: CSS "Page X of Y" counters printed Landscape named page Report pages Report file size
128 0 of 6 pages Yes 6 69,892 bytes
137 5 of 6 pages Yes 6 68,989 bytes
141 5 of 6 pages Yes 6 68,989 bytes
148 5 of 6 pages Yes 6 68,989 bytes
155 5 of 6 pages Yes 6 68,989 bytes

The report uses @page { @bottom-center { content: "Page " counter(page) " of " counter(pages) } } and suppresses the counter on the cover, so 5 of 6 is the correct result. Chrome 128 printed none: CSS page margin boxes arrived after it. On PDFShift's 116 processor, expect the same result as our 128 row. On 142, CSS counters should print, though we could not check PDFShift's build directly.

The practical rule for any migration: find out which Chrome both sides run, and test the templates that depend on newer CSS first. If your templates print page numbers with PDFShift's footer option rather than CSS, the version matters less, because both services fill the numbers in through Chrome's header and footer templates.

Option map

PDFShift (POST /v3/convert/pdf) RenderScreenshot (POST /v1/screenshot) Notes
X-API-Key header Authorization: Bearer rs_live_...
source (URL or HTML, detected) url or html Send exactly one
sandbox: true Use free plan credits No watermark mode
format: "A4" (the default) pdf.paper: "a4" Our default is letter. Ledger is not accepted
format: "210mmx297mm" pdf.width, pdf.height
landscape pdf.landscape
margin (object or value) pdf.margin, pdf.margin_top and the other sides CSS units as strings
zoom pdf.scale Same 0.1 to 2 range
pages: "1-3" pdf.page_ranges: "1-3"
disable_backgrounds: true pdf.print_background: false
use_print: true browser.media: "print" Both default to screen CSS
header.source, footer.source pdf.header, pdf.footer Placeholders differ; see below
header.height, start_at Margins; no start_at
wait_for_network (default on) wait.until: "networkidle" Ours defaults to load; set it
delay (max 10,000 ms) wait.delay
wait_for (global function) wait.for_selector Add an element instead of a function
timeout (up to 900 s) wait.timeout (up to 60 s)
lazy_load_images none Wait with networkidle, or inject a script that scrolls
http_headers, cookies, auth network.headers, network.cookies, network.auth Our cookies need a domain
css, javascript page.styles, page.scripts Arrays of strings or {url} objects; see page modifications. The translator leaves these to you
filename (returns JSON with a 2-day S3 link) output.filename + X-Cache-URL We return the bytes and a CDN link together
s3_destination Custom storage (storage.path with {hash} and {ext}) See storage
webhook webhook.url Ours returns 202 and a job ID
protection, watermark, is_hipaa, is_gdpr, remove_blank none

PDFShift fills {{page}}, {{total}}, {{date}}, {{title}} and {{url}} in its templates. Chrome's own templates, which our pdf.header and pdf.footer pass through, use empty elements with a class: <span class="pageNumber"></span>, totalPages, date, title and url. The translator below rewrites one into the other.

Two Chrome behaviors we confirmed while testing: a template without an explicit font-size prints at about 1 pt, and a footer with no header makes Chrome print its default header (date and page title). The translator adds an empty header for that reason. The PDF API overview has the full list.

Prices at three volumes

As listed on each pricing page on 2026-10-18. Each cell is the cheapest monthly plan that covers the volume, assuming every PDF is under 5 MB.

PDFs per month PDFShift RenderScreenshot
1,000 $24 (Boost, 2,500 credits) $10
10,000 $99 (Business, 25,000 credits) $40
100,000 $249 (Shift 100k) Above our largest plan (60,000 credits); not self-serve today

Three billing differences change those numbers for some workloads:

  • File size. PDFShift charges one credit per started 5 MB, so a 12 MB catalogue costs 3 credits. We charge one credit per successful render at any size.
  • Overage. PDFShift's paid plans continue past the quota at a per-credit rate. Ours stop with insufficient_credits until the period resets or you upgrade, so the bill never grows on its own (credits and billing).
  • Re-renders. If you print the same document twice inside the cache TTL with cache.available: true, the second response is the stored PDF and costs 0 credits.

The translator

Rather than rewrite every call, keep building the PDFShift body your code already builds and translate it at the edge. Each version below throws on options that have no equivalent, so nothing is dropped without you knowing. A bare number in margin is treated as pixels; check that against how you used PDFShift.

We ran unit checks for all three translators on 2026-10-18 (Node 22.18.0, Python 3.15, Ruby 4.0.7): paper names, custom sizes, margins, placeholder rewriting, cookies and the unsupported-option error. The HTTP calls were not run against either live API for this page.

Node.js

// Translate a PDFShift /v3/convert/pdf body into a RenderScreenshot /v1/screenshot body.
// Throws on options that have no equivalent, so nothing is dropped silently.
const PAPER = new Set(['a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'letter', 'legal', 'tabloid']);
const VARS = { page: 'pageNumber', total: 'totalPages', date: 'date', title: 'title', url: 'url' };
const UNSUPPORTED = ['protection', 'watermark', 'start_at', 's3_destination', 'remove_blank', 'encode', 'css', 'javascript'];

const template = (html) => html.replace(/\{\{\s*(page|total|date|title|url)\s*\}\}/g, (_, v) => `<span class="${VARS[v]}"></span>`);
const px = (v) => (typeof v === 'number' || /^\d+$/.test(v) ? `${v}px` : v);

export function translate(ps) {
  const unsupported = UNSUPPORTED.filter((k) => ps[k] !== undefined && ps[k] !== null && ps[k] !== false);
  if (unsupported.length) throw new Error(`no RenderScreenshot equivalent for: ${unsupported.join(', ')}`);

  const source = String(ps.source);
  const body = /^https?:\/\//i.test(source) ? { url: source } : { html: source };
  const pdf = { print_background: !ps.disable_backgrounds };
  const format = String(ps.format ?? 'A4').toLowerCase();       // PDFShift defaults to A4
  const custom = format.match(/^([\d.]+(?:in|cm|mm))x([\d.]+(?:in|cm|mm))$/);
  if (custom) [pdf.width, pdf.height] = [custom[1], custom[2]];
  else if (PAPER.has(format)) pdf.paper = format;
  else throw new Error(`paper format ${ps.format} is not supported`);
  if (ps.landscape) pdf.landscape = true;
  if (ps.zoom) pdf.scale = ps.zoom;
  if (ps.pages) pdf.page_ranges = String(ps.pages);
  if (ps.margin !== undefined && ps.margin !== null) {
    if (typeof ps.margin === 'object') for (const side of ['top', 'right', 'bottom', 'left']) {
      if (ps.margin[side] !== undefined) pdf[`margin_${side}`] = px(ps.margin[side]);
    } else pdf.margin = px(ps.margin);
  }
  if (ps.header?.source) pdf.header = template(ps.header.source);
  if (ps.footer?.source) {
    pdf.footer = template(ps.footer.source);
    pdf.header ??= '<span></span>';     // Chrome prints a default header if only a footer is set
  }
  Object.assign(body, { output: { format: 'pdf' }, pdf });
  if (ps.filename) body.output.filename = ps.filename;
  if (ps.use_print) body.browser = { media: 'print' };
  const wait = {};
  if (ps.wait_for_network !== false) wait.until = 'networkidle';  // PDFShift waits for the network by default
  if (ps.delay) wait.delay = ps.delay;
  if (Object.keys(wait).length) body.wait = wait;
  const network = {};
  if (ps.http_headers) network.headers = ps.http_headers;
  if (ps.cookies) {
    if (!body.url) throw new Error('cookies need a URL source: RenderScreenshot requires a cookie domain');
    const domain = new URL(body.url).hostname;
    network.cookies = ps.cookies.map(({ name, value, secure, http_only }) => ({ name, value, domain, secure, http_only }));
  }
  if (ps.auth) network.auth = { type: 'basic', username: ps.auth.username, password: ps.auth.password };
  if (Object.keys(network).length) body.network = network;
  if (ps.webhook) body.webhook = { url: typeof ps.webhook === 'string' ? ps.webhook : ps.webhook.url };
  return body;
}

Wire it in where you call PDFShift today:

import { translate } from './translate.mjs';

export async function convertPdf(pdfshiftBody) {
  const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(translate(pdfshiftBody)),
    signal: AbortSignal.timeout(90_000),
  });
  if (!res.ok) throw new Error(`RenderScreenshot ${res.status}: ${await res.text()}`);
  return { pdf: Buffer.from(await res.arrayBuffer()), link: res.headers.get('x-cache-url') };
}

Python

"""Translate a PDFShift request body into a RenderScreenshot body and render it."""
import os
import re
from urllib.parse import urlparse

import requests

PAPER = {'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'letter', 'legal', 'tabloid'}
VARS = {'page': 'pageNumber', 'total': 'totalPages', 'date': 'date', 'title': 'title', 'url': 'url'}
UNSUPPORTED = ('protection', 'watermark', 'start_at', 's3_destination', 'remove_blank', 'encode', 'css', 'javascript')


def _template(html):
    return re.sub(r'\{\{\s*(page|total|date|title|url)\s*\}\}', lambda m: f'<span class="{VARS[m.group(1)]}"></span>', html)


def _px(value):
    return f'{value}px' if str(value).isdigit() else value


def translate(ps):
    bad = [k for k in UNSUPPORTED if ps.get(k) not in (None, False)]
    if bad:
        raise ValueError(f"no RenderScreenshot equivalent for: {', '.join(bad)}")
    source = str(ps['source'])
    body = {'url': source} if re.match(r'https?://', source, re.I) else {'html': source}
    pdf = {'print_background': not ps.get('disable_backgrounds', False)}
    fmt = str(ps.get('format', 'A4')).lower()
    custom = re.fullmatch(r'([\d.]+(?:in|cm|mm))x([\d.]+(?:in|cm|mm))', fmt)
    if custom:
        pdf['width'], pdf['height'] = custom.groups()
    elif fmt in PAPER:
        pdf['paper'] = fmt
    else:
        raise ValueError(f"paper format {ps['format']} is not supported")
    if ps.get('landscape'):
        pdf['landscape'] = True
    if ps.get('zoom'):
        pdf['scale'] = ps['zoom']
    if ps.get('pages'):
        pdf['page_ranges'] = str(ps['pages'])
    margin = ps.get('margin')
    if isinstance(margin, dict):
        pdf.update({f'margin_{side}': _px(v) for side, v in margin.items() if side in ('top', 'right', 'bottom', 'left')})
    elif margin is not None:
        pdf['margin'] = _px(margin)
    if (ps.get('header') or {}).get('source'):
        pdf['header'] = _template(ps['header']['source'])
    if (ps.get('footer') or {}).get('source'):
        pdf['footer'] = _template(ps['footer']['source'])
        pdf.setdefault('header', '<span></span>')
    body.update(output={'format': 'pdf'}, pdf=pdf)
    if ps.get('use_print'):
        body['browser'] = {'media': 'print'}
    wait = {'until': 'networkidle'} if ps.get('wait_for_network', True) else {}
    if ps.get('delay'):
        wait['delay'] = ps['delay']
    if wait:
        body['wait'] = wait
    network = {}
    if ps.get('http_headers'):
        network['headers'] = ps['http_headers']
    if ps.get('cookies'):
        if 'url' not in body:
            raise ValueError('cookies need a URL source: RenderScreenshot requires a cookie domain')
        domain = urlparse(body['url']).hostname
        network['cookies'] = [{'name': c['name'], 'value': c['value'], 'domain': domain,
                               'secure': c.get('secure', False), 'http_only': c.get('http_only', False)}
                              for c in ps['cookies']]
    if ps.get('auth'):
        network['auth'] = {'type': 'basic', **ps['auth']}
    if network:
        body['network'] = network
    return body


def convert(ps):
    resp = requests.post('https://api.renderscreenshot.com/v1/screenshot',
                         headers={'Authorization': f"Bearer {os.environ['RS_API_KEY']}"},
                         json=translate(ps), timeout=90)
    resp.raise_for_status()
    return resp.content

Ruby

The Ruby version covers the options most apps send (paper, orientation, margins, templates, print CSS and waits) and raises on the rest.

# Translate the PDFShift options most apps use into a RenderScreenshot body, then render.
require 'json'
require 'net/http'

module PdfshiftToRs
  PAPER = %w[a0 a1 a2 a3 a4 a5 a6 letter legal tabloid].freeze
  VARS = { 'page' => 'pageNumber', 'total' => 'totalPages', 'date' => 'date', 'title' => 'title', 'url' => 'url' }.freeze
  UNSUPPORTED = %w[protection watermark start_at s3_destination remove_blank encode css javascript].freeze

  module_function

  def translate(ps)
    ps = ps.transform_keys(&:to_s)
    bad = UNSUPPORTED.select { |k| ps[k] }
    raise ArgumentError, "no RenderScreenshot equivalent for: #{bad.join(', ')}" if bad.any?

    source = ps.fetch('source').to_s
    body = source.match?(%r{\Ahttps?://}i) ? { url: source } : { html: source }
    paper = ps.fetch('format', 'A4').to_s.downcase
    raise ArgumentError, "paper format #{paper} is not supported" unless PAPER.include?(paper)

    pdf = { paper: paper, print_background: !ps['disable_backgrounds'] }
    pdf[:landscape] = true if ps['landscape']
    pdf[:page_ranges] = ps['pages'].to_s if ps['pages']
    case ps['margin']
    when Hash then ps['margin'].each { |side, v| pdf[:"margin_#{side}"] = v.to_s.match?(/\A\d+\z/) ? "#{v}px" : v }
    when nil then nil
    else pdf[:margin] = ps['margin'].to_s.match?(/\A\d+\z/) ? "#{ps['margin']}px" : ps['margin']
    end
    pdf[:header] = template(ps.dig('header', 'source')) if ps.dig('header', 'source')
    if ps.dig('footer', 'source')
      pdf[:footer] = template(ps.dig('footer', 'source'))
      pdf[:header] ||= '<span></span>'
    end
    body.merge!(output: { format: 'pdf' }, pdf: pdf)
    body[:browser] = { media: 'print' } if ps['use_print']
    wait = ps['wait_for_network'] == false ? {} : { until: 'networkidle' }
    wait[:delay] = ps['delay'] if ps['delay']
    body[:wait] = wait unless wait.empty?
    body
  end

  def template(html)
    html.gsub(/\{\{\s*(page|total|date|title|url)\s*\}\}/) { "<span class=\"#{VARS[Regexp.last_match(1)]}\"></span>" }
  end

  def convert(ps)
    uri = URI('https://api.renderscreenshot.com/v1/screenshot')
    req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json',
                                   'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}")
    req.body = translate(ps).to_json
    res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 90) { |http| http.request(req) }
    raise "RenderScreenshot #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)

    res.body
  end
end

Rolling it out

  1. Log the PDFShift bodies you send for a day. The translator's errors tell you which options you rely on that have no equivalent here.
  2. Render a sample both ways. Keep sandbox: true on the PDFShift side so the comparison costs nothing there, and compare page counts and pdftotext output.
  3. Fix defaults, not templates. Most differences come from paper size (A4 there, Letter here), networkidle waits and the Chrome version. The translator handles the first two.
  4. Switch one document type at a time, and keep the PDFShift call behind a flag until the error rate is flat.

When to stay with PDFShift

  • High volume at a fixed price. At 100,000 documents a month, PDFShift lists $249 and we have no self-serve plan.
  • Security features on the file. Passwords, print and copy restrictions and watermarks are request options there and do not exist here.
  • Very slow pages. Its timeout goes up to 900 seconds; our wait timeout stops at 60.
  • Older templates tied to Chrome 116. Pinning a processor version is a PDFShift feature we do not offer.

If none of those apply, get a free API key, run a day of your logged PDFShift bodies through the translator, and compare the files. The PDF parameters reference lists every option the translator writes.

Last tested 2026-10-18: Chrome 128.0.6613.119, 137.0.7151.55, 141.0.7390.122, 148.0.7778.97 and 155.0.8059.79 through Puppeteer 24.43.1 on Node 22.18.0, macOS on an Apple M1 Pro; translator unit checks in Node 22.18.0, Python 3.15 and Ruby 4.0.7. PDFShift parameters and prices read from its site on the same date.

Frequently asked questions

How does PDFShift pricing work?
PDFShift sells monthly plans with credits. One credit is one document per started 5 MB of output, so a 14 MB PDF uses 3 credits. As listed on its pricing page in October 2026, plans run from a free tier of 50 credits to $999 for 1,000,000 credits, with a per-credit overage on paid plans.
Which Chrome version does PDFShift use?
Its convert endpoint takes an X-Processor-Version header with the value 116 or 142, and new accounts default to the latest. The version changes what CSS prints: in our test, Chrome 128 printed no CSS page counters and Chrome 137 and later did.
Will my PDFShift header and footer templates work elsewhere?
The HTML will, the placeholders will not. PDFShift uses {{page}} and {{total}}; Chrome-based APIs that expose Chrome's templates use span elements with the classes pageNumber and totalPages. The translator on this page rewrites them.
Is there a PDFShift alternative that also makes screenshots?
PDFShift itself has PNG, JPEG and WebP endpoints. RenderScreenshot returns PDF, PNG, JPEG or WebP from the same endpoint by changing output.format, and adds a public CDN link to every render.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.