This guide shows how to put a signed URL in an og:image meta tag from the server-side template of six common frameworks. A signed URL makes the screenshot API render the card on the first crawler fetch, with no API key in your HTML and no image pipeline to run. The guide also covers the two decisions that framework examples usually skip: how long the URL should live, and what a stranger who reads your page source can do with it. It belongs to the OG image API cluster; the long-form tutorial with a card template is Generate OG images from HTML.
Step by step: Add social share images to every page walks through this task with screenshots of every step.
Tested on 2026-10-18: the Node.js, Python (Django 5.2.7), Ruby, PHP and Go listings ran with the RenderScreenshot SDKs built from their current source, and a copy of the API's signature check accepted every URL they printed. All five printed the same URL and signature for the same inputs. The Java listing was not run (no JVM on the test machine). No request went to the live API.
The result in your HTML
The tag holds a GET /v1/screenshot URL. In an HTML attribute, every & is written as &; the template engines below do that for you.
<meta property="og:image" content="https://api.renderscreenshot.com/v1/screenshot?cache_available=true&cache_ttl=2592000&expires=1794441600&key_id=rs_pub_...&preset=og_card&url=https%3A%2F%2Fexample.com%2Fog%2Fhello-world&signature=..."> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta property="og:image:alt" content="Hello world: a post title card">
url is the page that is your card: usually a route on your own site that lays out the title, author and logo at 1200×630. The og_card preset sets that viewport and PNG output.
The two requests
The crawler makes the GET. You make a POST only when you want to look at a card before you publish it, because POST needs your secret rs_live_ key and cannot be signed.
# GET: what the crawler does. This is a real render and uses a credit on a cache miss. curl -sS -o card.png -w '%{http_code} %{content_type}\n' "$SIGNED_URL" # POST: inspect the same card from your server, with JSON instead of image bytes. curl -sS https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H 'Content-Type: application/json' -H 'Accept: application/json' \ -d '{"url": "https://example.com/og/hello-world", "preset": "og_card"}'
A 200 image/png from the first command means the signature passed and the card rendered.
What the URL lets a stranger do
Everything in your HTML is public. This is the exact scope of what you publish:
| Someone who copies the tag can | They cannot |
|---|---|
Fetch that one image, with those options, until expires |
Change url, preset or any other parameter: the signature fails with invalid_signature |
| Fetch it many times. Each fetch is billed like a normal render unless it is a cache hit | Use rs_pub_... as an API key: it is rejected with missing_credentials |
Call POST /v1/screenshot, batch or the cache endpoints with it |
So the real exposure is cost, not access. Two settings close most of it:
- Sign
cache_available=trueandcache_ttl=2592000(30 days, the maximum). Repeat fetches of an unchanged card then return the earlier render for 0 credits. The caching reference explains the cache key;key_id,expiresandsignatureare not part of it, so a re-signed URL still finds last week's render. - Keep one signing key pair per site. Revoking either key of a pair revokes both, and every URL signed with it starts to return 401. A separate pair per site lets you rotate one without breaking previews on the others.
The rs_secret_ key and your rs_live_ key never leave the server. The publishable keys guide covers the key types in detail.
Choose the expiry from your HTML cache
expires is required and can be at most 30 days ahead. What decides the right value is how long your HTML, with the URL inside it, can be served after you sign it.
| How the page is served | Expiry to sign | Why |
|---|---|---|
| Rendered on each request | A windowed expiry (below), 21 to 28 days ahead | Crawlers often fetch days after the share; a fixed window keeps the URL stable |
| HTML cached at a CDN or by ISR for up to N days | The windowed expiry, with at least N + 7 days left | The cached HTML must still carry a valid URL on its last day |
| Static build, redeployed at least every 2 weeks | Build time + 30 days | Each deploy signs fresh URLs |
| Static build kept longer than about 3 weeks, or emails | Not a signed URL | Render once with a Bearer request and serve the stored file |
A different URL on every request is the mistake to avoid: now + 30 days changes every second, so platforms that cache by image URL treat each share as a new image. Every listing below uses a windowed expiry: time is cut into 7-day windows counted from the Unix epoch, and every request in a window signs the same expires, 21 to 28 days ahead. The reasoning is in the OG images tutorial. Because each language counts from the same epoch, a Rails app and a Go service sign the same URL for the same card.
SDK tabs: the tag in six frameworks
Each listing reads RS_API_KEY, RS_SIGNING_KEY (rs_secret_) and RS_PUBLIC_KEY_ID (rs_pub_) from the environment. The SDK methods are documented on the SDK pages. A Shopify theme can sign the URL in Liquid with no SDK; see Shopify OG images. For Webflow CMS items, see Webflow OG images.
Node.js (Express or any server)
// og-tags.mjs import { Client, TakeOptions } from 'renderscreenshot'; const DAY = 24 * 60 * 60 * 1000; const client = new Client(process.env.RS_API_KEY, { signingKey: process.env.RS_SIGNING_KEY, // rs_secret_... publicKeyId: process.env.RS_PUBLIC_KEY_ID, // rs_pub_... }); // Same URL for every request in a 7-day window; always 21 to 28 days left. export function windowedExpiry(now = Date.now(), windowDays = 7, minDaysLeft = 21) { const start = Math.floor(now / (windowDays * DAY)) * windowDays * DAY; return new Date(start + (windowDays + minDaysLeft) * DAY); } const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); export function ogImageTags(cardUrl, alt) { const image = client.generateUrl( TakeOptions.url(cardUrl).preset('og_card').cacheAvailable().cacheTtl(2592000), windowedExpiry(), ); return [ `<meta property="og:image" content="${escapeHtml(image)}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">', `<meta property="og:image:alt" content="${escapeHtml(alt)}">`, ].join('\n'); }
Python (Django template tag)
# cards/templatetags/og.py import os import time from django import template from renderscreenshot import Client, TakeOptions register = template.Library() DAY = 24 * 3600 client = Client( os.environ['RS_API_KEY'], signing_key=os.environ['RS_SIGNING_KEY'], # rs_secret_... public_key_id=os.environ['RS_PUBLIC_KEY_ID'], # rs_pub_... ) def windowed_expiry(now=None, window_days=7, min_days_left=21): now = time.time() if now is None else now start = int(now // (window_days * DAY)) * window_days * DAY return start + (window_days + min_days_left) * DAY @register.simple_tag def og_image_url(card_url): options = TakeOptions.url(card_url).preset('og_card').cache_available().cache_ttl(2592000) return client.generate_url(options, windowed_expiry())
{% load og %}{% og_image_url card_url as og_image %} <meta property="og:image" content="{{ og_image }}"> <meta property="og:image:alt" content="{{ post.title }}">
Ruby (Rails helper)
# app/helpers/og_image_helper.rb module OgImageHelper DAY = 24 * 3600 OG_CLIENT = RenderScreenshot::Client.new( ENV.fetch('RS_API_KEY'), signing_key: ENV.fetch('RS_SIGNING_KEY'), # rs_secret_... public_key_id: ENV.fetch('RS_PUBLIC_KEY_ID') # rs_pub_... ) def self.windowed_expiry(now = Time.now, window_days: 7, min_days_left: 21) start = (now.to_i / (window_days * DAY)) * window_days * DAY Time.at(start + ((window_days + min_days_left) * DAY)) end def og_image_url(card_url) options = RenderScreenshot::TakeOptions.url(card_url).preset('og_card').cache_available.cache_ttl(2_592_000) OG_CLIENT.generate_url(options, expires_at: OgImageHelper.windowed_expiry) end end
<%= tag.meta property: 'og:image', content: og_image_url(og_card_url(@post)) %>
PHP (Laravel)
<?php // app/Support/OgImage.php namespace App\Support; use DateTimeImmutable; use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; final class OgImage { private const DAY = 86400; public static function windowedExpiry(?int $now = null, int $windowDays = 7, int $minDaysLeft = 21): DateTimeImmutable { $now ??= time(); $start = intdiv($now, $windowDays * self::DAY) * $windowDays * self::DAY; return (new DateTimeImmutable())->setTimestamp($start + ($windowDays + $minDaysLeft) * self::DAY); } public static function url(string $cardUrl): string { $client = new Client( getenv('RS_API_KEY'), signingKey: getenv('RS_SIGNING_KEY'), // rs_secret_... publicKeyId: getenv('RS_PUBLIC_KEY_ID') // rs_pub_... ); $options = TakeOptions::url($cardUrl)->preset('og_card')->cacheAvailable()->cacheTtl(2592000); return $client->generateUrl($options, self::windowedExpiry()); } }
<meta property="og:image" content="{{ \App\Support\OgImage::url(route('og.card', $post)) }}">
Go (html/template)
// windowedExpiry counts 7-day windows from the Unix epoch, like the other languages. func windowedExpiry(now time.Time, windowDays, minDaysLeft int64) time.Time { window := windowDays * 86400 start := now.Unix() / window * window return time.Unix(start+(windowDays+minDaysLeft)*86400, 0) } func ogImageURL(client *rs.Client, cardURL string) (string, error) { return client.GenerateURL( rs.URL(cardURL).Preset("og_card").CacheAvailable().CacheTTL(2592000), windowedExpiry(time.Now(), 7, 21), "", "", // use the keys from rs.WithSigningKey and rs.WithPublicKeyID ) } var head = template.Must(template.New("head").Parse( `<meta property="og:image" content="{{.OGImage}}">` + "\n" + `<meta property="og:image:alt" content="{{.Alt}}">`))
Create the client once with rs.New(os.Getenv("RS_API_KEY"), rs.WithSigningKey(...), rs.WithPublicKeyID(...)). Use time.Truncate with care: it counts from year 1, not from 1970, so it picks different windows than the other languages. The Go screenshot API guide shows the same client in handlers and errgroup jobs.
Java (Spring and Thymeleaf)
// OgImageService.java (not run; see the note at the top) @Service public class OgImageService { private static final long DAY = 86_400; private final Client client = new Client( System.getenv("RS_API_KEY"), null, null, System.getenv("RS_SIGNING_KEY"), // rs_secret_... System.getenv("RS_PUBLIC_KEY_ID")); // rs_pub_... static Instant windowedExpiry(Instant now, long windowDays, long minDaysLeft) { long window = windowDays * DAY; long start = now.getEpochSecond() / window * window; return Instant.ofEpochSecond(start + (windowDays + minDaysLeft) * DAY); } public String url(String cardUrl) { return client.generateUrl( TakeOptions.url(cardUrl).preset("og_card").cacheAvailable().cacheTtl(2592000), windowedExpiry(Instant.now(), 7, 21)); } }
<meta property="og:image" th:content="${@ogImageService.url(cardUrl)}">
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Preview has no image; the URL returns 401 invalid_signature |
A template appended a parameter after signing, or the URL was HTML-escaped twice (&amp;) |
Put every option into the SDK call. Escape once, in the template. |
401 expired_signature on re-shares weeks later |
Static HTML outlived its URL | Rebuild more often than the expiry, or serve a stored file |
400 invalid_expiration |
Expiry more than 30 days ahead, or sent in milliseconds | Keep windowDays + minDaysLeft at 30 or less |
| A new URL on every page view | expires computed as now + N |
Use the windowed expiry |
| Credits drop each time a link is shared | No cache_available in the signed URL |
Sign cache_available=true and a long cache_ttl |
| Card shows the wrong page | The card URL's own query string was not passed as one value | Pass the raw card URL to the SDK; it encodes it once |
Every error code has its own page under /docs/errors.
Limits
- Expiry: required, at most 30 days ahead (5 minutes of clock drift is allowed).
- Cache TTL: 3600 to 2592000 seconds; a cache hit costs 0 credits.
- Signed URLs authenticate only
GETandHEADon/v1/screenshot. Options without a GET name, such as waiting for a selector, cannot be signed. Build the card page so it does not need them. - The rate limit is per account and shared by all keys, including traffic from signed URLs. See rate limits.
To try it, create a free account and a signing key pair, then run the curl GET above against your own card route.