URL2PNG Alternative: Move Signed Screenshot URLs

Migrating from URL2PNG? Map its token URLs and options to RenderScreenshot signed URLs, handle expiry, and switch with code in Node.js, Python and Ruby.

By Simon Chiu · Updated

Most URL2PNG integrations are one helper function: it takes a page URL, adds options such as a viewport and a thumbnail width, signs the query with an MD5 token, and returns an image URL that goes straight into an <img> tag. Moving to RenderScreenshot means replacing that one function. The new URLs are signed with HMAC-SHA256 instead of MD5, and they expire (at most 30 days), which is the one change that needs planning. This page covers the option mapping, the expiry change and the helper rewrite in Node.js, Python and Ruby.

Disclosure: I build RenderScreenshot. URL2PNG option names on this page are the ones its v6-style integrations commonly use; check them against your own helper, which is the source of truth for what you send today.

This page is part of our screenshot API comparison series. The signing details are in our signed URL docs.

Verdict

Stay with URL2PNG if Move to RenderScreenshot if
Your image URLs must work forever without regeneration You can generate image URLs when pages render
The integration works and you have no reason to touch it You want modern options: cookie-banner blocking, dark mode, WebP/AVIF, PDF
You depend on an option that has no row in the mapping below You want official SDKs in Node.js, Python, Ruby, PHP, Java and Go
You want typed errors, batch jobs and webhooks for server-side captures

The one real change: URLs that expire

A URL2PNG-style URL is deterministic. The token is a hash of the query string and your secret, so the same options always give the same URL, and it never expires. That made it easy to store the image URL in a database once.

RenderScreenshot signed URLs include an expires timestamp in the signed data, at most 30 days ahead. After that the URL returns an expired_signature error. The reason is safety: a leaked URL stops working, and nobody can reuse your quota from an old page forever.

What that means for each place you use URL2PNG today:

Where the image URL lives URL2PNG pattern RenderScreenshot pattern
Server-rendered pages (<img>, og:image) Build once, store in DB Build in the view on each render, with 7 to 30 days of expiry
Single-page app API returns stored URL API endpoint returns a freshly signed URL
Emails, PDFs, static exports Store URL2PNG URL Render once, store the file in your bucket, link to that file
Server-side jobs (thumbnails, archives) Fetch URL2PNG URL Call POST /v1/screenshot with your API key, no signing needed

Repeat requests for the same capture are served from cache for its TTL (24 hours by default, up to 30 days), so regenerating URLs on each page view does not mean re-rendering on each view. See caching for how the cache key is built.

Option mapping

URL2PNG option RenderScreenshot Notes
url url
viewport=1280x1024 width=1280, height=1024 (JSON: viewport.width, viewport.height) Two parameters instead of one
thumbnail_max_width=600 output.resize.width: 600 output.resize.fit controls cropping
fullpage=true full_page=true (JSON: capture.mode: "full_page") Add capture.scroll.simulate for lazy images
force=true cache.refresh: true Forces a fresh render
unique=<value> cache.key Your own cache key
ttl=<seconds> cache_ttl (JSON: cache.ttl) 3,600 to 2,592,000 seconds; default 86,400
delay=<n> delay in ms (JSON: wait.delay) Check the unit your helper uses
custom_css_url=<url> page.styles: [{ "url": "..." }] Inline CSS strings also work
user_agent=<ua> browser.user_agent
accept_languages=<langs> network.headers: { "Accept-Language": "..." } or browser.locale
say_cheese=true (wait for an element your page adds when ready) wait.for_selector: "#your-ready-element" Keep the same element, change nothing on the page
/png/ in the path format=png (also jpeg, webp, avif, pdf)

Query parameters available in GET form are listed in the GET endpoint reference. Options with no GET form (page.styles, cache.key, output.resize) are set through the SDK options or a POST body.

Before: a URL2PNG helper

Your helper probably looks something like this:

// legacy-url2png.mjs: the kind of helper URL2PNG integrations used (v6-style URL, MD5 token).
// Your own helper is the source of truth; compare it with this before you map options.
import { createHash } from 'node:crypto';

export function url2pngUrl(target, options = {}) {
  const query = new URLSearchParams({ url: target, ...options }).toString();
  const token = createHash('md5').update(query + process.env.URL2PNG_SECRET).digest('hex');
  return `https://api.url2png.com/v6/${process.env.URL2PNG_APIKEY}/${token}/png/?${query}`;
}

We ran this locally to check that it builds a well-formed URL. We did not call URL2PNG with it, so do not copy it as a reference for their signing rules.

After: Node.js

// signed-url.mjs: build an expiring RenderScreenshot image URL for an <img> or og:image tag.
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

export function screenshotUrl(pageUrl, { days = 7 } = {}) {
  return client.generateUrl(
    TakeOptions.url(pageUrl).width(1280).height(800).format('png'),
    new Date(Date.now() + days * 24 * 60 * 60 * 1000), // at most 30 days
  );
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const url = screenshotUrl(process.argv[2] ?? 'https://example.com');
  console.log(url.replace(/signature=[0-9a-f]+/, 'signature=…'));
}

