Automated Blog Cover Images — From Post Title to Cover in One API Call

Every new blog post needs a cover image. The default workflow — open Figma, drop the title into a template, tweak the illustration, export a PNG, upload it back to the CMS — takes ten to twenty minutes per post and quietly becomes the reason posts sit unpublished for days.
This page shows the pipeline that replaces that step: one webhook from your CMS, one POST to https://api.thumbapi.dev/v1/generate, one 1200×630 PNG attached back to the post. Ghost, WordPress, Sanity, Contentful, Notion, Payload — anything with a publish hook works. Under 30 seconds, no template maintenance, and every cover is unique.
Grab a free API key and wire your first cover in minutes
Get Started FreeThe Problem
A content team hitting Publish should not be waiting on a designer, and a solo blogger should not be reopening Figma every Tuesday. But the three usual answers all leak time or CTR:
- Design every cover by hand. Ten to twenty minutes per post, which compounds fast on a blog shipping two or three times a week.
- Reuse one generic cover. Every card on the index page and every social share looks the same — social CTR drops because nothing signals what the post is about.
- Design a template and swap the title in Figma. Feels scalable for a month, then the template starts to look tired and no one remembers who owns it.
The gap is not creativity — it is the manual step between a post exists and the cover exists. Close that gap and the covers stop being a bottleneck.
The Workflow
One trigger, one API call, one cache write. The pipeline is the same whether you host on Ghost, WordPress, or a headless CMS — only the webhook source changes.
- Trigger. A post is published or updated. Your CMS fires
post.published(Ghost),publish_post(WordPress), or a similar webhook to your worker URL. - Extract the title. The webhook payload contains the post title, slug, and (usually) the current featured image URL.
- Call ThumbAPI. POST the title to
/v1/generatewithformat: "blogpost". Response is a base64 PNG in theimagefield plus agenerationIdfor logs. - Store the cover.Either write the decoded PNG to object storage (S3, R2, Blob) or upload it directly through the CMS's media API.
- Attach it to the post. Patch the post record with the new
feature_image/featured_mediaURL. Publish stays green.
Because it is a generate-once pipeline, an existing post never regenerates unless its title changes. You pay per new post, not per page view.
Code Example — Ghost Webhook Worker (Node.js)
Ghost fires post.publishedwith a JSON payload. A small Node worker receives it, generates the cover through ThumbAPI, uploads it to Ghost's images endpoint, then patches the post. Deploy the handler anywhere that speaks HTTP — a Vercel Function, a Cloudflare Worker, a Fly.io box, whatever you already run.
import type { NextRequest } from "next/server";
const THUMBAPI_URL = "https://api.thumbapi.dev/v1/generate";
const GHOST_ADMIN_URL = process.env.GHOST_ADMIN_URL!;
const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_KEY!;
const THUMBAPI_KEY = process.env.THUMBAPI_API_KEY!;
export async function POST(req: NextRequest) {
const { post } = await req.json();
if (!post?.current?.title || post.current.feature_image) {
return new Response("skip", { status: 200 });
}
// 1. Generate the cover
const gen = await fetch(THUMBAPI_URL, {
method: "POST",
headers: {
"x-api-key": THUMBAPI_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: post.current.title,
format: "blogpost",
outputFormat: "png",
useLogo: true,
}),
});
if (!gen.ok) throw new Error(`ThumbAPI ${gen.status}`);
const { image, generationId } = await gen.json();
const png = Buffer.from(image.split(",", 2)[1], "base64");
// 2. Upload to Ghost's images endpoint
const form = new FormData();
form.append("file", new Blob([new Uint8Array(png)], { type: "image/png" }), `${post.current.slug}.png`);
const up = await fetch(`${GHOST_ADMIN_URL}/ghost/api/admin/images/upload/`, {
method: "POST",
headers: { Authorization: `Ghost ${GHOST_ADMIN_KEY}` },
body: form,
});
const { images } = await up.json();
// 3. Patch the post's feature_image
await fetch(`${GHOST_ADMIN_URL}/ghost/api/admin/posts/${post.current.id}/`, {
method: "PUT",
headers: {
Authorization: `Ghost ${GHOST_ADMIN_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts: [{
feature_image: images[0].url,
updated_at: post.current.updated_at,
}],
}),
});
return Response.json({ ok: true, generationId });
}Wire the endpoint URL into Ghost under Settings → Integrations → Custom → Webhook, event post.published. The guard on post.current.feature_image skips posts you already covered manually, so re-runs are safe.
Code Example — WordPress (any host)
WordPress ships a similar hook — publish_post fires when a post moves to publish. Point it at a small worker that generates the cover and uploads it through the REST API. The generate step is identical to the Ghost example; only the CMS upload changes.
const WP_URL = process.env.WP_URL; // https://example.com
const WP_USER = process.env.WP_USER; // WP username
const WP_APP_PASS = process.env.WP_APP_PASSWORD; // WP application password
const THUMBAPI_KEY = process.env.THUMBAPI_API_KEY;
const auth = "Basic " + Buffer.from(`${WP_USER}:${WP_APP_PASS}`).toString("base64");
export async function generateAndAttach(postId, title, slug) {
// 1. Generate cover
const gen = await fetch("https://api.thumbapi.dev/v1/generate", {
method: "POST",
headers: { "x-api-key": THUMBAPI_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ title, format: "blogpost", outputFormat: "png" }),
});
const { image } = await gen.json();
const png = Buffer.from(image.split(",", 2)[1], "base64");
// 2. Upload as WP media
const media = await fetch(`${WP_URL}/wp-json/wp/v2/media`, {
method: "POST",
headers: {
Authorization: auth,
"Content-Disposition": `attachment; filename="${slug}.png"`,
"Content-Type": "image/png",
},
body: new Uint8Array(png),
}).then((r) => r.json());
// 3. Set as featured image
await fetch(`${WP_URL}/wp-json/wp/v2/posts/${postId}`, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify({ featured_media: media.id }),
});
}Trigger the worker from publish_post via a small plugin that calls wp_remote_post(), or run the worker on a schedule that scans for posts with an empty featured image. Either works — pick the one that fits your host.
Customization
The generate endpoint takes a handful of parameters that matter for blog covers. Full reference in the generate endpoint docs.
| Parameter | What it does |
|---|---|
format | blogpost (1200×630) is the standard cover size — same aspect ratio as the OG spec so it doubles as a social share card. |
category | Niche hint that biases style — tech, finance, education, lifestyle, and nine more. Set once per blog or per category tag. |
useLogo | Overlays your saved brand logo. Upload it once in Assets → Logo — every cover carries the brand automatically. |
customAssetsId | References a saved reference set (1–6 sample thumbnails). The generator biases layout, palette, and composition toward your style dataset — see custom assets. |
outputFormat | png or webp. WebP is ~40% smaller; PNG plays safer with legacy CMS image pipelines. |
Cost at Scale
A standard cover on the blogpost format costs 10 credits. The pipeline is generate-once, so the bill scales with post count — not with page views or share count.
- 100 posts / month — 1,000 credits. Covered by the Pro plan ($49/mo, 2,500 credits) with headroom for regenerations.
- 1,000 posts / month — 10,000 credits. Exactly the Business plan ($199/mo).
- 10,000 posts / month — 100,000 credits. Bring your own volume — talk to us via the contact page for a custom plan.
For a typical blog shipping 10–20 posts a month, the Free tier (50 credits / month, no credit card) covers 5 new posts and Creator ($19/mo) covers 75. Rate limits and error codes live in docs → rate limits.
Handling Title Changes
A cover generated for the old title is stale the moment the title changes. Two clean options — pick one, do not mix:
- Regenerate on
post.updated. Compare the old vs. new title in the webhook payload; if it changed, re-run the generate step and swap the featured image. Simple and idempotent. - Hash the title into the filename. Save covers as
{slug}-{titleHash}.png. A retitle produces a new file; the old one gets orphaned but nothing breaks.
FAQ
Does this only work with Ghost and WordPress?
No — anything with a publish webhook works. Sanity fires document.publish, Contentful has entry-published webhooks, Notion has database change triggers via integrations, Payload has hooks. The generate step is identical; the last step (attach to post) uses the CMS's media API.
Can I test without wiring a webhook?
Yes. Run the worker locally with a fake payload, or use the public smoke-test key thumbapi_test — it returns a static placeholder image so you can verify the full pipeline end-to-end without spending credits. See the quickstart for a five-minute walk through.
What if my worker fails halfway?
The pipeline has two natural retry points. If the ThumbAPI call fails (timeout, 429), retry with exponential backoff — the endpoint is idempotent per generationId. If the CMS upload fails, the cover exists in your logs (via generationId) and can be replayed. See docs → errors for the full error taxonomy.
Can every post look on-brand?
Yes. Set useLogo: true for logo overlay and pass customAssetsId pointing at a saved reference set of 1–6 of your existing covers. The generator uses them as style inspiration — palette, layout, composition — so every new cover reads as your blog, not a generic template.
Is Next.js the recommended host for the worker?
Next.js Route Handlers work, and there's a Next.js-specific variant of this pipeline in the Next.js OG images use case. But the worker is a plain HTTP handler — Cloudflare Workers, Vercel Functions, Fly, Railway, a Node server on Hetzner all work the same.
Ship a unique cover on every post — with one webhook
Get Your Free API KeyRelated Pages
Product page for the blog cover endpoint — formats, output types, and framework-agnostic examples.
Next.js OG Images Use CaseThe same pipeline tuned for a Next.js App Router blog — build script + Route Handler variants.
Automating Content CreationHow the cover step slots into a full publishing pipeline — end-to-end walkthrough.
Custom Reference AssetsSave 1–6 sample covers and every generated image inherits your visual style.
QuickstartGet a key, make your first call, see the response shape. Under 5 minutes.
PricingFree tier + paid plans. Blog covers cost 10 credits each.