Creating Social Cards via API: Dynamic Image Generation

Creating Social Cards via API

Your content pipeline is already doing the hard work: titles, categories, author, publish date, sometimes a spicy pull-quote. Manually turning that into social cards is the part that feels like punishment. Automated social media image generation fixes that by treating images like build artifacts: deterministic, versionable, and produced the moment a post exists.

The clean architecture is simple: a blog post is created → a renderer API generates an image from a template → WordPress stores it in Media Library → the post’s Featured Image gets updated.

Do it right and you never touch Canva again, except when you want to, not because you must.

Why this works better than “designing” every image

Social cards are mostly a layout problem, not a creativity problem. The creativity is in the template. Automation is in the mapping. Once you separate those two, you can ship consistent cards across LinkedIn, X, Facebook, Slack unfurls, Open Graph, even email headers, without humans copy-pasting titles at 1 a.m.

Also: templates enforce brand discipline. Humans do not.

Using Bannerbear or Placid.app APIs

Both tools follow the same mental model: you design a template once, you fill “layers” (text, images, colors) via API payloads, you get back a rendered image URL (or binary), you download it, you upload it to WordPress.

Where they differ in practice is less about “features” and more about ergonomics: how quickly you can iterate templates, how predictable their render queue is, and how cleanly they handle fonts, long titles, and multi-size variants.

A sane decision rule: if your bottleneck is template iteration and team collaboration, pick the one whose editor your designer doesn’t hate. If your bottleneck is reliability at scale, pick the one whose API status history makes you sleep.

Mapping blog titles to the image canvas

The part most people underestimate is typography collision. Blog titles are chaotic. Your template is not. You need rules that turn chaos into a stable layout.

You’re mapping data fields into a constrained canvas. That means you should define a tiny spec like this: maximum characters per line, maximum lines, fallback shortening, optional subtitle injection, and a font-size strategy (either fixed or responsive).

Here’s a mapping model that behaves like production, not a demo.

Blog fieldTemplate layerTransform ruleFailure mode it prevents
post_titletitle_textsmart wrap to 2–4 lines, then truncate with ellipsistitle spills off-canvas
categorykicker_textuppercase, max 24 charscategory badge overflows
authorbyline_textoptional, hide if missingempty “By ” line
featured_quotesub_textoptional, max 90 charsparagraph pretending to be a quote
brand_coloraccent_bardefault to brand hex if missingrandom colors from chaos

Title wrapping that doesn’t look like a ransom note

This is the unglamorous fix: wrap by words, cap the lines, then scale down if needed.

function wrapTitle(title, maxLineLen = 26, maxLines = 3) {
  const words = title.trim().split(/\s+/);
  const lines = [];
  let cur = "";

  for (const w of words) {
    const next = cur ? `${cur} ${w}` : w;
    if (next.length <= maxLineLen) {
      cur = next;
    } else {
      if (cur) lines.push(cur);
      cur = w;
      if (lines.length === maxLines) break;
    }
  }
  if (lines.length < maxLines && cur) lines.push(cur);

  const usedWords = lines.join(" ").split(/\s+/).length;
  const truncated = usedWords < words.length;
  if (truncated) {
    lines[lines.length - 1] = lines[lines.length - 1].replace(/\.*$/, "") + "…";
  }
  return lines.join("\n");
}

Now your template can render \n line breaks cleanly, and you’ve stopped long titles from destroying your brand identity.

End-to-end flow with an API renderer

A robust workflow typically looks like this:

  1. WordPress publishes (or updates) a post.
  2. A webhook or hook calls your renderer endpoint with post metadata.
  3. The renderer calls Bannerbear/Placid with a template ID and layer modifications.
  4. The renderer downloads the image when ready.
  5. The renderer uploads it to WordPress Media Library.
  6. The renderer sets it as the post’s featured media.

The only “moving part” you need to choose is where the logic runs: inside WordPress (plugin) or outside (serverless). Outside is usually cleaner because you avoid PHP plugin entropy and you can reuse the same service for multiple sites.

Bannerbear example: create image from template

This is the shape you want: a template ID plus “modifications”. The exact property names depend on the template you built in their editor; the principle is constant.

async function createBannerbearImage({ apiKey, templateId, title, category, imageUrl }) {
  const res = await fetch("https://api.bannerbear.com/v2/images", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${apiKey}`
    },
    body: JSON.stringify({
      template: templateId,
      modifications: [
        { name: "title_text", text: title },
        { name: "kicker_text", text: category },
        { name: "bg_image", image_url: imageUrl }
      ]
    })
  });

  if (!res.ok) throw new Error(`Bannerbear create failed: ${res.status}`);
  return await res.json();
}

Rendering is often asynchronous. You create a job, then poll until image_url exists.

async function pollBannerbearImage({ apiKey, imageId, timeoutMs = 30000 }) {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    const res = await fetch(`https://api.bannerbear.com/v2/images/${imageId}`, {
      headers: { "Authorization": `Bearer ${apiKey}` }
    });
    const data = await res.json();
    if (data.image_url && data.status === "completed") return data.image_url;
    await new Promise(r => setTimeout(r, 1200));
  }
  throw new Error("Bannerbear render timed out");
}