In an Express view, call it where you used to call the URL2PNG helper:

// Express route (fragment)
app.get('/directory/:id', async (req, res) => {
  const site = await db.sites.find(req.params.id);
  res.render('site', { site, thumbnail: screenshotUrl(site.url) });
});

After: Python

# signed_url.py: an expiring RenderScreenshot image URL for Django, Flask or Jinja templates.
import os
from datetime import datetime, timedelta

from renderscreenshot import Client, TakeOptions

client = Client(os.environ["RS_API_KEY"])


def screenshot_url(page_url: str, days: int = 7) -> str:
    return client.generate_url(
        TakeOptions.url(page_url).width(1280).height(800).format("png"),
        datetime.now() + timedelta(days=days),  # at most 30 days
    )
<!-- Jinja or Django template; the view passes thumbnail=screenshot_url(site.url) -->
<img src="{{ thumbnail }}" width="600" alt="Thumbnail of {{ site.name }}">

The Python SDK installs from GitHub until it is on PyPI: see the Python SDK page.

After: Ruby on Rails

# signed_url.rb: an expiring RenderScreenshot image URL, for a Rails helper or any Ruby view.
require 'renderscreenshot'

module ScreenshotUrl
  CLIENT = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))

  def self.for(page_url, expires_in: 7 * 86_400) # at most 30 days
    CLIENT.generate_url(
      RenderScreenshot::TakeOptions.url(page_url).width(1280).height(800).format('png'),
      expires_at: Time.now + expires_in,
      signing_key: ENV.fetch('RS_SECRET_KEY'),    # rs_secret_...
      public_key_id: ENV.fetch('RS_PUBLIC_KEY_ID') # rs_pub_...
    )
  end
end

puts ScreenshotUrl.for(ARGV.fetch(0, 'https://example.com')).sub(/signature=\h+/, 'signature=…') if $PROGRAM_NAME == __FILE__
<%%# app/views/sites/show.html.erb %>
<%%= image_tag ScreenshotUrl.for(@site.url), width: 600, alt: "Thumbnail of #{@site.name}" %>

Tested on 2026-10-15: the Node.js helper (renderscreenshot 1.1.0, Node.js 22.18.0) and the Ruby helper (renderscreenshot gem 1.0.0, Ruby 4.0.7) were run locally and produced signed URLs with an expires value 7 days ahead. Signing happens on your server with no network call, so this checks the helper, not a live render. The Python helper follows the Python SDK docs and was not run for this page. The Express and Rails view fragments were not run. Never log a full signed URL in production; the examples above mask the signature when they print.

Keep secret keys on the server. The signed URL carries the capture options, the expiry and the signature, never a secret key. The signed URL docs explain which key each SDK signs with.

Roll out the helper switch

  1. Wrap first. If your code calls the URL2PNG helper in many places, put one thumbnailUrl() function in front of it, with no behaviour change, and deploy.
  2. Switch by flag. Inside the wrapper, return the RenderScreenshot URL when a feature flag is on. Turn it on for an internal page first.
  3. Warm the cache for busy pages. The first request for each new URL renders it. For a directory of thousands of sites, run the batch endpoint once over the list so visitors get cached images from the start.
  4. Fix stored URLs. Search your database for api.url2png.com. Replace stored image URLs with page URLs and build the image URL at render time, or download the images to your own storage if they must live forever.
  5. Retire the old key after a full cache cycle with no URL2PNG traffic in your logs.

Pricing at 1,000, 10,000 and 100,000 screenshots

RenderScreenshot plans, checked 2026-10-15 (see pricing). Requests served from cache are not new renders; each response reports its cost in the X-Credits-Used header.

Screenshots per month Plan Price
Up to 150 Free, no credit card $0
1,000 Starter (2,500 credits) $10
10,000 Growth (12,000 credits) $40
100,000 Above Scale (60,000 credits for $100): custom plan Quote

Compare with URL2PNG's current plans on its own pricing page at the same volumes, and count how many of your requests are repeats that a cache would absorb.

When to stay with URL2PNG

  • You have stored URL2PNG image URLs in places you cannot update (sent emails, partner sites, printed QR codes that point to images).
  • Your integration is stable, cheap at your volume, and has no feature gap you care about.
  • You rely on an option that has no equivalent in the mapping table.

Try the new helper on one page

Create a free key, drop the helper for your language next to the URL2PNG one, and render one page with both. When the images match what you expect, switch the flag.

Frequently asked questions

Do RenderScreenshot signed URLs expire?
Yes. Every signed URL carries an expires timestamp, at most 30 days ahead. Generate the URL when you render the page that shows the image, not once when you store the record.
What replaces URL2PNG's force option?
cache.refresh. It renders a fresh screenshot and updates the cache. Without it, repeat requests within the cache TTL are served from cache.
Can I keep using image URLs in emails after switching?
Not signed URLs, because an email can be opened after the URL expires. For emails and other long-lived documents, render the image once, store it in your own bucket with the storage parameter or your own upload, and link to that file.
Do I need to change my img tags?
Only the URL inside them. Replace the helper that builds URL2PNG URLs with one that builds RenderScreenshot signed URLs, and keep the same call sites.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.