ThumbAPI logoThumbAPI

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

Automated blog cover image generator — CMS webhook to 1200×630 PNG 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 Free

The 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.

  1. Trigger. A post is published or updated. Your CMS fires post.published (Ghost), publish_post (WordPress), or a similar webhook to your worker URL.
  2. Extract the title. The webhook payload contains the post title, slug, and (usually) the current featured image URL.
  3. Call ThumbAPI. POST the title to /v1/generate with format: "blogpost". Response is a base64 PNG in the image field plus a generationId for logs.
  4. Store the cover.Either write the decoded PNG to object storage (S3, R2, Blob) or upload it directly through the CMS's media API.
  5. Attach it to the post. Patch the post record with the new feature_image / featured_media URL. 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.

api/ghost-cover-webhook.ts
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.

wp-cover-worker.mjs
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.

ParameterWhat it does
formatblogpost (1200×630) is the standard cover size — same aspect ratio as the OG spec so it doubles as a social share card.
categoryNiche hint that biases style — tech, finance, education, lifestyle, and nine more. Set once per blog or per category tag.
useLogoOverlays your saved brand logo. Upload it once in Assets → Logo — every cover carries the brand automatically.
customAssetsIdReferences a saved reference set (1–6 sample thumbnails). The generator biases layout, palette, and composition toward your style dataset — see custom assets.
outputFormatpng 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 Key