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