Shopify gives each product page an og:image: the first product photo, cropped by whatever the network decides. This page replaces it with a real social card (photo, title, price, a "Sold out" label) built from your product data. Two theme files do the work: a Liquid template that lays out the card, and a snippet that signs a RenderScreenshot URL for it. No app, no server and no build step. It is one recipe in the OG image API set.
What you can build
- Product cards with the price on them. A link shared in a group chat shows what the item costs, not only a photo on a white square.
- Sale cards. The template shows the compare-at price struck through when a product is on sale, and the card updates when the sale ends.
- Stock-aware cards. Sold-out products say so, which saves a click for the person who sees the link.
How it fits together
flowchart LR A[Crawler reads product page] --> B[og:image = signed API URL] B --> C[RenderScreenshot API] C -->|loads| D["/products/handle?view=og-card"] D --> C C -->|1200x630 JPEG| A
The card is an ordinary Shopify template, so it has full access to Liquid objects and your theme's fonts. The API opens it like a browser and returns a 1200 x 630 image. Because the og:image is a signed URL, the image request carries no API key. The secret key stays inside Liquid, which Shopify renders on its own servers.
Prerequisites
- An Online Store 2.0 theme you can edit (Dawn and most current themes). Work on a duplicate first.
- A RenderScreenshot account. Sign up, open API Keys, choose Create Key and then Signing key pair. Copy the
rs_pub_ID and thers_secret_key; both are shown once. Make a pair only for this store.
Step 1: two theme settings
In Edit code, open config/settings_schema.json and add this object to the top-level array:
{ "name": "RenderScreenshot", "settings": [ { "type": "text", "id": "rs_key_id", "label": "Public key ID (rs_pub_...)" }, { "type": "text", "id": "rs_secret", "label": "Secret key (rs_secret_...)" } ] }
Fill both values in Customize > Theme settings > RenderScreenshot. Never print settings.rs_secret in any template. The snippet in Step 3 only passes it to hmac_sha256.
Step 2: the card template
Create templates/product.og-card.liquid. Shopify serves it at any product URL plus ?view=og-card:
{%- layout none -%} <!doctype html> <html lang="{{ request.locale.iso_code }}"> <head> <meta charset="utf-8"> <meta name="robots" content="noindex"> <style> * { box-sizing: border-box; } body { margin: 0; width: 1200px; height: 630px; display: flex; overflow: hidden; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; background: #111827; color: #f9fafb; } .photo { width: 630px; height: 630px; background: #fff; display: flex; align-items: center; justify-content: center; } .photo img { max-width: 100%; max-height: 100%; object-fit: contain; } .info { flex: 1; padding: 56px 48px; display: flex; flex-direction: column; justify-content: space-between; } .shop { font-size: 26px; opacity: .7; } h1 { margin: 0; font-size: 50px; line-height: 1.12; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } .price { font-size: 46px; font-weight: 700; } .price s { font-size: 30px; font-weight: 400; opacity: .6; margin-left: 12px; } .stock { font-size: 24px; color: #fca5a5; } </style> </head> <body> <div class="photo"> {%- if product.featured_image -%} <img src="{{ product.featured_image | image_url: width: 1260 }}" width="630" height="630" alt=""> {%- endif -%} </div> <div class="info"> <div class="shop">{{ shop.name | escape }}</div> <h1>{{ product.title | escape }}</h1> <div> <div class="price"> {{- product.price | money -}} {%- if product.compare_at_price > product.price -%}<s>{{ product.compare_at_price | money }}</s>{%- endif -%} </div> {%- unless product.available -%}<div class="stock">Sold out</div>{%- endunless -%} </div> </div> </body> </html>
{% layout none %} drops the header, footer and scripts of the theme, so the page is only the card. Liquid does not escape output on its own: escape on the title and shop name stops a product called <b>New from breaking the layout. The image is requested at 1260 px wide, twice the box, so it stays sharp. Open /products/<any-handle>?view=og-card in a browser window 1200 px wide to design the card.
Step 3: the signing snippet
Create snippets/rs-og-image.liquid:
{%- comment -%} Prints a signed RenderScreenshot URL for the product's social card. Use: {% render 'rs-og-image', product: product %} Reads theme settings rs_key_id (rs_pub_...) and rs_secret (rs_secret_...). {%- endcomment -%} {%- liquid # Expiry rounds up to a 14-day window: the URL stays the same for up to two weeks # and always lies 14 to 28 days ahead (the API accepts at most 30). assign window = 1209600 assign now = 'now' | date: '%s' | plus: 0 assign expires = now | divided_by: window | plus: 2 | times: window # Editing the product changes updated_at, which changes the card URL and forces a new render. assign version = product.updated_at | date: '%s' assign card_url = shop.url | append: product.url | append: '?view=og-card&v=' | append: version # Values encoded like the API's CGI.escape; pairs written in sorted order. assign key_enc = settings.rs_key_id | url_encode assign url_enc = card_url | url_encode assign query = 'cache_available=true&cache_ttl=2592000&expires=' | append: expires | append: '&format=jpeg&key_id=' | append: key_enc | append: '&preset=og_card&url=' | append: url_enc assign signature = query | hmac_sha256: settings.rs_secret -%} https://api.renderscreenshot.com/v1/screenshot?{{ query }}&signature={{ signature }}
Why it is written this way:
- The string to sign has strict rules. The API escapes every name and value, sorts the pairs and joins them with
&before it checks the HMAC. Liquid'surl_encodeuses the same rules, and the pairs are already in alphabetical order, so the snippet builds the exact string the server rebuilds. - A stable expiry. If
expireswere "now plus 30 days", the URL would change on every page view, and each network would treat each view as a new image. Rounding to a 14-day window keeps one URL per product for up to two weeks. - Cache reuse.
cache_available=truewith a 30-daycache_ttllets repeat crawler hits reuse the last render for 0 credits. Reuse matches on the render options, not onexpiresorsignature, so a new expiry window does not cost a new render.
Step 4: use it in the meta tags
In Dawn, the image tags live in snippets/meta-tags.liquid, inside {%- if page_image -%}. Put this branch in front of that block, and turn its if into elsif:
{%- if request.page_type == 'product' and settings.rs_key_id != blank -%} {%- capture rs_og -%}{% render 'rs-og-image', product: product %}{%- endcapture -%} {%- assign rs_og = rs_og | strip -%} <meta property="og:image" content="{{ rs_og | escape }}"> <meta property="og:image:secure_url" content="{{ rs_og | escape }}"> <meta property="og:image:width" content="1200"> <meta property="og:image:height" content="630"> <meta property="og:image:alt" content="{{ product.title | escape }}"> <meta name="twitter:image" content="{{ rs_og | escape }}">
Other themes put these tags in layout/theme.liquid or a social-meta snippet. Search the theme for og:image.
Parameter mapping
| Signed URL parameter | Value | Source |
|---|---|---|
url |
shop.url + product.url + ?view=og-card&v=<updated_at> |
The card template from Step 2 |
preset |
og_card |
1200 x 630 viewport (presets) |
format |
jpeg |
Overrides the preset's PNG; photos compress far better |
cache_available, cache_ttl |
true, 2592000 |
Reuse each render for 30 days |
key_id, expires, signature |
Theme settings and Liquid | hmac_sha256 with rs_secret |
Add dark_mode, delay or other flat GET parameters only in sorted position in query. Any parameter out of order, or added after signing, fails with invalid_signature.
Errors and retries
A crawler cannot retry for you, and it shows no error. It shows no image. Paste the signed URL from the page source into a browser to see the API's JSON error:
| Error | Cause | Fix |
|---|---|---|
invalid_signature |
Wrong secret, a parameter out of order, or a key ID with spaces | Re-copy both keys; keep query sorted |
invalid_expiration |
expires more than 30 days ahead |
Do not raise window above 15 days |
navigation_failed |
The store is password-protected, so the API sees the password page | Test on a live store, or remove the password |
insufficient_credits |
The account has no credits left | Every request fails, cache hits too, until credits renew or you upgrade; see pricing |
The first request for a new card renders it, which takes a few seconds. Some crawlers give up sooner. Load each new card URL once yourself, or run the network's sharing debugger twice, to warm the cache. The social card checklist covers what each network caches.
Limits and costs
- One credit per fresh render. Editing a product, or a render older than 30 days, costs one more. Plan on about one credit per shared product per month, plus one per edit.
- The rate limit is per account and shared by every key, including this signing pair. A crawler sweep of a large catalog can reach it; a 429 then means a missing image until the next crawl.
- Theme exports and staff with theme access can read
rs_secret. If it leaks, revoke the pair in the dashboard and enter a new one. Old URLs stop working at once.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Card shows the theme's header and footer | {% layout none %} missing or not the first line |
Add it at the top of product.og-card.liquid |
| Card shows the store password page | Store is still password-protected | Remove the password, or test with a public dev store |
| Old product photo after an update | The network cached the earlier og:image URL |
Re-scrape in the network's debugger; the new URL has a new v |
& in the page source |
escape encodes & inside the attribute |
Expected: HTML parsers and crawlers decode it back to & |
Last checked 2026-10-18. Tested with Ruby 4.0.7 and the liquid gem 5.14.0, with Shopify's hmac_sha256 filter modeled as a hex HMAC-SHA256. The snippet's signature matched the server's signing rule from the API spec for a product URL with a non-ASCII handle, and its expiry fell between 14 and 28 days. The card template rendered with an escaped title, a sale price and a sold-out label. Not yet run on a Shopify development store.