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. If you reach URL2PNG through Cloudinary's add-on, the Cloudinary alternative maps those delivery URLs instead.

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, 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

By default every request renders fresh and uses one credit, so an <img> that is viewed a thousand times would cost a thousand credits. The helpers below add cache_available=true to the signed URL. With it, a repeat request inside the cache TTL (24 hours by default, up to 30 days with cache_ttl) reuses the earlier render for 0 credits. They also round the expiry to a whole day, so the URL stays the same for a day and browsers can cache it too. 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> No equivalent The cache key is a hash of the rendering options, so different options already give a different image
ttl=<seconds> cache_ttl (JSON: cache.ttl) 3,600 to 2,592,000 seconds; default 86,400
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, pdf)

Query parameters available in GET form are listed in the GET endpoint reference. Options with no GET form (page.styles, output.resize, wait.for_selector) need a POST body, so they cannot go into a signed image URL. For those, render server-side with POST and store the file.

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}`;
}

This is a sketch for comparison only. It was not called against URL2PNG, so do not copy it as a reference for their signing rules.

Signed URLs use a key pair that is separate from your rs_live_ API key: a public key ID (rs_pub_) that goes into the URL, and a secret key (rs_secret_) that signs it and never leaves your server. The helpers below use the signing code from our signed URL docs, with no SDK needed.

After: Node.js

// signed-url.mjs: build an expiring RenderScreenshot image URL for an <img> or og:image tag.
import { createHmac } from 'node:crypto';

const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot';
const DAY = 24 * 60 * 60;

// Percent-encode like the server: keep A-Z a-z 0-9 - _ . ~, write a space as "+".
const encode = (value) => encodeURIComponent(String(value))
  .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
  .replace(/%20/g, '+');

export function screenshotUrl(pageUrl, { days = 7 } = {}) {
  // Whole days, so the URL stays the same for a day. At most 30 days ahead.
  const expires = (Math.floor(Date.now() / 1000 / DAY) + days) * DAY;
  const fields = {
    url: pageUrl,
    width: 1280,
    height: 800,
    format: 'png',
    cache_available: 'true', // reuse an identical render inside the cache TTL for 0 credits
    key_id: process.env.RS_PUBLIC_KEY_ID, // rs_pub_...
    expires,
  };
  const query = Object.entries(fields).map(([k, v]) => `${encode(k)}=${encode(v)}`).sort().join('&');
  const signature = createHmac('sha256', process.env.RS_SECRET_KEY).update(query).digest('hex');
  return `${ENDPOINT}?${query}&signature=${signature}`;
}

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 hashlib
import hmac
import os
import time
from urllib.parse import quote_plus

ENDPOINT = "https://api.renderscreenshot.com/v1/screenshot"
DAY = 24 * 3600


def _encode(value) -> str:
    # Same rules as the server: space -> "+", only A-Z a-z 0-9 - _ . ~ unescaped.
    return quote_plus(str(value), safe="")


def screenshot_url(page_url: str, days: int = 7) -> str:
    expires = (int(time.time()) // DAY + days) * DAY  # whole days, at most 30 ahead
    fields = {
        "url": page_url,
        "width": 1280,
        "height": 800,
        "format": "png",
        "cache_available": "true",  # reuse an identical render inside the TTL for 0 credits
        "key_id": os.environ["RS_PUBLIC_KEY_ID"],  # rs_pub_...
        "expires": expires,
    }
    query = "&".join(sorted(f"{_encode(k)}={_encode(v)}" for k, v in fields.items()))
    signature = hmac.new(os.environ["RS_SECRET_KEY"].encode(), query.encode(), hashlib.sha256).hexdigest()
    return f"{ENDPOINT}?{query}&signature={signature}"
<!-- Jinja or Django template; the view passes thumbnail=screenshot_url(site.url) -->
<img src="{{ thumbnail }}" width="600" alt="Thumbnail of {{ site.name }}">

After: Ruby on Rails

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

module ScreenshotUrl
  ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'
  DAY = 86_400

  def self.for(page_url, days: 7)
    expires = ((Time.now.to_i / DAY) + days) * DAY # whole days, at most 30 ahead
    fields = {
      'url' => page_url, 'width' => 1280, 'height' => 800, 'format' => 'png',
      'cache_available' => 'true', # reuse an identical render inside the TTL for 0 credits
      'key_id' => ENV.fetch('RS_PUBLIC_KEY_ID'), # rs_pub_...
      'expires' => expires
    }
    query = fields.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.sort.join('&')
    signature = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('RS_SECRET_KEY'), query)
    "#{ENDPOINT}?#{query}&signature=#{signature}"
  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}" %>

The helpers follow the signing steps in the signed URL docs. They were not run against the live API for this page, and the Express and Rails view fragments were not run either. Before you switch, sign one URL with your own keys, including a target URL with spaces and ~*'()!, and load it in a browser. 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 public key ID, the expiry and the signature, never a secret key. The signed URL docs have the same signer in PHP, Go and Java.

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. Budget one render per image per TTL. The first request for each image renders it and uses a credit; later requests inside the cache TTL reuse it for 0 credits because the helper sends cache_available. For a directory of thousands of sites, add cache_ttl (up to 2,592,000 seconds) to the signed fields to render each site less often.
  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 that reuse a render through cache_available cost 0 credits; 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 (cache_refresh=true on GET). It always renders a fresh screenshot. Without any cache flag every request also renders fresh; a request reuses an earlier render for 0 credits only when it sends cache_available=true, as the helpers on this page do.
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.