Ghost prints Open Graph tags for you, but the image is whatever you uploaded: the feature image, or a Facebook card image you set by hand. Posts without either share as a bare link. This page fixes that in two parts. A theme partial draws a 1200×630 card from the post's own data, and an Admin API script asks RenderScreenshot to capture it, uploads the PNG to Ghost and sets it as the post's og_image and twitter_image. All integrations are listed on the integrations hub, and the product side is on the OG image API page.
Last tested: 2026-10-18 with Node 22.18. We ran
ghost-og-cards.mjsagainst a local stub that implements the Ghost Admin API calls it uses (JWT check, paged post list, multipart image upload,updated_atcollision check) and the documented RenderScreenshot responses. The runs covered a post without an image,--allover posts that already had one, a page that times out (three tries, then exit code 1) and a wrong Admin key (401). We did not run it against a live Ghost site for this page, and the theme partial was not rendered in a real theme, so there are no Ghost Admin screenshots.
What you can build
- Cards for an archive. Run the script once with
--all, and every published post gets a card with its title, author and date. - Cards for new posts only. Run it without flags from cron or a CI schedule. It skips posts that already have an
og_image, so hand-made images are never replaced. - A card that matches the blog. The partial uses your theme's CSS, web fonts and
@sitesettings, not a separate template service.
Why the card is stored, not linked
A signed screenshot URL in og_image would be the shortest route, but signed URLs expire after 30 days at most, and Ghost has no hook where a theme could re-sign one. A social network that fetches the post after that would get an error. So the script renders the card once, uploads the bytes to Ghost's own image storage, and stores Ghost's URL. The image then lives as long as the post.
flowchart LR S[ghost-og-cards.mjs] -->|list posts| G[Ghost Admin API] S -->|POST /v1/screenshot url=post?rs-card=1| R[RenderScreenshot] R -->|loads| P[Post page with card partial] R -->|PNG bytes| S S -->|images/upload + PUT og_image| G
Prerequisites
- Ghost 5 or later, self-hosted or Ghost(Pro), with a theme you can edit and upload.
- A custom integration: in Ghost Admin open Settings → Integrations → Add custom integration and copy the Admin API key (
id:secret) and the API URL. - A RenderScreenshot key that starts with
rs_live_. Create a free account to get one. - Node 22 or later where the script runs. It has no dependencies.
Step 1: add the card partial to your theme
Save this as partials/rs-og-card.hbs. It is invisible to readers. Only a URL with ?rs-card=1 turns it on, and then it covers the whole viewport.
{{!-- partials/rs-og-card.hbs: a 1200x630 social card, shown only with ?rs-card=1 --}} <div id="rs-og-card"> <p class="rs-site">{{@site.title}}</p> <h1 class="rs-title">{{title}}</h1> <p class="rs-meta">{{primary_author.name}} · {{date format="D MMM YYYY"}} · {{reading_time}}</p> </div> <style> #rs-og-card { display: none; } html.rs-card-mode, html.rs-card-mode body { overflow: hidden; } html.rs-card-mode #rs-og-card { display: flex; flex-direction: column; justify-content: space-between; position: fixed; inset: 0; z-index: 2147483647; box-sizing: border-box; width: 1200px; height: 630px; padding: 72px 80px; background: var(--ghost-accent-color, #15171a); color: #fff; } .rs-site { font-size: 28px; opacity: .8; margin: 0; } .rs-title { font-size: 64px; line-height: 1.1; margin: 0; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } .rs-meta { font-size: 26px; margin: 0; } </style> <script> if (new URLSearchParams(location.search).get('rs-card') === '1') { document.documentElement.classList.add('rs-card-mode'); } </script>
Then include it in post.hbs, inside the {{#post}} block so that title and primary_author resolve:
{{#post}} {{> "rs-og-card"}} {{!-- the rest of your post template --}} {{/post}}
Zip the theme, upload it under Settings → Design → Change theme, and open any post with ?rs-card=1 added. You should see only the card. --ghost-accent-color is the accent color from your Ghost design settings, so the card follows your brand without a hard-coded color.
Step 2: run the Admin API script
Save this as ghost-og-cards.mjs:
// ghost-og-cards.mjs: render a 1200x630 card for each published Ghost post // and store it as the post's og_image and twitter_image. Node 22, no dependencies. // Usage: node ghost-og-cards.mjs (posts without an og_image) // node ghost-og-cards.mjs --all (every published post) import crypto from 'node:crypto'; const GHOST_URL = process.env.GHOST_URL; // https://blog.example.com const [keyId, keySecret] = (process.env.GHOST_ADMIN_API_KEY ?? '').split(':'); const RS_API_KEY = process.env.RS_API_KEY; // rs_live_... const RS_BASE = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com'; const ACCEPT_VERSION = process.env.GHOST_ACCEPT_VERSION ?? 'v5.0'; const all = process.argv.includes('--all'); if (!GHOST_URL || !keySecret || !RS_API_KEY) { console.error('Set GHOST_URL, GHOST_ADMIN_API_KEY (id:secret) and RS_API_KEY.'); process.exit(1); } // Ghost Admin API tokens are short-lived HS256 JWTs signed with the hex-decoded secret. function ghostToken() { const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); const iat = Math.floor(Date.now() / 1000); const head = b64({ alg: 'HS256', typ: 'JWT', kid: keyId }); const body = b64({ iat, exp: iat + 300, aud: '/admin/' }); const sig = crypto.createHmac('sha256', Buffer.from(keySecret, 'hex')).update(`${head}.${body}`).digest('base64url'); return `${head}.${body}.${sig}`; } async function ghost(path, init = {}) { const res = await fetch(`${GHOST_URL}/ghost/api/admin${path}`, { ...init, headers: { Authorization: `Ghost ${ghostToken()}`, 'Accept-Version': ACCEPT_VERSION, ...init.headers }, }); if (!res.ok) throw new Error(`Ghost ${init.method ?? 'GET'} ${path}: ${res.status} ${await res.text()}`); return res.json(); } async function renderCard(postUrl) { const url = new URL(postUrl); url.searchParams.set('rs-card', '1'); // the theme partial shows the card only with this flag const res = await fetch(`${RS_BASE}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${RS_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.toString(), preset: 'og_card', wait: { timeout: 30 } }), signal: AbortSignal.timeout(65_000), }); if (!res.ok) { const { error } = await res.json(); const e = new Error(`${res.status} ${error.code}: ${error.message}`); e.retryable = error.retryable; e.retryAfter = Number(res.headers.get('retry-after') ?? 0); throw e; } return Buffer.from(await res.arrayBuffer()); } async function renderWithRetry(postUrl) { for (let attempt = 1; ; attempt++) { try { return await renderCard(postUrl); } catch (e) { if (!e.retryable || attempt === 3) throw e; const waitS = e.retryAfter || 2 ** attempt; console.warn(` retry ${attempt} in ${waitS}s (${e.message})`); await new Promise((r) => setTimeout(r, waitS * 1000)); } } } let failed = 0; for (let page = 1; page; ) { const { posts, meta } = await ghost( `/posts/?filter=status:published&fields=id,title,url,slug,updated_at,og_image&limit=50&page=${page}`, ); for (const post of posts) { if (post.og_image && !all) continue; try { const png = await renderWithRetry(post.url); const form = new FormData(); form.append('file', new Blob([png], { type: 'image/png' }), `og-${post.slug}.png`); form.append('purpose', 'image'); const { images } = await ghost('/images/upload/', { method: 'POST', body: form }); await ghost(`/posts/${post.id}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, // updated_at must match the stored value, or Ghost answers 409 (someone edited the post). body: JSON.stringify({ posts: [{ og_image: images[0].url, twitter_image: images[0].url, updated_at: post.updated_at }] }), }); console.log(`ok ${post.slug} -> ${images[0].url}`); } catch (e) { failed++; console.error(`fail ${post.slug}: ${e.message}`); } } page = meta.pagination.next; } process.exitCode = failed ? 1 : 0;
Run it:
GHOST_URL=https://blog.example.com \ GHOST_ADMIN_API_KEY='6720...:9f3c...' \ RS_API_KEY=rs_live_... \ node ghost-og-cards.mjs
Each line of output names a post slug and the new image URL, or the error code. In Ghost Admin the post's Facebook card and X card settings now show the generated image.
Parameter mapping
| Where | Value | Why |
|---|---|---|
url |
post.url plus ?rs-card=1 |
The real post, switched into card mode by the partial |
preset |
og_card |
A 1200×630 viewport and PNG output; see presets |
wait.timeout |
30 |
Seconds before the API gives up; the maximum is 60 |
Ghost og_image, twitter_image |
Uploaded image URL | {{ghost_head}} prints these ahead of the feature image |
Ghost updated_at |
Value from the list call | Ghost's guard against overwriting a newer edit |
The request uses the POST endpoint, so you can add any option from the POST reference, for example wait.until for a theme that fades content in.
Errors and retries
The script retries only what the API marks as retryable, and it waits for Retry-After on a 429. Everything else fails that post and moves on, and the exit code is 1 if any post failed, so cron or CI can alert you.
| Message | Cause | Fix |
|---|---|---|
422 timeout after three tries |
The post did not finish loading in 30 seconds | Raise wait.timeout, or check that the post is public |
401 missing_credentials |
Wrong or revoked rs_live_ key |
Fix RS_API_KEY; the script does not retry it |
403 insufficient_credits |
The month's credits are used | Wait for the reset or change plan; see pricing |
Ghost 401 |
Wrong Admin key, or a clock more than a few minutes off | Copy the key again; sync the clock, because the JWT is valid for 5 minutes |
Ghost 409 |
Someone saved the post while the script ran | Run the script again; it reads a fresh updated_at |
The full list of codes and their retry rules is in the error handling guide.
Limits and costs
- Credits. Each card is one fresh render and one credit. A failed render costs nothing. The default run skips posts that have an image, so a nightly cron costs credits only for new posts.
- Rate limit. The script renders one post at a time. On a plan with a low per-minute limit (the Free plan allows 10 requests a minute) a long
--allrun can still reach it; the script then waits forRetry-Afterand tries again. All keys on your account share the limit, so the run can also slow down other jobs that use the API. - Ghost storage. Each run with
--alluploads a new file per post. Ghost does not delete the old file, so do not run--allon a schedule.
Troubleshooting
- The card shows the whole post. The partial is outside
{{#post}}, or your theme does not usepost.hbsfor that post (custom templates such ascustom-wide.hbsneed the include too). - The title is missing on the card. The same cause: outside the post block,
{{title}}has no value. - Share previews still show the old image. Social networks cache previews. Use their debugger tools to fetch the post again; the social card checklist lists them.
The same "draw the card in your own template" approach works on other platforms: the WordPress plugin does it with PHP, a Netlify function does it for static sites on request, and the screenshot social card guide covers card design.