A user pastes a link into your chat, CRM note or bookmark list. They expect what Slack and iMessage show: a card with a picture, a title and a line of description. Building that is called link unfurling, and most of it is easy until you meet real links: pages with no og:image, images that 404, sites that block your fetcher, and URLs that point at your own internal network.
This page shows a pipeline that handles those cases with one request per new link. It is one of our use cases. For social cards on your own pages, the OG image API is the better fit.
Why not only read og:image
Reading the linked page's og:image is the cheapest preview when it works. On 2026-10-18 we crawled the home pages of the top 10,000 domains for these tags, and the results are in the state of OG images:
- 41.8% of readable home pages had neither an
og:imagenor atwitter:image. - 3.4% of the
og:imageURLs that existed did not load. - 19.3% of the images were square, mostly logos, which look poor in a wide card.
- 758 of 10,000 home pages answered our honest crawler user agent with 403.
So a preview feature that relies on og:image alone shows a bare link for close to half of pasted URLs. A screenshot of the page is the fallback that always has something to show.
The request
The link_preview preset sets an 800x600 viewport, a 4:3 frame that fits chat bubbles and list rows better than a 1.91:1 social card. With Accept: application/json, the response holds a CDN URL for the image and the page metadata, so one call fills the whole card.
{ "url": "https://example.com/", "preset": "link_preview", "output": { "format": "webp", "quality": 80 }, "block": { "cookie_banners": true, "chat_widgets": true }, "wait": { "timeout": 15 }, "cache": { "available": true, "ttl": 2592000 } }
The response you read:
{ "image": { "url": "https://cdn.renderscreenshot.com/...", "width": 800, "height": 600, "format": "webp" }, "metadata": { "title": "Example Domain", "description": null, "favicon": null }, "cache": { "hit": false, "expires_at": "..." } }
Request variants
Each row changes one thing from the request above. We describe them rather than show images; this page was written without calls to the live API.
| Goal | Change | Why |
|---|---|---|
| Smallest file for chat lists | Keep "format": "webp", "quality": 80 |
Photos and gradients shrink most; text stays readable at 800 px |
| Crisp preview on high-density screens | Add "viewport": {"scale": 2} |
1600x1200 pixels: four times the pixels to store and send |
| Match a dark chat theme | Add "browser": {"dark_mode": true} |
Sites that support prefers-color-scheme render dark |
| Cleaner page | "block": {"ads": true, "cookie_banners": true} |
Consent dialogs and ads cover the top of many pages |
| Mobile look | Replace the preset with "viewport": {"device": "iphone_14_pro"} |
For apps where the preview opens on phones |
| Thumbnail for a dense list | Add "output": {"resize": {"width": 400, "height": 300, "fit": "cover"}} |
Same layout, a quarter of the pixels; see image resize |
How it works
flowchart LR
U[User pastes a link] --> A[Your API]
A --> D{Preview in your DB<br/>and not expired?}
D -- yes --> R[Return stored card]
D -- no --> Q[Background job]
Q --> RS[POST /v1/screenshot<br/>preset link_preview<br/>cache.available]
RS -- 200 --> S[Store title, description,<br/>favicon, image URL, expires_at]
RS -- 4xx/422 --> F[Store fallback card:<br/>domain + favicon]
S --> P[Push card to clients]
F --> P
Two choices make this work:
- Render in a background job. A capture takes seconds. Send the message at once with a plain link, then update it when the card is ready. Chat users are used to cards that appear a moment later.
- Cache twice. Your database keeps the card per normalized URL, so a link pasted 1,000 times is rendered once.
cache.availableadds a second layer: if your job renders the same URL with the same options inside the TTL, the API returns the stored image for 0 credits. See caching.
The job, in Node
This function builds a card for one URL. It strips utm_ parameters and the fragment so that shared copies of one link hit the same cache entry, and it turns any API error into a fallback card instead of an exception.
// preview.mjs. Env: RS_API_KEY. Node 22. const BASE = 'https://api.renderscreenshot.com'; const THIRTY_DAYS = 30 * 24 * 60 * 60; export function normalize(input) { const u = new URL(input); if (!['http:', 'https:'].includes(u.protocol)) throw new Error('only http(s) links get previews'); u.hash = ''; for (const p of [...u.searchParams.keys()]) if (p.startsWith('utm_')) u.searchParams.delete(p); return u.toString(); } export async function buildPreview(input) { const url = normalize(input); const res = await fetch(`${BASE}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json', Accept: 'application/json', // JSON with a CDN URL instead of image bytes }, body: JSON.stringify({ url, preset: 'link_preview', // 800x600 output: { format: 'webp', quality: 80 }, block: { cookie_banners: true, chat_widgets: true }, wait: { timeout: 15 }, cache: { available: true, ttl: THIRTY_DAYS }, // same URL within 30 days: 0 credits }), signal: AbortSignal.timeout(20_000), }); const body = await res.json().catch(() => ({})); if (!res.ok) { // A target_error (422) means the site failed to load: store the fallback card, retry later. return { url, image: null, title: null, description: null, favicon: null, error: body.error?.code ?? `http_${res.status}` }; } return { url, image: body.image?.url ?? null, expiresAt: body.cache?.expires_at ?? null, title: body.metadata?.title ?? new URL(url).hostname, description: body.metadata?.description ?? null, favicon: body.metadata?.favicon ?? null, error: null, }; }
We ran it on 2026-10-18 (Node 22.18.0) against a local stub that returns the documented JSON. It sent the request body shown above, mapped a success to a card, mapped a 422 timeout to a fallback card with error: "timeout", and rejected a javascript: link before any request.
Call buildPreview from your job runner (BullMQ, Sidekiq, Celery or a plain queue table), save the result, and push it to clients. When expiresAt passes, the image URL stops working: re-run the job on the next view, or copy the file to your bucket with the storage option on paid plans.
The fallback card
Some links will fail: the site is down, blocks automated browsers, or times out. Errors in the target_error group return HTTP 422 and carry a retryable flag; the error reference lists them. For a preview, do not block the message on a retry:
- Show the domain and, if you have it, the favicon.
- Retry once in the background for retryable errors such as
timeoutandnavigation_failed. - Do not retry
blocked. Either the API refused the URL (for example a private or internal address), or the site blocked the renderer. The same request fails the same way again.
Settings that matter for previews
- Preset
link_preview(800x600) rather thanog_card. A 4:3 frame shows more of the page's first screen and suits narrow chat columns. - WebP at quality 80. Previews are small on screen, so a lossy format is fine and lists load faster.
- TTL of 30 days, the maximum. Pages change, but a preview is a hint, not a record.
wait.timeoutof 15 seconds. A preview that takes longer is not worth the wait; fall back instead.- Block cookie banners and chat widgets. Otherwise many previews show a consent dialog instead of the page.
What it costs
You pay one credit for each new link that renders; cache hits cost nothing. A team chat product where users paste 10,000 distinct links a month needs about 10,000 credits. The Growth plan includes 12,000 credits for $40 a month, which works out to $0.0040 per preview at that volume. An app with only a few new links a day fits the Free plan's 150 monthly credits; a few hundred a month fits Starter (2,500 credits for $10). If you read og:image first and render only when it is missing, you pay for fewer renders. Current plans are on the pricing page.
Start
Add the job, paste a link, and watch the card arrive. Get a free API key to try it with your own links.
Last tested: 2026-10-18. Request format checked against the API reference; the job function tested against a local stub with Node 22.18.0, not the live API.