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
- 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. - Switch by flag. Inside the wrapper, return the RenderScreenshot URL when a feature flag is on. Turn it on for an internal page first.
- 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, addcache_ttl(up to 2,592,000 seconds) to the signed fields to render each site less often. - 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. - 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.