Disclosure: RenderScreenshot is our product. Placid facts on this page come from its image API docs, URL API docs, URL API authentication docs and pricing page, all checked on 2026-10-18. We have no Placid account, so this page has no Placid renders, timings or side-by-side images. The adapter and rollout code ran against a local stub of both APIs, not the live services.
Placid is a template editor with an API. You design a card in its editor, give layers names such as title or photo, and your code fills them. RenderScreenshot renders HTML in Chromium. So the move is not a change of endpoint. It is a change of where the design lives: from Placid's editor to an HTML file in your repository. The OG image generator roundup compares the wider field; this page is about the move itself.
Short answer
| Placid is the better fit when | RenderScreenshot is the better fit when |
|---|---|
| Marketers edit templates and must not wait for a deploy | Developers own templates and review them in pull requests |
| You need video and PDF from the same template, or the Editor SDK in your own app | You make still images and PDFs, and also need website screenshots |
| Volume varies a lot by month and credit rollover helps | You want to render your site's real CSS, fonts and components |
| You want unsigned URL-based images with no server code | You can sign URLs on your server (every plan, including free) |
How the two models map
| Placid | RenderScreenshot | Notes |
|---|---|---|
template_uuid |
An HTML file or a route on your site | Rebuild once; the template is code from then on |
layers.<name>.text |
A placeholder in the HTML, filled and escaped by your code | The adapter below uses {{title.text}} |
layers.<name>.image |
<img src="..."> in the template |
Chromium fetches the image during the render |
layers.<name>.hide |
A hidden attribute or a CSS class |
|
modifications.width / height |
viewport.width / viewport.height |
Placid keeps the template's aspect ratio. In HTML, the viewport sets the layout, so a new size can get a new layout |
modifications.image_format (auto, jpg, png, webp) |
output.format (png, jpeg, webp, pdf) |
No auto. Pick PNG for flat cards and transparency, JPEG or WebP for photos |
modifications.filename |
output.filename |
|
create_now: true |
The default | POST /v1/screenshot waits for the render and returns the image |
Queue + polling_url |
Batches only: GET /v1/batch/:id |
|
webhook_success |
webhook.url with events: ["completed", "failed"] |
Webhook events are signed |
passthrough |
No equivalent | Keep your own job ID with the request ID |
transfer to S3 (keys in each request) |
storage with credentials saved in the dashboard |
Paid plans. storage.path must contain {hash} and {ext} |
image_url |
X-Cache-URL header, or image.url in JSON |
Lasts until the cache TTL ends, 30 days at most |
URL API /u/{template_uuid}?title[text]=... |
Signed GET /v1/screenshot?url=<your card page>&preset=og_card |
Our signature uses a separate rs_secret_ key; expires is required |
URL API &s= HMAC (optional) |
signature HMAC-SHA256 (required) |
Placid allows unsigned URLs unless you restrict them. A public URL here should always be signed, because an api_key query parameter would expose your key |
References for our side: HTML input, output options, webhooks, storage and signed URLs.
Rebuilding one template
Take a typical blog card: an author photo on the left, a title and a byline on the right. In HTML it is a short file. Layer names from Placid become placeholders, so the data your code sends does not change:
<!doctype html> <html><head><meta charset="utf-8"><style> body { margin: 0; width: 1200px; height: 630px; display: flex; align-items: center; gap: 48px; padding: 0 80px; box-sizing: border-box; background: #111827; color: #fff; font-family: system-ui, sans-serif; } img { width: 220px; height: 220px; border-radius: 50%; object-fit: cover; } img[hidden] { display: none; } h1 { font-size: 64px; line-height: 1.1; margin: 0; } p { font-size: 30px; color: #9ca3af; margin: 16px 0 0; } </style></head> <body> <img src="{{photo.image}}" alt="" {{photo.hide}}> <div><h1>{{title.text}}</h1><p>{{author.text}}</p></div> </body></html>
Three things change when you leave an editor:
- Text fitting is CSS now. In an editor, how a long title shrinks or wraps is a setting on the layer. In HTML you choose:
-webkit-line-clampto cut long titles, a smaller font size for long strings, orclamp()with container units. Test with the longest title you have. - Fonts are files you load. Use
@font-facein the template. Characters outside the font (emoji, CJK) need a fallback font, or they show as empty boxes. - Escaping is your job. A title is data. Without escaping, a title such as
<img src=x onerror=...>becomes markup in your card. The adapters below escape every text value.
The adapter: same layers, new renderer
Each adapter reads templates/<name>.html, fills the placeholders, and sends the HTML to POST /v1/screenshot. A layer value of hide: true fills {{layer.hide}} with hidden.
Node.js
// adapter.mjs: Placid-style layers in, PNG bytes out. Env: RS_API_KEY. import { readFile } from 'node:fs/promises'; const BASE = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const escapeHtml = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); export function fill(template, layers) { return template.replace(/\{\{(\w+)\.(\w+)\}\}/g, (_, layer, prop) => { const value = layers[layer]?.[prop]; if (prop === 'hide') return value ? 'hidden' : ''; return value == null ? '' : escapeHtml(value); }); } export async function render(templateName, layers, { format = 'png', width = 1200, height = 630 } = {}) { const template = await readFile(new URL(`./templates/${templateName}.html`, import.meta.url), 'utf8'); const res = await fetch(`${BASE}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ html: fill(template, layers), viewport: { width, height }, output: { format } }), signal: AbortSignal.timeout(60_000), }); if (!res.ok) { const { error } = await res.json().catch(() => ({})); throw new Error(`RenderScreenshot ${res.status} ${error?.code ?? ''}: ${error?.message ?? ''}`); } return { bytes: Buffer.from(await res.arrayBuffer()), cdnUrl: res.headers.get('x-cache-url') }; }
Python
# adapter.py: Placid-style layers in, PNG bytes out. Standard library only. Env: RS_API_KEY. import html, json, os, re, urllib.error, urllib.request from pathlib import Path BASE = os.environ.get("RS_BASE_URL", "https://api.renderscreenshot.com") TEMPLATES = Path(__file__).parent / "templates" def fill(template: str, layers: dict) -> str: def sub(m): layer, prop = m.group(1), m.group(2) value = layers.get(layer, {}).get(prop) if prop == "hide": return "hidden" if value else "" return "" if value is None else html.escape(str(value), quote=True) return re.sub(r"\{\{(\w+)\.(\w+)\}\}", sub, template) def render(template_name: str, layers: dict, fmt: str = "png", width: int = 1200, height: int = 630): page = fill((TEMPLATES / f"{template_name}.html").read_text(), layers) req = urllib.request.Request( f"{BASE}/v1/screenshot", data=json.dumps({"html": page, "viewport": {"width": width, "height": height}, "output": {"format": fmt}}).encode(), headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=60) as res: return res.read(), res.headers.get("X-Cache-URL") except urllib.error.HTTPError as e: error = json.loads(e.read() or b"{}").get("error", {}) raise RuntimeError(f"RenderScreenshot {e.code} {error.get('code')}: {error.get('message')}") from e
Ruby
# adapter.rb: Placid-style layers in, PNG bytes out. Standard library only. Env: RS_API_KEY. require 'cgi' require 'json' require 'net/http' module CardAdapter BASE = ENV.fetch('RS_BASE_URL', 'https://api.renderscreenshot.com') TEMPLATES = File.join(__dir__, 'templates') def self.fill(template, layers) template.gsub(/\{\{(\w+)\.(\w+)\}\}/) do layer, prop = Regexp.last_match(1), Regexp.last_match(2) value = layers.dig(layer.to_sym, prop.to_sym) next(value ? 'hidden' : '') if prop == 'hide' value.nil? ? '' : CGI.escapeHTML(value.to_s) end end def self.render(template_name, layers, format: 'png', width: 1200, height: 630) page = fill(File.read(File.join(TEMPLATES, "#{template_name}.html")), layers) uri = URI("#{BASE}/v1/screenshot") req = Net::HTTP::Post.new(uri, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') req.body = { html: page, viewport: { width:, height: }, output: { format: } }.to_json res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 60) { |http| http.request(req) } unless res.is_a?(Net::HTTPSuccess) error = (JSON.parse(res.body) rescue {}).fetch('error', {}) raise "RenderScreenshot #{res.code} #{error['code']}: #{error['message']}" end [res.body, res['X-Cache-URL']] end end
We ran all three on 2026-10-18 (Node 22.18.0, Python 3.15.0, Ruby 4.0.7) against a local stub that logs each request body. Each sent the same JSON: the template with the title escaped (<and back?>), a 1200x630 viewport and png. The Python run with hide: True added the hidden attribute, and a request with a bad key raised an error with the API's error code and a non-zero exit.
A gradual rollout
Do not switch every card at once. This wrapper sends a share of cards to the new renderer, keeps Placid for the rest, and falls back to Placid when a render fails. A hash of the card key picks the bucket, so one card does not flip between providers on every request.
// rollout.mjs. Env: RS_API_KEY, PLACID_TOKEN, RS_SHARE (0-100), PLACID_BLOG_CARD_UUID. import { createHash } from 'node:crypto'; import { render as renderHtml } from './adapter.mjs'; const PLACID_TEMPLATES = { 'blog-card': process.env.PLACID_BLOG_CARD_UUID }; const share = Number(process.env.RS_SHARE ?? 0); const bucket = (key) => createHash('sha256').update(key).digest().readUInt16BE(0) % 100; async function renderPlacid(templateName, layers) { const res = await fetch('https://api.placid.app/api/rest/images', { method: 'POST', headers: { Authorization: `Bearer ${process.env.PLACID_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ template_uuid: PLACID_TEMPLATES[templateName], layers, create_now: true }), signal: AbortSignal.timeout(60_000), }); const image = await res.json(); // create_now can still return "queued" when Placid is busy; poll image.polling_url in that case. if (!res.ok || image.status !== 'finished') throw new Error(`Placid ${res.status} ${image.status ?? ''}`); return { provider: 'placid', url: image.image_url }; } export async function renderCard(cardKey, templateName, layers) { if (bucket(cardKey) >= share) return renderPlacid(templateName, layers); try { const { bytes, cdnUrl } = await renderHtml(templateName, layers); return { provider: 'renderscreenshot', url: cdnUrl, bytes }; } catch (err) { console.warn(`renderscreenshot failed for ${cardKey}, using Placid: ${err.message}`); return renderPlacid(templateName, layers); } }
Against the stub, 200 card keys with RS_SHARE=25 went 44 to RenderScreenshot and 156 to Placid. With RS_SHARE=100 and a broken key, all 200 fell back to Placid. Start at 5 to 10%, compare the two images for the same card by eye, then raise the share.
Replacing the URL API in meta tags
The Placid URL API puts layer values in the image URL. The equivalent here is a card page on your own site, rendered through a signed GET URL:
- Add a route such as
/cards/blog/:slugthat renders the HTML template for one post at 1200x630. - On each page render, sign
GET /v1/screenshot?url=https://yoursite.com/cards/blog/my-post&preset=og_cardwith anexpiresup to 30 days ahead. The SDKs do this withgenerateUrl/generate_url; the signed URL docs have the rules. - Put the signed URL in
og:image. The first crawler fetch renders the card.
Signed URLs work only for GET render requests, so every option must be a flat query parameter. A card page keeps that list short, because the design and the data are in the page.
Price at three volumes
Placid prices in credits: one image is one credit for canvases up to 4,000 px, a PDF page is two, and video is 10 credits per 10 seconds. Unused credits roll over up to twice the monthly amount, and there are no overage fees: rendering stops until you upgrade. The dollar prices on Placid's pricing page load in the browser and were not in our text capture on 2026-10-18, so check them there.
| Images per month | Placid plan (credits per month) | RenderScreenshot plan |
|---|---|---|
| 1,000 | Pro (2,500) | Starter: 2,500 credits for $10/month |
| 10,000 | Business (25,000) | Growth: 12,000 credits for $40/month |
| 100,000 | VIP (100,000) | More than Scale's 60,000 credits ($100/month); contact us |
Our credit rule is one credit per successful render, whatever the format or scale. Website screenshots and PDFs use the same credits. See pricing for current plans.
When to stay with Placid
- Non-developers own the designs. An editor lets a marketer move a logo without a pull request. This is the main reason to stay.
- You use video, or the Editor SDK. We render still images and PDFs only.
- You rely on Placid's no-code integrations listed on its pricing page. Our integrations cover tools such as n8n, Make and Zapier, but check that the ones you use are there.
- Your volume swings. Rollover credits suit a team that renders a lot in one month and little in the next.
If your cards already exist as pages or components on your site, the case goes the other way: the template is already written. The OG image API page shows that workflow, and the Bannerbear vs Placid comparison covers the other editor-first option, and the APITemplate.io alternative covers a third.
Rebuild one template, run the adapter with a free API key, and compare the result with your current Placid image.
Last verified: 2026-10-18. Placid docs and pricing page checked on that date. Adapters tested against a local stub with Node 22.18.0, Python 3.15.0 and Ruby 4.0.7; not run against live Placid or RenderScreenshot.