Placid example: render with layers

Same concept: a template plus layer values. You’ll typically provide layers (text/image) keyed by IDs from the template.

async function createPlacidImage({ apiKey, templateId, title, category }) {
  const res = await fetch(`https://api.placid.app/api/rest/${templateId}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${apiKey}`
    },
    body: JSON.stringify({
      layers: {
        title_text: title,
        kicker_text: category
      }
    })
  });

  if (!res.ok) throw new Error(`Placid render failed: ${res.status}`);
  const data = await res.json();
  return data.image_url || data.url;
}

Whether you use Bannerbear or Placid, the integration pattern stays boring: build template once, map fields, render, upload.

Auto-updating the WordPress Featured Image

You’re updating featured_media on the post. That requires two API calls: upload to /wp-json/wp/v2/media, then update the post at /wp-json/wp/v2/posts/{id}.

The simplest auth for a single-site automation is WordPress Application Passwords (fast to set up, good enough for internal tooling). If you’re running this across multiple sites or clients, you’ll eventually want OAuth or a signed internal proxy, but start simple.

Upload image to WordPress Media Library

async function wpUploadMedia({ wpBaseUrl, username, appPassword, filename, imageBuffer }) {
  const auth = Buffer.from(`${username}:${appPassword}`).toString("base64");

  const res = await fetch(`${wpBaseUrl}/wp-json/wp/v2/media`, {
    method: "POST",
    headers: {
      "Authorization": `Basic ${auth}`,
      "Content-Disposition": `attachment; filename="${filename}"`,
      "Content-Type": "image/png"
    },
    body: imageBuffer
  });

  if (!res.ok) throw new Error(`WP media upload failed: ${res.status}`);
  return await res.json();
}

Set Featured Image (featured_media)

async function wpSetFeaturedImage({ wpBaseUrl, username, appPassword, postId, mediaId }) {
  const auth = Buffer.from(`${username}:${appPassword}`).toString("base64");

  const res = await fetch(`${wpBaseUrl}/wp-json/wp/v2/posts/${postId}`, {
    method: "POST",
    headers: {
      "Authorization": `Basic ${auth}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ featured_media: mediaId })
  });

  if (!res.ok) throw new Error(`WP post update failed: ${res.status}`);
  return await res.json();
}

Putting it together

async function generateAndAttachSocialCard({
  postId,
  postTitle,
  category,
  wpBaseUrl,
  wpUser,
  wpAppPassword,
  bannerbearKey,
  bannerbearTemplateId
}) {
  const title = wrapTitle(postTitle, 26, 3);

  const job = await createBannerbearImage({
    apiKey: bannerbearKey,
    templateId: bannerbearTemplateId,
    title,
    category,
    imageUrl: "https://example.com/static/bg.png"
  });

  const imageUrl = await pollBannerbearImage({ apiKey: bannerbearKey, imageId: job.id });
  const imgRes = await fetch(imageUrl);
  const imgBuf = Buffer.from(await imgRes.arrayBuffer());

  const media = await wpUploadMedia({
    wpBaseUrl,
    username: wpUser,
    appPassword: wpAppPassword,
    filename: `social-card-${postId}.png`,
    imageBuffer: imgBuf
  });

  return await wpSetFeaturedImage({
    wpBaseUrl,
    username: wpUser,
    appPassword: wpAppPassword,
    postId,
    mediaId: media.id
  });
}

This is the core. Everything “advanced” is just production hardening: retries, idempotency, caching, and handling updates without spamming your media library.

The production problems you should solve early

Template drift is real. Someone edits the template, renames a layer, your automation silently starts producing cards missing the title. The fix is to store a “template contract” in code: required layer names and a health check endpoint that renders a test card daily.

Duplicate media bloat is also real. If you regenerate a card on every minor edit, WordPress fills up with dead images. The fix is to store the last generated media ID in post meta and replace it, or delete prior media when you regenerate.

Title entropy will keep humiliating your layout. Add a second strategy: if wrap+truncate still looks bad, fall back to “short title + subtitle” using the first clause before a colon, or the first sentence.

What I’d do for Triumphoid, specifically?

I’d standardize on one template for Open Graph and one for “feed card”, and I’d generate both every publish. Even if you only use one today, you’ll eventually care when LinkedIn crops differently from X.

I’d also stop letting the social card be “just the title.” Add a short category kicker and a subtle site mark. The whole point is recognizability at scroll speed.

Previous Article

OCR Automation: Extracting Text from Images in Gmail Attachments